Passed
Pull Request — master (#203)
by Alexander
05:12 queued 02:19
created

BaseActiveRecord::getTableName()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 0
dl 0
loc 9
rs 10
c 0
b 0
f 0
ccs 4
cts 4
cp 1
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord;
6
7
use ArrayAccess;
8
use Closure;
9
use IteratorAggregate;
10
use ReflectionException;
11
use Throwable;
12
use Yiisoft\Db\Connection\ConnectionInterface;
13
use Yiisoft\Db\Exception\Exception;
14
use Yiisoft\Db\Exception\InvalidArgumentException;
15
use Yiisoft\Db\Exception\InvalidCallException;
16
use Yiisoft\Db\Exception\InvalidConfigException;
17
use Yiisoft\Db\Exception\NotSupportedException;
18
use Yiisoft\Db\Exception\StaleObjectException;
19
use Yiisoft\Db\Expression\Expression;
20
use Yiisoft\Strings\Inflector;
21
use Yiisoft\Strings\StringHelper;
22
23
use function array_combine;
24
use function array_flip;
25
use function array_intersect;
26
use function array_key_exists;
27
use function array_keys;
28
use function array_search;
29
use function array_values;
30
use function count;
31
use function in_array;
32
use function is_array;
33
use function is_int;
34
use function reset;
35
36
/**
37
 * ActiveRecord is the base class for classes representing relational data in terms of objects.
38
 *
39
 * See {@see ActiveRecord} for a concrete implementation.
40
 *
41
 * @property array $dirtyAttributes The changed attribute values (name-value pairs). This property is read-only.
42
 * @property bool $isNewRecord Whether the record is new and should be inserted when calling {@see save()}.
43
 * @property array $oldAttributes The old attribute values (name-value pairs). Note that the type of this property
44
 * differs in getter and setter. See {@see getOldAttributes()} and {@see setOldAttributes()} for details.
45
 * @property mixed $oldPrimaryKey The old primary key value. An array (column name => column value) is returned if the
46
 * primary key is composite. A string is returned otherwise (null will be returned if the key value is null).
47
 * This property is read-only.
48
 * @property mixed $primaryKey The primary key value. An array (column name => column value) is returned if the primary
49
 * key is composite. A string is returned otherwise (null will be returned if the key value is null).
50
 * This property is read-only.
51
 * @property array $relatedRecords An array of related records indexed by relation names. This property is read-only.
52
 */
53
abstract class BaseActiveRecord implements ActiveRecordInterface, IteratorAggregate, ArrayAccess
54
{
55
    use BaseActiveRecordTrait;
56
57
    private array $attributes = [];
58
    private array|null $oldAttributes = null;
59
    private array $related = [];
60
    private array $relationsDependencies = [];
61
62
    public function __construct(
63 719
        protected ConnectionInterface $db,
64
        private ActiveRecordFactory|null $arFactory = null,
65 719
        private string $tableName = ''
66 719
    ) {
67 719
    }
68
69
    public function delete(): false|int
70
    {
71
        /**
72
         * We do not check the return value of deleteAll() because it's possible the record is already deleted in
73
         * the database and thus the method will return 0
74
         */
75
        $condition = $this->getOldPrimaryKey(true);
76
        $lock = $this->optimisticLock();
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $lock is correct as $this->optimisticLock() targeting Yiisoft\ActiveRecord\Bas...ecord::optimisticLock() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
77
78
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
79
            $condition[$lock] = $lock;
80
        }
81
82
        $result = $this->deleteAll($condition);
83
84
        if ($lock !== null && !$result) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
85
            throw new StaleObjectException('The object being deleted is outdated.');
86
        }
87
88 1
        $this->oldAttributes = null;
89
90 1
        return $result;
91
    }
92
93
    public function deleteAll(array $condition = [], array $params = []): int
94
    {
95
        $command = $this->db->createCommand();
96
        $command->delete($this->getTableName(), $condition, $params);
97
98
        return $command->execute();
99
    }
100
101
    public function equals(ActiveRecordInterface $record): bool
102
    {
103
        if ($this->getIsNewRecord() || ($record->getIsNewRecord())) {
104
            return false;
105
        }
106
107
        return $this->getTableName() === $record->getTableName() && $this->getPrimaryKey() === $record->getPrimaryKey();
108
    }
109
110
    /**
111 1
     * @return array The default implementation returns the names of the relations that have been populated into this
112
     * record.
113 1
     */
114
    public function extraFields(): array
115
    {
116
        $fields = array_keys($this->getRelatedRecords());
117
118
        return array_combine($fields, $fields);
119
    }
120
121
    /**
122
     * @return array The default implementation returns the names of the columns whose values have been populated into
123
     * this record.
124
     */
125
    public function fields(): array
126
    {
127
        $fields = array_keys($this->attributes);
128
129
        return array_combine($fields, $fields);
130
    }
131
132
    public function getAttribute(string $name): mixed
133
    {
134
        return $this->attributes[$name] ?? null;
135
    }
136 1
137
    /**
138 1
     * Returns attribute values.
139
     *
140
     * @param array|null $names List of attributes whose value needs to be returned. Defaults to null, meaning all
141
     * attributes listed in {@see attributes()} will be returned. If it is an array, only the attributes in the array
142
     * will be returned.
143
     * @param array $except List of attributes whose value should NOT be returned.
144
     *
145
     * @return array Attribute values (name => value).
146
     */
147
    public function getAttributes(array $names = null, array $except = []): array
148
    {
149
        $values = [];
150
151
        if ($names === null) {
152
            $names = $this->attributes();
153
        }
154
155
        foreach ($names as $name) {
156
            $values[$name] = $this->$name;
157
        }
158
159
        foreach ($except as $name) {
160
            unset($values[$name]);
161
        }
162 41
163
        return $values;
164 41
    }
165
166
    public function getIsNewRecord(): bool
167
    {
168
        return $this->oldAttributes === null;
169
    }
170
171
    /**
172
     * Returns the old value of the named attribute.
173
     *
174
     * If this record is the result of a query and the attribute is not loaded, `null` will be returned.
175
     *
176
     * @param string $name The attribute name.
177
     *
178
     * @return mixed the old attribute value. `null` if the attribute is not loaded before or does not exist.
179
     *
180
     * {@see hasAttribute()}
181
     */
182
    public function getOldAttribute(string $name): mixed
183
    {
184
        return $this->oldAttributes[$name] ?? null;
185
    }
186
187
    /**
188
     * Returns the attribute values that have been modified since they are loaded or saved most recently.
189
     *
190
     * The comparison of new and old values is made for identical values using `===`.
191
     *
192
     * @param array|null $names The names of the attributes whose values may be returned if they are changed recently.
193
     * If null, {@see attributes()} will be used.
194
     *
195
     * @return array The changed attribute values (name-value pairs).
196
     */
197
    public function getDirtyAttributes(array $names = null): array
198 116
    {
199
        if ($names === null) {
200 116
            $names = $this->attributes();
201
        }
202
203
        $names = array_flip($names);
204
        $attributes = [];
205
206
        if ($this->oldAttributes === null) {
207
            foreach ($this->attributes as $name => $value) {
208
                if (isset($names[$name])) {
209
                    $attributes[$name] = $value;
210
                }
211
            }
212
        } else {
213
            foreach ($this->attributes as $name => $value) {
214
                if (
215
                    isset($names[$name])
216
                    && (!array_key_exists($name, $this->oldAttributes) || $value !== $this->oldAttributes[$name])
217
                ) {
218
                    $attributes[$name] = $value;
219
                }
220
            }
221
        }
222
223
        return $attributes;
224
    }
225
226
    /**
227
     * Returns the old attribute values.
228
     *
229
     * @return array The old attribute values (name-value pairs).
230
     */
231
    public function getOldAttributes(): array
232
    {
233
        return $this->oldAttributes ?? [];
234 202
    }
235
236 202
    public function getOldPrimaryKey(bool $asArray = false): mixed
237
    {
238
        $keys = $this->primaryKey();
239
240
        if (empty($keys)) {
241
            throw new Exception(
242
                static::class . ' does not have a primary key. You should either define a primary key for '
243
                . 'the corresponding table or override the primaryKey() method.'
244
            );
245
        }
246
247
        if (!$asArray && count($keys) === 1) {
248
            return $this->oldAttributes[$keys[0]] ?? null;
249
        }
250
251 248
        $values = [];
252
253 248
        foreach ($keys as $name) {
254
            $values[$name] = $this->oldAttributes[$name] ?? null;
255
        }
256
257
        return $values;
258
    }
259
260
    public function getPrimaryKey(bool $asArray = false): mixed
261
    {
262
        $keys = $this->primaryKey();
263
264
        if (!$asArray && count($keys) === 1) {
265
            return $this->attributes[$keys[0]] ?? null;
266
        }
267
268
        $values = [];
269 162
270
        foreach ($keys as $name) {
271 162
            $values[$name] = $this->attributes[$name] ?? null;
272 19
        }
273
274
        return $values;
275 162
    }
276 162
277
    /**
278
     * Returns all populated related records.
279
     *
280
     * @return array An array of related records indexed by relation names.
281
     *
282
     * {@see getRelation()}
283
     */
284
    public function getRelatedRecords(): array
285
    {
286
        return $this->related;
287
    }
288 103
289
    public function hasAttribute($name): bool
290 103
    {
291
        return isset($this->attributes[$name]) || in_array($name, $this->attributes(), true);
292
    }
293
294
    /**
295
     * Declares a `has-many` relation.
296
     *
297
     * The declaration is returned in terms of a relational {@see ActiveQuery} instance  through which the related
298
     * record can be queried and retrieved back.
299
     *
300 14
     * A `has-many` relation means that there are multiple related records matching the criteria set by this relation,
301
     * e.g., a customer has many orders.
302 14
     *
303
     * For example, to declare the `orders` relation for `Customer` class, we can write the following code in the
304
     * `Customer` class:
305
     *
306
     * ```php
307
     * public function getOrders()
308
     * {
309
     *     return $this->hasMany(Order::className(), ['customer_id' => 'id']);
310
     * }
311
     * ```
312 386
     *
313
     * Note that in the above, the 'customer_id' key in the `$link` parameter refers to an attribute name in the related
314 386
     * class `Order`, while the 'id' value refers to an attribute name in the current AR class.
315
     *
316
     * Call methods declared in {@see ActiveQuery} to further customize the relation.
317
     *
318
     * @param string $class The class name of the related record
319
     * @param array $link The primary-foreign key constraint. The keys of the array refer to the attributes of the
320
     * record associated with the `$class` model, while the values of the array refer to the corresponding attributes in
321
     * **this** AR class.
322
     *
323
     * @return ActiveQueryInterface The relational query object.
324
     */
325
    public function hasMany(string $class, array $link): ActiveQueryInterface
326
    {
327
        return $this->createRelationQuery($class, $link, true);
328 116
    }
329
330 116
    /**
331
     * Declares a `has-one` relation.
332
     *
333
     * The declaration is returned in terms of a relational {@see ActiveQuery} instance through which the related record
334
     * can be queried and retrieved back.
335
     *
336
     * A `has-one` relation means that there is at most one related record matching the criteria set by this relation,
337
     * e.g., a customer has one country.
338
     *
339
     * For example, to declare the `country` relation for `Customer` class, we can write the following code in the
340
     * `Customer` class:
341
     *
342
     * ```php
343
     * public function getCountry()
344
     * {
345 154
     *     return $this->hasOne(Country::className(), ['id' => 'country_id']);
346
     * }
347 154
     * ```
348
     *
349 150
     * Note that in the above, the 'id' key in the `$link` parameter refers to an attribute name in the related class
350 150
     * `Country`, while the 'country_id' value refers to an attribute name in the current AR class.
351
     *
352 10
     * Call methods declared in {@see ActiveQuery} to further customize the relation.
353
     *
354 150
     * @param string $class The class name of the related record.
355
     * @param array $link The primary-foreign key constraint. The keys of the array refer to the attributes of the
356 4
     * record associated with the `$class` model, while the values of the array refer to the corresponding attributes in
357
     * **this** AR class.
358 150
     *
359
     * @return ActiveQueryInterface The relational query object.
360
     */
361
    public function hasOne(string $class, array $link): ActiveQueryInterface
362
    {
363
        return $this->createRelationQuery($class, $link, false);
364
    }
365 8
366
    public function instantiateQuery(string $arClass): ActiveQueryInterface
367 8
    {
368
        return new ActiveQuery($arClass, $this->db, $this->arFactory);
369
    }
370
371
    /**
372
     * Returns a value indicating whether the named attribute has been changed.
373
     *
374
     * @param string $name The name of the attribute.
375
     * @param bool $identical Whether the comparison of new and old value is made for identical values using `===`,
376
     * defaults to `true`. Otherwise `==` is used for comparison.
377
     *
378 137
     * @return bool Whether the attribute has been changed.
379
     */
380 137
    public function isAttributeChanged(string $name, bool $identical = true): bool
381 137
    {
382
        if (isset($this->attributes[$name], $this->oldAttributes[$name])) {
383
            if ($identical) {
384
                return $this->attributes[$name] !== $this->oldAttributes[$name];
385
            }
386
387
            return $this->attributes[$name] !== $this->oldAttributes[$name];
388
        }
389
390
        return isset($this->attributes[$name]) || isset($this->oldAttributes[$name]);
391
    }
392
393
    public function isPrimaryKey(array $keys): bool
394 24
    {
395
        $pks = $this->primaryKey();
396 24
397
        if (count($keys) === count($pks)) {
398
            return count(array_intersect($keys, $pks)) === count($pks);
399
        }
400
401
        return false;
402
    }
403
404
    public function isRelationPopulated(string $name): bool
405
    {
406
        return array_key_exists($name, $this->related);
407
    }
408
409 8
    public function link(string $name, ActiveRecordInterface $arClass, array $extraColumns = []): void
410
    {
411 8
        $viaClass = null;
412 4
        $viaTable = null;
413
        $relation = $this->getRelation($name);
414 4
        $via = $relation->getVia();
415
416 4
        if ($via !== null) {
417
            if ($this->getIsNewRecord() || $arClass->getIsNewRecord()) {
418
                throw new InvalidCallException(
419
                    'Unable to link models: the models being linked cannot be newly created.'
420
                );
421
            }
422
423
            if (is_array($via)) {
0 ignored issues
show
introduced by
The condition is_array($via) is always false.
Loading history...
424
                [$viaName, $viaRelation] = $via;
425
                $viaClass = $viaRelation->getARInstance();
426 6
                /** unset $viaName so that it can be reloaded to reflect the change */
427
                unset($this->related[$viaName]);
428 6
            } else {
429 6
                $viaRelation = $via;
430
                $from = $via->getFrom();
431
                $viaTable = reset($from);
432
            }
433
434
            $columns = [];
435
436
            foreach ($viaRelation->getLink() as $a => $b) {
437
                $columns[$a] = $this->$b;
438
            }
439
440 12
            foreach ($relation->getLink() as $a => $b) {
441
                $columns[$b] = $arClass->$a;
442 12
            }
443 8
444 4
            foreach ($extraColumns as $k => $v) {
445
                $columns[$k] = $v;
446
            }
447 4
448
            if (is_array($via)) {
449
                foreach ($columns as $column => $value) {
450 4
                    $viaClass->$column = $value;
451
                }
452
453
                $viaClass->insert();
454
            } else {
455
                $this->db->createCommand()->insert($viaTable, $columns)->execute();
456
            }
457
        } else {
458
            $p1 = $arClass->isPrimaryKey(array_keys($relation->getLink()));
459
            $p2 = $this->isPrimaryKey(array_values($relation->getLink()));
460
461
            if ($p1 && $p2) {
462
                if ($this->getIsNewRecord() && $arClass->getIsNewRecord()) {
463 149
                    throw new InvalidCallException('Unable to link models: at most one model can be newly created.');
464
                }
465 149
466 145
                if ($this->getIsNewRecord()) {
467
                    $this->bindModels(array_flip($relation->getLink()), $this, $arClass);
468
                } else {
469 149
                    $this->bindModels($relation->getLink(), $arClass, $this);
470 149
                }
471
            } elseif ($p1) {
472 149
                $this->bindModels(array_flip($relation->getLink()), $this, $arClass);
473 133
            } elseif ($p2) {
474 129
                $this->bindModels($relation->getLink(), $arClass, $this);
475 129
            } else {
476
                throw new InvalidCallException(
477
                    'Unable to link models: the link defining the relation does not involve any primary key.'
478
                );
479 45
            }
480
        }
481 45
482 45
        /** update lazily loaded related objects */
483
        if (!$relation->getMultiple()) {
484 45
            $this->related[$name] = $arClass;
485
        } elseif (isset($this->related[$name])) {
486
            if ($relation->getIndexBy() !== null) {
487
                if ($relation->getIndexBy() instanceof Closure) {
488
                    $index = $relation->indexBy($arClass::class);
489 149
                } else {
490
                    $index = $arClass->{$relation->getIndexBy()};
491
                }
492
                $this->related[$name][$index] = $arClass;
493
            } else {
494
                $this->related[$name][] = $arClass;
495
            }
496
        }
497
    }
498
499
    /**
500
     * Marks an attribute dirty.
501
     *
502
     * This method may be called to force updating a record when calling {@see update()}, even if there is no change
503
     * being made to the record.
504
     *
505
     * @param string $name The attribute name.
506
     */
507
    public function markAttributeDirty(string $name): void
508
    {
509
        unset($this->oldAttributes[$name]);
510
    }
511
512
    /**
513
     * Returns the name of the column that stores the lock version for implementing optimistic locking.
514 141
     *
515
     * Optimistic locking allows multiple users to access the same record for edits and avoids potential conflicts. In
516 141
     * case when a user attempts to save the record upon some staled data (because another user has modified the data),
517 125
     * a {@see StaleObjectException} exception will be thrown, and the update or deletion is skipped.
518
     *
519
     * Optimistic locking is only supported by {@see update()} and {@see delete()}.
520 30
     *
521
     * To use Optimistic locking:
522
     *
523
     * 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`.
524
     *    Override this method to return the name of this column.
525
     * 2. In the Web form that collects the user input, add a hidden field that stores the lock version of the recording
526
     *    being updated.
527
     * 3. In the controller action that does the data updating, try to catch the {@see StaleObjectException} and
528
     *    implement necessary business logic (e.g. merging the changes, prompting stated data) to resolve the conflict.
529
     *
530
     * @return string|null The column name that stores the lock version of a table row. If `null` is returned (default
531
     * implemented), optimistic locking will not be supported.
532
     */
533
    public function optimisticLock(): string|null
534
    {
535
        return null;
536
    }
537
538
    /**
539
     * Populates an active record object using a row of data from the database/storage.
540
     *
541
     * This is an internal method meant to be called to create active record objects after fetching data from the
542
     * database. It is mainly used by {@see ActiveQuery} to populate the query results into active records.
543
     *
544
     * @param array|object $row Attribute values (name => value).
545
     */
546
    public function populateRecord(array|object $row): void
547
    {
548
        $columns = array_flip($this->attributes());
549
550
        foreach ($row as $name => $value) {
551
            if (isset($columns[$name])) {
552
                $this->attributes[$name] = $value;
553
            } elseif ($this->canSetProperty($name)) {
554
                $this->$name = $value;
555
            }
556
        }
557
558 6
        $this->oldAttributes = $this->attributes;
559
        $this->related = [];
560 6
        $this->relationsDependencies = [];
561
    }
562
563
    public function populateRelation(string $name, array|ActiveRecordInterface|null $records): void
564
    {
565
        foreach ($this->relationsDependencies as &$relationNames) {
566
            unset($relationNames[$name]);
567
        }
568
569
        $this->related[$name] = $records;
570
    }
571
572
    /**
573
     * Repopulates this active record with the latest data.
574
     *
575
     * @return bool Whether the row still exists in the database. If `true`, the latest data will be populated to this
576
     * active record. Otherwise, this record will remain unchanged.
577
     */
578
    public function refresh(): bool
579
    {
580
        $record = $this->instantiateQuery(static::class)->findOne($this->getPrimaryKey(true));
581
582 5
        return $this->refreshInternal($record);
583
    }
584 5
585
    /**
586 5
     * Saves the current record.
587 5
     *
588 1
     * This method will call {@see insert()} when {@see isNewRecord} is `true`, or {@see update()} when
589
     * {@see isNewRecord} is `false`.
590 5
     *
591 5
     * For example, to save a customer record:
592
     *
593
     * ```php
594
     * $customer = new Customer($db);
595 5
     * $customer->name = $name;
596
     * $customer->email = $email;
597 5
     * $customer->save();
598 4
     * ```
599
     *
600
     * @param array|null $attributeNames List of attribute names that need to be saved. Defaults to null, meaning all
601 5
     * attributes that are loaded from DB will be saved.
602
     *
603 5
     * @throws Exception|StaleObjectException
604 5
     *
605
     * @return bool Whether the saving succeeded (i.e. no validation errors occurred).
606
     */
607 5
    public function save(array $attributeNames = null): bool
608
    {
609
        if ($this->getIsNewRecord()) {
610
            return $this->insert($attributeNames);
611
        }
612
613
        return $this->update($attributeNames) !== false;
614
    }
615
616
    public function setAttribute(string $name, mixed $value): void
617
    {
618
        if ($this->hasAttribute($name)) {
619 40
            if (
620
                !empty($this->relationsDependencies[$name])
621 40
                && (!array_key_exists($name, $this->attributes) || $this->attributes[$name] !== $value)
622
            ) {
623 40
                $this->resetDependentRelations($name);
624 4
            }
625
            $this->attributes[$name] = $value;
626
        } else {
627 40
            throw new InvalidArgumentException(static::class . ' has no attribute named "' . $name . '".');
628 40
        }
629
    }
630 40
631 4
    /**
632 4
     * Sets the attribute values in a massive way.
633
     *
634
     * @param array $values Attribute values (name => value) to be assigned to the model.
635
     *
636
     * {@see attributes()}
637
     */
638
    public function setAttributes(array $values): void
639 40
    {
640
        foreach ($values as $name => $value) {
641 40
            if (in_array($name, $this->attributes(), true)) {
642 4
                $this->$name = $value;
643
            }
644
        }
645 40
    }
646 4
647
    /**
648
     * Sets the value indicating whether the record is new.
649 40
     *
650
     * @param bool $value whether the record is new and should be inserted when calling {@see save()}.
651 40
     *
652 40
     * @see getIsNewRecord()
653 40
     */
654
    public function setIsNewRecord(bool $value): void
655
    {
656 40
        $this->oldAttributes = $value ? null : $this->attributes;
657
    }
658
659
    /**
660
     * Sets the old value of the named attribute.
661
     *
662
     * @param string $name The attribute name.
663
     * @param mixed $value The old attribute value.
664
     *
665
     * @throws InvalidArgumentException If the named attribute does not exist.
666
     *
667
     * {@see hasAttribute()}
668
     */
669
    public function setOldAttribute(string $name, mixed $value): void
670
    {
671
        if (isset($this->oldAttributes[$name]) || $this->hasAttribute($name)) {
672
            $this->oldAttributes[$name] = $value;
673
        } else {
674
            throw new InvalidArgumentException(static::class . ' has no attribute named "' . $name . '".');
675
        }
676
    }
677
678
    /**
679
     * Sets the old attribute values.
680
     *
681 9
     * All existing old attribute values will be discarded.
682
     *
683 9
     * @param array|null $values Old attribute values to be set. If set to `null` this record is considered to be
684 9
     * {@see isNewRecord|new}.
685 9
     */
686 4
    public function setOldAttributes(array $values = null): void
687
    {
688 5
        $this->oldAttributes = $values;
689
    }
690
691 9
    public function update(array $attributeNames = null): false|int
692
    {
693
        return $this->updateInternal($attributeNames);
694 9
    }
695
696
    public function updateAll(array $attributes, array|string $condition = [], array $params = []): int
697
    {
698
        $command = $this->db->createCommand();
699
700
        $command->update($this->getTableName(), $attributes, $condition, $params);
701
702
        return $command->execute();
703
    }
704
705
    /**
706
     * Updates the specified attributes.
707
     *
708
     * This method is a shortcut to {@see update()} when data validation is not needed and only a small set attributes
709
     * need to be updated.
710
     *
711
     * You may specify the attributes to be updated as name list or name-value pairs. If the latter, the corresponding
712 2
     * attribute values will be modified accordingly.
713
     *
714
     * The method will then save the specified attributes into database.
715
     *
716
     * Note that this method will **not** perform data validation and will **not** trigger events.
717
     *
718 2
     * @param array $attributes The attributes (names or name-value pairs) to be updated.
719 2
     *
720
     * @throws Exception
721 2
     * @throws NotSupportedException
722
     *
723
     * @return int The number of rows affected.
724
     */
725 2
    public function updateAttributes(array $attributes): int
726
    {
727 2
        $attrs = [];
728
729
        foreach ($attributes as $name => $value) {
730
            if (is_int($name)) {
731 2
                $attrs[] = $value;
732
            } else {
733 2
                $this->$name = $value;
734
                $attrs[] = $name;
735
            }
736
        }
737
738
        $values = $this->getDirtyAttributes($attrs);
739
740
        if (empty($values) || $this->getIsNewRecord()) {
741 153
            return 0;
742
        }
743 153
744
        $rows = $this->updateAll($values, $this->getOldPrimaryKey(true));
745
746
        foreach ($values as $name => $value) {
747
            $this->oldAttributes[$name] = $this->attributes[$name];
748
        }
749
750
        return $rows;
751
    }
752
753
    /**
754
     * Updates the whole table using the provided counter changes and conditions.
755
     *
756
     * For example, to increment all customers' age by 1,
757
     *
758
     * ```php
759
     * $customer = new Customer($db);
760
     * $customer->updateAllCounters(['age' => 1]);
761
     * ```
762
     *
763
     * Note that this method will not trigger any events.
764 4
     *
765
     * @param array $counters The counters to be updated (attribute name => increment value).
766
     * Use negative values if you want to decrement the counters.
767 4
     * @param array|string $condition The conditions that will be put in the WHERE part of the UPDATE SQL. Please refer
768
     * to {@see Query::where()} on how to specify this parameter.
769 4
     * @param array $params The parameters (name => value) to be bound to the query.
770
     *
771
     * Do not name the parameters as `:bp0`, `:bp1`, etc., because they are used internally by this method.
772
     *
773
     * @throws Exception
774
     * @throws InvalidConfigException
775
     * @throws Throwable
776
     *
777
     * @return int The number of rows updated.
778
     */
779
    public function updateAllCounters(array $counters, $condition = '', array $params = []): int
780
    {
781 32
        $n = 0;
782
783 32
        foreach ($counters as $name => $value) {
784 5
            $counters[$name] = new Expression("[[$name]]+:bp{$n}", [":bp{$n}" => $value]);
785
            $n++;
786
        }
787 32
788 32
        $command = $this->db->createCommand();
789
        $command->update($this->getTableName(), $counters, $condition, $params);
790
791 32
        return $command->execute();
792 32
    }
793 32
794
    /**
795 32
     * Updates one or several counter columns for the current AR object.
796
     *
797
     * Note that this method differs from {@see updateAllCounters()} in that it only saves counters for the current AR
798
     * object.
799
     *
800
     * An example usage is as follows:
801
     *
802
     * ```php
803
     * $post = new Post($db);
804
     * $post->updateCounters(['view_count' => 1]);
805
     * ```
806
     *
807
     * @param array $counters The counters to be updated (attribute name => increment value), use negative values if you
808 2
     * want to decrement the counters.
809
     *
810 2
     * @throws Exception
811 1
     * @throws NotSupportedException
812
     *
813
     * @return bool Whether the saving is successful.
814 1
     *
815
     * {@see updateAllCounters()}
816
     */
817
    public function updateCounters(array $counters): bool
818
    {
819
        if ($this->updateAllCounters($counters, $this->getOldPrimaryKey(true)) > 0) {
820
            foreach ($counters as $name => $value) {
821
                if (!isset($this->attributes[$name])) {
822
                    $this->attributes[$name] = $value;
823
                } else {
824
                    $this->attributes[$name] += $value;
825
                }
826
827
                $this->oldAttributes[$name] = $this->attributes[$name];
828
            }
829
830 53
            return true;
831
        }
832 53
833
        return false;
834 53
    }
835 21
836
    public function unlink(string $name, ActiveRecordInterface $arClass, bool $delete = false): void
837
    {
838 32
        $viaClass = null;
839
        $viaTable = null;
840 32
        $relation = $this->getRelation($name);
841 32
842
        if ($relation->getVia() !== null) {
843
            if (is_array($relation->getVia())) {
0 ignored issues
show
introduced by
The condition is_array($relation->getVia()) is always false.
Loading history...
844 32
                [$viaName, $viaRelation] = $relation->getVia();
845
                $viaClass = $viaRelation->getARInstance();
846
                unset($this->related[$viaName]);
847
            } else {
848
                $viaRelation = $relation->getVia();
849
                $from = $relation->getVia()->getFrom();
850
                $viaTable = reset($from);
851
            }
852
853
            $columns = [];
854
            foreach ($viaRelation->getLink() as $a => $b) {
855
                $columns[$a] = $this->$b;
856
            }
857
858
            foreach ($relation->getLink() as $a => $b) {
859
                $columns[$b] = $arClass->$a;
860
            }
861
            $nulls = [];
862
863
            foreach (array_keys($columns) as $a) {
864
                $nulls[$a] = null;
865
            }
866
867 60
            if (is_array($relation->getVia())) {
0 ignored issues
show
introduced by
The condition is_array($relation->getVia()) is always false.
Loading history...
868
                if ($delete) {
869 60
                    $viaClass->deleteAll($columns);
870
                } else {
871 60
                    $viaClass->updateAll($nulls, $columns);
872 1
                }
873 1
            } else {
874 1
                $command = $this->db->createCommand();
875
                if ($delete) {
876
                    $command->delete($viaTable, $columns)->execute();
877
                } else {
878 59
                    $command->update($viaTable, $nulls, $columns)->execute();
879
                }
880
            }
881
        } else {
882 59
            $p1 = $arClass->isPrimaryKey(array_keys($relation->getLink()));
883
            $p2 = $this->isPrimaryKey(array_values($relation->getLink()));
884 59
            if ($p2) {
885 59
                if ($delete) {
886
                    $arClass->delete();
887
                } else {
888 59
                    foreach ($relation->getLink() as $a => $b) {
889
                        $arClass->$a = null;
890
                    }
891
                    $arClass->save();
892
                }
893
            } elseif ($p1) {
894
                foreach ($relation->getLink() as $a => $b) {
895
                    /** relation via array valued attribute */
896
                    if (is_array($this->$b)) {
897
                        if (($key = array_search($arClass->$a, $this->$b, false)) !== false) {
898
                            $values = $this->$b;
899 445
                            unset($values[$key]);
900
                            $this->$b = array_values($values);
901 445
                        }
902
                    } else {
903 445
                        $this->$b = null;
904 445
                    }
905 445
                }
906 8
                $delete ? $this->delete() : $this->save();
907 8
            } else {
908
                throw new InvalidCallException('Unable to unlink models: the link does not involve any primary key.');
909
            }
910
        }
911 445
912 445
        if (!$relation->getMultiple()) {
913 445
            unset($this->related[$name]);
914 445
        } elseif (isset($this->related[$name])) {
915
            /** @var ActiveRecordInterface $b */
916 287
            foreach ($this->related[$name] as $a => $b) {
917
                if ($arClass->getPrimaryKey() === $b->getPrimaryKey()) {
918 287
                    unset($this->related[$name][$a]);
919 31
                }
920
            }
921
        }
922 256
    }
923
924
    /**
925
     * Destroys the relationship in current model.
926
     *
927
     * The active record with the foreign key of the relationship will be deleted if `$delete` is `true`. Otherwise, the
928
     * foreign key will be set `null` and the model will be saved without validation.
929
     *
930
     * Note that to destroy the relationship without removing records make sure your keys can be set to null.
931
     *
932
     * @param string $name The case sensitive name of the relationship, e.g. `orders` for a relation defined via
933
     * `getOrders()` method.
934
     * @param bool $delete Whether to delete the model that contains the foreign key.
935
     *
936
     * @throws Exception
937
     * @throws ReflectionException
938
     * @throws StaleObjectException
939
     * @throws Throwable
940
     */
941
    public function unlinkAll(string $name, bool $delete = false): void
942
    {
943
        $viaClass = null;
944
        $viaTable = null;
945
        $relation = $this->getRelation($name);
946
947
        if ($relation->getVia() !== null) {
948 9
            if (is_array($relation->getVia())) {
0 ignored issues
show
introduced by
The condition is_array($relation->getVia()) is always false.
Loading history...
949
                /* @var $viaRelation ActiveQuery */
950 9
                [$viaName, $viaRelation] = $relation->getVia();
951
                $viaClass = $viaRelation->getARInstance();
952 9
                unset($this->related[$viaName]);
953 5
            } else {
954
                $viaRelation = $relation->getVia();
955
                $from = $relation->getVia()->getFrom();
956
                $viaTable = reset($from);
957
            }
958
959 5
            $condition = [];
960
            $nulls = [];
961 5
962 5
            foreach ($viaRelation->getLink() as $a => $b) {
963
                $nulls[$a] = null;
964 5
                $condition[$a] = $this->$b;
965
            }
966
967
            if (!empty($viaRelation->getWhere())) {
968
                $condition = ['and', $condition, $viaRelation->getWhere()];
969
            }
970
971 5
            if (!empty($viaRelation->getOn())) {
972
                $condition = ['and', $condition, $viaRelation->getOn()];
973 5
            }
974 5
975
            if (is_array($relation->getVia())) {
0 ignored issues
show
introduced by
The condition is_array($relation->getVia()) is always false.
Loading history...
976
                if ($delete) {
977 5
                    $viaClass->deleteAll($condition);
978 5
                } else {
979
                    $viaClass->updateAll($nulls, $condition);
980
                }
981 5
            } else {
982 5
                $command = $this->db->createCommand();
983
                if ($delete) {
984
                    $command->delete($viaTable, $condition)->execute();
985 5
                } else {
986 5
                    $command->update($viaTable, $nulls, $condition)->execute();
987 5
                }
988
            }
989
        } else {
990 5
            $relatedModel = $relation->getARInstance();
991
992
            $link = $relation->getLink();
993 5
            if (!$delete && count($link) === 1 && is_array($this->{$b = reset($link)})) {
994
                /** relation via array valued attribute */
995
                $this->$b = [];
996 9
                $this->save();
997 9
            } else {
998
                $nulls = [];
999 9
                $condition = [];
1000
1001
                foreach ($relation->getLink() as $a => $b) {
1002
                    $nulls[$a] = null;
1003
                    $condition[$a] = $this->$b;
1004
                }
1005
1006
                if (!empty($relation->getWhere())) {
1007
                    $condition = ['and', $condition, $relation->getWhere()];
1008
                }
1009 9
1010 5
                if (!empty($relation->getOn())) {
1011 4
                    $condition = ['and', $condition, $relation->getOn()];
1012 4
                }
1013
1014
                if ($delete) {
1015
                    $relatedModel->deleteAll($condition);
1016
                } else {
1017
                    $relatedModel->updateAll($nulls, $condition);
1018
                }
1019
            }
1020
        }
1021 9
1022 5
        unset($this->related[$name]);
1023 9
    }
1024 9
1025 4
    /**
1026
     * Sets relation dependencies for a property.
1027
     *
1028 4
     * @param string $name property name.
1029
     * @param ActiveQuery $relation relation instance.
1030 4
     * @param string|null $viaRelationName intermediate relation.
1031
     */
1032 5
    private function setRelationDependencies(
1033
        string $name,
1034
        ActiveQuery $relation,
1035 9
        string $viaRelationName = null
1036
    ): void {
1037
        $via = $relation->getVia();
1038
1039
        if (empty($via) && $relation->getLink()) {
1040
            foreach ($relation->getLink() as $attribute) {
1041
                $this->relationsDependencies[$attribute][$name] = $name;
1042
                if ($viaRelationName !== null) {
1043
                    $this->relationsDependencies[$attribute][] = $viaRelationName;
1044
                }
1045
            }
1046
        } elseif ($via instanceof ActiveQueryInterface) {
1047
            $this->setRelationDependencies($name, $via);
1048
        } elseif (is_array($via)) {
1049
            [$viaRelationName, $viaQuery] = $via;
1050
            $this->setRelationDependencies($name, $viaQuery, $viaRelationName);
1051
        }
1052
    }
1053 5
1054
    /**
1055 5
     * Creates a query instance for `has-one` or `has-many` relation.
1056
     *
1057 5
     * @param string $arClass The class name of the related record.
1058 5
     * @param array $link The primary-foreign key constraint.
1059
     * @param bool $multiple Whether this query represents a relation to more than one record.
1060 5
     *
1061 5
     * @return ActiveQueryInterface The relational query object.
1062 5
1063
     * {@see hasOne()}
1064 4
     * {@see hasMany()}
1065 4
     */
1066 4
    protected function createRelationQuery(string $arClass, array $link, bool $multiple): ActiveQueryInterface
1067
    {
1068
        return $this->instantiateQuery($arClass)->primaryModel($this)->link($link)->multiple($multiple);
1069 5
    }
1070 5
1071 5
    /**
1072
     * Repopulates this active record with the latest data from a newly fetched instance.
1073
     *
1074 5
     * @param ActiveRecord|array|null $record The record to take attributes from.
1075 5
     *
1076
     * @return bool Whether refresh was successful.
1077 5
     *
1078
     * {@see refresh()}
1079 5
     */
1080 5
    protected function refreshInternal(array|ActiveRecord $record = null): bool
1081
    {
1082
        if ($record === null || $record === []) {
1083 5
            return false;
1084
        }
1085 5
1086 5
        foreach ($this->attributes() as $name) {
1087
            $this->attributes[$name] = $record->attributes[$name] ?? null;
1088 5
        }
1089
1090
        $this->oldAttributes = $record->oldAttributes;
1091
        $this->related = [];
1092 4
        $this->relationsDependencies = [];
1093 4
1094
        return true;
1095
    }
1096 5
1097
    /**
1098
     * {@see update()}
1099
     *
1100 5
     * @param array|null $attributes Attributes to update.
1101 5
     *
1102 5
     * @throws Exception
1103 5
     * @throws NotSupportedException
1104 5
     * @throws StaleObjectException
1105
     *
1106 5
     * @return int The number of rows affected.
1107 5
     */
1108
    protected function updateInternal(array $attributes = null): int
1109 5
    {
1110
        $values = $this->getDirtyAttributes($attributes);
1111
1112
        if (empty($values)) {
1113
            return 0;
1114
        }
1115
1116
        $condition = $this->getOldPrimaryKey(true);
1117
        $lock = $this->optimisticLock();
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $lock is correct as $this->optimisticLock() targeting Yiisoft\ActiveRecord\Bas...ecord::optimisticLock() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1118
1119
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
1120
            $values[$lock] = $this->$lock + 1;
1121
            $condition[$lock] = $this->$lock;
1122
        }
1123
1124
        /**
1125
         * We do not check the return value of updateAll() because it's possible that the UPDATE statement doesn't
1126
         * change anything and thus returns 0.
1127
         */
1128
        $rows = $this->updateAll($values, $condition);
1129
1130 5
        if ($lock !== null && !$rows) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
1131
            throw new StaleObjectException('The object being updated is outdated.');
1132 5
        }
1133
1134 5
        if (isset($values[$lock])) {
1135 5
            $this->$lock = $values[$lock];
1136 5
        }
1137
1138
        $changedAttributes = [];
1139
1140 5
        foreach ($values as $name => $value) {
1141
            $changedAttributes[$name] = $this->oldAttributes[$name] ?? null;
1142
            $this->oldAttributes[$name] = $value;
1143
        }
1144
1145
        return $rows;
1146
    }
1147
1148
    private function bindModels(
1149
        array $link,
1150
        ActiveRecordInterface $foreignModel,
1151
        ActiveRecordInterface $primaryModel
1152
    ): void {
1153
        foreach ($link as $fk => $pk) {
1154
            $value = $primaryModel->$pk;
1155
1156 22
            if ($value === null) {
1157
                throw new InvalidCallException(
1158 22
                    'Unable to link active record: the primary key of ' . $primaryModel::class . ' is null.'
1159
                );
1160 22
            }
1161 8
1162
            /** relation via array valued attribute */
1163 4
            if (is_array($foreignModel->$fk)) {
1164 4
                $foreignModel->{$fk}[] = $value;
1165 4
            } else {
1166
                $foreignModel->{$fk} = $value;
1167 4
            }
1168 4
        }
1169 4
1170
        $foreignModel->save();
1171
    }
1172 8
1173 8
    /**
1174
     * Resets dependent related models checking if their links contain specific attribute.
1175 8
     *
1176 8
     * @param string $attribute The changed attribute name.
1177 8
     */
1178
    private function resetDependentRelations(string $attribute): void
1179
    {
1180 8
        foreach ($this->relationsDependencies[$attribute] as $relation) {
1181
            unset($this->related[$relation]);
1182
        }
1183
1184 8
        unset($this->relationsDependencies[$attribute]);
1185
    }
1186
1187
    public function getTableName(): string
1188 8
    {
1189
        $inflector = new Inflector();
1190 4
1191 4
        if ($this->tableName === '') {
1192
            $this->tableName = '{{%' . $inflector->pascalCaseToId(StringHelper::baseName(static::class), '_') . '}}';
1193 4
        }
1194
1195
        return $this->tableName;
1196
    }
1197
}
1198