Passed
Pull Request — master (#201)
by
unknown
26:59 queued 23:43
created

BaseActiveRecord   F

Complexity

Total Complexity 169

Size/Duplication

Total Lines 1071
Duplicated Lines 0 %

Test Coverage

Coverage 89.93%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 361
dl 0
loc 1071
ccs 250
cts 278
cp 0.8993
rs 2
c 2
b 0
f 0
wmc 169

44 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 2 1
A delete() 0 22 4
A createRelationQuery() 0 3 1
B updateInternal() 0 38 7
D link() 0 86 21
A update() 0 3 1
A hasAttribute() 0 3 2
C unlinkAll() 0 82 16
A getAttribute() 0 3 1
A hasOne() 0 3 1
A updateAttributes() 0 26 6
A populateRecord() 0 15 4
A setIsNewRecord() 0 3 2
A getPrimaryKey() 0 15 4
A hasMany() 0 3 1
A optimisticLock() 0 3 1
A updateCounters() 0 17 4
A populateRelation() 0 7 2
A getAttributes() 0 17 4
A getOldAttribute() 0 3 1
D unlink() 0 83 21
A getRelatedRecords() 0 3 1
A getOldAttributes() 0 3 1
B setRelationDependencies() 0 19 7
A refreshInternal() 0 15 4
B getDirtyAttributes() 0 27 9
A isPrimaryKey() 0 9 2
A setAttributes() 0 5 3
A isAttributeChanged() 0 11 4
A save() 0 7 2
A equals() 0 7 4
A getIsNewRecord() 0 3 1
A markAttributeDirty() 0 3 1
A setOldAttribute() 0 6 3
A extraFields() 0 5 1
A setOldAttributes() 0 3 1
A isRelationPopulated() 0 3 1
A resetDependentRelations() 0 7 2
A setAttribute() 0 12 5
A refresh() 0 5 1
A fields() 0 5 1
A bindModels() 0 23 4
A getOldPrimaryKey() 0 22 5
A instantiateQuery() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like BaseActiveRecord often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use BaseActiveRecord, and based on these observations, apply Extract Interface, too.

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\NotSupportedException;
17
use Yiisoft\Db\Exception\StaleObjectException;
18
19
use function array_combine;
20
use function array_flip;
21
use function array_intersect;
22
use function array_key_exists;
23
use function array_keys;
24
use function array_search;
25
use function array_values;
26
use function count;
27
use function in_array;
28
use function is_array;
29
use function is_int;
30
use function reset;
31
32
/**
33
 * ActiveRecord is the base class for classes representing relational data in terms of objects.
34
 *
35
 * See {@see ActiveRecord} for a concrete implementation.
36
 *
37
 * @property array $dirtyAttributes The changed attribute values (name-value pairs). This property is read-only.
38
 * @property bool $isNewRecord Whether the record is new and should be inserted when calling {@see save()}.
39
 * @property array $oldAttributes The old attribute values (name-value pairs). Note that the type of this property
40
 * differs in getter and setter. See {@see getOldAttributes()} and {@see setOldAttributes()} for details.
41
 * @property mixed $oldPrimaryKey The old primary key value. An array (column name => column value) is returned if the
42
 * primary key is composite. A string is returned otherwise (null will be returned if the key value is null).
43
 * This property is read-only.
44
 * @property mixed $primaryKey The primary key value. An array (column name => column value) is returned if the primary
45
 * key is composite. A string is returned otherwise (null will be returned if the key value is null).
46
 * This property is read-only.
47
 * @property array $relatedRecords An array of related records indexed by relation names. This property is read-only.
48
 */
49
abstract class BaseActiveRecord implements ActiveRecordInterface, IteratorAggregate, ArrayAccess
50
{
51
    use BaseActiveRecordTrait;
52
53
    private array $attributes = [];
54
    private array|null $oldAttributes = null;
55
    private array $related = [];
56
    private array $relationsDependencies = [];
57
58
    public function __construct(protected ConnectionInterface $db, private ActiveRecordFactory|null $arFactory = null)
59
    {
60
    }
61
62
    public function delete(): false|int
63 719
    {
64
        /**
65 719
         * We do not check the return value of deleteAll() because it's possible the record is already deleted in
66 719
         * the database and thus the method will return 0
67 719
         */
68
        $condition = $this->getOldPrimaryKey(true);
69
        $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...
70
71
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
72
            $condition[$lock] = $this->$lock;
73
        }
74
75
        $result = $this->deleteAll($condition);
76
77
        if ($lock !== null && !$result) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
78
            throw new StaleObjectException('The object being deleted is outdated.');
79
        }
80
81
        $this->oldAttributes = null;
82
83
        return $result;
84
    }
85
86
    public function equals(ActiveRecordInterface $record): bool
87
    {
88 1
        if ($this->getIsNewRecord() || ($record->getIsNewRecord())) {
89
            return false;
90 1
        }
91
92
        return static::tableName() === $record::tableName() && $this->getPrimaryKey() === $record->getPrimaryKey();
93
    }
94
95
    /**
96
     * @return array The default implementation returns the names of the relations that have been populated into this
97
     * record.
98
     */
99
    public function extraFields(): array
100
    {
101
        $fields = array_keys($this->getRelatedRecords());
102
103
        return array_combine($fields, $fields);
104
    }
105
106
    /**
107
     * @return array The default implementation returns the names of the columns whose values have been populated into
108
     * this record.
109
     */
110
    public function fields(): array
111 1
    {
112
        $fields = array_keys($this->attributes);
113 1
114
        return array_combine($fields, $fields);
115
    }
116
117
    public function getAttribute(string $name): mixed
118
    {
119
        return $this->attributes[$name] ?? null;
120
    }
121
122
    /**
123
     * Returns attribute values.
124
     *
125
     * @param array|null $names List of attributes whose value needs to be returned. Defaults to null, meaning all
126
     * attributes listed in {@see attributes()} will be returned. If it is an array, only the attributes in the array
127
     * will be returned.
128
     * @param array $except List of attributes whose value should NOT be returned.
129
     *
130
     * @return array Attribute values (name => value).
131
     */
132
    public function getAttributes(array $names = null, array $except = []): array
133
    {
134
        $values = [];
135
136 1
        if ($names === null) {
137
            $names = $this->attributes();
138 1
        }
139
140
        foreach ($names as $name) {
141
            $values[$name] = $this->$name;
142
        }
143
144
        foreach ($except as $name) {
145
            unset($values[$name]);
146
        }
147
148
        return $values;
149
    }
150
151
    public function getIsNewRecord(): bool
152
    {
153
        return $this->oldAttributes === null;
154
    }
155
156
    /**
157
     * Returns the old value of the named attribute.
158
     *
159
     * If this record is the result of a query and the attribute is not loaded, `null` will be returned.
160
     *
161
     * @param string $name The attribute name.
162 41
     *
163
     * @return mixed the old attribute value. `null` if the attribute is not loaded before or does not exist.
164 41
     *
165
     * {@see hasAttribute()}
166
     */
167
    public function getOldAttribute(string $name): mixed
168
    {
169
        return $this->oldAttributes[$name] ?? null;
170
    }
171
172
    /**
173
     * Returns the attribute values that have been modified since they are loaded or saved most recently.
174
     *
175
     * The comparison of new and old values is made for identical values using `===`.
176
     *
177
     * @param array|null $names The names of the attributes whose values may be returned if they are changed recently.
178
     * If null, {@see attributes()} will be used.
179
     *
180
     * @return array The changed attribute values (name-value pairs).
181
     */
182
    public function getDirtyAttributes(array $names = null): array
183
    {
184
        if ($names === null) {
185
            $names = $this->attributes();
186
        }
187
188
        $names = array_flip($names);
189
        $attributes = [];
190
191
        if ($this->oldAttributes === null) {
192
            foreach ($this->attributes as $name => $value) {
193
                if (isset($names[$name])) {
194
                    $attributes[$name] = $value;
195
                }
196
            }
197
        } else {
198 116
            foreach ($this->attributes as $name => $value) {
199
                if (
200 116
                    isset($names[$name])
201
                    && (!array_key_exists($name, $this->oldAttributes) || $value !== $this->oldAttributes[$name])
202
                ) {
203
                    $attributes[$name] = $value;
204
                }
205
            }
206
        }
207
208
        return $attributes;
209
    }
210
211
    /**
212
     * Returns the old attribute values.
213
     *
214
     * @return array The old attribute values (name-value pairs).
215
     */
216
    public function getOldAttributes(): array
217
    {
218
        return $this->oldAttributes ?? [];
219
    }
220
221
    public function getOldPrimaryKey(bool $asArray = false): mixed
222
    {
223
        $keys = $this->primaryKey();
224
225
        if (empty($keys)) {
226
            throw new Exception(
227
                static::class . ' does not have a primary key. You should either define a primary key for '
228
                . 'the corresponding table or override the primaryKey() method.'
229
            );
230
        }
231
232
        if (!$asArray && count($keys) === 1) {
233
            return $this->oldAttributes[$keys[0]] ?? null;
234 202
        }
235
236 202
        $values = [];
237
238
        foreach ($keys as $name) {
239
            $values[$name] = $this->oldAttributes[$name] ?? null;
240
        }
241
242
        return $values;
243
    }
244
245
    public function getPrimaryKey(bool $asArray = false): mixed
246
    {
247
        $keys = $this->primaryKey();
248
249
        if (!$asArray && count($keys) === 1) {
250
            return $this->attributes[$keys[0]] ?? null;
251 248
        }
252
253 248
        $values = [];
254
255
        foreach ($keys as $name) {
256
            $values[$name] = $this->attributes[$name] ?? null;
257
        }
258
259
        return $values;
260
    }
261
262
    /**
263
     * Returns all populated related records.
264
     *
265
     * @return array An array of related records indexed by relation names.
266
     *
267
     * {@see getRelation()}
268
     */
269 162
    public function getRelatedRecords(): array
270
    {
271 162
        return $this->related;
272 19
    }
273
274
    public function hasAttribute($name): bool
275 162
    {
276 162
        return isset($this->attributes[$name]) || in_array($name, $this->attributes(), true);
277
    }
278
279
    /**
280
     * Declares a `has-many` relation.
281
     *
282
     * The declaration is returned in terms of a relational {@see ActiveQuery} instance  through which the related
283
     * record can be queried and retrieved back.
284
     *
285
     * A `has-many` relation means that there are multiple related records matching the criteria set by this relation,
286
     * e.g., a customer has many orders.
287
     *
288 103
     * For example, to declare the `orders` relation for `Customer` class, we can write the following code in the
289
     * `Customer` class:
290 103
     *
291
     * ```php
292
     * public function getOrders()
293
     * {
294
     *     return $this->hasMany(Order::className(), ['customer_id' => 'id']);
295
     * }
296
     * ```
297
     *
298
     * Note that in the above, the 'customer_id' key in the `$link` parameter refers to an attribute name in the related
299
     * class `Order`, while the 'id' value refers to an attribute name in the current AR class.
300 14
     *
301
     * Call methods declared in {@see ActiveQuery} to further customize the relation.
302 14
     *
303
     * @param string $class The class name of the related record
304
     * @param array $link The primary-foreign key constraint. The keys of the array refer to the attributes of the
305
     * record associated with the `$class` model, while the values of the array refer to the corresponding attributes in
306
     * **this** AR class.
307
     *
308
     * @return ActiveQueryInterface The relational query object.
309
     */
310
    public function hasMany(string $class, array $link): ActiveQueryInterface
311
    {
312 386
        return $this->createRelationQuery($class, $link, true);
313
    }
314 386
315
    /**
316
     * Declares a `has-one` relation.
317
     *
318
     * The declaration is returned in terms of a relational {@see ActiveQuery} instance through which the related record
319
     * can be queried and retrieved back.
320
     *
321
     * A `has-one` relation means that there is at most one related record matching the criteria set by this relation,
322
     * e.g., a customer has one country.
323
     *
324
     * For example, to declare the `country` relation for `Customer` class, we can write the following code in the
325
     * `Customer` class:
326
     *
327
     * ```php
328 116
     * public function getCountry()
329
     * {
330 116
     *     return $this->hasOne(Country::className(), ['id' => 'country_id']);
331
     * }
332
     * ```
333
     *
334
     * Note that in the above, the 'id' key in the `$link` parameter refers to an attribute name in the related class
335
     * `Country`, while the 'country_id' value refers to an attribute name in the current AR class.
336
     *
337
     * Call methods declared in {@see ActiveQuery} to further customize the relation.
338
     *
339
     * @param string $class The class name of the related record.
340
     * @param array $link The primary-foreign key constraint. The keys of the array refer to the attributes of the
341
     * record associated with the `$class` model, while the values of the array refer to the corresponding attributes in
342
     * **this** AR class.
343
     *
344
     * @return ActiveQueryInterface The relational query object.
345 154
     */
346
    public function hasOne(string $class, array $link): ActiveQueryInterface
347 154
    {
348
        return $this->createRelationQuery($class, $link, false);
349 150
    }
350 150
351
    public function instantiateQuery(string $arClass): ActiveQueryInterface
352 10
    {
353
        return new ActiveQuery($arClass, $this->db, $this->arFactory);
354 150
    }
355
356 4
    /**
357
     * Returns a value indicating whether the named attribute has been changed.
358 150
     *
359
     * @param string $name The name of the attribute.
360
     * @param bool $identical Whether the comparison of new and old value is made for identical values using `===`,
361
     * defaults to `true`. Otherwise `==` is used for comparison.
362
     *
363
     * @return bool Whether the attribute has been changed.
364
     */
365 8
    public function isAttributeChanged(string $name, bool $identical = true): bool
366
    {
367 8
        if (isset($this->attributes[$name], $this->oldAttributes[$name])) {
368
            if ($identical) {
369
                return $this->attributes[$name] !== $this->oldAttributes[$name];
370
            }
371
372
            return $this->attributes[$name] !== $this->oldAttributes[$name];
373
        }
374
375
        return isset($this->attributes[$name]) || isset($this->oldAttributes[$name]);
376
    }
377
378 137
    public function isPrimaryKey(array $keys): bool
379
    {
380 137
        $pks = $this->primaryKey();
381 137
382
        if (count($keys) === count($pks)) {
383
            return count(array_intersect($keys, $pks)) === count($pks);
384
        }
385
386
        return false;
387
    }
388
389
    public function isRelationPopulated(string $name): bool
390
    {
391
        return array_key_exists($name, $this->related);
392
    }
393
394 24
    public function link(string $name, ActiveRecordInterface $arClass, array $extraColumns = []): void
395
    {
396 24
        $viaClass = null;
397
        $viaTable = null;
398
        $relation = $this->getRelation($name);
399
        $via = $relation->getVia();
400
401
        if ($via !== null) {
402
            if ($this->getIsNewRecord() || $arClass->getIsNewRecord()) {
403
                throw new InvalidCallException(
404
                    'Unable to link models: the models being linked cannot be newly created.'
405
                );
406
            }
407
408
            if (is_array($via)) {
0 ignored issues
show
introduced by
The condition is_array($via) is always false.
Loading history...
409 8
                [$viaName, $viaRelation] = $via;
410
                $viaClass = $viaRelation->getARInstance();
411 8
                /** unset $viaName so that it can be reloaded to reflect the change */
412 4
                unset($this->related[$viaName]);
413
            } else {
414 4
                $viaRelation = $via;
415
                $from = $via->getFrom();
416 4
                $viaTable = reset($from);
417
            }
418
419
            $columns = [];
420
421
            foreach ($viaRelation->getLink() as $a => $b) {
422
                $columns[$a] = $this->$b;
423
            }
424
425
            foreach ($relation->getLink() as $a => $b) {
426 6
                $columns[$b] = $arClass->$a;
427
            }
428 6
429 6
            foreach ($extraColumns as $k => $v) {
430
                $columns[$k] = $v;
431
            }
432
433
            if (is_array($via)) {
434
                foreach ($columns as $column => $value) {
435
                    $viaClass->$column = $value;
436
                }
437
438
                $viaClass->insert();
439
            } else {
440 12
                $this->db->createCommand()->insert($viaTable, $columns)->execute();
441
            }
442 12
        } else {
443 8
            $p1 = $arClass->isPrimaryKey(array_keys($relation->getLink()));
444 4
            $p2 = $this->isPrimaryKey(array_values($relation->getLink()));
445
446
            if ($p1 && $p2) {
447 4
                if ($this->getIsNewRecord() && $arClass->getIsNewRecord()) {
448
                    throw new InvalidCallException('Unable to link models: at most one model can be newly created.');
449
                }
450 4
451
                if ($this->getIsNewRecord()) {
452
                    $this->bindModels(array_flip($relation->getLink()), $this, $arClass);
453
                } else {
454
                    $this->bindModels($relation->getLink(), $arClass, $this);
455
                }
456
            } elseif ($p1) {
457
                $this->bindModels(array_flip($relation->getLink()), $this, $arClass);
458
            } elseif ($p2) {
459
                $this->bindModels($relation->getLink(), $arClass, $this);
460
            } else {
461
                throw new InvalidCallException(
462
                    'Unable to link models: the link defining the relation does not involve any primary key.'
463 149
                );
464
            }
465 149
        }
466 145
467
        /** update lazily loaded related objects */
468
        if (!$relation->getMultiple()) {
469 149
            $this->related[$name] = $arClass;
470 149
        } elseif (isset($this->related[$name])) {
471
            if ($relation->getIndexBy() !== null) {
472 149
                if ($relation->getIndexBy() instanceof Closure) {
473 133
                    $index = $relation->indexBy($arClass::class);
474 129
                } else {
475 129
                    $index = $arClass->{$relation->getIndexBy()};
476
                }
477
                $this->related[$name][$index] = $arClass;
478
            } else {
479 45
                $this->related[$name][] = $arClass;
480
            }
481 45
        }
482 45
    }
483
484 45
    /**
485
     * Marks an attribute dirty.
486
     *
487
     * This method may be called to force updating a record when calling {@see update()}, even if there is no change
488
     * being made to the record.
489 149
     *
490
     * @param string $name The attribute name.
491
     */
492
    public function markAttributeDirty(string $name): void
493
    {
494
        unset($this->oldAttributes[$name]);
495
    }
496
497
    /**
498
     * Returns the name of the column that stores the lock version for implementing optimistic locking.
499
     *
500
     * Optimistic locking allows multiple users to access the same record for edits and avoids potential conflicts. In
501
     * case when a user attempts to save the record upon some staled data (because another user has modified the data),
502
     * a {@see StaleObjectException} exception will be thrown, and the update or deletion is skipped.
503
     *
504
     * Optimistic locking is only supported by {@see update()} and {@see delete()}.
505
     *
506
     * To use Optimistic locking:
507
     *
508
     * 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`.
509
     *    Override this method to return the name of this column.
510
     * 2. In the Web form that collects the user input, add a hidden field that stores the lock version of the recording
511
     *    being updated.
512
     * 3. In the controller action that does the data updating, try to catch the {@see StaleObjectException} and
513
     *    implement necessary business logic (e.g. merging the changes, prompting stated data) to resolve the conflict.
514 141
     *
515
     * @return string|null The column name that stores the lock version of a table row. If `null` is returned (default
516 141
     * implemented), optimistic locking will not be supported.
517 125
     */
518
    public function optimisticLock(): string|null
519
    {
520 30
        return null;
521
    }
522
523
    /**
524
     * Populates an active record object using a row of data from the database/storage.
525
     *
526
     * This is an internal method meant to be called to create active record objects after fetching data from the
527
     * database. It is mainly used by {@see ActiveQuery} to populate the query results into active records.
528
     *
529
     * @param array|object $row Attribute values (name => value).
530
     */
531
    public function populateRecord(array|object $row): void
532
    {
533
        $columns = array_flip($this->attributes());
534
535
        foreach ($row as $name => $value) {
536
            if (isset($columns[$name])) {
537
                $this->attributes[$name] = $value;
538
            } elseif ($this->canSetProperty($name)) {
539
                $this->$name = $value;
540
            }
541
        }
542
543
        $this->oldAttributes = $this->attributes;
544
        $this->related = [];
545
        $this->relationsDependencies = [];
546
    }
547
548
    public function populateRelation(string $name, array|ActiveRecordInterface|null $records): void
549
    {
550
        foreach ($this->relationsDependencies as &$relationNames) {
551
            unset($relationNames[$name]);
552
        }
553
554
        $this->related[$name] = $records;
555
    }
556
557
    /**
558 6
     * Repopulates this active record with the latest data.
559
     *
560 6
     * @return bool Whether the row still exists in the database. If `true`, the latest data will be populated to this
561
     * active record. Otherwise, this record will remain unchanged.
562
     */
563
    public function refresh(): bool
564
    {
565
        $record = $this->instantiateQuery(static::class)->findOne($this->getPrimaryKey(true));
566
567
        return $this->refreshInternal($record);
568
    }
569
570
    /**
571
     * Saves the current record.
572
     *
573
     * This method will call {@see insert()} when {@see isNewRecord} is `true`, or {@see update()} when
574
     * {@see isNewRecord} is `false`.
575
     *
576
     * For example, to save a customer record:
577
     *
578
     * ```php
579
     * $customer = new Customer($db);
580
     * $customer->name = $name;
581
     * $customer->email = $email;
582 5
     * $customer->save();
583
     * ```
584 5
     *
585
     * @param array|null $attributeNames List of attribute names that need to be saved. Defaults to null, meaning all
586 5
     * attributes that are loaded from DB will be saved.
587 5
     *
588 1
     * @throws Exception|StaleObjectException
589
     *
590 5
     * @return bool Whether the saving succeeded (i.e. no validation errors occurred).
591 5
     */
592
    public function save(array $attributeNames = null): bool
593
    {
594
        if ($this->getIsNewRecord()) {
595 5
            return $this->insert($attributeNames);
596
        }
597 5
598 4
        return $this->update($attributeNames) !== false;
599
    }
600
601 5
    public function setAttribute(string $name, mixed $value): void
602
    {
603 5
        if ($this->hasAttribute($name)) {
604 5
            if (
605
                !empty($this->relationsDependencies[$name])
606
                && (!array_key_exists($name, $this->attributes) || $this->attributes[$name] !== $value)
607 5
            ) {
608
                $this->resetDependentRelations($name);
609
            }
610
            $this->attributes[$name] = $value;
611
        } else {
612
            throw new InvalidArgumentException(static::class . ' has no attribute named "' . $name . '".');
613
        }
614
    }
615
616
    /**
617
     * Sets the attribute values in a massive way.
618
     *
619 40
     * @param array $values Attribute values (name => value) to be assigned to the model.
620
     *
621 40
     * {@see attributes()}
622
     */
623 40
    public function setAttributes(array $values): void
624 4
    {
625
        foreach ($values as $name => $value) {
626
            if (in_array($name, $this->attributes(), true)) {
627 40
                $this->$name = $value;
628 40
            }
629
        }
630 40
    }
631 4
632 4
    /**
633
     * Sets the value indicating whether the record is new.
634
     *
635
     * @param bool $value whether the record is new and should be inserted when calling {@see save()}.
636
     *
637
     * @see getIsNewRecord()
638
     */
639 40
    public function setIsNewRecord(bool $value): void
640
    {
641 40
        $this->oldAttributes = $value ? null : $this->attributes;
642 4
    }
643
644
    /**
645 40
     * Sets the old value of the named attribute.
646 4
     *
647
     * @param string $name The attribute name.
648
     * @param mixed $value The old attribute value.
649 40
     *
650
     * @throws InvalidArgumentException If the named attribute does not exist.
651 40
     *
652 40
     * {@see hasAttribute()}
653 40
     */
654
    public function setOldAttribute(string $name, mixed $value): void
655
    {
656 40
        if (isset($this->oldAttributes[$name]) || $this->hasAttribute($name)) {
657
            $this->oldAttributes[$name] = $value;
658
        } else {
659
            throw new InvalidArgumentException(static::class . ' has no attribute named "' . $name . '".');
660
        }
661
    }
662
663
    /**
664
     * Sets the old attribute values.
665
     *
666
     * All existing old attribute values will be discarded.
667
     *
668
     * @param array|null $values Old attribute values to be set. If set to `null` this record is considered to be
669
     * {@see isNewRecord|new}.
670
     */
671
    public function setOldAttributes(array $values = null): void
672
    {
673
        $this->oldAttributes = $values;
674
    }
675
676
    public function update(array $attributeNames = null): false|int
677
    {
678
        return $this->updateInternal($attributeNames);
679
    }
680
681 9
    /**
682
     * Updates the specified attributes.
683 9
     *
684 9
     * This method is a shortcut to {@see update()} when data validation is not needed and only a small set attributes
685 9
     * need to be updated.
686 4
     *
687
     * You may specify the attributes to be updated as name list or name-value pairs. If the latter, the corresponding
688 5
     * attribute values will be modified accordingly.
689
     *
690
     * The method will then save the specified attributes into database.
691 9
     *
692
     * Note that this method will **not** perform data validation and will **not** trigger events.
693
     *
694 9
     * @param array $attributes The attributes (names or name-value pairs) to be updated.
695
     *
696
     * @throws Exception
697
     * @throws NotSupportedException
698
     *
699
     * @return int The number of rows affected.
700
     */
701
    public function updateAttributes(array $attributes): int
702
    {
703
        $attrs = [];
704
705
        foreach ($attributes as $name => $value) {
706
            if (is_int($name)) {
707
                $attrs[] = $value;
708
            } else {
709
                $this->$name = $value;
710
                $attrs[] = $name;
711
            }
712 2
        }
713
714
        $values = $this->getDirtyAttributes($attrs);
715
716
        if (empty($values) || $this->getIsNewRecord()) {
717
            return 0;
718 2
        }
719 2
720
        $rows = $this->updateAll($values, $this->getOldPrimaryKey(true));
721 2
722
        foreach ($values as $name => $value) {
723
            $this->oldAttributes[$name] = $this->attributes[$name];
724
        }
725 2
726
        return $rows;
727 2
    }
728
729
    /**
730
     * Updates one or several counter columns for the current AR object.
731 2
     *
732
     * Note that this method differs from {@see updateAllCounters()} in that it only saves counters for the current AR
733 2
     * object.
734
     *
735
     * An example usage is as follows:
736
     *
737
     * ```php
738
     * $post = new Post($db);
739
     * $post->updateCounters(['view_count' => 1]);
740
     * ```
741 153
     *
742
     * @param array $counters The counters to be updated (attribute name => increment value), use negative values if you
743 153
     * want to decrement the counters.
744
     *
745
     * @throws Exception
746
     * @throws NotSupportedException
747
     *
748
     * @return bool Whether the saving is successful.
749
     *
750
     * {@see updateAllCounters()}
751
     */
752
    public function updateCounters(array $counters): bool
753
    {
754
        if ($this->updateAllCounters($counters, $this->getOldPrimaryKey(true)) > 0) {
0 ignored issues
show
Bug introduced by
The method updateAllCounters() does not exist on Yiisoft\ActiveRecord\BaseActiveRecord. Did you maybe mean update()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

754
        if ($this->/** @scrutinizer ignore-call */ updateAllCounters($counters, $this->getOldPrimaryKey(true)) > 0) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
755
            foreach ($counters as $name => $value) {
756
                if (!isset($this->attributes[$name])) {
757
                    $this->attributes[$name] = $value;
758
                } else {
759
                    $this->attributes[$name] += $value;
760
                }
761
762
                $this->oldAttributes[$name] = $this->attributes[$name];
763
            }
764 4
765
            return true;
766
        }
767 4
768
        return false;
769 4
    }
770
771
    public function unlink(string $name, ActiveRecordInterface $arClass, bool $delete = false): void
772
    {
773
        $viaClass = null;
774
        $viaTable = null;
775
        $relation = $this->getRelation($name);
776
777
        if ($relation->getVia() !== null) {
778
            if (is_array($relation->getVia())) {
0 ignored issues
show
introduced by
The condition is_array($relation->getVia()) is always false.
Loading history...
779
                [$viaName, $viaRelation] = $relation->getVia();
780
                $viaClass = $viaRelation->getARInstance();
781 32
                unset($this->related[$viaName]);
782
            } else {
783 32
                $viaRelation = $relation->getVia();
784 5
                $from = $relation->getVia()->getFrom();
785
                $viaTable = reset($from);
786
            }
787 32
788 32
            $columns = [];
789
            foreach ($viaRelation->getLink() as $a => $b) {
790
                $columns[$a] = $this->$b;
791 32
            }
792 32
793 32
            foreach ($relation->getLink() as $a => $b) {
794
                $columns[$b] = $arClass->$a;
795 32
            }
796
            $nulls = [];
797
798
            foreach (array_keys($columns) as $a) {
799
                $nulls[$a] = null;
800
            }
801
802
            if (is_array($relation->getVia())) {
0 ignored issues
show
introduced by
The condition is_array($relation->getVia()) is always false.
Loading history...
803
                if ($delete) {
804
                    $viaClass->deleteAll($columns);
805
                } else {
806
                    $viaClass->updateAll($nulls, $columns);
807
                }
808 2
            } else {
809
                $command = $this->db->createCommand();
810 2
                if ($delete) {
811 1
                    $command->delete($viaTable, $columns)->execute();
812
                } else {
813
                    $command->update($viaTable, $nulls, $columns)->execute();
814 1
                }
815
            }
816
        } else {
817
            $p1 = $arClass->isPrimaryKey(array_keys($relation->getLink()));
818
            $p2 = $this->isPrimaryKey(array_values($relation->getLink()));
819
            if ($p2) {
820
                if ($delete) {
821
                    $arClass->delete();
822
                } else {
823
                    foreach ($relation->getLink() as $a => $b) {
824
                        $arClass->$a = null;
825
                    }
826
                    $arClass->save();
827
                }
828
            } elseif ($p1) {
829
                foreach ($relation->getLink() as $a => $b) {
830 53
                    /** relation via array valued attribute */
831
                    if (is_array($this->$b)) {
832 53
                        if (($key = array_search($arClass->$a, $this->$b, false)) !== false) {
833
                            $values = $this->$b;
834 53
                            unset($values[$key]);
835 21
                            $this->$b = array_values($values);
836
                        }
837
                    } else {
838 32
                        $this->$b = null;
839
                    }
840 32
                }
841 32
                $delete ? $this->delete() : $this->save();
842
            } else {
843
                throw new InvalidCallException('Unable to unlink models: the link does not involve any primary key.');
844 32
            }
845
        }
846
847
        if (!$relation->getMultiple()) {
848
            unset($this->related[$name]);
849
        } elseif (isset($this->related[$name])) {
850
            /** @var ActiveRecordInterface $b */
851
            foreach ($this->related[$name] as $a => $b) {
852
                if ($arClass->getPrimaryKey() === $b->getPrimaryKey()) {
853
                    unset($this->related[$name][$a]);
854
                }
855
            }
856
        }
857
    }
858
859
    /**
860
     * Destroys the relationship in current model.
861
     *
862
     * The active record with the foreign key of the relationship will be deleted if `$delete` is `true`. Otherwise, the
863
     * foreign key will be set `null` and the model will be saved without validation.
864
     *
865
     * Note that to destroy the relationship without removing records make sure your keys can be set to null.
866
     *
867 60
     * @param string $name The case sensitive name of the relationship, e.g. `orders` for a relation defined via
868
     * `getOrders()` method.
869 60
     * @param bool $delete Whether to delete the model that contains the foreign key.
870
     *
871 60
     * @throws Exception
872 1
     * @throws ReflectionException
873 1
     * @throws StaleObjectException
874 1
     * @throws Throwable
875
     */
876
    public function unlinkAll(string $name, bool $delete = false): void
877
    {
878 59
        $viaClass = null;
879
        $viaTable = null;
880
        $relation = $this->getRelation($name);
881
882 59
        if ($relation->getVia() !== null) {
883
            if (is_array($relation->getVia())) {
0 ignored issues
show
introduced by
The condition is_array($relation->getVia()) is always false.
Loading history...
884 59
                /* @var $viaRelation ActiveQuery */
885 59
                [$viaName, $viaRelation] = $relation->getVia();
886
                $viaClass = $viaRelation->getARInstance();
887
                unset($this->related[$viaName]);
888 59
            } else {
889
                $viaRelation = $relation->getVia();
890
                $from = $relation->getVia()->getFrom();
891
                $viaTable = reset($from);
892
            }
893
894
            $condition = [];
895
            $nulls = [];
896
897
            foreach ($viaRelation->getLink() as $a => $b) {
898
                $nulls[$a] = null;
899 445
                $condition[$a] = $this->$b;
900
            }
901 445
902
            if (!empty($viaRelation->getWhere())) {
903 445
                $condition = ['and', $condition, $viaRelation->getWhere()];
904 445
            }
905 445
906 8
            if (!empty($viaRelation->getOn())) {
907 8
                $condition = ['and', $condition, $viaRelation->getOn()];
908
            }
909
910
            if (is_array($relation->getVia())) {
0 ignored issues
show
introduced by
The condition is_array($relation->getVia()) is always false.
Loading history...
911 445
                if ($delete) {
912 445
                    $viaClass->deleteAll($condition);
913 445
                } else {
914 445
                    $viaClass->updateAll($nulls, $condition);
915
                }
916 287
            } else {
917
                $command = $this->db->createCommand();
918 287
                if ($delete) {
919 31
                    $command->delete($viaTable, $condition)->execute();
920
                } else {
921
                    $command->update($viaTable, $nulls, $condition)->execute();
922 256
                }
923
            }
924
        } else {
925
            $relatedModel = $relation->getARInstance();
926
927
            $link = $relation->getLink();
928
            if (!$delete && count($link) === 1 && is_array($this->{$b = reset($link)})) {
929
                /** relation via array valued attribute */
930
                $this->$b = [];
931
                $this->save();
932
            } else {
933
                $nulls = [];
934
                $condition = [];
935
936
                foreach ($relation->getLink() as $a => $b) {
937
                    $nulls[$a] = null;
938
                    $condition[$a] = $this->$b;
939
                }
940
941
                if (!empty($relation->getWhere())) {
942
                    $condition = ['and', $condition, $relation->getWhere()];
943
                }
944
945
                if (!empty($relation->getOn())) {
946
                    $condition = ['and', $condition, $relation->getOn()];
947
                }
948 9
949
                if ($delete) {
950 9
                    $relatedModel->deleteAll($condition);
951
                } else {
952 9
                    $relatedModel->updateAll($nulls, $condition);
953 5
                }
954
            }
955
        }
956
957
        unset($this->related[$name]);
958
    }
959 5
960
    /**
961 5
     * Sets relation dependencies for a property.
962 5
     *
963
     * @param string $name property name.
964 5
     * @param ActiveQuery $relation relation instance.
965
     * @param string|null $viaRelationName intermediate relation.
966
     */
967
    private function setRelationDependencies(
968
        string $name,
969
        ActiveQuery $relation,
970
        string $viaRelationName = null
971 5
    ): void {
972
        $via = $relation->getVia();
973 5
974 5
        if (empty($via) && $relation->getLink()) {
975
            foreach ($relation->getLink() as $attribute) {
976
                $this->relationsDependencies[$attribute][$name] = $name;
977 5
                if ($viaRelationName !== null) {
978 5
                    $this->relationsDependencies[$attribute][] = $viaRelationName;
979
                }
980
            }
981 5
        } elseif ($via instanceof ActiveQueryInterface) {
982 5
            $this->setRelationDependencies($name, $via);
983
        } elseif (is_array($via)) {
984
            [$viaRelationName, $viaQuery] = $via;
985 5
            $this->setRelationDependencies($name, $viaQuery, $viaRelationName);
986 5
        }
987 5
    }
988
989
    /**
990 5
     * Creates a query instance for `has-one` or `has-many` relation.
991
     *
992
     * @param string $arClass The class name of the related record.
993 5
     * @param array $link The primary-foreign key constraint.
994
     * @param bool $multiple Whether this query represents a relation to more than one record.
995
     *
996 9
     * @return ActiveQueryInterface The relational query object.
997 9
998
     * {@see hasOne()}
999 9
     * {@see hasMany()}
1000
     */
1001
    protected function createRelationQuery(string $arClass, array $link, bool $multiple): ActiveQueryInterface
1002
    {
1003
        return $this->instantiateQuery($arClass)->primaryModel($this)->link($link)->multiple($multiple);
1004
    }
1005
1006
    /**
1007
     * Repopulates this active record with the latest data from a newly fetched instance.
1008
     *
1009 9
     * @param ActiveRecord|array|null $record The record to take attributes from.
1010 5
     *
1011 4
     * @return bool Whether refresh was successful.
1012 4
     *
1013
     * {@see refresh()}
1014
     */
1015
    protected function refreshInternal(array|ActiveRecord $record = null): bool
1016
    {
1017
        if ($record === null || $record === []) {
1018
            return false;
1019
        }
1020
1021 9
        foreach ($this->attributes() as $name) {
1022 5
            $this->attributes[$name] = $record->attributes[$name] ?? null;
1023 9
        }
1024 9
1025 4
        $this->oldAttributes = $record->oldAttributes;
1026
        $this->related = [];
1027
        $this->relationsDependencies = [];
1028 4
1029
        return true;
1030 4
    }
1031
1032 5
    /**
1033
     * {@see update()}
1034
     *
1035 9
     * @param array|null $attributes Attributes to update.
1036
     *
1037
     * @throws Exception
1038
     * @throws NotSupportedException
1039
     * @throws StaleObjectException
1040
     *
1041
     * @return int The number of rows affected.
1042
     */
1043
    protected function updateInternal(array $attributes = null): int
1044
    {
1045
        $values = $this->getDirtyAttributes($attributes);
1046
1047
        if (empty($values)) {
1048
            return 0;
1049
        }
1050
1051
        $condition = $this->getOldPrimaryKey(true);
1052
        $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...
1053 5
1054
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
1055 5
            $values[$lock] = $this->$lock + 1;
1056
            $condition[$lock] = $this->$lock;
1057 5
        }
1058 5
1059
        /**
1060 5
         * We do not check the return value of updateAll() because it's possible that the UPDATE statement doesn't
1061 5
         * change anything and thus returns 0.
1062 5
         */
1063
        $rows = $this->updateAll($values, $condition);
1064 4
1065 4
        if ($lock !== null && !$rows) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
1066 4
            throw new StaleObjectException('The object being updated is outdated.');
1067
        }
1068
1069 5
        if (isset($values[$lock])) {
1070 5
            $this->$lock = $values[$lock];
1071 5
        }
1072
1073
        $changedAttributes = [];
1074 5
1075 5
        foreach ($values as $name => $value) {
1076
            $changedAttributes[$name] = $this->oldAttributes[$name] ?? null;
1077 5
            $this->oldAttributes[$name] = $value;
1078
        }
1079 5
1080 5
        return $rows;
1081
    }
1082
1083 5
    private function bindModels(
1084
        array $link,
1085 5
        ActiveRecordInterface $foreignModel,
1086 5
        ActiveRecordInterface $primaryModel
1087
    ): void {
1088 5
        foreach ($link as $fk => $pk) {
1089
            $value = $primaryModel->$pk;
1090
1091
            if ($value === null) {
1092 4
                throw new InvalidCallException(
1093 4
                    'Unable to link active record: the primary key of ' . $primaryModel::class . ' is null.'
1094
                );
1095
            }
1096 5
1097
            /** relation via array valued attribute */
1098
            if (is_array($foreignModel->$fk)) {
1099
                $foreignModel->{$fk}[] = $value;
1100 5
            } else {
1101 5
                $foreignModel->{$fk} = $value;
1102 5
            }
1103 5
        }
1104 5
1105
        $foreignModel->save();
1106 5
    }
1107 5
1108
    /**
1109 5
     * Resets dependent related models checking if their links contain specific attribute.
1110
     *
1111
     * @param string $attribute The changed attribute name.
1112
     */
1113
    private function resetDependentRelations(string $attribute): void
1114
    {
1115
        foreach ($this->relationsDependencies[$attribute] as $relation) {
1116
            unset($this->related[$relation]);
1117
        }
1118
1119
        unset($this->relationsDependencies[$attribute]);
1120
    }
1121
}
1122