Passed
Pull Request — master (#315)
by Sergei
16:31 queued 13:33
created

BaseActiveRecord::unlink()   F

Complexity

Conditions 26
Paths 171

Size

Total Lines 95
Code Lines 60

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 26
CRAP Score 26

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 60
c 1
b 0
f 0
dl 0
loc 95
ccs 26
cts 26
cp 1
rs 3.575
cc 26
nc 171
nop 3
crap 26

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1059
1060 5
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
1061 5
            $condition[$lock] = $this->$lock;
1062 5
1063
            $result = $this->deleteAll($condition);
1064 4
1065 4
            if ($result === 0) {
1066 4
                throw new StaleObjectException('The object being deleted is outdated.');
1067
            }
1068
        } else {
1069 5
            $result = $this->deleteAll($condition);
1070 5
        }
1071 5
1072
        $this->setOldAttributes();
1073
1074 5
        return $result;
1075 5
    }
1076
1077 5
    /**
1078
     * Repopulates this active record with the latest data from a newly fetched instance.
1079 5
     *
1080 5
     * @param ActiveRecordInterface|array|null $record The record to take attributes from.
1081
     *
1082
     * @return bool Whether refresh was successful.
1083 5
     *
1084
     * {@see refresh()}
1085 5
     */
1086 5
    protected function refreshInternal(array|ActiveRecordInterface $record = null): bool
1087
    {
1088 5
        if ($record === null || is_array($record)) {
0 ignored issues
show
introduced by
The condition is_array($record) is always true.
Loading history...
1089
            return false;
1090
        }
1091
1092 4
        foreach ($this->attributes() as $name) {
1093 4
            $this->setAttribute($name, $record->getAttribute($name));
1094
        }
1095
1096 5
        $this->oldAttributes = $record->getOldAttributes();
1097
        $this->related = [];
1098
        $this->relationsDependencies = [];
1099
1100 5
        return true;
1101 5
    }
1102 5
1103 5
    /**
1104 5
     * {@see update()}
1105
     *
1106 5
     * @param array|null $attributes Attributes to update.
1107 5
     *
1108
     * @throws Exception
1109 5
     * @throws NotSupportedException
1110
     * @throws StaleObjectException
1111
     *
1112
     * @return int The number of rows affected.
1113
     */
1114
    protected function updateInternal(array $attributes = null): int
1115
    {
1116
        $values = $this->getDirtyAttributes($attributes);
1117
1118
        if (empty($values)) {
1119
            return 0;
1120
        }
1121
1122
        $condition = $this->getOldPrimaryKey(true);
1123
        $lock = $this->optimisticLock();
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $lock is correct as $this->optimisticLock() targeting Yiisoft\ActiveRecord\Bas...ecord::optimisticLock() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
1124
1125
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
1126
            $lockValue = $this->$lock;
1127
1128
            $condition[$lock] = $lockValue;
1129
            $values[$lock] = ++$lockValue;
1130 5
1131
            $rows = $this->updateAll($values, $condition);
1132 5
1133
            if ($rows === 0) {
1134 5
                throw new StaleObjectException('The object being updated is outdated.');
1135 5
            }
1136 5
1137
            $this->$lock = $lockValue;
1138
        } else {
1139
            $rows = $this->updateAll($values, $condition);
1140 5
        }
1141
1142
        $this->oldAttributes = array_merge($this->oldAttributes ?? [], $values);
1143
1144
        return $rows;
1145
    }
1146
1147
    private function bindModels(
1148
        array $link,
1149
        ActiveRecordInterface $foreignModel,
1150
        ActiveRecordInterface $primaryModel
1151
    ): void {
1152
        /** @psalm-var string[] $link */
1153
        foreach ($link as $fk => $pk) {
1154
            /** @psalm-var mixed $value */
1155
            $value = $primaryModel->$pk;
1156 22
1157
            if ($value === null) {
1158 22
                throw new InvalidCallException(
1159
                    'Unable to link active record: the primary key of ' . $primaryModel::class . ' is null.'
1160 22
                );
1161 8
            }
1162
1163 4
            /**
1164 4
             * relation via array valued attribute
1165 4
             *
1166
             * @psalm-suppress MixedArrayAssignment
1167 4
             */
1168 4
            if (is_array($foreignModel->getAttribute($fk))) {
1169 4
                /** @psalm-var mixed */
1170
                $foreignModel->{$fk}[] = $value;
1171
            } else {
1172 8
                $foreignModel->setAttribute($fk, $value);
1173 8
            }
1174
        }
1175 8
1176 8
        $foreignModel->save();
1177 8
    }
1178
1179
    /**
1180 8
     * Resets dependent related models checking if their links contain specific attribute.
1181
     *
1182
     * @param string $attribute The changed attribute name.
1183
     */
1184 8
    private function resetDependentRelations(string $attribute): void
1185
    {
1186
        foreach ($this->relationsDependencies[$attribute] as $relation) {
1187
            unset($this->related[$relation]);
1188 8
        }
1189
1190 4
        unset($this->relationsDependencies[$attribute]);
1191 4
    }
1192
1193 4
    public function getTableName(): string
1194
    {
1195
        if ($this->tableName === '') {
1196
            $this->tableName = '{{%' . DbStringHelper::pascalCaseToId(DbStringHelper::baseName(static::class)) . '}}';
1197 4
        }
1198 4
1199 4
        return $this->tableName;
1200
    }
1201
}
1202