Passed
Pull Request — master (#279)
by Sergei
02:43
created

BaseActiveRecord::resetDependentRelations()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

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