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

BaseActiveRecord::updateInternal()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 33
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 5.0042

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 18
c 1
b 0
f 0
dl 0
loc 33
ccs 17
cts 18
cp 0.9444
rs 9.3554
cc 5
nc 6
nop 1
crap 5.0042
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord;
6
7
use ArrayAccess;
8
use Closure;
9
use IteratorAggregate;
10
use ReflectionException;
11
use Throwable;
12
use Yiisoft\Arrays\ArrayableInterface;
13
use Yiisoft\Arrays\ArrayableTrait;
14
use Yiisoft\Db\Connection\ConnectionInterface;
15
use Yiisoft\Db\Exception\Exception;
16
use Yiisoft\Db\Exception\InvalidArgumentException;
17
use Yiisoft\Db\Exception\InvalidCallException;
18
use Yiisoft\Db\Exception\InvalidConfigException;
19
use Yiisoft\Db\Exception\NotSupportedException;
20
use Yiisoft\Db\Exception\StaleObjectException;
21
use Yiisoft\Db\Expression\Expression;
22
use Yiisoft\Db\Helper\DbStringHelper;
23
24
use function array_combine;
25
use function array_flip;
26
use function array_intersect;
27
use function array_key_exists;
28
use function array_keys;
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
 * @template-implements ArrayAccess<int, mixed>
43
 * @template-implements IteratorAggregate<int>
44
 */
45
abstract class BaseActiveRecord implements ActiveRecordInterface, IteratorAggregate, ArrayAccess, ArrayableInterface
46
{
47
    use ArrayableTrait;
48
    use BaseActiveRecordTrait;
49
50
    private array $attributes = [];
51
    private array|null $oldAttributes = null;
52
    private array $related = [];
53
    /** @psalm-var string[][] */
54
    private array $relationsDependencies = [];
55
56
    public function __construct(
57
        protected ConnectionInterface $db,
58
        private ActiveRecordFactory|null $arFactory = null,
59
        private string $tableName = ''
60
    ) {
61
    }
62
63 719
    public function delete(): false|int
64
    {
65 719
        /**
66 719
          * We do not check the return value of deleteAll() because it's possible the record is already deleted in
67 719
          * the database and thus the method will return 0
68
          */
69
        $condition = $this->getOldPrimaryKey(true);
70
71
        $lock = $this->optimisticLock();
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $lock is correct as $this->optimisticLock() targeting Yiisoft\ActiveRecord\Bas...ecord::optimisticLock() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

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

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1162
1163 4
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
1164 4
            $lockValue = $this->getAttribute($lock);
1165 4
1166
            $condition[$lock] = $lockValue;
1167 4
            $values[$lock] = ++$lockValue;
1168 4
1169 4
            $rows = $this->updateAll($values, $condition);
1170
1171
            if ($rows === 0) {
1172 8
                throw new StaleObjectException('The object being updated is outdated.');
1173 8
            }
1174
1175 8
            $this->setAttribute($lock, $lockValue);
1176 8
        } else {
1177 8
            $rows = $this->updateAll($values, $condition);
1178
        }
1179
1180 8
        foreach ($values as $name => $value) {
1181
            $this->oldAttributes[$name] = $value;
1182
        }
1183
1184 8
        return $rows;
1185
    }
1186
1187
    private function bindModels(
1188 8
        array $link,
1189
        ActiveRecordInterface $foreignModel,
1190 4
        ActiveRecordInterface $primaryModel
1191 4
    ): void {
1192
        /** @psalm-var string[] $link */
1193 4
        foreach ($link as $fk => $pk) {
1194
            /** @psalm-var mixed $value */
1195
            $value = $primaryModel->$pk;
1196
1197 4
            if ($value === null) {
1198 4
                throw new InvalidCallException(
1199 4
                    'Unable to link active record: the primary key of ' . $primaryModel::class . ' is null.'
1200
                );
1201 8
            }
1202
1203
            /**
1204
             * relation via array valued attribute
1205 14
             *
1206
             * @psalm-suppress MixedArrayAssignment
1207 14
             */
1208 14
            if (is_array($foreignModel->$fk)) {
1209
                /** @psalm-var mixed */
1210
                $foreignModel->{$fk}[] = $value;
1211
            } else {
1212
                $foreignModel->{$fk} = $value;
1213 14
            }
1214 14
        }
1215
1216 14
        $foreignModel->save();
1217 14
    }
1218 14
1219
    /**
1220
     * Resets dependent related models checking if their links contain specific attribute.
1221 14
     *
1222 10
     * @param string $attribute The changed attribute name.
1223
     */
1224
    private function resetDependentRelations(string $attribute): void
1225 14
    {
1226 4
        foreach ($this->relationsDependencies[$attribute] as $relation) {
1227
            unset($this->related[$relation]);
1228
        }
1229 14
1230 9
        unset($this->relationsDependencies[$attribute]);
1231
    }
1232 5
1233
    public function getTableName(): string
1234
    {
1235
        if ($this->tableName === '') {
1236
            $this->tableName = '{{%' . DbStringHelper::pascalCaseToId(DbStringHelper::baseName(static::class)) . '}}';
1237 22
        }
1238 22
1239
        return $this->tableName;
1240 9
    }
1241
}
1242