Passed
Pull Request — master (#284)
by Sergei
03:41 queued 54s
created

BaseActiveRecord::deleteInternal()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 24
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 5.3915

Importance

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