AbstractActiveRecord::retrieveRelation()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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

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