Passed
Pull Request — master (#278)
by Sergei
02:46
created

BaseActiveRecord::isRelationPopulated()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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