Passed
Push — master ( 41bd32...7d4b9e )
by Wilmer
08:47 queued 05:52
created

BaseActiveRecord::updateAttributes()   B

Complexity

Conditions 8
Paths 10

Size

Total Lines 35
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 8

Importance

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

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

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

}

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

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

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

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