Passed
Pull Request — master (#273)
by
unknown
02:35
created

BaseActiveRecord::setRelationDependencies()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 23
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 7.0178

Importance

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

The PSR-2 coding standard requires that all properties in a class have their visibility explicitly declared. If you declare a property using

class A {
    var $property;
}

the property is implicitly global.

To learn more about the PSR-2, please see the PHP-FIG site on the PSR-2.

Loading history...
1278
1279
        foreach ($this->resolveFields($fields, $expand) as $field => $definition) {
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
Coding Style introduced by
The visibility should be declared for property $this.

The PSR-2 coding standard requires that all properties in a class have their visibility explicitly declared. If you declare a property using

class A {
    var $property;
}

the property is implicitly global.

To learn more about the PSR-2, please see the PHP-FIG site on the PSR-2.

Loading history...
1280 27
            $attribute = $definition instanceof Closure ? $definition($this, $field) : $this[$definition];
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
1281
1282
            if ($recursive) {
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
1283 4
                $nestedFields = $this->extractFieldsFor($fields, $field);
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
1284
                $nestedExpand = $this->extractFieldsFor($expand, $field);
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
1285 4
                if ($attribute instanceof ArrayableInterface) {
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
1286
                    $attribute = $attribute->toArray($nestedFields, $nestedExpand);
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
1287 4
                } elseif (is_array($attribute) && ($nestedExpand || $nestedFields)) {
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
1288
                    $attribute = $this->filterAndExpand($attribute, $nestedFields, $nestedExpand);
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
1289
                }
1290 4
            }
1291
            $data[$field] = $attribute;
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
1292 4
        }
1293
1294 4
        return $recursive ? ArrayHelper::toArray($data) : $data;
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
Coding Style introduced by
The visibility should be declared for property $recursive.

The PSR-2 coding standard requires that all properties in a class have their visibility explicitly declared. If you declare a property using

class A {
    var $property;
}

the property is implicitly global.

To learn more about the PSR-2, please see the PHP-FIG site on the PSR-2.

Loading history...
1295
    }
1296
}
1297