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

BaseActiveRecord::hasAttribute()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

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