Passed
Pull Request — master (#323)
by Sergei
02:50
created

AbstractActiveRecord::relationQuery()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 3
c 1
b 0
f 0
dl 0
loc 7
rs 10
cc 2
nc 2
nop 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord;
6
7
use Closure;
8
use ReflectionException;
9
use Throwable;
10
use Yiisoft\Db\Connection\ConnectionInterface;
11
use Yiisoft\Db\Exception\Exception;
12
use Yiisoft\Db\Exception\InvalidArgumentException;
13
use Yiisoft\Db\Exception\InvalidCallException;
14
use Yiisoft\Db\Exception\InvalidConfigException;
15
use Yiisoft\Db\Exception\NotSupportedException;
16
use Yiisoft\Db\Exception\StaleObjectException;
17
use Yiisoft\Db\Expression\Expression;
18
use Yiisoft\Db\Helper\DbStringHelper;
19
20
use function array_diff_key;
21
use function array_diff;
22
use function array_fill_keys;
23
use function array_flip;
24
use function array_intersect;
25
use function array_intersect_key;
26
use function array_key_exists;
27
use function array_keys;
28
use function array_merge;
29
use function array_search;
30
use function array_values;
31
use function count;
32
use function get_object_vars;
33
use function in_array;
34
use function is_array;
35
use function is_int;
36
use function reset;
37
38
/**
39
 * ActiveRecord is the base class for classes representing relational data in terms of objects.
40
 *
41
 * See {@see ActiveRecord} for a concrete implementation.
42
 */
43
abstract class AbstractActiveRecord implements ActiveRecordInterface
44
{
45
    private array|null $oldAttributes = null;
46
    private array $related = [];
47
    /** @psalm-var string[][] */
48
    private array $relationsDependencies = [];
49
50
    public function __construct(
51
        protected ConnectionInterface $db,
52
        private ActiveRecordFactory|null $arFactory = null,
53
        private string $tableName = ''
54
    ) {
55
    }
56
57
    public function delete(): int
58
    {
59
        return $this->deleteInternal();
60
    }
61
62
    public function deleteAll(array $condition = [], array $params = []): int
63
    {
64
        $command = $this->db->createCommand();
65
        $command->delete($this->getTableName(), $condition, $params);
66
67
        return $command->execute();
68
    }
69
70
    public function equals(ActiveRecordInterface $record): bool
71
    {
72
        if ($this->getIsNewRecord() || $record->getIsNewRecord()) {
73
            return false;
74
        }
75
76
        return $this->getTableName() === $record->getTableName() && $this->getPrimaryKey() === $record->getPrimaryKey();
77
    }
78
79
    public function getAttribute(string $name): mixed
80
    {
81
        return get_object_vars($this)[$name] ?? null;
82
    }
83
84
    public function getAttributes(array $names = null, array $except = []): array
85
    {
86
        $names ??= $this->attributes();
87
        $attributes = get_object_vars($this);
88
89
        if ($except !== []) {
90
            $names = array_diff($names, $except);
91
        }
92
93
        return array_intersect_key($attributes, array_flip($names));
94
    }
95
96
    public function getIsNewRecord(): bool
97
    {
98
        return $this->oldAttributes === null;
99
    }
100
101
    /**
102
     * Returns the old value of the named attribute.
103
     *
104
     * If this record is the result of a query and the attribute is not loaded, `null` will be returned.
105
     *
106
     * @param string $name The attribute name.
107
     *
108
     * @return mixed the old attribute value. `null` if the attribute is not loaded before or does not exist.
109
     *
110
     * {@see hasAttribute()}
111
     */
112
    public function getOldAttribute(string $name): mixed
113
    {
114
        return $this->oldAttributes[$name] ?? null;
115
    }
116
117
    /**
118
     * Returns the attribute values that have been modified since they are loaded or saved most recently.
119
     *
120
     * The comparison of new and old values is made for identical values using `===`.
121
     *
122
     * @param array|null $names The names of the attributes whose values may be returned if they are changed recently.
123
     * If null, {@see attributes()} will be used.
124
     *
125
     * @return array The changed attribute values (name-value pairs).
126
     */
127
    public function getDirtyAttributes(array $names = null): array
128
    {
129
        $attributes = $this->getAttributes($names);
130
131
        if ($this->oldAttributes === null) {
132
            return $attributes;
133
        }
134
135
        $result = array_diff_key($attributes, $this->oldAttributes);
136
137
        foreach (array_diff_key($attributes, $result) as $name => $value) {
138
            if ($value !== $this->oldAttributes[$name]) {
139
                $result[$name] = $value;
140
            }
141
        }
142
143
        return $result;
144
    }
145
146
    public function getOldAttributes(): array
147
    {
148
        return $this->oldAttributes ?? [];
149
    }
150
151
    /**
152
     * @throws InvalidConfigException
153
     * @throws Exception
154
     */
155
    public function getOldPrimaryKey(bool $asArray = false): mixed
156
    {
157
        $keys = $this->primaryKey();
158
159
        if (empty($keys)) {
160
            throw new Exception(
161
                static::class . ' does not have a primary key. You should either define a primary key for '
162
                . 'the corresponding table or override the primaryKey() method.'
163
            );
164
        }
165
166
        if (count($keys) === 1) {
167
            $key = $this->oldAttributes[$keys[0]] ?? null;
168
169
            return $asArray ? [$keys[0] => $key] : $key;
170
        }
171
172
        $values = [];
173
174
        foreach ($keys as $name) {
175
            $values[$name] = $this->oldAttributes[$name] ?? null;
176
        }
177
178
        return $values;
179
    }
180
181
    public function getPrimaryKey(bool $asArray = false): mixed
182
    {
183
        $keys = $this->primaryKey();
184
185
        if (count($keys) === 1) {
186
            return $asArray ? [$keys[0] => $this->getAttribute($keys[0])] : $this->getAttribute($keys[0]);
187
        }
188
189
        $values = [];
190
191
        foreach ($keys as $name) {
192
            $values[$name] = $this->getAttribute($name);
193
        }
194
195
        return $values;
196
    }
197
198
    /**
199
     * Returns all populated related records.
200
     *
201
     * @return array An array of related records indexed by relation names.
202
     *
203
     * {@see relationQuery()}
204
     */
205
    public function getRelatedRecords(): array
206
    {
207
        return $this->related;
208
    }
209
210
    public function hasAttribute(string $name): bool
211
    {
212
        return in_array($name, $this->attributes(), true);
213
    }
214
215
    /**
216
     * Declares a `has-many` relation.
217
     *
218
     * The declaration is returned in terms of a relational {@see ActiveQuery} instance  through which the related
219
     * record can be queried and retrieved back.
220
     *
221
     * A `has-many` relation means that there are multiple related records matching the criteria set by this relation,
222
     * e.g., a customer has many orders.
223
     *
224
     * For example, to declare the `orders` relation for `Customer` class, we can write the following code in the
225
     * `Customer` class:
226
     *
227
     * ```php
228
     * public function getOrders()
229
     * {
230
     *     return $this->hasMany(Order::className(), ['customer_id' => 'id']);
231
     * }
232
     * ```
233
     *
234
     * Note that in the above, the 'customer_id' key in the `$link` parameter refers to an attribute name in the related
235
     * class `Order`, while the 'id' value refers to an attribute name in the current AR class.
236
     *
237
     * Call methods declared in {@see ActiveQuery} to further customize the relation.
238
     *
239
     * @param string $class The class name of the related record
240
     * @param array $link The primary-foreign key constraint. The keys of the array refer to the attributes of the
241
     * record associated with the `$class` model, while the values of the array refer to the corresponding attributes in
242
     * **this** AR class.
243
     *
244
     * @return ActiveQueryInterface The relational query object.
245
     *
246
     * @psalm-param class-string<ActiveRecordInterface> $class
247
     */
248
    public function hasMany(string $class, array $link): ActiveQueryInterface
249
    {
250
        return $this->createRelationQuery($class, $link, true);
251
    }
252
253
    /**
254
     * Declares a `has-one` relation.
255
     *
256
     * The declaration is returned in terms of a relational {@see ActiveQuery} instance through which the related record
257
     * can be queried and retrieved back.
258
     *
259
     * A `has-one` relation means that there is at most one related record matching the criteria set by this relation,
260
     * e.g., a customer has one country.
261
     *
262
     * For example, to declare the `country` relation for `Customer` class, we can write the following code in the
263
     * `Customer` class:
264
     *
265
     * ```php
266
     * public function getCountry()
267
     * {
268
     *     return $this->hasOne(Country::className(), ['id' => 'country_id']);
269
     * }
270
     * ```
271
     *
272
     * Note that in the above, the 'id' key in the `$link` parameter refers to an attribute name in the related class
273
     * `Country`, while the 'country_id' value refers to an attribute name in the current AR class.
274
     *
275
     * Call methods declared in {@see ActiveQuery} to further customize the relation.
276
     *
277
     * @param string $class The class name of the related record.
278
     * @param array $link The primary-foreign key constraint. The keys of the array refer to the attributes of the
279
     * record associated with the `$class` model, while the values of the array refer to the corresponding attributes in
280
     * **this** AR class.
281
     *
282
     * @return ActiveQueryInterface The relational query object.
283
     *
284
     * @psalm-param class-string<ActiveRecordInterface> $class
285
     */
286
    public function hasOne(string $class, array $link): ActiveQueryInterface
287
    {
288
        return $this->createRelationQuery($class, $link, false);
289
    }
290
291
    public function insert(array $attributes = null): bool
292
    {
293
        return $this->insertInternal($attributes);
294
    }
295
296
    abstract protected function insertInternal(array $attributes = null): bool;
297
298
    /**
299
     * @psalm-param class-string<ActiveRecordInterface> $arClass
300
     */
301
    public function instantiateQuery(string $arClass): ActiveQueryInterface
302
    {
303
        return new ActiveQuery($arClass, $this->db, $this->arFactory);
304
    }
305
306
    /**
307
     * Returns a value indicating whether the named attribute has been changed.
308
     *
309
     * @param string $name The name of the attribute.
310
     * @param bool $identical Whether the comparison of new and old value is made for identical values using `===`,
311
     * defaults to `true`. Otherwise `==` is used for comparison.
312
     *
313
     * @return bool Whether the attribute has been changed.
314
     */
315
    public function isAttributeChanged(string $name, bool $identical = true): bool
0 ignored issues
show
Unused Code introduced by
The parameter $identical is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

315
    public function isAttributeChanged(string $name, /** @scrutinizer ignore-unused */ bool $identical = true): bool

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
316
    {
317
        if (isset($this->oldAttributes[$name])) {
318
            return $this->$name !== $this->oldAttributes[$name];
319
        }
320
321
        return false;
322
    }
323
324
    public function isPrimaryKey(array $keys): bool
325
    {
326
        $pks = $this->primaryKey();
327
328
        return count($keys) === count($pks)
329
            && count(array_intersect($keys, $pks)) === count($pks);
330
    }
331
332
    public function isRelationPopulated(string $name): bool
333
    {
334
        return array_key_exists($name, $this->related);
335
    }
336
337
    public function link(string $name, ActiveRecordInterface $arClass, array $extraColumns = []): void
338
    {
339
        $viaClass = null;
340
        $viaTable = null;
341
        $relation = $this->relationQuery($name);
342
        $via = $relation?->getVia();
343
344
        if ($via !== null) {
345
            if ($this->getIsNewRecord() || $arClass->getIsNewRecord()) {
346
                throw new InvalidCallException(
347
                    'Unable to link models: the models being linked cannot be newly created.'
348
                );
349
            }
350
351
            if (is_array($via)) {
352
                [$viaName, $viaRelation] = $via;
353
                /** @psalm-var ActiveQueryInterface $viaRelation */
354
                $viaClass = $viaRelation->getARInstance();
355
                // unset $viaName so that it can be reloaded to reflect the change.
356
                /** @psalm-var string $viaName */
357
                unset($this->related[$viaName]);
358
            }
359
360
            if ($via instanceof ActiveQueryInterface) {
361
                $viaRelation = $via;
362
                $from = $via->getFrom();
363
                /** @psalm-var string $viaTable */
364
                $viaTable = reset($from);
365
            }
366
367
            $columns = [];
368
369
            /** @psalm-var ActiveQueryInterface|null $viaRelation */
370
            $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...
371
372
            /**
373
             * @psalm-var string $a
374
             * @psalm-var string $b
375
             */
376
            foreach ($viaLink as $a => $b) {
377
                /** @psalm-var mixed */
378
                $columns[$a] = $this->$b;
379
            }
380
381
            $link = $relation?->getLink() ?? [];
382
383
            /**
384
             * @psalm-var string $a
385
             * @psalm-var string $b
386
             */
387
            foreach ($link as $a => $b) {
388
                /** @psalm-var mixed */
389
                $columns[$b] = $arClass->$a;
390
            }
391
392
            /**
393
             * @psalm-var string $k
394
             * @psalm-var mixed $v
395
             */
396
            foreach ($extraColumns as $k => $v) {
397
                /** @psalm-var mixed */
398
                $columns[$k] = $v;
399
            }
400
401
            if ($viaClass instanceof ActiveRecordInterface && is_array($via)) {
402
                /**
403
                 * @psalm-var string $column
404
                 * @psalm-var mixed $value
405
                 */
406
                foreach ($columns as $column => $value) {
407
                    $viaClass->$column = $value;
408
                }
409
410
                $viaClass->insert();
411
            } elseif (is_string($viaTable)) {
412
                $this->db->createCommand()->insert($viaTable, $columns)->execute();
413
            }
414
        } elseif ($relation instanceof ActiveQueryInterface) {
415
            $link = $relation->getLink();
416
            $p1 = $arClass->isPrimaryKey(array_keys($link));
417
            $p2 = $this->isPrimaryKey(array_values($link));
418
419
            if ($p1 && $p2) {
420
                if ($this->getIsNewRecord() && $arClass->getIsNewRecord()) {
421
                    throw new InvalidCallException('Unable to link models: at most one model can be newly created.');
422
                }
423
424
                if ($this->getIsNewRecord()) {
425
                    $this->bindModels(array_flip($link), $this, $arClass);
426
                } else {
427
                    $this->bindModels($link, $arClass, $this);
428
                }
429
            } elseif ($p1) {
430
                $this->bindModels(array_flip($link), $this, $arClass);
431
            } elseif ($p2) {
432
                $this->bindModels($link, $arClass, $this);
433
            } else {
434
                throw new InvalidCallException(
435
                    'Unable to link models: the link defining the relation does not involve any primary key.'
436
                );
437
            }
438
        }
439
440
        // update lazily loaded related objects
441
        if ($relation instanceof ActiveRecordInterface && !$relation->getMultiple()) {
0 ignored issues
show
Bug introduced by
The method getMultiple() does not exist on Yiisoft\ActiveRecord\ActiveRecordInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

441
        if ($relation instanceof ActiveRecordInterface && !$relation->/** @scrutinizer ignore-call */ getMultiple()) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
442
            $this->related[$name] = $arClass;
443
        } elseif (isset($this->related[$name])) {
444
            $indexBy = $relation?->getIndexBy();
445
            if ($indexBy !== null) {
446
                if ($indexBy instanceof Closure) {
447
                    $index = $relation?->indexBy($arClass::class);
448
                } else {
449
                    $index = $arClass->$indexBy;
450
                }
451
452
                if ($index !== null) {
453
                    $this->related[$name][$index] = $arClass;
454
                }
455
            } else {
456
                $this->related[$name][] = $arClass;
457
            }
458
        }
459
    }
460
461
    /**
462
     * Marks an attribute dirty.
463
     *
464
     * This method may be called to force updating a record when calling {@see update()}, even if there is no change
465
     * being made to the record.
466
     *
467
     * @param string $name The attribute name.
468
     */
469
    public function markAttributeDirty(string $name): void
470
    {
471
        if ($this->oldAttributes !== null && $name !== '') {
472
            unset($this->oldAttributes[$name]);
473
        }
474
    }
475
476
    /**
477
     * Returns the name of the column that stores the lock version for implementing optimistic locking.
478
     *
479
     * Optimistic locking allows multiple users to access the same record for edits and avoids potential conflicts. In
480
     * case when a user attempts to save the record upon some staled data (because another user has modified the data),
481
     * a {@see StaleObjectException} exception will be thrown, and the update or deletion is skipped.
482
     *
483
     * Optimistic locking is only supported by {@see update()} and {@see delete()}.
484
     *
485
     * To use Optimistic locking:
486
     *
487
     * 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`.
488
     *    Override this method to return the name of this column.
489
     * 2. In the Web form that collects the user input, add a hidden field that stores the lock version of the recording
490
     *    being updated.
491
     * 3. In the controller action that does the data updating, try to catch the {@see StaleObjectException} and
492
     *    implement necessary business logic (e.g. merging the changes, prompting stated data) to resolve the conflict.
493
     *
494
     * @return string|null The column name that stores the lock version of a table row. If `null` is returned (default
495
     * implemented), optimistic locking will not be supported.
496
     */
497
    public function optimisticLock(): string|null
498
    {
499
        return null;
500
    }
501
502
    /**
503
     * Populates an active record object using a row of data from the database/storage.
504
     *
505
     * This is an internal method meant to be called to create active record objects after fetching data from the
506
     * database. It is mainly used by {@see ActiveQuery} to populate the query results into active records.
507
     *
508
     * @param array|object $row Attribute values (name => value).
509
     */
510
    public function populateRecord(array|object $row): void
511
    {
512
        foreach ($row as $name => $value) {
513
            $this->populateAttribute($name, $value);
514
            $this->oldAttributes[$name] = $value;
515
        }
516
517
        $this->related = [];
518
        $this->relationsDependencies = [];
519
    }
520
521
    public function populateRelation(string $name, array|ActiveRecordInterface|null $records): void
522
    {
523
        foreach ($this->relationsDependencies as &$relationNames) {
524
            unset($relationNames[$name]);
525
        }
526
527
        $this->related[$name] = $records;
528
    }
529
530
    /**
531
     * Repopulates this active record with the latest data.
532
     *
533
     * @return bool Whether the row still exists in the database. If `true`, the latest data will be populated to this
534
     * active record. Otherwise, this record will remain unchanged.
535
     */
536
    public function refresh(): bool
537
    {
538
        $record = $this->instantiateQuery(static::class)->findOne($this->getPrimaryKey(true));
539
540
        return $this->refreshInternal($record);
541
    }
542
543
    public function relation(string $name): ActiveRecordInterface|array|null
544
    {
545
        if (array_key_exists($name, $this->related)) {
546
            return $this->related[$name];
547
        }
548
549
        return $this->retrieveRelation($name);
550
    }
551
552
    /**
553
     * @inheritdoc
554
     *
555
     * Relations can be defined using {@see hasOne()} and {@see hasMany()} methods. For example:
556
     *
557
     * ```php
558
     * public function relationQuery(string $name): ActiveQueryInterface
559
     * {
560
     *     return match ($name) {
561
     *         'orders' => $this->hasMany(Order::class, ['customer_id' => 'id']),
562
     *         'country' => $this->hasOne(Country::class, ['id' => 'country_id']),
563
     *         default => parent::relationQuery($name),
564
     *     };
565
     * }
566
     * ```
567
     */
568
    public function relationQuery(string $name, bool $throwException = true): ActiveQueryInterface|null
569
    {
570
        if (!$throwException) {
571
            return null;
572
        }
573
574
        throw new InvalidArgumentException(static::class . ' has no relation named "' . $name . '".');
575
    }
576
577
    public function resetRelation(string $name): void
578
    {
579
        foreach ($this->relationsDependencies as &$relationNames) {
580
            unset($relationNames[$name]);
581
        }
582
583
        unset($this->related[$name]);
584
    }
585
586
    protected function retrieveRelation(string $name): ActiveRecordInterface|array|null
587
    {
588
        /** @var ActiveQueryInterface $query */
589
        $query = $this->relationQuery($name);
590
591
        $this->setRelationDependencies($name, $query);
592
593
        return $this->related[$name] = $query->relatedRecords();
594
    }
595
596
    /**
597
     * Saves the current record.
598
     *
599
     * This method will call {@see insert()} when {@see getIsNewRecord} is `true`, or {@see update()} when
600
     * {@see getIsNewRecord} is `false`.
601
     *
602
     * For example, to save a customer record:
603
     *
604
     * ```php
605
     * $customer = new Customer($db);
606
     * $customer->name = $name;
607
     * $customer->email = $email;
608
     * $customer->save();
609
     * ```
610
     *
611
     * @param array|null $attributeNames List of attribute names that need to be saved. Defaults to null, meaning all
612
     * attributes that are loaded from DB will be saved.
613
     *
614
     * @throws InvalidConfigException
615
     * @throws StaleObjectException
616
     * @throws Throwable
617
     *
618
     * @return bool Whether the saving succeeded (i.e. no validation errors occurred).
619
     */
620
    public function save(array $attributeNames = null): bool
621
    {
622
        if ($this->getIsNewRecord()) {
623
            return $this->insert($attributeNames);
624
        }
625
626
        $this->update($attributeNames);
627
628
        return true;
629
    }
630
631
    public function setAttribute(string $name, mixed $value): void
632
    {
633
        if (
634
            isset($this->relationsDependencies[$name])
635
            && (!isset(get_object_vars($this)[$name]) || $this->$name !== $value)
636
        ) {
637
            $this->resetDependentRelations($name);
638
        }
639
640
        $this->$name = $value;
641
    }
642
643
    /**
644
     * Sets the attribute values in a massive way.
645
     *
646
     * @param array $values Attribute values (name => value) to be assigned to the model.
647
     *
648
     * {@see attributes()}
649
     */
650
    public function setAttributes(array $values): void
651
    {
652
        $values = array_intersect_key($values, array_flip($this->attributes()));
653
654
        /** @psalm-var mixed $value */
655
        foreach ($values as $name => $value) {
656
            $this->$name = $value;
657
        }
658
    }
659
660
    /**
661
     * Sets the value indicating whether the record is new.
662
     *
663
     * @param bool $value whether the record is new and should be inserted when calling {@see save()}.
664
     *
665
     * @see getIsNewRecord()
666
     */
667
    public function setIsNewRecord(bool $value): void
668
    {
669
        $this->oldAttributes = $value ? null : get_object_vars($this);
670
    }
671
672
    /**
673
     * Sets the old value of the named attribute.
674
     *
675
     * @param string $name The attribute name.
676
     *
677
     * @throws InvalidArgumentException If the named attribute does not exist.
678
     *
679
     * {@see hasAttribute()}
680
     */
681
    public function setOldAttribute(string $name, mixed $value): void
682
    {
683
        if (isset($this->oldAttributes[$name]) || $this->hasAttribute($name)) {
684
            $this->oldAttributes[$name] = $value;
685
        } else {
686
            throw new InvalidArgumentException(static::class . ' has no attribute named "' . $name . '".');
687
        }
688
    }
689
690
    /**
691
     * Sets the old attribute values.
692
     *
693
     * All existing old attribute values will be discarded.
694
     *
695
     * @param array|null $values Old attribute values to be set. If set to `null` this record is considered to be
696
     * {@see isNewRecord|new}.
697
     */
698
    public function setOldAttributes(array $values = null): void
699
    {
700
        $this->oldAttributes = $values;
701
    }
702
703
    public function update(array $attributeNames = null): int
704
    {
705
        return $this->updateInternal($attributeNames);
706
    }
707
708
    public function updateAll(array $attributes, array|string $condition = [], array $params = []): int
709
    {
710
        $command = $this->db->createCommand();
711
712
        $command->update($this->getTableName(), $attributes, $condition, $params);
713
714
        return $command->execute();
715
    }
716
717
    public function updateAttributes(array $attributes): int
718
    {
719
        $attrs = [];
720
721
        foreach ($attributes as $name => $value) {
722
            if (is_int($name)) {
723
                $attrs[] = $value;
724
            } else {
725
                $this->setAttribute($name, $value);
726
                $attrs[] = $name;
727
            }
728
        }
729
730
        $values = $this->getDirtyAttributes($attrs);
731
732
        if (empty($values) || $this->getIsNewRecord()) {
733
            return 0;
734
        }
735
736
        $rows = $this->updateAll($values, $this->getOldPrimaryKey(true));
737
738
        $this->oldAttributes = array_merge($this->oldAttributes ?? [], $values);
739
740
        return $rows;
741
    }
742
743
    /**
744
     * Updates the whole table using the provided counter changes and conditions.
745
     *
746
     * For example, to increment all customers' age by 1,
747
     *
748
     * ```php
749
     * $customer = new Customer($db);
750
     * $customer->updateAllCounters(['age' => 1]);
751
     * ```
752
     *
753
     * Note that this method will not trigger any events.
754
     *
755
     * @param array $counters The counters to be updated (attribute name => increment value).
756
     * Use negative values if you want to decrement the counters.
757
     * @param array|string $condition The conditions that will be put in the WHERE part of the UPDATE SQL. Please refer
758
     * to {@see Query::where()} on how to specify this parameter.
759
     * @param array $params The parameters (name => value) to be bound to the query.
760
     *
761
     * Do not name the parameters as `:bp0`, `:bp1`, etc., because they are used internally by this method.
762
     *
763
     * @throws Exception
764
     * @throws InvalidConfigException
765
     * @throws Throwable
766
     *
767
     * @return int The number of rows updated.
768
     */
769
    public function updateAllCounters(array $counters, array|string $condition = '', array $params = []): int
770
    {
771
        $n = 0;
772
773
        /** @psalm-var array<string, int> $counters */
774
        foreach ($counters as $name => $value) {
775
            $counters[$name] = new Expression("[[$name]]+:bp$n", [":bp$n" => $value]);
776
            $n++;
777
        }
778
779
        $command = $this->db->createCommand();
780
        $command->update($this->getTableName(), $counters, $condition, $params);
781
782
        return $command->execute();
783
    }
784
785
    /**
786
     * Updates one or several counter columns for the current AR object.
787
     *
788
     * Note that this method differs from {@see updateAllCounters()} in that it only saves counters for the current AR
789
     * object.
790
     *
791
     * An example usage is as follows:
792
     *
793
     * ```php
794
     * $post = new Post($db);
795
     * $post->updateCounters(['view_count' => 1]);
796
     * ```
797
     *
798
     * @param array $counters The counters to be updated (attribute name => increment value), use negative values if you
799
     * want to decrement the counters.
800
     *
801
     * @psalm-param array<string, int> $counters
802
     *
803
     * @throws Exception
804
     * @throws NotSupportedException
805
     *
806
     * @return bool Whether the saving is successful.
807
     *
808
     * {@see updateAllCounters()}
809
     */
810
    public function updateCounters(array $counters): bool
811
    {
812
        if ($this->updateAllCounters($counters, $this->getOldPrimaryKey(true)) === 0) {
813
            return false;
814
        }
815
816
        foreach ($counters as $name => $value) {
817
            $value += $this->$name ?? 0;
818
            $this->$name = $value;
819
            $this->oldAttributes[$name] = $value;
820
        }
821
822
        return true;
823
    }
824
825
    public function unlink(string $name, ActiveRecordInterface $arClass, bool $delete = false): void
826
    {
827
        $viaClass = null;
828
        $viaTable = null;
829
        $relation = $this->relationQuery($name);
830
        $viaRelation = $relation?->getVia();
831
832
        if ($viaRelation !== null) {
833
            if (is_array($viaRelation)) {
834
                [$viaName, $viaRelation] = $viaRelation;
835
                /** @psalm-var ActiveQueryInterface $viaRelation */
836
                $viaClass = $viaRelation->getARInstance();
837
                /** @psalm-var string $viaName */
838
                unset($this->related[$viaName]);
839
            }
840
841
            $columns = [];
842
            $nulls = [];
843
844
            if ($viaRelation instanceof ActiveQueryInterface) {
845
                $from = $viaRelation->getFrom();
846
                /** @psalm-var mixed $viaTable */
847
                $viaTable = reset($from);
848
849
                foreach ($viaRelation->getLink() as $a => $b) {
850
                    /** @psalm-var mixed */
851
                    $columns[$a] = $this->$b;
852
                }
853
854
                $link = $relation?->getLink() ?? [];
855
856
                foreach ($link as $a => $b) {
857
                    /** @psalm-var mixed */
858
                    $columns[$b] = $arClass->$a;
859
                }
860
861
                $nulls = array_fill_keys(array_keys($columns), null);
862
863
                if ($viaRelation->getOn() !== null) {
864
                    $columns = ['and', $columns, $viaRelation->getOn()];
865
                }
866
            }
867
868
            if ($viaClass instanceof ActiveRecordInterface) {
869
                if ($delete) {
870
                    $viaClass->deleteAll($columns);
871
                } else {
872
                    $viaClass->updateAll($nulls, $columns);
873
                }
874
            } elseif (is_string($viaTable)) {
875
                $command = $this->db->createCommand();
876
                if ($delete) {
877
                    $command->delete($viaTable, $columns)->execute();
878
                } else {
879
                    $command->update($viaTable, $nulls, $columns)->execute();
880
                }
881
            }
882
        } elseif ($relation instanceof ActiveQueryInterface) {
883
            if ($this->isPrimaryKey($relation->getLink())) {
884
                if ($delete) {
885
                    $arClass->delete();
886
                } else {
887
                    foreach ($relation->getLink() as $a => $b) {
888
                        $arClass->$a = null;
889
                    }
890
                    $arClass->save();
891
                }
892
            } elseif ($arClass->isPrimaryKey(array_keys($relation->getLink()))) {
893
                foreach ($relation->getLink() as $a => $b) {
894
                    /** @psalm-var mixed $values */
895
                    $values = $this->$b;
896
                    /** relation via array valued attribute */
897
                    if (is_array($values)) {
898
                        if (($key = array_search($arClass->$a, $values, false)) !== false) {
899
                            unset($values[$key]);
900
                            $this->$b = array_values($values);
901
                        }
902
                    } else {
903
                        $this->$b = null;
904
                    }
905
                }
906
                $delete ? $this->delete() : $this->save();
907
            } else {
908
                throw new InvalidCallException('Unable to unlink models: the link does not involve any primary key.');
909
            }
910
        }
911
912
        if ($relation instanceof ActiveQueryInterface && !$relation->getMultiple()) {
913
            unset($this->related[$name]);
914
        } elseif (isset($this->related[$name]) && is_array($this->related[$name])) {
915
            /** @psalm-var array<array-key, ActiveRecordInterface> $related */
916
            $related = $this->related[$name];
917
            foreach ($related as $a => $b) {
918
                if ($arClass->getPrimaryKey() === $b->getPrimaryKey()) {
919
                    unset($this->related[$name][$a]);
920
                }
921
            }
922
        }
923
    }
924
925
    /**
926
     * Destroys the relationship in current model.
927
     *
928
     * The active record with the foreign key of the relationship will be deleted if `$delete` is `true`. Otherwise, the
929
     * foreign key will be set `null` and the model will be saved without validation.
930
     *
931
     * Note that to destroy the relationship without removing records make sure your keys can be set to null.
932
     *
933
     * @param string $name The case sensitive name of the relationship, e.g. `orders` for a relation defined via
934
     * `getOrders()` method.
935
     * @param bool $delete Whether to delete the model that contains the foreign key.
936
     *
937
     * @throws Exception
938
     * @throws ReflectionException
939
     * @throws StaleObjectException
940
     * @throws Throwable
941
     */
942
    public function unlinkAll(string $name, bool $delete = false): void
943
    {
944
        $viaClass = null;
945
        $viaTable = null;
946
        $relation = $this->relationQuery($name);
947
        $viaRelation = $relation?->getVia();
948
949
        if ($relation instanceof ActiveQueryInterface && $viaRelation !== null) {
950
            if (is_array($viaRelation)) {
951
                [$viaName, $viaRelation] = $viaRelation;
952
                /** @psalm-var ActiveQueryInterface $viaRelation */
953
                $viaClass = $viaRelation->getARInstance();
954
                /** @psalm-var string $viaName */
955
                unset($this->related[$viaName]);
956
            } else {
957
                $from = $viaRelation->getFrom();
958
                /** @psalm-var mixed $viaTable */
959
                $viaTable = reset($from);
960
            }
961
962
            $condition = [];
963
            $nulls = [];
964
965
            if ($viaRelation instanceof ActiveQueryInterface) {
966
                foreach ($viaRelation->getLink() as $a => $b) {
967
                    $nulls[$a] = null;
968
                    /** @psalm-var mixed */
969
                    $condition[$a] = $this->$b;
970
                }
971
972
                if (!empty($viaRelation->getWhere())) {
973
                    $condition = ['and', $condition, $viaRelation->getWhere()];
974
                }
975
976
                if (!empty($viaRelation->getOn())) {
977
                    $condition = ['and', $condition, $viaRelation->getOn()];
978
                }
979
            }
980
981
            if ($viaClass instanceof ActiveRecordInterface) {
982
                if ($delete) {
983
                    $viaClass->deleteAll($condition);
984
                } else {
985
                    $viaClass->updateAll($nulls, $condition);
986
                }
987
            } elseif (is_string($viaTable)) {
988
                $command = $this->db->createCommand();
989
                if ($delete) {
990
                    $command->delete($viaTable, $condition)->execute();
991
                } else {
992
                    $command->update($viaTable, $nulls, $condition)->execute();
993
                }
994
            }
995
        } elseif ($relation instanceof ActiveQueryInterface) {
996
            $relatedModel = $relation->getARInstance();
997
998
            $link = $relation->getLink();
999
            if (!$delete && count($link) === 1 && is_array($this->{$b = reset($link)})) {
1000
                /** relation via array valued attribute */
1001
                $this->$b = [];
1002
                $this->save();
1003
            } else {
1004
                $nulls = [];
1005
                $condition = [];
1006
1007
                foreach ($relation->getLink() as $a => $b) {
1008
                    $nulls[$a] = null;
1009
                    /** @psalm-var mixed */
1010
                    $condition[$a] = $this->$b;
1011
                }
1012
1013
                if (!empty($relation->getWhere())) {
1014
                    $condition = ['and', $condition, $relation->getWhere()];
1015
                }
1016
1017
                if (!empty($relation->getOn())) {
1018
                    $condition = ['and', $condition, $relation->getOn()];
1019
                }
1020
1021
                if ($delete) {
1022
                    $relatedModel->deleteAll($condition);
1023
                } else {
1024
                    $relatedModel->updateAll($nulls, $condition);
1025
                }
1026
            }
1027
        }
1028
1029
        unset($this->related[$name]);
1030
    }
1031
1032
    /**
1033
     * Sets relation dependencies for a property.
1034
     *
1035
     * @param string $name property name.
1036
     * @param ActiveQueryInterface $relation relation instance.
1037
     * @param string|null $viaRelationName intermediate relation.
1038
     */
1039
    protected function setRelationDependencies(
1040
        string $name,
1041
        ActiveQueryInterface $relation,
1042
        string $viaRelationName = null
1043
    ): void {
1044
        $via = $relation->getVia();
1045
1046
        if (empty($via)) {
1047
            foreach ($relation->getLink() as $attribute) {
1048
                $this->relationsDependencies[$attribute][$name] = $name;
1049
                if ($viaRelationName !== null) {
1050
                    $this->relationsDependencies[$attribute][] = $viaRelationName;
1051
                }
1052
            }
1053
        } elseif ($via instanceof ActiveQueryInterface) {
1054
            $this->setRelationDependencies($name, $via);
1055
        } else {
1056
            /**
1057
             * @psalm-var string|null $viaRelationName
1058
             * @psalm-var ActiveQueryInterface $viaQuery
1059
             */
1060
            [$viaRelationName, $viaQuery] = $via;
1061
            $this->setRelationDependencies($name, $viaQuery, $viaRelationName);
1062
        }
1063
    }
1064
1065
    /**
1066
     * Creates a query instance for `has-one` or `has-many` relation.
1067
     *
1068
     * @param string $arClass The class name of the related record.
1069
     * @param array $link The primary-foreign key constraint.
1070
     * @param bool $multiple Whether this query represents a relation to more than one record.
1071
     *
1072
     * @return ActiveQueryInterface The relational query object.
1073
     *
1074
     * @psalm-param class-string<ActiveRecordInterface> $arClass
1075
1076
     * {@see hasOne()}
1077
     * {@see hasMany()}
1078
     */
1079
    protected function createRelationQuery(string $arClass, array $link, bool $multiple): ActiveQueryInterface
1080
    {
1081
        return $this->instantiateQuery($arClass)->primaryModel($this)->link($link)->multiple($multiple);
1082
    }
1083
1084
    /**
1085
     * {@see delete()}
1086
     *
1087
     * @throws Exception
1088
     * @throws StaleObjectException
1089
     * @throws Throwable
1090
     *
1091
     * @return int The number of rows deleted.
1092
     */
1093
    protected function deleteInternal(): int
1094
    {
1095
        /**
1096
         * We do not check the return value of deleteAll() because it's possible the record is already deleted in
1097
         * the database and thus the method will return 0
1098
         */
1099
        $condition = $this->getOldPrimaryKey(true);
1100
        $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\Abs...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...
1101
1102
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
1103
            $condition[$lock] = $this->$lock;
1104
1105
            $result = $this->deleteAll($condition);
1106
1107
            if ($result === 0) {
1108
                throw new StaleObjectException('The object being deleted is outdated.');
1109
            }
1110
        } else {
1111
            $result = $this->deleteAll($condition);
1112
        }
1113
1114
        $this->setOldAttributes();
1115
1116
        return $result;
1117
    }
1118
1119
    /**
1120
     * Repopulates this active record with the latest data from a newly fetched instance.
1121
     *
1122
     * @param ActiveRecordInterface|array|null $record The record to take attributes from.
1123
     *
1124
     * @return bool Whether refresh was successful.
1125
     *
1126
     * {@see refresh()}
1127
     */
1128
    protected function refreshInternal(array|ActiveRecordInterface $record = null): bool
1129
    {
1130
        if ($record === null || is_array($record)) {
0 ignored issues
show
introduced by
The condition is_array($record) is always true.
Loading history...
1131
            return false;
1132
        }
1133
1134
        foreach ($this->attributes() as $name) {
1135
            $this->setAttribute($name, $record->getAttribute($name));
1136
        }
1137
1138
        $this->oldAttributes = $record->getOldAttributes();
1139
        $this->related = [];
1140
        $this->relationsDependencies = [];
1141
1142
        return true;
1143
    }
1144
1145
    /**
1146
     * {@see update()}
1147
     *
1148
     * @param array|null $attributes Attributes to update.
1149
     *
1150
     * @throws Exception
1151
     * @throws NotSupportedException
1152
     * @throws StaleObjectException
1153
     *
1154
     * @return int The number of rows affected.
1155
     */
1156
    protected function updateInternal(array $attributes = null): int
1157
    {
1158
        $values = $this->getDirtyAttributes($attributes);
1159
1160
        if (empty($values)) {
1161
            return 0;
1162
        }
1163
1164
        $condition = $this->getOldPrimaryKey(true);
1165
        $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\Abs...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...
1166
1167
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
1168
            $lockValue = $this->$lock;
1169
1170
            $condition[$lock] = $lockValue;
1171
            $values[$lock] = ++$lockValue;
1172
1173
            $rows = $this->updateAll($values, $condition);
1174
1175
            if ($rows === 0) {
1176
                throw new StaleObjectException('The object being updated is outdated.');
1177
            }
1178
1179
            $this->$lock = $lockValue;
1180
        } else {
1181
            $rows = $this->updateAll($values, $condition);
1182
        }
1183
1184
        $this->oldAttributes = array_merge($this->oldAttributes ?? [], $values);
1185
1186
        return $rows;
1187
    }
1188
1189
    private function bindModels(
1190
        array $link,
1191
        ActiveRecordInterface $foreignModel,
1192
        ActiveRecordInterface $primaryModel
1193
    ): void {
1194
        /** @psalm-var string[] $link */
1195
        foreach ($link as $fk => $pk) {
1196
            /** @psalm-var mixed $value */
1197
            $value = $primaryModel->$pk;
1198
1199
            if ($value === null) {
1200
                throw new InvalidCallException(
1201
                    'Unable to link active record: the primary key of ' . $primaryModel::class . ' is null.'
1202
                );
1203
            }
1204
1205
            /**
1206
             * relation via array valued attribute
1207
             *
1208
             * @psalm-suppress MixedArrayAssignment
1209
             */
1210
            if (is_array($foreignModel->getAttribute($fk))) {
1211
                /** @psalm-var mixed */
1212
                $foreignModel->{$fk}[] = $value;
1213
            } else {
1214
                $foreignModel->setAttribute($fk, $value);
1215
            }
1216
        }
1217
1218
        $foreignModel->save();
1219
    }
1220
1221
    protected function hasDependentRelations(string $attribute): bool
1222
    {
1223
        return isset($this->relationsDependencies[$attribute]);
1224
    }
1225
1226
    /**
1227
     * Resets dependent related models checking if their links contain specific attribute.
1228
     *
1229
     * @param string $attribute The changed attribute name.
1230
     */
1231
    protected function resetDependentRelations(string $attribute): void
1232
    {
1233
        foreach ($this->relationsDependencies[$attribute] as $relation) {
1234
            unset($this->related[$relation]);
1235
        }
1236
1237
        unset($this->relationsDependencies[$attribute]);
1238
    }
1239
1240
    public function getTableName(): string
1241
    {
1242
        if ($this->tableName === '') {
1243
            $this->tableName = '{{%' . DbStringHelper::pascalCaseToId(DbStringHelper::baseName(static::class)) . '}}';
1244
        }
1245
1246
        return $this->tableName;
1247
    }
1248
1249
    protected function populateAttribute(string $name, mixed $value): void
1250
    {
1251
        $this->$name = $value;
1252
    }
1253
}
1254