Passed
Pull Request — master (#295)
by Wilmer
15:21 queued 02:00
created

BaseActiveRecord::updateAttributes()   A

Complexity

Conditions 6
Paths 9

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 7.3329

Importance

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