Passed
Pull Request — master (#283)
by Sergei
02:54
created

BaseActiveRecord   F

Complexity

Total Complexity 195

Size/Duplication

Total Lines 1209
Duplicated Lines 0 %

Test Coverage

Coverage 88.86%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
wmc 195
eloc 388
c 5
b 0
f 0
dl 0
loc 1209
ccs 303
cts 341
cp 0.8886
rs 2

48 Methods

Rating   Name   Duplication   Size   Complexity  
A hasOne() 0 3 1
A isAttributeChanged() 0 11 4
A instantiateQuery() 0 3 1
A getAttribute() 0 3 1
A fields() 0 5 1
A delete() 0 27 5
A __construct() 0 5 1
A equals() 0 7 4
A extraFields() 0 5 1
A deleteAll() 0 6 1
B updateInternal() 0 47 8
A createRelationQuery() 0 3 1
F link() 0 120 27
A update() 0 3 1
A hasAttribute() 0 3 2
D unlinkAll() 0 88 21
A setIsNewRecord() 0 3 2
A populateRecord() 0 19 4
B updateAttributes() 0 35 8
A getPrimaryKey() 0 17 4
A optimisticLock() 0 3 1
A hasMany() 0 3 1
A updateCounters() 0 14 4
A getAttributes() 0 20 4
A populateRelation() 0 7 2
A getRelatedRecords() 0 3 1
F unlink() 0 100 28
A getOldAttribute() 0 3 1
A getOldAttributes() 0 3 1
B setRelationDependencies() 0 23 7
A refreshInternal() 0 15 4
A getDirtyAttributes() 0 21 5
A setAttributes() 0 6 3
A isPrimaryKey() 0 9 2
A save() 0 7 2
A getIsNewRecord() 0 3 1
A updateAll() 0 7 1
A markAttributeDirty() 0 4 3
A setOldAttribute() 0 6 3
A getTableName() 0 7 2
A setOldAttributes() 0 3 1
A isRelationPopulated() 0 3 1
A setAttribute() 0 12 5
A resetDependentRelations() 0 7 2
A refresh() 0 5 1
A bindModels() 0 30 4
A getOldPrimaryKey() 0 25 5
A updateAllCounters() 0 14 2

How to fix   Complexity   

Complex Class

Complex classes like BaseActiveRecord often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use BaseActiveRecord, and based on these observations, apply Extract Interface, too.

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