Passed
Pull Request — master (#273)
by
unknown
03:25
created

BaseActiveRecord::toArray()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3

Importance

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