Passed
Push — master ( c5a508...155a53 )
by Sergei
03:09
created

AbstractActiveRecord::populateAttribute()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord;
6
7
use Closure;
8
use ReflectionException;
9
use Throwable;
10
use Yiisoft\Db\Connection\ConnectionInterface;
11
use Yiisoft\Db\Exception\Exception;
12
use Yiisoft\Db\Exception\InvalidArgumentException;
13
use Yiisoft\Db\Exception\InvalidCallException;
14
use Yiisoft\Db\Exception\InvalidConfigException;
15
use Yiisoft\Db\Exception\NotSupportedException;
16
use Yiisoft\Db\Exception\StaleObjectException;
17
use Yiisoft\Db\Expression\Expression;
18
use Yiisoft\Db\Helper\DbStringHelper;
19
20
use function array_diff_key;
21
use function array_diff;
22
use function array_fill_keys;
23
use function array_flip;
24
use function array_intersect;
25
use function array_intersect_key;
26
use function array_key_exists;
27
use function array_keys;
28
use function array_merge;
29
use function array_search;
30
use function array_values;
31
use function count;
32
use function in_array;
33
use function is_array;
34
use function is_int;
35
use function reset;
36
37
/**
38
 * ActiveRecord is the base class for classes representing relational data in terms of objects.
39
 *
40
 * See {@see ActiveRecord} for a concrete implementation.
41
 */
42
abstract class AbstractActiveRecord implements ActiveRecordInterface
43
{
44
    private array|null $oldAttributes = null;
45
    private array $related = [];
46
    /** @psalm-var string[][] */
47
    private array $relationsDependencies = [];
48
49
    public function __construct(
50
        private ConnectionInterface $db,
51
        private ActiveRecordFactory|null $arFactory = null,
52
        private string $tableName = ''
53
    ) {
54
    }
55
56
    /**
57
     * Returns the public and protected property values of an Active Record object.
58
     *
59
     * This method is provided because a direct call of {@see get_object_vars()} within the {@see AbstractActiveRecord}
60
     * class will return also private property values of {@see AbstractActiveRecord} class.
61
     *
62
     * @param ActiveRecordInterface $object
63
     *
64
     * @return array
65
     * @link https://www.php.net/manual/en/function.get-object-vars.php
66
     *
67
     * @psalm-return array<string, mixed>
68
     */
69
    abstract protected function getObjectVars(ActiveRecordInterface $object): array;
70
71
    /**
72
     * Inserts Active Record values into DB without considering transaction.
73
     *
74
     * @param array|null $attributes List of attributes that need to be saved. Defaults to `null`, meaning all
75
     * attributes that are loaded from DB will be saved.
76
     *
77
     * @throws Exception
78
     * @throws InvalidArgumentException
79
     * @throws InvalidConfigException
80
     * @throws Throwable
81
     *
82
     * @return bool Whether the record is inserted successfully.
83
     */
84
    abstract protected function insertInternal(array $attributes = null): bool;
85
86
    /**
87
     * Sets the value of the named attribute.
88
     */
89
    abstract protected function populateAttribute(string $name, mixed $value): void;
90
91
    public function delete(): int
92
    {
93
        return $this->deleteInternal();
94
    }
95
96
    public function deleteAll(array $condition = [], array $params = []): int
97
    {
98
        $command = $this->db->createCommand();
99
        $command->delete($this->getTableName(), $condition, $params);
100
101
        return $command->execute();
102
    }
103
104
    public function equals(ActiveRecordInterface $record): bool
105
    {
106
        if ($this->getIsNewRecord() || $record->getIsNewRecord()) {
107
            return false;
108
        }
109
110
        return $this->getTableName() === $record->getTableName() && $this->getPrimaryKey() === $record->getPrimaryKey();
111
    }
112
113
    public function getAttribute(string $name): mixed
114
    {
115
        return $this->getObjectVars($this)[$name] ?? null;
116
    }
117
118
    public function getAttributes(array $names = null, array $except = []): array
119
    {
120
        $names ??= $this->attributes();
121
122
        if ($except !== []) {
123
            $names = array_diff($names, $except);
124
        }
125
126
        return array_intersect_key($this->getObjectVars($this), array_flip($names));
127
    }
128
129
    public function getIsNewRecord(): bool
130
    {
131
        return $this->oldAttributes === null;
132
    }
133
134
    /**
135
     * Returns the old value of the named attribute.
136
     *
137
     * If this record is the result of a query and the attribute is not loaded, `null` will be returned.
138
     *
139
     * @param string $name The attribute name.
140
     *
141
     * @return mixed the old attribute value. `null` if the attribute is not loaded before or does not exist.
142
     *
143
     * {@see hasAttribute()}
144
     */
145
    public function getOldAttribute(string $name): mixed
146
    {
147
        return $this->oldAttributes[$name] ?? null;
148
    }
149
150
    /**
151
     * Returns the attribute values that have been modified since they are loaded or saved most recently.
152
     *
153
     * The comparison of new and old values is made for identical values using `===`.
154
     *
155
     * @param array|null $names The names of the attributes whose values may be returned if they are changed recently.
156
     * If null, {@see attributes()} will be used.
157
     *
158
     * @return array The changed attribute values (name-value pairs).
159
     */
160
    public function getDirtyAttributes(array $names = null): array
161
    {
162
        $attributes = $this->getAttributes($names);
163
164
        if ($this->oldAttributes === null) {
165
            return $attributes;
166
        }
167
168
        $result = array_diff_key($attributes, $this->oldAttributes);
169
170
        foreach (array_diff_key($attributes, $result) as $name => $value) {
171
            if ($value !== $this->oldAttributes[$name]) {
172
                $result[$name] = $value;
173
            }
174
        }
175
176
        return $result;
177
    }
178
179
    public function getOldAttributes(): array
180
    {
181
        return $this->oldAttributes ?? [];
182
    }
183
184
    /**
185
     * @throws InvalidConfigException
186
     * @throws Exception
187
     */
188
    public function getOldPrimaryKey(bool $asArray = false): mixed
189
    {
190
        $keys = $this->primaryKey();
191
192
        if (empty($keys)) {
193
            throw new Exception(
194
                static::class . ' does not have a primary key. You should either define a primary key for '
195
                . 'the corresponding table or override the primaryKey() method.'
196
            );
197
        }
198
199
        if (count($keys) === 1) {
200
            $key = $this->oldAttributes[$keys[0]] ?? null;
201
202
            return $asArray ? [$keys[0] => $key] : $key;
203
        }
204
205
        $values = [];
206
207
        foreach ($keys as $name) {
208
            $values[$name] = $this->oldAttributes[$name] ?? null;
209
        }
210
211
        return $values;
212
    }
213
214
    public function getPrimaryKey(bool $asArray = false): mixed
215
    {
216
        $keys = $this->primaryKey();
217
218
        if (count($keys) === 1) {
219
            return $asArray ? [$keys[0] => $this->getAttribute($keys[0])] : $this->getAttribute($keys[0]);
220
        }
221
222
        $values = [];
223
224
        foreach ($keys as $name) {
225
            $values[$name] = $this->getAttribute($name);
226
        }
227
228
        return $values;
229
    }
230
231
    /**
232
     * Returns all populated related records.
233
     *
234
     * @return array An array of related records indexed by relation names.
235
     *
236
     * {@see relationQuery()}
237
     */
238
    public function getRelatedRecords(): array
239
    {
240
        return $this->related;
241
    }
242
243
    public function hasAttribute(string $name): bool
244
    {
245
        return in_array($name, $this->attributes(), true);
246
    }
247
248
    /**
249
     * Declares a `has-many` relation.
250
     *
251
     * The declaration is returned in terms of a relational {@see ActiveQuery} instance  through which the related
252
     * record can be queried and retrieved back.
253
     *
254
     * A `has-many` relation means that there are multiple related records matching the criteria set by this relation,
255
     * e.g., a customer has many orders.
256
     *
257
     * For example, to declare the `orders` relation for `Customer` class, we can write the following code in the
258
     * `Customer` class:
259
     *
260
     * ```php
261
     * public function getOrdersQuery()
262
     * {
263
     *     return $this->hasMany(Order::className(), ['customer_id' => 'id']);
264
     * }
265
     * ```
266
     *
267
     * Note that in the above, the 'customer_id' key in the `$link` parameter refers to an attribute name in the related
268
     * class `Order`, while the 'id' value refers to an attribute name in the current AR class.
269
     *
270
     * Call methods declared in {@see ActiveQuery} to further customize the relation.
271
     *
272
     * @param string $class The class name of the related record
273
     * @param array $link The primary-foreign key constraint. The keys of the array refer to the attributes of the
274
     * record associated with the `$class` model, while the values of the array refer to the corresponding attributes in
275
     * **this** AR class.
276
     *
277
     * @return ActiveQueryInterface The relational query object.
278
     *
279
     * @psalm-param class-string<ActiveRecordInterface> $class
280
     */
281
    public function hasMany(string $class, array $link): ActiveQueryInterface
282
    {
283
        return $this->createRelationQuery($class, $link, true);
284
    }
285
286
    /**
287
     * Declares a `has-one` relation.
288
     *
289
     * The declaration is returned in terms of a relational {@see ActiveQuery} instance through which the related record
290
     * can be queried and retrieved back.
291
     *
292
     * A `has-one` relation means that there is at most one related record matching the criteria set by this relation,
293
     * e.g., a customer has one country.
294
     *
295
     * For example, to declare the `country` relation for `Customer` class, we can write the following code in the
296
     * `Customer` class:
297
     *
298
     * ```php
299
     * public function getCountryQuery()
300
     * {
301
     *     return $this->hasOne(Country::className(), ['id' => 'country_id']);
302
     * }
303
     * ```
304
     *
305
     * Note that in the above, the 'id' key in the `$link` parameter refers to an attribute name in the related class
306
     * `Country`, while the 'country_id' value refers to an attribute name in the current AR class.
307
     *
308
     * Call methods declared in {@see ActiveQuery} to further customize the relation.
309
     *
310
     * @param string $class The class name of the related record.
311
     * @param array $link The primary-foreign key constraint. The keys of the array refer to the attributes of the
312
     * record associated with the `$class` model, while the values of the array refer to the corresponding attributes in
313
     * **this** AR class.
314
     *
315
     * @return ActiveQueryInterface The relational query object.
316
     *
317
     * @psalm-param class-string<ActiveRecordInterface> $class
318
     */
319
    public function hasOne(string $class, array $link): ActiveQueryInterface
320
    {
321
        return $this->createRelationQuery($class, $link, false);
322
    }
323
324
    public function insert(array $attributes = null): bool
325
    {
326
        return $this->insertInternal($attributes);
327
    }
328
329
    /**
330
     * @psalm-param class-string<ActiveRecordInterface> $arClass
331
     */
332
    public function instantiateQuery(string $arClass): ActiveQueryInterface
333
    {
334
        return new ActiveQuery($arClass, $this->db, $this->arFactory);
335
    }
336
337
    /**
338
     * Returns a value indicating whether the named attribute has been changed.
339
     *
340
     * @param string $name The name of the attribute.
341
     * @param bool $identical Whether the comparison of new and old value is made for identical values using `===`,
342
     * defaults to `true`. Otherwise `==` is used for comparison.
343
     *
344
     * @return bool Whether the attribute has been changed.
345
     */
346
    public function isAttributeChanged(string $name, bool $identical = true): bool
0 ignored issues
show
Unused Code introduced by
The parameter $identical is not used and could be removed. ( Ignorable by Annotation )

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

346
    public function isAttributeChanged(string $name, /** @scrutinizer ignore-unused */ bool $identical = true): bool

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
347
    {
348
        if (!isset($this->oldAttributes[$name])) {
349
            return array_key_exists($name, $this->getObjectVars($this));
350
        }
351
352
        return $this->getAttribute($name) !== $this->oldAttributes[$name];
353
    }
354
355
    public function isPrimaryKey(array $keys): bool
356
    {
357
        $pks = $this->primaryKey();
358
359
        return count($keys) === count($pks)
360
            && count(array_intersect($keys, $pks)) === count($pks);
361
    }
362
363
    public function isRelationPopulated(string $name): bool
364
    {
365
        return array_key_exists($name, $this->related);
366
    }
367
368
    public function link(string $name, ActiveRecordInterface $arClass, array $extraColumns = []): void
369
    {
370
        $viaClass = null;
371
        $viaTable = null;
372
        $relation = $this->relationQuery($name);
373
        $via = $relation->getVia();
374
375
        if ($via !== null) {
376
            if ($this->getIsNewRecord() || $arClass->getIsNewRecord()) {
377
                throw new InvalidCallException(
378
                    'Unable to link models: the models being linked cannot be newly created.'
379
                );
380
            }
381
382
            if (is_array($via)) {
383
                [$viaName, $viaRelation] = $via;
384
                /** @psalm-var ActiveQueryInterface $viaRelation */
385
                $viaClass = $viaRelation->getARInstance();
386
                // unset $viaName so that it can be reloaded to reflect the change.
387
                /** @psalm-var string $viaName */
388
                unset($this->related[$viaName]);
389
            } else {
390
                $viaRelation = $via;
391
                $from = $via->getFrom();
392
                /** @psalm-var string $viaTable */
393
                $viaTable = reset($from);
394
            }
395
396
            $columns = [];
397
398
            $viaLink = $viaRelation->getLink();
399
400
            /**
401
             * @psalm-var string $a
402
             * @psalm-var string $b
403
             */
404
            foreach ($viaLink as $a => $b) {
405
                /** @psalm-var mixed */
406
                $columns[$a] = $this->getAttribute($b);
407
            }
408
409
            $link = $relation->getLink();
410
411
            /**
412
             * @psalm-var string $a
413
             * @psalm-var string $b
414
             */
415
            foreach ($link as $a => $b) {
416
                /** @psalm-var mixed */
417
                $columns[$b] = $arClass->getAttribute($a);
418
            }
419
420
            /**
421
             * @psalm-var string $k
422
             * @psalm-var mixed $v
423
             */
424
            foreach ($extraColumns as $k => $v) {
425
                /** @psalm-var mixed */
426
                $columns[$k] = $v;
427
            }
428
429
            if ($viaClass instanceof ActiveRecordInterface) {
430
                /**
431
                 * @psalm-var string $column
432
                 * @psalm-var mixed $value
433
                 */
434
                foreach ($columns as $column => $value) {
435
                    $viaClass->setAttribute($column, $value);
436
                }
437
438
                $viaClass->insert();
439
            } elseif (is_string($viaTable)) {
440
                $this->db->createCommand()->insert($viaTable, $columns)->execute();
441
            }
442
        } else {
443
            $link = $relation->getLink();
444
            $p1 = $arClass->isPrimaryKey(array_keys($link));
445
            $p2 = $this->isPrimaryKey(array_values($link));
446
447
            if ($p1 && $p2) {
448
                if ($this->getIsNewRecord() && $arClass->getIsNewRecord()) {
449
                    throw new InvalidCallException('Unable to link models: at most one model can be newly created.');
450
                }
451
452
                if ($this->getIsNewRecord()) {
453
                    $this->bindModels(array_flip($link), $this, $arClass);
454
                } else {
455
                    $this->bindModels($link, $arClass, $this);
456
                }
457
            } elseif ($p1) {
458
                $this->bindModels(array_flip($link), $this, $arClass);
459
            } elseif ($p2) {
460
                $this->bindModels($link, $arClass, $this);
461
            } else {
462
                throw new InvalidCallException(
463
                    'Unable to link models: the link defining the relation does not involve any primary key.'
464
                );
465
            }
466
        }
467
468
        // update lazily loaded related objects
469
        if (!$relation->getMultiple()) {
470
            $this->related[$name] = $arClass;
471
        } elseif (isset($this->related[$name])) {
472
            $indexBy = $relation->getIndexBy();
473
            if ($indexBy !== null) {
474
                if ($indexBy instanceof Closure) {
475
                    $index = $indexBy($arClass->getAttributes());
476
                } else {
477
                    $index = $arClass->getAttribute($indexBy);
478
                }
479
480
                if ($index !== null) {
481
                    $this->related[$name][$index] = $arClass;
482
                }
483
            } else {
484
                $this->related[$name][] = $arClass;
485
            }
486
        }
487
    }
488
489
    /**
490
     * Marks an attribute dirty.
491
     *
492
     * This method may be called to force updating a record when calling {@see update()}, even if there is no change
493
     * being made to the record.
494
     *
495
     * @param string $name The attribute name.
496
     */
497
    public function markAttributeDirty(string $name): void
498
    {
499
        if ($this->oldAttributes !== null && $name !== '') {
500
            unset($this->oldAttributes[$name]);
501
        }
502
    }
503
504
    /**
505
     * Returns the name of the column that stores the lock version for implementing optimistic locking.
506
     *
507
     * Optimistic locking allows multiple users to access the same record for edits and avoids potential conflicts. In
508
     * case when a user attempts to save the record upon some staled data (because another user has modified the data),
509
     * a {@see StaleObjectException} exception will be thrown, and the update or deletion is skipped.
510
     *
511
     * Optimistic locking is only supported by {@see update()} and {@see delete()}.
512
     *
513
     * To use Optimistic locking:
514
     *
515
     * 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`.
516
     *    Override this method to return the name of this column.
517
     * 2. In the Web form that collects the user input, add a hidden field that stores the lock version of the recording
518
     *    being updated.
519
     * 3. In the controller action that does the data updating, try to catch the {@see StaleObjectException} and
520
     *    implement necessary business logic (e.g. merging the changes, prompting stated data) to resolve the conflict.
521
     *
522
     * @return string|null The column name that stores the lock version of a table row. If `null` is returned (default
523
     * implemented), optimistic locking will not be supported.
524
     */
525
    public function optimisticLock(): string|null
526
    {
527
        return null;
528
    }
529
530
    /**
531
     * Populates an active record object using a row of data from the database/storage.
532
     *
533
     * This is an internal method meant to be called to create active record objects after fetching data from the
534
     * database. It is mainly used by {@see ActiveQuery} to populate the query results into active records.
535
     *
536
     * @param array|object $row Attribute values (name => value).
537
     */
538
    public function populateRecord(array|object $row): void
539
    {
540
        foreach ($row as $name => $value) {
541
            $this->populateAttribute($name, $value);
542
            $this->oldAttributes[$name] = $value;
543
        }
544
545
        $this->related = [];
546
        $this->relationsDependencies = [];
547
    }
548
549
    public function populateRelation(string $name, array|ActiveRecordInterface|null $records): void
550
    {
551
        foreach ($this->relationsDependencies as &$relationNames) {
552
            unset($relationNames[$name]);
553
        }
554
555
        $this->related[$name] = $records;
556
    }
557
558
    /**
559
     * Repopulates this active record with the latest data.
560
     *
561
     * @return bool Whether the row still exists in the database. If `true`, the latest data will be populated to this
562
     * active record. Otherwise, this record will remain unchanged.
563
     */
564
    public function refresh(): bool
565
    {
566
        $record = $this->instantiateQuery(static::class)->findOne($this->getPrimaryKey(true));
567
568
        return $this->refreshInternal($record);
569
    }
570
571
    public function relation(string $name): ActiveRecordInterface|array|null
572
    {
573
        if (array_key_exists($name, $this->related)) {
574
            return $this->related[$name];
575
        }
576
577
        return $this->retrieveRelation($name);
578
    }
579
580
    public function relationQuery(string $name): ActiveQueryInterface
581
    {
582
        throw new InvalidArgumentException(static::class . ' has no relation named "' . $name . '".');
583
    }
584
585
    public function resetRelation(string $name): void
586
    {
587
        foreach ($this->relationsDependencies as &$relationNames) {
588
            unset($relationNames[$name]);
589
        }
590
591
        unset($this->related[$name]);
592
    }
593
594
    protected function retrieveRelation(string $name): ActiveRecordInterface|array|null
595
    {
596
        /** @var ActiveQueryInterface $query */
597
        $query = $this->relationQuery($name);
598
599
        $this->setRelationDependencies($name, $query);
600
601
        return $this->related[$name] = $query->relatedRecords();
602
    }
603
604
    /**
605
     * Saves the current record.
606
     *
607
     * This method will call {@see insert()} when {@see getIsNewRecord} is `true`, or {@see update()} when
608
     * {@see getIsNewRecord} is `false`.
609
     *
610
     * For example, to save a customer record:
611
     *
612
     * ```php
613
     * $customer = new Customer($db);
614
     * $customer->name = $name;
615
     * $customer->email = $email;
616
     * $customer->save();
617
     * ```
618
     *
619
     * @param array|null $attributeNames List of attribute names that need to be saved. Defaults to null, meaning all
620
     * attributes that are loaded from DB will be saved.
621
     *
622
     * @throws InvalidConfigException
623
     * @throws StaleObjectException
624
     * @throws Throwable
625
     *
626
     * @return bool Whether the saving succeeded (i.e. no validation errors occurred).
627
     */
628
    public function save(array $attributeNames = null): bool
629
    {
630
        if ($this->getIsNewRecord()) {
631
            return $this->insert($attributeNames);
632
        }
633
634
        $this->update($attributeNames);
635
636
        return true;
637
    }
638
639
    public function setAttribute(string $name, mixed $value): void
640
    {
641
        if (
642
            isset($this->relationsDependencies[$name])
643
            && ($value === null || $this->getAttribute($name) !== $value)
644
        ) {
645
            $this->resetDependentRelations($name);
646
        }
647
648
        $this->populateAttribute($name, $value);
649
    }
650
651
    /**
652
     * Sets the attribute values in a massive way.
653
     *
654
     * @param array $values Attribute values (name => value) to be assigned to the model.
655
     *
656
     * {@see attributes()}
657
     */
658
    public function setAttributes(array $values): void
659
    {
660
        $values = array_intersect_key($values, array_flip($this->attributes()));
661
662
        /** @psalm-var mixed $value */
663
        foreach ($values as $name => $value) {
664
            $this->populateAttribute($name, $value);
665
        }
666
    }
667
668
    /**
669
     * Sets the value indicating whether the record is new.
670
     *
671
     * @param bool $value whether the record is new and should be inserted when calling {@see save()}.
672
     *
673
     * @see getIsNewRecord()
674
     */
675
    public function setIsNewRecord(bool $value): void
676
    {
677
        $this->oldAttributes = $value ? null : $this->getObjectVars($this);
678
    }
679
680
    /**
681
     * Sets the old value of the named attribute.
682
     *
683
     * @param string $name The attribute name.
684
     *
685
     * @throws InvalidArgumentException If the named attribute does not exist.
686
     *
687
     * {@see hasAttribute()}
688
     */
689
    public function setOldAttribute(string $name, mixed $value): void
690
    {
691
        if (isset($this->oldAttributes[$name]) || $this->hasAttribute($name)) {
692
            $this->oldAttributes[$name] = $value;
693
        } else {
694
            throw new InvalidArgumentException(static::class . ' has no attribute named "' . $name . '".');
695
        }
696
    }
697
698
    /**
699
     * Sets the old attribute values.
700
     *
701
     * All existing old attribute values will be discarded.
702
     *
703
     * @param array|null $values Old attribute values to be set. If set to `null` this record is considered to be
704
     * {@see isNewRecord|new}.
705
     */
706
    public function setOldAttributes(array $values = null): void
707
    {
708
        $this->oldAttributes = $values;
709
    }
710
711
    public function update(array $attributeNames = null): int
712
    {
713
        return $this->updateInternal($attributeNames);
714
    }
715
716
    public function updateAll(array $attributes, array|string $condition = [], array $params = []): int
717
    {
718
        $command = $this->db->createCommand();
719
720
        $command->update($this->getTableName(), $attributes, $condition, $params);
721
722
        return $command->execute();
723
    }
724
725
    public function updateAttributes(array $attributes): int
726
    {
727
        $attrs = [];
728
729
        foreach ($attributes as $name => $value) {
730
            if (is_int($name)) {
731
                $attrs[] = $value;
732
            } else {
733
                $this->setAttribute($name, $value);
734
                $attrs[] = $name;
735
            }
736
        }
737
738
        $values = $this->getDirtyAttributes($attrs);
739
740
        if (empty($values) || $this->getIsNewRecord()) {
741
            return 0;
742
        }
743
744
        $rows = $this->updateAll($values, $this->getOldPrimaryKey(true));
745
746
        $this->oldAttributes = array_merge($this->oldAttributes ?? [], $values);
747
748
        return $rows;
749
    }
750
751
    /**
752
     * Updates the whole table using the provided counter changes and conditions.
753
     *
754
     * For example, to increment all customers' age by 1,
755
     *
756
     * ```php
757
     * $customer = new Customer($db);
758
     * $customer->updateAllCounters(['age' => 1]);
759
     * ```
760
     *
761
     * Note that this method will not trigger any events.
762
     *
763
     * @param array $counters The counters to be updated (attribute name => increment value).
764
     * Use negative values if you want to decrement the counters.
765
     * @param array|string $condition The conditions that will be put in the WHERE part of the UPDATE SQL. Please refer
766
     * to {@see Query::where()} on how to specify this parameter.
767
     * @param array $params The parameters (name => value) to be bound to the query.
768
     *
769
     * Do not name the parameters as `:bp0`, `:bp1`, etc., because they are used internally by this method.
770
     *
771
     * @throws Exception
772
     * @throws InvalidConfigException
773
     * @throws Throwable
774
     *
775
     * @return int The number of rows updated.
776
     */
777
    public function updateAllCounters(array $counters, array|string $condition = '', array $params = []): int
778
    {
779
        $n = 0;
780
781
        /** @psalm-var array<string, int> $counters */
782
        foreach ($counters as $name => $value) {
783
            $counters[$name] = new Expression("[[$name]]+:bp$n", [":bp$n" => $value]);
784
            $n++;
785
        }
786
787
        $command = $this->db->createCommand();
788
        $command->update($this->getTableName(), $counters, $condition, $params);
789
790
        return $command->execute();
791
    }
792
793
    /**
794
     * Updates one or several counter columns for the current AR object.
795
     *
796
     * Note that this method differs from {@see updateAllCounters()} in that it only saves counters for the current AR
797
     * object.
798
     *
799
     * An example usage is as follows:
800
     *
801
     * ```php
802
     * $post = new Post($db);
803
     * $post->updateCounters(['view_count' => 1]);
804
     * ```
805
     *
806
     * @param array $counters The counters to be updated (attribute name => increment value), use negative values if you
807
     * want to decrement the counters.
808
     *
809
     * @psalm-param array<string, int> $counters
810
     *
811
     * @throws Exception
812
     * @throws NotSupportedException
813
     *
814
     * @return bool Whether the saving is successful.
815
     *
816
     * {@see updateAllCounters()}
817
     */
818
    public function updateCounters(array $counters): bool
819
    {
820
        if ($this->updateAllCounters($counters, $this->getOldPrimaryKey(true)) === 0) {
821
            return false;
822
        }
823
824
        foreach ($counters as $name => $value) {
825
            $value += $this->getAttribute($name) ?? 0;
826
            $this->populateAttribute($name, $value);
827
            $this->oldAttributes[$name] = $value;
828
        }
829
830
        return true;
831
    }
832
833
    public function unlink(string $name, ActiveRecordInterface $arClass, bool $delete = false): void
834
    {
835
        $viaClass = null;
836
        $viaTable = null;
837
        $relation = $this->relationQuery($name);
838
        $viaRelation = $relation->getVia();
839
840
        if ($viaRelation !== null) {
841
            if (is_array($viaRelation)) {
842
                [$viaName, $viaRelation] = $viaRelation;
843
                /** @psalm-var ActiveQueryInterface $viaRelation */
844
                $viaClass = $viaRelation->getARInstance();
845
                /** @psalm-var string $viaName */
846
                unset($this->related[$viaName]);
847
            }
848
849
            $columns = [];
850
            $nulls = [];
851
852
            if ($viaRelation instanceof ActiveQueryInterface) {
853
                $from = $viaRelation->getFrom();
854
                /** @psalm-var mixed $viaTable */
855
                $viaTable = reset($from);
856
857
                foreach ($viaRelation->getLink() as $a => $b) {
858
                    /** @psalm-var mixed */
859
                    $columns[$a] = $this->getAttribute($b);
860
                }
861
862
                $link = $relation->getLink();
863
864
                foreach ($link as $a => $b) {
865
                    /** @psalm-var mixed */
866
                    $columns[$b] = $arClass->getAttribute($a);
867
                }
868
869
                $nulls = array_fill_keys(array_keys($columns), null);
870
871
                if ($viaRelation->getOn() !== null) {
872
                    $columns = ['and', $columns, $viaRelation->getOn()];
873
                }
874
            }
875
876
            if ($viaClass instanceof ActiveRecordInterface) {
877
                if ($delete) {
878
                    $viaClass->deleteAll($columns);
879
                } else {
880
                    $viaClass->updateAll($nulls, $columns);
881
                }
882
            } elseif (is_string($viaTable)) {
883
                $command = $this->db->createCommand();
884
                if ($delete) {
885
                    $command->delete($viaTable, $columns)->execute();
886
                } else {
887
                    $command->update($viaTable, $nulls, $columns)->execute();
888
                }
889
            }
890
        } elseif ($relation instanceof ActiveQueryInterface) {
891
            if ($this->isPrimaryKey($relation->getLink())) {
892
                if ($delete) {
893
                    $arClass->delete();
894
                } else {
895
                    foreach ($relation->getLink() as $a => $b) {
896
                        $arClass->setAttribute($a, null);
897
                    }
898
                    $arClass->save();
899
                }
900
            } elseif ($arClass->isPrimaryKey(array_keys($relation->getLink()))) {
901
                foreach ($relation->getLink() as $a => $b) {
902
                    /** @psalm-var mixed $values */
903
                    $values = $this->getAttribute($b);
904
                    /** relation via array valued attribute */
905
                    if (is_array($values)) {
906
                        if (($key = array_search($arClass->getAttribute($a), $values, false)) !== false) {
907
                            unset($values[$key]);
908
                            $this->setAttribute($b, array_values($values));
909
                        }
910
                    } else {
911
                        $this->setAttribute($b, null);
912
                    }
913
                }
914
                $delete ? $this->delete() : $this->save();
915
            } else {
916
                throw new InvalidCallException('Unable to unlink models: the link does not involve any primary key.');
917
            }
918
        }
919
920
        if (!$relation->getMultiple()) {
921
            unset($this->related[$name]);
922
        } elseif (isset($this->related[$name]) && is_array($this->related[$name])) {
923
            /** @psalm-var array<array-key, ActiveRecordInterface> $related */
924
            $related = $this->related[$name];
925
            foreach ($related as $a => $b) {
926
                if ($arClass->getPrimaryKey() === $b->getPrimaryKey()) {
927
                    unset($this->related[$name][$a]);
928
                }
929
            }
930
        }
931
    }
932
933
    /**
934
     * Destroys the relationship in current model.
935
     *
936
     * The active record with the foreign key of the relationship will be deleted if `$delete` is `true`. Otherwise, the
937
     * foreign key will be set `null` and the model will be saved without validation.
938
     *
939
     * Note that to destroy the relationship without removing records make sure your keys can be set to null.
940
     *
941
     * @param string $name The case sensitive name of the relationship, e.g. `orders` for a relation defined via
942
     * `getOrders()` method.
943
     * @param bool $delete Whether to delete the model that contains the foreign key.
944
     *
945
     * @throws Exception
946
     * @throws ReflectionException
947
     * @throws StaleObjectException
948
     * @throws Throwable
949
     */
950
    public function unlinkAll(string $name, bool $delete = false): void
951
    {
952
        $viaClass = null;
953
        $viaTable = null;
954
        $relation = $this->relationQuery($name);
955
        $viaRelation = $relation->getVia();
956
957
        if ($viaRelation !== null) {
958
            if (is_array($viaRelation)) {
959
                [$viaName, $viaRelation] = $viaRelation;
960
                /** @psalm-var ActiveQueryInterface $viaRelation */
961
                $viaClass = $viaRelation->getARInstance();
962
                /** @psalm-var string $viaName */
963
                unset($this->related[$viaName]);
964
            } else {
965
                $from = $viaRelation->getFrom();
966
                /** @psalm-var mixed $viaTable */
967
                $viaTable = reset($from);
968
            }
969
970
            $condition = [];
971
            $nulls = [];
972
973
            if ($viaRelation instanceof ActiveQueryInterface) {
974
                foreach ($viaRelation->getLink() as $a => $b) {
975
                    $nulls[$a] = null;
976
                    /** @psalm-var mixed */
977
                    $condition[$a] = $this->getAttribute($b);
978
                }
979
980
                if (!empty($viaRelation->getWhere())) {
981
                    $condition = ['and', $condition, $viaRelation->getWhere()];
982
                }
983
984
                if (!empty($viaRelation->getOn())) {
985
                    $condition = ['and', $condition, $viaRelation->getOn()];
986
                }
987
            }
988
989
            if ($viaClass instanceof ActiveRecordInterface) {
990
                if ($delete) {
991
                    $viaClass->deleteAll($condition);
992
                } else {
993
                    $viaClass->updateAll($nulls, $condition);
994
                }
995
            } elseif (is_string($viaTable)) {
996
                $command = $this->db->createCommand();
997
                if ($delete) {
998
                    $command->delete($viaTable, $condition)->execute();
999
                } else {
1000
                    $command->update($viaTable, $nulls, $condition)->execute();
1001
                }
1002
            }
1003
        } else {
1004
            $relatedModel = $relation->getARInstance();
1005
1006
            $link = $relation->getLink();
1007
            if (!$delete && count($link) === 1 && is_array($this->getAttribute($b = reset($link)))) {
1008
                /** relation via array valued attribute */
1009
                $this->setAttribute($b, []);
1010
                $this->save();
1011
            } else {
1012
                $nulls = [];
1013
                $condition = [];
1014
1015
                foreach ($relation->getLink() as $a => $b) {
1016
                    $nulls[$a] = null;
1017
                    /** @psalm-var mixed */
1018
                    $condition[$a] = $this->getAttribute($b);
1019
                }
1020
1021
                if (!empty($relation->getWhere())) {
1022
                    $condition = ['and', $condition, $relation->getWhere()];
1023
                }
1024
1025
                if (!empty($relation->getOn())) {
1026
                    $condition = ['and', $condition, $relation->getOn()];
1027
                }
1028
1029
                if ($delete) {
1030
                    $relatedModel->deleteAll($condition);
1031
                } else {
1032
                    $relatedModel->updateAll($nulls, $condition);
1033
                }
1034
            }
1035
        }
1036
1037
        unset($this->related[$name]);
1038
    }
1039
1040
    /**
1041
     * Sets relation dependencies for a property.
1042
     *
1043
     * @param string $name property name.
1044
     * @param ActiveQueryInterface $relation relation instance.
1045
     * @param string|null $viaRelationName intermediate relation.
1046
     */
1047
    protected function setRelationDependencies(
1048
        string $name,
1049
        ActiveQueryInterface $relation,
1050
        string $viaRelationName = null
1051
    ): void {
1052
        $via = $relation->getVia();
1053
1054
        if (empty($via)) {
1055
            foreach ($relation->getLink() as $attribute) {
1056
                $this->relationsDependencies[$attribute][$name] = $name;
1057
                if ($viaRelationName !== null) {
1058
                    $this->relationsDependencies[$attribute][] = $viaRelationName;
1059
                }
1060
            }
1061
        } elseif ($via instanceof ActiveQueryInterface) {
1062
            $this->setRelationDependencies($name, $via);
1063
        } else {
1064
            /**
1065
             * @psalm-var string|null $viaRelationName
1066
             * @psalm-var ActiveQueryInterface $viaQuery
1067
             */
1068
            [$viaRelationName, $viaQuery] = $via;
1069
            $this->setRelationDependencies($name, $viaQuery, $viaRelationName);
1070
        }
1071
    }
1072
1073
    /**
1074
     * Creates a query instance for `has-one` or `has-many` relation.
1075
     *
1076
     * @param string $arClass The class name of the related record.
1077
     * @param array $link The primary-foreign key constraint.
1078
     * @param bool $multiple Whether this query represents a relation to more than one record.
1079
     *
1080
     * @return ActiveQueryInterface The relational query object.
1081
     *
1082
     * @psalm-param class-string<ActiveRecordInterface> $arClass
1083
1084
     * {@see hasOne()}
1085
     * {@see hasMany()}
1086
     */
1087
    protected function createRelationQuery(string $arClass, array $link, bool $multiple): ActiveQueryInterface
1088
    {
1089
        return $this->instantiateQuery($arClass)->primaryModel($this)->link($link)->multiple($multiple);
1090
    }
1091
1092
    /**
1093
     * {@see delete()}
1094
     *
1095
     * @throws Exception
1096
     * @throws StaleObjectException
1097
     * @throws Throwable
1098
     *
1099
     * @return int The number of rows deleted.
1100
     */
1101
    protected function deleteInternal(): int
1102
    {
1103
        /**
1104
         * We do not check the return value of deleteAll() because it's possible the record is already deleted in
1105
         * the database and thus the method will return 0
1106
         */
1107
        $condition = $this->getOldPrimaryKey(true);
1108
        $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\Abs...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...
1109
1110
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
1111
            $condition[$lock] = $this->getAttribute($lock);
1112
1113
            $result = $this->deleteAll($condition);
1114
1115
            if ($result === 0) {
1116
                throw new StaleObjectException('The object being deleted is outdated.');
1117
            }
1118
        } else {
1119
            $result = $this->deleteAll($condition);
1120
        }
1121
1122
        $this->setOldAttributes();
1123
1124
        return $result;
1125
    }
1126
1127
    /**
1128
     * Repopulates this active record with the latest data from a newly fetched instance.
1129
     *
1130
     * @param ActiveRecordInterface|array|null $record The record to take attributes from.
1131
     *
1132
     * @return bool Whether refresh was successful.
1133
     *
1134
     * {@see refresh()}
1135
     */
1136
    protected function refreshInternal(array|ActiveRecordInterface $record = null): bool
1137
    {
1138
        if ($record === null || is_array($record)) {
0 ignored issues
show
introduced by
The condition is_array($record) is always true.
Loading history...
1139
            return false;
1140
        }
1141
1142
        foreach ($this->attributes() as $name) {
1143
            $this->populateAttribute($name, $record->getAttribute($name));
1144
        }
1145
1146
        $this->oldAttributes = $record->getOldAttributes();
1147
        $this->related = [];
1148
        $this->relationsDependencies = [];
1149
1150
        return true;
1151
    }
1152
1153
    /**
1154
     * {@see update()}
1155
     *
1156
     * @param array|null $attributes Attributes to update.
1157
     *
1158
     * @throws Exception
1159
     * @throws NotSupportedException
1160
     * @throws StaleObjectException
1161
     *
1162
     * @return int The number of rows affected.
1163
     */
1164
    protected function updateInternal(array $attributes = null): int
1165
    {
1166
        $values = $this->getDirtyAttributes($attributes);
1167
1168
        if (empty($values)) {
1169
            return 0;
1170
        }
1171
1172
        $condition = $this->getOldPrimaryKey(true);
1173
        $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\Abs...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...
1174
1175
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
1176
            $lockValue = $this->getAttribute($lock);
1177
1178
            $condition[$lock] = $lockValue;
1179
            $values[$lock] = ++$lockValue;
1180
1181
            $rows = $this->updateAll($values, $condition);
1182
1183
            if ($rows === 0) {
1184
                throw new StaleObjectException('The object being updated is outdated.');
1185
            }
1186
1187
            $this->populateAttribute($lock, $lockValue);
1188
        } else {
1189
            $rows = $this->updateAll($values, $condition);
1190
        }
1191
1192
        $this->oldAttributes = array_merge($this->oldAttributes ?? [], $values);
1193
1194
        return $rows;
1195
    }
1196
1197
    private function bindModels(
1198
        array $link,
1199
        ActiveRecordInterface $foreignModel,
1200
        ActiveRecordInterface $primaryModel
1201
    ): void {
1202
        /** @psalm-var string[] $link */
1203
        foreach ($link as $fk => $pk) {
1204
            /** @psalm-var mixed $value */
1205
            $value = $primaryModel->getAttribute($pk);
1206
1207
            if ($value === null) {
1208
                throw new InvalidCallException(
1209
                    'Unable to link active record: the primary key of ' . $primaryModel::class . ' is null.'
1210
                );
1211
            }
1212
1213
            /**
1214
             * relation via array valued attribute
1215
             */
1216
            if (is_array($fkValue = $foreignModel->getAttribute($fk))) {
1217
                /** @psalm-var mixed */
1218
                $fkValue[] = $value;
1219
                $foreignModel->setAttribute($fk, $fkValue);
1220
            } else {
1221
                $foreignModel->setAttribute($fk, $value);
1222
            }
1223
        }
1224
1225
        $foreignModel->save();
1226
    }
1227
1228
    protected function hasDependentRelations(string $attribute): bool
1229
    {
1230
        return isset($this->relationsDependencies[$attribute]);
1231
    }
1232
1233
    /**
1234
     * Resets dependent related models checking if their links contain specific attribute.
1235
     *
1236
     * @param string $attribute The changed attribute name.
1237
     */
1238
    protected function resetDependentRelations(string $attribute): void
1239
    {
1240
        foreach ($this->relationsDependencies[$attribute] as $relation) {
1241
            unset($this->related[$relation]);
1242
        }
1243
1244
        unset($this->relationsDependencies[$attribute]);
1245
    }
1246
1247
    public function getTableName(): string
1248
    {
1249
        if ($this->tableName === '') {
1250
            $this->tableName = '{{%' . DbStringHelper::pascalCaseToId(DbStringHelper::baseName(static::class)) . '}}';
1251
        }
1252
1253
        return $this->tableName;
1254
    }
1255
1256
    protected function db(): ConnectionInterface
1257
    {
1258
        return $this->db;
1259
    }
1260
}
1261