Passed
Pull Request — master (#368)
by Sergei
03:20
created

AbstractActiveRecord::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

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

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