Passed
Pull Request — master (#261)
by Wilmer
12:06
created

BaseActiveRecord::link()   F

Complexity

Conditions 27
Paths 122

Size

Total Lines 120
Code Lines 62

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 28
CRAP Score 27

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 62
c 2
b 0
f 0
dl 0
loc 120
ccs 28
cts 28
cp 1
rs 3.9833
cc 27
nc 122
nop 3
crap 27

How to fix   Long Method    Complexity   

Long Method

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

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

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord;
6
7
use ArrayAccess;
8
use Closure;
9
use IteratorAggregate;
10
use ReflectionException;
11
use Throwable;
12
use Yiisoft\Db\Connection\ConnectionInterface;
13
use Yiisoft\Db\Exception\Exception;
14
use Yiisoft\Db\Exception\InvalidArgumentException;
15
use Yiisoft\Db\Exception\InvalidCallException;
16
use Yiisoft\Db\Exception\InvalidConfigException;
17
use Yiisoft\Db\Exception\NotSupportedException;
18
use Yiisoft\Db\Exception\StaleObjectException;
19
use Yiisoft\Db\Expression\Expression;
20
use Yiisoft\Db\Helper\DbStringHelper;
21
22
use function array_combine;
23
use function array_flip;
24
use function array_intersect;
25
use function array_key_exists;
26
use function array_keys;
27
use function array_search;
28
use function array_values;
29
use function count;
30
use function in_array;
31
use function is_array;
32
use function is_int;
33
use function reset;
34
35
/**
36
 * ActiveRecord is the base class for classes representing relational data in terms of objects.
37
 *
38
 * See {@see ActiveRecord} for a concrete implementation.
39
 *
40
 * @template-implements ArrayAccess<int, mixed>
41
 * @template-implements IteratorAggregate<int>
42
 */
43
abstract class BaseActiveRecord implements ActiveRecordInterface, IteratorAggregate, ArrayAccess
44
{
45
    use BaseActiveRecordTrait;
46
47
    private array $attributes = [];
48
    private array|null $oldAttributes = null;
49
    private array $related = [];
50
    /** @psalm-var string[][] */
51
    private array $relationsDependencies = [];
52
53
    public function __construct(
54
        protected ConnectionInterface $db,
55
        private ActiveRecordFactory|null $arFactory = null,
56
        private string $tableName = ''
57
    ) {
58
    }
59
60
    public function delete(): false|int
61
    {
62
        $result = false;
63 719
64
        /**
65 719
         * We do not check the return value of deleteAll() because it's possible the record is already deleted in
66 719
         * the database and thus the method will return 0
67 719
         */
68
        $condition = $this->getOldPrimaryKey(true);
69
        $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...
70
71
        if ($lock !== null && is_array($condition)) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
72
            $condition[$lock] = $lock;
73
        }
74
75
        if ($condition !== null && is_array($condition) && count($condition) > 0) {
76
            $result = $this->deleteAll($condition);
77
        }
78
79
        if ($lock !== null && !$result) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
Bug Best Practice introduced by
The expression $result of type false|integer is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

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