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