Passed
Push — master ( 364bec...91edba )
by Wilmer
02:56
created

BaseActiveRecord::setAttributes()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

1018
    public static function instantiate(/** @scrutinizer ignore-unused */ $row): ActiveRecordInterface

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
1019
    {
1020 320
        return new static();
1021
    }
1022
1023
    /**
1024
     * Establishes the relationship between two models.
1025
     *
1026
     * The relationship is established by setting the foreign key value(s) in one model to be the corresponding primary
1027
     * key value(s) in the other model.
1028
     *
1029
     * The model with the foreign key will be saved into database without performing validation.
1030
     *
1031
     * If the relationship involves a junction table, a new row will be inserted into the junction table which contains
1032
     * the primary key values from both models.
1033
     *
1034
     * Note that this method requires that the primary key value is not null.
1035
     *
1036
     * @param string $name the case sensitive name of the relationship, e.g. `orders` for a relation defined via
1037
     * `getOrders()` method.
1038
     * @param ActiveRecordInterface $model the model to be linked with the current one.
1039
     * @param array $extraColumns additional column values to be saved into the junction table. This parameter is only
1040
     * meaningful for a relationship involving a junction table (i.e., a relation set with
1041
     * {@see ActiveRelationTrait::via()} or {@see ActiveQuery::viaTable()}).
1042
     *
1043
     * @throws ReflectionException
1044
     * @throws Exception
1045
     * @throws InvalidArgumentException
1046
     * @throws InvalidCallException if the method is unable to link two models.
1047
     * @throws InvalidConfigException
1048
     * @throws NotSupportedException
1049
     * @throws Throwable
1050
     */
1051 8
    public function link(string $name, ActiveRecordInterface $model, array $extraColumns = []): void
1052
    {
1053 8
        $relation = $this->getRelation($name);
1054
1055 8
        if ($relation->getVia() !== null) {
0 ignored issues
show
Bug introduced by
The method getVia() does not exist on Yiisoft\ActiveRecord\ActiveQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveQueryInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1055
        if ($relation->/** @scrutinizer ignore-call */ getVia() !== null) {
Loading history...
1056 4
            if ($this->getIsNewRecord() || $model->getIsNewRecord()) {
1057
                throw new InvalidCallException(
1058
                    'Unable to link models: the models being linked cannot be newly created.'
1059
                );
1060
            }
1061
1062 4
            if (is_array($relation->getVia())) {
1063
                /** @var $viaRelation ActiveQuery */
1064 4
                [$viaName, $viaRelation] = $relation->getVia();
1065 4
                $viaClass = $viaRelation->getModelClass();
1066
                /* unset $viaName so that it can be reloaded to reflect the change */
1067 4
                unset($this->related[$viaName]);
1068
            } else {
1069
                $viaRelation = $relation->getVia();
1070
                $from = $relation->getVia()->getFrom();
1071
                $viaTable = reset($from);
1072
            }
1073
1074 4
            $columns = [];
1075
1076 4
            foreach ($viaRelation->getLink() as $a => $b) {
1077 4
                $columns[$a] = $this->$b;
1078
            }
1079
1080 4
            foreach ($relation->getLink() as $a => $b) {
0 ignored issues
show
Bug introduced by
The method getLink() does not exist on Yiisoft\ActiveRecord\ActiveQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveQueryInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1080
            foreach ($relation->/** @scrutinizer ignore-call */ getLink() as $a => $b) {
Loading history...
1081 4
                $columns[$b] = $model->$a;
1082
            }
1083
1084 4
            foreach ($extraColumns as $k => $v) {
1085 4
                $columns[$k] = $v;
1086
            }
1087
1088 4
            if (is_array($relation->getVia())) {
1089
                /** @var $viaClass ActiveRecordInterface */
1090
                /** @var $record ActiveRecordInterface */
1091 4
                $record = new $viaClass();
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $viaClass does not seem to be defined for all execution paths leading up to this point.
Loading history...
1092
1093 4
                foreach ($columns as $column => $value) {
1094 4
                    $record->$column = $value;
1095
                }
1096
1097 4
                $record->insert();
1098
            } else {
1099
                /** @var $viaTable string */
1100 4
                static::getConnection()->createCommand()->insert($viaTable, $columns)->execute();
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $viaTable does not seem to be defined for all execution paths leading up to this point.
Loading history...
1101
            }
1102
        } else {
1103 8
            $p1 = $model->isPrimaryKey(array_keys($relation->getLink()));
1104 8
            $p2 = static::isPrimaryKey(array_values($relation->getLink()));
1105
1106 8
            if ($p1 && $p2) {
1107
                if ($this->getIsNewRecord() && $model->getIsNewRecord()) {
1108
                    throw new InvalidCallException('Unable to link models: at most one model can be newly created.');
1109
                }
1110
1111
                if ($this->getIsNewRecord()) {
1112
                    $this->bindModels(array_flip($relation->getLink()), $this, $model);
1113
                } else {
1114
                    $this->bindModels($relation->getLink(), $model, $this);
1115
                }
1116 8
            } elseif ($p1) {
1117 4
                $this->bindModels(array_flip($relation->getLink()), $this, $model);
1118 4
            } elseif ($p2) {
1119 4
                $this->bindModels($relation->getLink(), $model, $this);
1120
            } else {
1121
                throw new InvalidCallException(
1122
                    'Unable to link models: the link defining the relation does not involve any primary key.'
1123
                );
1124
            }
1125
        }
1126
1127
        // update lazily loaded related objects
1128 8
        if (!$relation->getMultiple()) {
0 ignored issues
show
Bug introduced by
The method getMultiple() does not exist on Yiisoft\ActiveRecord\ActiveQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveQueryInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1128
        if (!$relation->/** @scrutinizer ignore-call */ getMultiple()) {
Loading history...
1129 4
            $this->related[$name] = $model;
1130 8
        } elseif (isset($this->related[$name])) {
1131 8
            if ($relation->getIndexBy() !== null) {
0 ignored issues
show
Bug introduced by
The method getIndexBy() does not exist on Yiisoft\ActiveRecord\ActiveQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveQueryInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1131
            if ($relation->/** @scrutinizer ignore-call */ getIndexBy() !== null) {
Loading history...
1132 4
                if ($relation->getIndexBy() instanceof Closure) {
1133
                    $index = $relation->indexBy($model);
0 ignored issues
show
Bug introduced by
$model of type Yiisoft\ActiveRecord\ActiveRecordInterface is incompatible with the type callable|string expected by parameter $column of Yiisoft\ActiveRecord\Act...eryInterface::indexBy(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1133
                    $index = $relation->indexBy(/** @scrutinizer ignore-type */ $model);
Loading history...
1134
                } else {
1135 4
                    $index = $model->{$relation->getIndexBy()};
1136
                }
1137 4
                $this->related[$name][$index] = $model;
1138
            } else {
1139 4
                $this->related[$name][] = $model;
1140
            }
1141
        }
1142 8
    }
1143
1144
    /**
1145
     * Destroys the relationship between two models.
1146
     *
1147
     * The model with the foreign key of the relationship will be deleted if `$delete` is `true`. Otherwise, the
1148
     * foreign key will be set `null` and the model will be saved without validation.
1149
     *
1150
     * @param string $name the case sensitive name of the relationship, e.g. `orders` for a relation defined via
1151
     * `getOrders()` method.
1152
     * @param ActiveRecordInterface $model the model to be unlinked from the current one.
1153
     * You have to make sure that the model is really related with the current model as this method does not check this.
1154
     * @param bool $delete whether to delete the model that contains the foreign key. If `false`, the model's foreign
1155
     * key will be set `null` and saved. If `true`, the model containing the foreign key will be deleted.
1156
     *
1157
     * @throws ReflectionException
1158
     * @throws Exception
1159
     * @throws InvalidCallException if the models cannot be unlinked.
1160
     * @throws StaleObjectException
1161
     */
1162 4
    public function unlink(string $name, ActiveRecordInterface $model, bool $delete = false): void
1163
    {
1164 4
        $relation = $this->getRelation($name);
1165
1166 4
        if ($relation->getVia() !== null) {
1167 4
            if (is_array($relation->getVia())) {
1168
                /* @var $viaRelation ActiveQuery */
1169 4
                [$viaName, $viaRelation] = $relation->getVia();
1170 4
                $viaClass = $viaRelation->getModelClass();
1171 4
                unset($this->related[$viaName]);
1172
            } else {
1173 4
                $viaRelation = $relation->getVia();
1174 4
                $from = $relation->getVia()->getFrom();
1175 4
                $viaTable = reset($from);
1176
            }
1177
1178 4
            $columns = [];
1179 4
            foreach ($viaRelation->getLink() as $a => $b) {
1180 4
                $columns[$a] = $this->$b;
1181
            }
1182
1183 4
            foreach ($relation->getLink() as $a => $b) {
1184 4
                $columns[$b] = $model->$a;
1185
            }
1186 4
            $nulls = [];
1187
1188 4
            foreach (array_keys($columns) as $a) {
1189 4
                $nulls[$a] = null;
1190
            }
1191
1192 4
            if (is_array($relation->getVia())) {
1193
                /* @var $viaClass ActiveRecordInterface */
1194 4
                if ($delete) {
1195 4
                    $viaClass::deleteAll($columns);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $viaClass does not seem to be defined for all execution paths leading up to this point.
Loading history...
1196
                } else {
1197 4
                    $viaClass::updateAll($nulls, $columns);
1198
                }
1199
            } else {
1200
                /* @var $viaTable string */
1201
                /* @var $command Command */
1202 4
                $command = static::getConnection()->createCommand();
1203 4
                if ($delete) {
1204
                    $command->delete($viaTable, $columns)->execute();
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $viaTable does not seem to be defined for all execution paths leading up to this point.
Loading history...
1205
                } else {
1206 4
                    $command->update($viaTable, $nulls, $columns)->execute();
1207
                }
1208
            }
1209
        } else {
1210 4
            $p1 = $model->isPrimaryKey(array_keys($relation->getLink()));
1211 4
            $p2 = static::isPrimaryKey(array_values($relation->getLink()));
1212 4
            if ($p2) {
1213 4
                if ($delete) {
1214 4
                    $model->delete();
1215
                } else {
1216 4
                    foreach ($relation->getLink() as $a => $b) {
1217 4
                        $model->$a = null;
1218
                    }
1219 4
                    $model->save();
1220
                }
1221
            } elseif ($p1) {
1222
                foreach ($relation->getLink() as $a => $b) {
1223
                    if (is_array($this->$b)) { // relation via array valued attribute
1224
                        if (($key = array_search($model->$a, $this->$b, false)) !== false) {
1225
                            $values = $this->$b;
1226
                            unset($values[$key]);
1227
                            $this->$b = array_values($values);
1228
                        }
1229
                    } else {
1230
                        $this->$b = null;
1231
                    }
1232
                }
1233
                $delete ? $this->delete() : $this->save();
1234
            } else {
1235
                throw new InvalidCallException('Unable to unlink models: the link does not involve any primary key.');
1236
            }
1237
        }
1238
1239 4
        if (!$relation->getMultiple()) {
1240
            unset($this->related[$name]);
1241 4
        } elseif (isset($this->related[$name])) {
1242
            /* @var $b ActiveRecordInterface */
1243 4
            foreach ($this->related[$name] as $a => $b) {
1244 4
                if ($model->getPrimaryKey() === $b->getPrimaryKey()) {
1245 4
                    unset($this->related[$name][$a]);
1246
                }
1247
            }
1248
        }
1249 4
    }
1250
1251
    /**
1252
     * Destroys the relationship in current model.
1253
     *
1254
     * The model with the foreign key of the relationship will be deleted if `$delete` is `true`.
1255
     * Otherwise, the foreign key will be set `null` and the model will be saved without validation.
1256
     *
1257
     * Note that to destroy the relationship without removing records make sure your keys can be set to null.
1258
     *
1259
     * @param string $name the case sensitive name of the relationship, e.g. `orders` for a relation defined via
1260
     * `getOrders()` method.
1261
     * @param bool $delete whether to delete the model that contains the foreign key.
1262
     *
1263
     * Note that the deletion will be performed using {@see deleteAll()}, which will not trigger any events on the
1264
     * related models. If you need {@see EVENT_BEFORE_DELETE} or {@see EVENT_AFTER_DELETE} to be triggered, you need to
1265
     * {@see find()|find} the models first and then call {@see delete()} on each of them.
1266
     *
1267
     * @throws ReflectionException
1268
     * @throws Exception
1269
     * @throws StaleObjectException */
1270 20
    public function unlinkAll(string $name, bool $delete = false): void
1271
    {
1272 20
        $relation = $this->getRelation($name);
1273
1274 20
        if ($relation->getVia() !== null) {
1275 8
            if (is_array($relation->getVia())) {
1276
                /* @var $viaRelation ActiveQuery */
1277 4
                [$viaName, $viaRelation] = $relation->getVia();
1278 4
                $viaClass = $viaRelation->getModelClass();
1279 4
                unset($this->related[$viaName]);
1280
            } else {
1281 4
                $viaRelation = $relation->getVia();
1282 4
                $from = $relation->getVia()->getFrom();
1283 4
                $viaTable = reset($from);
1284
            }
1285
1286 8
            $condition = [];
1287 8
            $nulls = [];
1288
1289 8
            foreach ($viaRelation->getLink() as $a => $b) {
1290 8
                $nulls[$a] = null;
1291 8
                $condition[$a] = $this->$b;
1292
            }
1293
1294 8
            if (!empty($viaRelation->getWhere())) {
1295
                $condition = ['and', $condition, $viaRelation->getWhere()];
1296
            }
1297
1298 8
            if (!empty($viaRelation->getOn())) {
1299
                $condition = ['and', $condition, $viaRelation->getOn()];
1300
            }
1301
1302 8
            if (is_array($relation->getVia())) {
1303
                /** @var $viaClass ActiveRecordInterface */
1304 4
                if ($delete) {
1305 4
                    $viaClass::deleteAll($condition);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $viaClass does not seem to be defined for all execution paths leading up to this point.
Loading history...
1306
                } else {
1307 4
                    $viaClass::updateAll($nulls, $condition);
1308
                }
1309
            } else {
1310
                /** @var $viaTable string */
1311
                /** @var $command Command */
1312 4
                $command = static::getConnection()->createCommand();
1313 4
                if ($delete) {
1314 4
                    $command->delete($viaTable, $condition)->execute();
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $viaTable does not seem to be defined for all execution paths leading up to this point.
Loading history...
1315
                } else {
1316 8
                    $command->update($viaTable, $nulls, $condition)->execute();
1317
                }
1318
            }
1319
        } else {
1320
            /* @var $relatedModel ActiveRecordInterface */
1321 12
            $relatedModel = $relation->getModelClass();
0 ignored issues
show
Bug introduced by
The method getModelClass() does not exist on Yiisoft\ActiveRecord\ActiveQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveQueryInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1321
            /** @scrutinizer ignore-call */ 
1322
            $relatedModel = $relation->getModelClass();
Loading history...
1322
1323 12
            $link = $relation->getLink();
1324 12
            if (!$delete && count($link) === 1 && is_array($this->{$b = reset($link)})) {
1325
                /** relation via array valued attribute */
1326
                $this->$b = [];
1327
                $this->save();
1328
            } else {
1329 12
                $nulls = [];
1330 12
                $condition = [];
1331
1332 12
                foreach ($relation->getLink() as $a => $b) {
1333 12
                    $nulls[$a] = null;
1334 12
                    $condition[$a] = $this->$b;
1335
                }
1336
1337 12
                if (!empty($relation->getWhere())) {
0 ignored issues
show
Bug introduced by
The method getWhere() does not exist on Yiisoft\ActiveRecord\ActiveQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveQueryInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1337
                if (!empty($relation->/** @scrutinizer ignore-call */ getWhere())) {
Loading history...
1338 8
                    $condition = ['and', $condition, $relation->getWhere()];
1339
                }
1340
1341 12
                if (!empty($relation->getOn())) {
0 ignored issues
show
Bug introduced by
The method getOn() does not exist on Yiisoft\ActiveRecord\ActiveQueryInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveQueryInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1341
                if (!empty($relation->/** @scrutinizer ignore-call */ getOn())) {
Loading history...
1342 4
                    $condition = ['and', $condition, $relation->getOn()];
1343
                }
1344
1345 12
                if ($delete) {
1346 8
                    $relatedModel::deleteAll($condition);
1347
                } else {
1348 4
                    $relatedModel::updateAll($nulls, $condition);
1349
                }
1350
            }
1351
        }
1352
1353 20
        unset($this->related[$name]);
1354 20
    }
1355
1356 8
    private function bindModels(
1357
        array $link,
1358
        ActiveRecordInterface $foreignModel,
1359
        ActiveRecordInterface $primaryModel
1360
    ): void {
1361 8
        foreach ($link as $fk => $pk) {
1362 8
            $value = $primaryModel->$pk;
1363
1364 8
            if ($value === null) {
1365
                throw new InvalidCallException(
1366
                    'Unable to link models: the primary key of ' . get_class($primaryModel) . ' is null.'
1367
                );
1368
            }
1369
1370
            /** relation via array valued attribute */
1371 8
            if (is_array($foreignModel->$fk)) {
1372
                $foreignModel->{$fk}[] = $value;
1373
            } else {
1374 8
                $foreignModel->{$fk} = $value;
1375
            }
1376
        }
1377
1378 8
        $foreignModel->save();
1379 8
    }
1380
1381
    /**
1382
     * Returns a value indicating whether the given set of attributes represents the primary key for this model.
1383
     *
1384
     * @param array $keys the set of attributes to check.
1385
     *
1386
     * @return bool whether the given set of attributes represents the primary key for this model.
1387
     */
1388 16
    public static function isPrimaryKey(array $keys): bool
1389
    {
1390 16
        $pks = static::primaryKey();
1391
1392 16
        if (count($keys) === count($pks)) {
1393 16
            return count(array_intersect($keys, $pks)) === count($pks);
1394
        }
1395
1396 8
        return false;
1397
    }
1398
1399
    /**
1400
     * Returns the text label for the specified attribute.
1401
     *
1402
     * If the attribute looks like `relatedModel.attribute`, then the attribute will be received from the related model.
1403
     *
1404
     * @param string $attribute the attribute name.
1405
     *
1406
     * @throws ReflectionException
1407
     * @throws InvalidArgumentException
1408
     *
1409
     * @return string the attribute label.
1410
     *
1411
     * {@see generateAttributeLabel()}
1412
     * {@see attributeLabels()}
1413
     */
1414
    public function getAttributeLabel(string $attribute): string
1415
    {
1416
        $labels = $this->attributeLabels();
0 ignored issues
show
Bug introduced by
The method attributeLabels() does not exist on Yiisoft\ActiveRecord\BaseActiveRecord. Did you maybe mean attributes()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1416
        /** @scrutinizer ignore-call */ 
1417
        $labels = $this->attributeLabels();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
1417
1418
        if (isset($labels[$attribute])) {
1419
            return $labels[$attribute];
1420
        }
1421
1422
        if (strpos($attribute, '.')) {
1423
            $attributeParts = explode('.', $attribute);
1424
            $neededAttribute = array_pop($attributeParts);
1425
1426
            $relatedModel = $this;
1427
            foreach ($attributeParts as $relationName) {
1428
                if ($relatedModel->isRelationPopulated($relationName) && $relatedModel->$relationName instanceof self) {
1429
                    $relatedModel = $relatedModel->$relationName;
1430
                } else {
1431
                    try {
1432
                        $relation = $relatedModel->getRelation($relationName);
1433
                    } catch (InvalidParamException $e) {
1434
                        return $this->generateAttributeLabel($attribute);
0 ignored issues
show
Bug introduced by
The method generateAttributeLabel() does not exist on Yiisoft\ActiveRecord\BaseActiveRecord. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1434
                        return $this->/** @scrutinizer ignore-call */ generateAttributeLabel($attribute);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
1435
                    }
1436
                    /* @var $modelClass ActiveRecordInterface */
1437
                    $modelClass = $relation->getModelClass();
1438
                    $relatedModel = $modelClass::instance();
0 ignored issues
show
Bug introduced by
The method instance() does not exist on Yiisoft\ActiveRecord\ActiveRecordInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\ActiveRecord\ActiveRecordInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1438
                    /** @scrutinizer ignore-call */ 
1439
                    $relatedModel = $modelClass::instance();
Loading history...
1439
                }
1440
            }
1441
1442
            $labels = $relatedModel->attributeLabels();
1443
1444
            if (isset($labels[$neededAttribute])) {
1445
                return $labels[$neededAttribute];
1446
            }
1447
        }
1448
1449
        return $this->generateAttributeLabel($attribute);
1450
    }
1451
1452
    /**
1453
     * Returns the text hint for the specified attribute.
1454
     *
1455
     * If the attribute looks like `relatedModel.attribute`, then the attribute will be received from the related model.
1456
     *
1457
     * @param string $attribute the attribute name
1458
     *
1459
     * @throws ReflectionException
1460
     * @throws InvalidArgumentException
1461
     *
1462
     * @return string the attribute hint
1463
     *
1464
     * {@see attributeHints()}
1465
     */
1466
    public function getAttributeHint(string $attribute): string
1467
    {
1468
        $hints = $this->attributeHints();
0 ignored issues
show
Bug introduced by
The method attributeHints() does not exist on Yiisoft\ActiveRecord\BaseActiveRecord. Did you maybe mean attributes()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1468
        /** @scrutinizer ignore-call */ 
1469
        $hints = $this->attributeHints();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
1469
1470
        if (isset($hints[$attribute])) {
1471
            return $hints[$attribute];
1472
        } elseif (strpos($attribute, '.')) {
1473
            $attributeParts = explode('.', $attribute);
1474
            $neededAttribute = array_pop($attributeParts);
1475
            $relatedModel = $this;
1476
1477
            foreach ($attributeParts as $relationName) {
1478
                if ($relatedModel->isRelationPopulated($relationName) && $relatedModel->$relationName instanceof self) {
1479
                    $relatedModel = $relatedModel->$relationName;
1480
                } else {
1481
                    try {
1482
                        $relation = $relatedModel->getRelation($relationName);
1483
                    } catch (InvalidParamException $e) {
1484
                        return '';
1485
                    }
1486
                    /* @var $modelClass ActiveRecordInterface */
1487
                    $modelClass = $relation->getModelClass();
1488
                    $relatedModel = $modelClass::instance();
1489
                }
1490
            }
1491
1492
            $hints = $relatedModel->attributeHints();
1493
1494
            if (isset($hints[$neededAttribute])) {
1495
                return $hints[$neededAttribute];
1496
            }
1497
        }
1498
1499
        return '';
1500
    }
1501
1502
    public function fields(): array
1503
    {
1504
        $fields = array_keys($this->attributes);
1505
1506
        return array_combine($fields, $fields);
0 ignored issues
show
Bug Best Practice introduced by
The expression return array_combine($fields, $fields) could return the type false which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
1507
    }
1508
1509
    public function extraFields(): array
1510
    {
1511
        $fields = array_keys($this->getRelatedRecords());
1512
1513
        return array_combine($fields, $fields);
0 ignored issues
show
Bug Best Practice introduced by
The expression return array_combine($fields, $fields) could return the type false which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
1514
    }
1515
1516
    /**
1517
     * Resets dependent related models checking if their links contain specific attribute.
1518
     *
1519
     * @param string $attribute The changed attribute name.
1520
     */
1521 20
    private function resetDependentRelations(string $attribute): void
1522
    {
1523 20
        foreach ($this->relationsDependencies[$attribute] as $relation) {
1524 20
            unset($this->related[$relation]);
1525
        }
1526
1527 20
        unset($this->relationsDependencies[$attribute]);
1528 20
    }
1529
1530
    /**
1531
     * Sets relation dependencies for a property.
1532
     *
1533
     * @param string $name property name.
1534
     * @param ActiveQueryInterface $relation relation instance.
1535
     * @param string|null $viaRelationName intermediate relation.
1536
     */
1537 92
    private function setRelationDependencies(
1538
        string $name,
1539
        ActiveQueryInterface $relation,
1540
        ?string $viaRelationName = null
1541
    ): void {
1542 92
        if (empty($relation->getVia()) && $relation->getLink()) {
1543 88
            foreach ($relation->getLink() as $attribute) {
1544 88
                $this->relationsDependencies[$attribute][$name] = $name;
1545 88
                if ($viaRelationName !== null) {
1546 28
                    $this->relationsDependencies[$attribute][] = $viaRelationName;
1547
                }
1548
            }
1549 48
        } elseif ($relation->getVia() instanceof ActiveQueryInterface) {
1550 20
            $this->setRelationDependencies($name, $relation->getVia());
1551 32
        } elseif (is_array($relation->getVia())) {
1552 28
            [$viaRelationName, $viaQuery] = $relation->getVia();
1553 28
            $this->setRelationDependencies($name, $viaQuery, $viaRelationName);
1554
        }
1555 92
    }
1556
1557
    /**
1558
     * Returns attribute values.
1559
     *
1560
     * @param array|null $names list of attributes whose value needs to be returned. Defaults to null, meaning all
1561
     * attributes listed in {@see attributes()} will be returned. If it is an array, only the attributes in the array
1562
     * will be returned.
1563
     * @param array $except list of attributes whose value should NOT be returned.
1564
     *
1565
     * @return array attribute values (name => value).
1566
     */
1567 4
    public function getAttributes(?array $names = null, array $except = []): array
1568
    {
1569 4
        $values = [];
1570
1571 4
        if ($names === null) {
1572 4
            $names = $this->attributes();
1573
        }
1574
1575 4
        foreach ($names as $name) {
1576 4
            $values[$name] = $this->$name;
1577
        }
1578
1579 4
        foreach ($except as $name) {
1580
            unset($values[$name]);
1581
        }
1582
1583 4
        return $values;
1584
    }
1585
1586
    /**
1587
     * Sets the attribute values in a massive way.
1588
     *
1589
     * @param array $values attribute values (name => value) to be assigned to the model.
1590
     *
1591
     * {@see attributes()}
1592
     */
1593
    public function setAttributes(array $values): void
1594
    {
1595
        foreach ($values as $name => $value) {
1596
            if (in_array($name, $this->attributes(), true)) {
1597
                $this->$name = $value;
1598
            }
1599
        }
1600
    }
1601
}
1602