Passed
Pull Request — master (#83)
by Wilmer
24:07 queued 09:08
created

BaseActiveRecord::setAttribute()   A

Complexity

Conditions 5
Paths 3

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 5

Importance

Changes 0
Metric Value
eloc 8
dl 0
loc 12
ccs 2
cts 2
cp 1
rs 9.6111
c 0
b 0
f 0
cc 5
nc 3
nop 2
crap 5
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord;
6
7
use Yiisoft\Arrays\ArrayHelper;
8
use Yiisoft\Db\Exception\Exception;
9
use Yiisoft\Db\Exception\InvalidArgumentException;
10
use Yiisoft\Db\Exception\InvalidCallException;
11
use Yiisoft\Db\Exception\InvalidConfigException;
12
use Yiisoft\Db\Exception\InvalidParamException;
13
use Yiisoft\Db\Exception\NotSupportedException;
14
use Yiisoft\Db\Exception\StaleObjectException;
15
16
/**
17
 * ActiveRecord is the base class for classes representing relational data in terms of objects.
18
 *
19
 * See {@see ActiveRecord} for a concrete implementation.
20
 *
21
 * @property array $dirtyAttributes The changed attribute values (name-value pairs). This property is read-only.
22
 * @property bool $isNewRecord Whether the record is new and should be inserted when calling {@see save()}.
23
 * @property array $oldAttributes The old attribute values (name-value pairs). Note that the type of this property
24
 * differs in getter and setter. See {@see getOldAttributes()} and {@see setOldAttributes()} for details.
25
 * @property mixed $oldPrimaryKey The old primary key value. An array (column name => column value) is returned if the
26
 * primary key is composite. A string is returned otherwise (null will be returned if the key value is null).
27
 * This property is read-only.
28
 * @property mixed $primaryKey The primary key value. An array (column name => column value) is returned if the primary
29
 * key is composite. A string is returned otherwise (null will be returned if the key value is null).
30
 * This property is read-only.
31
 * @property array $relatedRecords An array of related records indexed by relation names. This property is read-only.
32
 */
33
abstract class BaseActiveRecord implements ActiveRecordInterface, \IteratorAggregate, \ArrayAccess
34
{
35
    use StaticInstanceTrait;
36
    use BaseActiveRecordTrait;
37
38
    private array $attributes = [];
39
    private ?array $oldAttributes = null;
40
    private array $related = [];
41
    private array $relationsDependencies = [];
42
43
    public static function findOne($condition): ?ActiveRecordInterface
44
    {
45
        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...
46
    }
47
48
    public static function findAll($condition): ActiveRecordInterface
49
    {
50
        return static::findByCondition($condition)->all();
0 ignored issues
show
Bug Best Practice introduced by
The expression return static::findByCondition($condition)->all() returns the type array which is incompatible with the type-hinted return Yiisoft\ActiveRecord\ActiveRecordInterface.
Loading history...
51
    }
52
53
    /**
54
     * Finds ActiveRecord instance(s) by the given condition.
55
     *
56
     * This method is internally called by {@see findOne()} and {@see findAll()}.
57
     *
58
     * @param mixed $condition please refer to {@see findOne()} for the explanation of this parameter.
59
     *
60
     * @throws InvalidConfigException if there is no primary key defined.
61
     *
62
     * @return ActiveQueryInterface the newly created {@see ActiveQueryInterface|ActiveQuery} instance.
63
     */
64
    protected static function findByCondition($condition): ActiveQueryInterface
65
    {
66
        $query = static::find();
67 72
68
        if (!\is_array($condition)) {
69 72
            $condition = [$condition];
70
        }
71
72
        if (!ArrayHelper::isAssociative($condition)) {
73
            /** query by primary key */
74
            $primaryKey = static::primaryKey();
75
76
            if (isset($primaryKey[0])) {
77
                /** if condition is scalar, search for a single primary key, if it is array, search for multiple
78
                 *  primary key values
79
                 */
80
                $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...
81
            } else {
82
                throw new InvalidConfigException('"' . static::class . '" must have a primary key.');
83
            }
84
        }
85
86
        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...
87
    }
88
89
    /**
90
     * Updates the whole table using the provided attribute values and conditions.
91
     *
92
     * For example, to change the status to be 1 for all customers whose status is 2:
93
     *
94
     * ```php
95
     * Customer::updateAll(['status' => 1], 'status = 2');
96
     * ```
97
     *
98
     * @param array $attributes attribute values (name-value pairs) to be saved into the table.
99
     * @param string|array $condition the conditions that will be put in the WHERE part of the UPDATE SQL. Please refer
100
     * to {@see Query::where()} on how to specify this parameter.
101
     * @param array $params
102
     *
103
     * @throws NotSupportedException if not overridden.
104
     *
105
     * @return int the number of rows updated.
106
     */
107
    public static function updateAll(array $attributes, $condition = '', array $params = []): int
108
    {
109
        throw new NotSupportedException(__METHOD__ . ' is not supported.');
110
    }
111
112
    /**
113
     * Updates the whole table using the provided counter changes and conditions.
114
     *
115
     * For example, to increment all customers' age by 1,
116
     *
117
     * ```php
118
     * Customer::updateAllCounters(['age' => 1]);
119
     * ```
120
     *
121
     * @param array $counters the counters to be updated (attribute name => increment value).
122
     * Use negative values if you want to decrement the counters.
123
     * @param string|array $condition the conditions that will be put in the WHERE part of the UPDATE SQL.
124
     * Please refer to {@see Query::where()} on how to specify this parameter.
125
     *
126
     * @throws NotSupportedException if not override.
127
     *
128
     * @return int the number of rows updated.
129
     */
130
    public static function updateAllCounters(array $counters, $condition = ''): int
131
    {
132
        throw new NotSupportedException(__METHOD__ . ' is not supported.');
133
    }
134
135
    /**
136
     * Deletes rows in the table using the provided conditions.
137
     *
138
     * WARNING: If you do not specify any condition, this method will delete ALL rows in the table.
139
     *
140
     * For example, to delete all customers whose status is 3:
141
     *
142
     * ```php
143
     * Customer::deleteAll('status = 3');
144
     * ```
145
     *
146
     * @param array|string $condition the conditions that will be put in the WHERE part of the DELETE SQL. Please refer
147
     * to {@see Query::where()} on how to specify this parameter.
148
     *
149
     * @return int the number of rows deleted.
150
     *
151
     * @throws NotSupportedException if not overridden.
152
     */
153
    public static function deleteAll($condition = null): int
154
    {
155
        throw new NotSupportedException(__METHOD__ . ' is not supported.');
156
    }
157
158
    /**
159
     * Returns the name of the column that stores the lock version for implementing optimistic locking.
160
     *
161
     * Optimistic locking allows multiple users to access the same record for edits and avoids potential conflicts. In
162
     * case when a user attempts to save the record upon some staled data (because another user has modified the data),
163
     * a {@see StaleObjectException} exception will be thrown, and the update or deletion is skipped.
164
     *
165
     * Optimistic locking is only supported by {@see update()} and {@see delete()}.
166
     *
167
     * To use Optimistic locking:
168
     *
169
     * 1. Create a column to store the version number of each row. The column type should be `BIGINT DEFAULT 0`.
170
     *    Override this method to return the name of this column.
171
     * 2. In the Web form that collects the user input, add a hidden field that stores the lock version of the recording
172
     *    being updated.
173
     * 3. In the controller action that does the data updating, try to catch the {@see StaleObjectException} and
174
     *    implement necessary business logic (e.g. merging the changes, prompting stated data) to resolve the conflict.
175
     *
176
     * @return string|null the column name that stores the lock version of a table row. If `null` is returned
177
     * (default implemented), optimistic locking will not be supported.
178
     */
179
    public function optimisticLock(): ?string
180
    {
181
        return null;
182
    }
183
184
    /**
185
     * Declares a `has-one` relation.
186
     *
187
     * The declaration is returned in terms of a relational {@see ActiveQuery} instance through which the related record
188
     * can be queried and retrieved back.
189
     *
190
     * A `has-one` relation means that there is at most one related record matching the criteria set by this relation,
191
     * e.g., a customer has one country.
192
     *
193
     * For example, to declare the `country` relation for `Customer` class, we can write the following code in the
194
     * `Customer` class:
195
     *
196
     * ```php
197
     * public function getCountry()
198
     * {
199
     *     return $this->hasOne(Country::className(), ['id' => 'country_id']);
200
     * }
201
     * ```
202
     *
203
     * Note that in the above, the 'id' key in the `$link` parameter refers to an attribute name in the related class
204
     * `Country`, while the 'country_id' value refers to an attribute name in the current AR class.
205
     *
206
     * Call methods declared in {@see ActiveQuery} to further customize the relation.
207
     *
208
     * @param array|string $class the class name of the related record
209
     * @param array $link the primary-foreign key constraint. The keys of the array refer to the attributes of the
210
     * record associated with the `$class` model, while the values of the array refer to the corresponding attributes in
211
     * **this** AR class.
212 6
     *
213
     * @return ActiveQueryInterface the relational query object.
214 6
     */
215
    public function hasOne($class, array $link): ActiveQueryInterface
216
    {
217
        return $this->createRelationQuery($class, $link, false);
218
    }
219
220
    /**
221
     * Declares a `has-many` relation.
222
     *
223
     * The declaration is returned in terms of a relational {@see ActiveQuery} instance  through which the related
224
     * record can be queried and retrieved back.
225
     *
226
     * A `has-many` relation means that there are multiple related records matching the criteria set by this relation,
227
     * e.g., a customer has many orders.
228
     *
229
     * For example, to declare the `orders` relation for `Customer` class, we can write
230
     * the following code in the `Customer` class:
231
     *
232
     * ```php
233
     * public function getOrders()
234
     * {
235
     *     return $this->hasMany(Order::className(), ['customer_id' => 'id']);
236
     * }
237 6
     * ```
238
     *
239 6
     * Note that in the above, the 'customer_id' key in the `$link` parameter refers to an attribute name in the related
240 6
     * class `Order`, while the '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
    public function hasMany($class, array $link): ActiveQueryInterface
252
    {
253
        return $this->createRelationQuery($class, $link, true);
254
    }
255
256
    /**
257
     * Creates a query instance for `has-one` or `has-many` relation.
258
     *
259
     * @param array|string $class the class name of the related record.
260
     * @param array $link the primary-foreign key constraint.
261
     * @param bool $multiple whether this query represents a relation to more than one record.
262
     *
263
     * @return ActiveQueryInterface the relational query object.
264
265
     * {@see hasOne()}
266
     * {@see hasMany()}
267
     */
268
    protected function createRelationQuery($class, array $link, bool $multiple): ActiveQueryInterface
269
    {
270
        /** @var $query ActiveQuery */
271
        $query = ($class::find())->primaryModel($this)->link($link)->multiple($multiple);
272
273
        return $query;
274
    }
275
276
    /**
277
     * Populates the named relation with the related records.
278
     *
279
     * Note that this method does not check if the relation exists or not.
280
     *
281
     * @param string $name the relation name, e.g. `orders` for a relation defined via `getOrders()` method
282 54
     * (case-sensitive).
283
     * @param ActiveRecordInterface|array|null $records the related records to be populated into the relation.
284 54
     *
285
     * @return void
286
     *
287
     * {@see getRelation()}
288
     */
289
    public function populateRelation(string $name, $records): void
290
    {
291
        foreach ($this->relationsDependencies as &$relationNames) {
292
            unset($relationNames[$name]);
293
        }
294
295
        $this->related[$name] = $records;
296
    }
297
298
    /**
299
     * Check whether the named relation has been populated with records.
300
     *
301
     * @param string $name the relation name, e.g. `orders` for a relation defined via `getOrders()` method
302
     * (case-sensitive).
303
     *
304
     * @return bool whether relation has been populated with records.
305
     *
306
     * {@see getRelation()}
307
     */
308
    public function isRelationPopulated(string $name): bool
309
    {
310
        return \array_key_exists($name, $this->related);
311
    }
312
313
    /**
314
     * Returns all populated related records.
315
     *
316
     * @return array an array of related records indexed by relation names.
317
     *
318 81
     * {@see getRelation()}
319
     */
320 81
    public function getRelatedRecords(): array
321
    {
322
        return $this->related;
323
    }
324
325
    /**
326
     * Returns a value indicating whether the model has an attribute with the specified name.
327
     *
328
     * @param string|int $name the name or position of the attribute.
329
     *
330
     * @return bool whether the model has an attribute with the specified name.
331
     */
332
    public function hasAttribute($name): bool
333
    {
334
        return isset($this->attributes[$name]) || \in_array($name, $this->attributes(), true);
335 99
    }
336
337
    /**
338 99
     * Returns the named attribute value.
339 99
     *
340 99
     * If this record is the result of a query and the attribute is not loaded, `null` will be returned.
341 99
     *
342
     * @param string $name the attribute name.
343 99
     *
344
     * @return mixed the attribute value. `null` if the attribute is not set or does not exist.
345
     *
346
     * {@see hasAttribute()}
347
     */
348
    public function getAttribute(string $name)
349
    {
350
        return $this->attributes[$name] ?? null;
351
    }
352
353
    /**
354
     * Sets the named attribute value.
355
     *
356
     * @param string $name the attribute name.
357
     * @param mixed $value the attribute value.
358
     *
359 69
     * @throws InvalidArgumentException if the named attribute does not exist.
360
     *
361 69
     * @return void
362 9
     *
363
     * {@see hasAttribute()}
364
     */
365 69
    public function setAttribute(string $name, $value): void
366 69
    {
367
        if ($this->hasAttribute($name)) {
368
            if (
369
                !empty($this->relationsDependencies[$name])
370
                && (!\array_key_exists($name, $this->attributes) || $this->attributes[$name] !== $value)
371
            ) {
372
                $this->resetDependentRelations($name);
373
            }
374
            $this->attributes[$name] = $value;
375
        } else {
376
            throw new InvalidArgumentException(\get_class($this) . ' has no attribute named "' . $name . '".');
377
        }
378 27
    }
379
380 27
    /**
381
     * Returns the old attribute values.
382
     *
383
     * @return array the old attribute values (name-value pairs).
384
     */
385
    public function getOldAttributes(): array
386
    {
387
        return $this->oldAttributes ?? [];
388
    }
389
390
    /**
391
     * Sets the old attribute values.
392
     *
393
     * All existing old attribute values will be discarded.
394
     *
395
     * @param array|null $values old attribute values to be set. If set to `null` this record is considered to be
396
     * {@see isNewRecord|new}.
397
     *
398
     * @return void
399
     */
400
    public function setOldAttributes($values): void
401
    {
402 126
        $this->oldAttributes = $values;
403
    }
404 126
405
    /**
406
     * Returns the old value of the named attribute.
407
     *
408
     * If this record is the result of a query and the attribute is not loaded, `null` will be returned.
409
     *
410
     * @param string $name the attribute name
411
     *
412
     * @return mixed the old attribute value. `null` if the attribute is not loaded before or does not exist.
413
     *
414
     * {@see hasAttribute()}
415
     */
416
    public function getOldAttribute(string $name)
417
    {
418
        return $this->oldAttributes[$name] ?? null;
419
    }
420
421
    /**
422
     * Sets the old value of the named attribute.
423
     *
424
     * @param string $name the attribute name.
425
     * @param mixed $value the old attribute value.
426
     *
427
     * @throws InvalidArgumentException if the named attribute does not exist.
428
     *
429
     * {@see hasAttribute()}
430
     */
431
    public function setOldAttribute(string $name, $value)
432
    {
433
        if (isset($this->oldAttributes[$name]) || $this->hasAttribute($name)) {
434
            $this->oldAttributes[$name] = $value;
435 30
        } else {
436
            throw new InvalidArgumentException(get_class($this) . ' has no attribute named "' . $name . '".');
437 30
        }
438
    }
439 30
440 30
    /**
441
     * Marks an attribute dirty.
442 6
     *
443
     * This method may be called to force updating a record when calling {@see update()}, even if there is no change
444 30
     * being made to the record.
445
     *
446
     * @param string $name the attribute name.
447
     */
448 30
    public function markAttributeDirty(string $name): void
449
    {
450
        unset($this->oldAttributes[$name]);
451
    }
452
453
    /**
454
     * Returns a value indicating whether the named attribute has been changed.
455
     *
456
     * @param string $name the name of the attribute.
457
     * @param bool $identical whether the comparison of new and old value is made for identical values using `===`,
458
     * defaults to `true`. Otherwise `==` is used for comparison.
459
     *
460
     * @return bool whether the attribute has been changed.
461
     */
462
    public function isAttributeChanged(string $name, bool $identical = true): bool
463
    {
464
        if (isset($this->attributes[$name], $this->oldAttributes[$name])) {
465
            if ($identical) {
466
                return $this->attributes[$name] !== $this->oldAttributes[$name];
467
            }
468
469
            return $this->attributes[$name] !== $this->oldAttributes[$name];
470 27
        }
471
472 27
        return isset($this->attributes[$name]) || isset($this->oldAttributes[$name]);
473 27
    }
474
475
    /**
476
     * Returns the attribute values that have been modified since they are loaded or saved most recently.
477
     *
478
     * The comparison of new and old values is made for identical values using `===`.
479
     *
480
     * @param string[]|null $names the names of the attributes whose values may be returned if they are changed
481
     * recently. If null, {@see attributes()} will be used.
482
     *
483
     * @return array the changed attribute values (name-value pairs).
484
     */
485
    public function getDirtyAttributes($names = null): array
486
    {
487
        if ($names === null) {
488
            $names = $this->attributes();
489
        }
490
491
        $names = \array_flip($names);
492
        $attributes = [];
493
494
        if ($this->oldAttributes === null) {
495
            foreach ($this->attributes as $name => $value) {
496
                if (isset($names[$name])) {
497
                    $attributes[$name] = $value;
498
                }
499
            }
500
        } else {
501
            foreach ($this->attributes as $name => $value) {
502
                if (
503
                    isset($names[$name])
504
                    && (!\array_key_exists($name, $this->oldAttributes) || $value !== $this->oldAttributes[$name])
505
                ) {
506
                    $attributes[$name] = $value;
507
                }
508
            }
509
        }
510
511
        return $attributes;
512
    }
513
514
    /**
515
     * Saves the current record.
516
     *
517
     * This method will call {@see insert()} when {@see isNewRecord} is `true`, or {@see update()} when
518
     * {@see isNewRecord} is `false`.
519
     *
520
     * For example, to save a customer record:
521
     *
522
     * ```php
523
     * $customer = new Customer; // or $customer = Customer::findOne($id);
524
     * $customer->name = $name;
525
     * $customer->email = $email;
526
     * $customer->save();
527
     * ```
528
     *
529
     * @param array $attributeNames list of attribute names that need to be saved. Defaults to null, meaning all
530
     * attributes that are loaded from DB will be saved.
531
     *
532
     * @throws Exception
533
     * @throws StaleObjectException
534
     *
535
     * @return bool whether the saving succeeded (i.e. no validation errors occurred).
536
     */
537
    public function save(array $attributeNames = null): bool
538
    {
539
        if ($this->getIsNewRecord()) {
540
            return $this->insert($attributeNames);
541
        }
542
543
        return $this->update($attributeNames) !== false;
544
    }
545
546
    /**
547
     * Saves the changes to this active record into the associated database table.
548
     *
549
     * This method performs the following steps in order:
550
     *
551
     * 1. call {@see beforeValidate()} when `$runValidation` is `true`. If {@see beforeValidate()} returns `false`, the
552
     *    rest of the steps will be skipped;
553
     * 2. call {@see afterValidate()} when `$runValidation` is `true`. If validation failed, the rest of the steps will
554
     *    be skipped;
555
     * 3. call {@see beforeSave()}. If {@see beforeSave()} returns `false`, the rest of the steps will be skipped;
556 33
     * 4. save the record into database. If this fails, it will skip the rest of the steps;
557
     * 5. call {@see afterSave()};
558 33
     *
559 30
     * In the above step 1, 2, 3 and 5, events {@see EVENT_BEFORE_VALIDATE}, {@see EVENT_AFTER_VALIDATE},
560
     * {@see EVENT_BEFORE_UPDATE}, and {@see EVENT_AFTER_UPDATE} will be raised by the corresponding methods.
561
     *
562 33
     * Only the {@see dirtyAttributes|changed attribute values} will be saved into database.
563 33
     *
564
     * For example, to update a customer record:
565 33
     *
566 30
     * ```php
567 27
     * $customer = Customer::findOne($id);
568 27
     * $customer->name = $name;
569
     * $customer->email = $email;
570
     * $customer->update();
571
     * ```
572 12
     *
573
     * Note that it is possible the update does not affect any row in the table.
574 12
     * In this case, this method will return 0. For this reason, you should use the following code to check if update()
575 12
     * is successful or not:
576
     *
577 12
     * ```php
578
     * if ($customer->update() !== false) {
579
     *     // update successful
580
     * } else {
581
     *     // update failed
582 33
     * }
583
     * ```
584
     *
585
     * @param array $attributeNames list of attribute names that need to be saved. Defaults to null, meaning all
586
     * attributes that are loaded from DB will be saved.
587
     *
588
     * @throws StaleObjectException if {@see optimisticLock|optimistic locking} is enabled and the data being updated is
589
     * outdated.
590
     * @throws Exception in case update failed.
591
     *
592
     * @return int|false the number of rows affected, or `false` if validation fails or {@see beforeSave()} stops the
593
     * updating process.
594
     */
595
    public function update(array $attributeNames = null)
596
    {
597
        return $this->updateInternal($attributeNames);
598
    }
599
600
    /**
601
     * Updates the specified attributes.
602
     *
603
     * This method is a shortcut to {@see update()} when data validation is not needed and only a small set attributes
604
     * need to be updated.
605
     *
606
     * You may specify the attributes to be updated as name list or name-value pairs. If the latter, the corresponding
607
     * attribute values will be modified accordingly.
608 27
     *
609
     * The method will then save the specified attributes into database.
610 27
     *
611 24
     * Note that this method will **not** perform data validation and will **not** trigger events.
612
     *
613
     * @param array $attributes the attributes (names or name-value pairs) to be updated.
614 6
     *
615
     * @throws Exception
616
     * @throws NotSupportedException
617
     *
618
     * @return int the number of rows affected.
619
     */
620
    public function updateAttributes(array $attributes): int
621
    {
622
        $attrs = [];
623
624
        foreach ($attributes as $name => $value) {
625
            if (\is_int($name)) {
626
                $attrs[] = $value;
627
            } else {
628
                $this->$name = $value;
629
                $attrs[] = $name;
630
            }
631
        }
632
633
        $values = $this->getDirtyAttributes($attrs);
634
635
        if (empty($values) || $this->getIsNewRecord()) {
636
            return 0;
637
        }
638
639
        $rows = static::updateAll($values, $this->getOldPrimaryKey(true));
640
641
        foreach ($values as $name => $value) {
642
            $this->oldAttributes[$name] = $this->attributes[$name];
643
        }
644
645
        return $rows;
646
    }
647
648
    /**
649
     * {@see update()}
650
     *
651
     * @param array $attributes attributes to update.
652
     *
653
     * @throws Exception
654
     * @throws NotSupportedException
655
     * @throws StaleObjectException
656
     *
657
     * @return int|false the number of rows affected, or false if {@see beforeSave()} stops the updating process.
658
     */
659
    protected function updateInternal(?array $attributes = null)
660
    {
661
        $values = $this->getDirtyAttributes($attributes);
662
663
        if (empty($values)) {
664
            $this->afterSave(false, $values);
0 ignored issues
show
Bug introduced by
The method afterSave() 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

664
            $this->/** @scrutinizer ignore-call */ 
665
                   afterSave(false, $values);

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...
665
            return 0;
666
        }
667
668
        $condition = $this->getOldPrimaryKey(true);
669
        $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...
670
671
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
672
            $values[$lock] = $this->$lock + 1;
673
            $condition[$lock] = $this->$lock;
674
        }
675
676
        /**
677
         * We do not check the return value of updateAll() because it's possible that the UPDATE statement doesn't
678
         * change anything and thus returns 0.
679
         */
680
        $rows = static::updateAll($values, $condition);
681
682
        if ($lock !== null && !$rows) {
683
            throw new StaleObjectException('The object being updated is outdated.');
684
        }
685
686
        if (isset($values[$lock])) {
687
            $this->$lock = $values[$lock];
688
        }
689
690
        $changedAttributes = [];
691 3
692
        foreach ($values as $name => $value) {
693 3
            $changedAttributes[$name] = $this->oldAttributes[$name] ?? null;
694
            $this->oldAttributes[$name] = $value;
695 3
        }
696 3
697
        return $rows;
698
    }
699 3
700 3
    /**
701
     * Updates one or several counter columns for the current AR object.
702
     *
703
     * Note that this method differs from {@see updateAllCounters()} in that it only saves counters for the current AR
704 3
     * object.
705
     *
706 3
     * An example usage is as follows:
707 3
     *
708
     * ```php
709
     * $post = Post::findOne($id);
710 3
     * $post->updateCounters(['view_count' => 1]);
711
     * ```
712 3
     *
713 3
     * @param array $counters the counters to be updated (attribute name => increment value), use negative values if you
714
     * want to decrement the counters.
715
     *
716 3
     * @throws Exception
717
     * @throws NotSupportedException
718
     *
719
     * @return bool whether the saving is successful.
720
     *
721
     * {@see updateAllCounters()}
722
     */
723
    public function updateCounters(array $counters): bool
724
    {
725
        if (static::updateAllCounters($counters, $this->getOldPrimaryKey(true)) > 0) {
726
            foreach ($counters as $name => $value) {
727
                if (!isset($this->attributes[$name])) {
728
                    $this->attributes[$name] = $value;
729
                } else {
730 9
                    $this->attributes[$name] += $value;
731
                }
732 9
733
                $this->oldAttributes[$name] = $this->attributes[$name];
734 9
            }
735
736
            return true;
737
        }
738
739 9
        return false;
740 9
    }
741
742 9
    /**
743 3
     * Deletes the table row corresponding to this active record.
744 3
     *
745
     * This method performs the following steps in order:
746
     *
747
     * 1. call {@see beforeDelete()}. If the method returns `false`, it will skip the rest of the steps;
748
     * 2. delete the record from the database;
749
     * 3. call {@see afterDelete()}.
750
     *
751 9
     * In the above step 1 and 3, events named {@see EVENT_BEFORE_DELETE} and {@see EVENT_AFTER_DELETE} will be raised
752
     * by the corresponding methods.
753 9
     *
754 3
     * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data
755
     * being deleted is outdated.
756
     * @throws Exception in case delete failed.
757 9
     *
758 3
     * @return bool|int the number of rows deleted, or `false` if the deletion is unsuccessful for some reason.
759
     * Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful.
760
     */
761 9
    public function delete()
762
    {
763 9
        $result = false;
764 9
765 9
        if ($this->beforeDelete()) {
0 ignored issues
show
Bug introduced by
The method beforeDelete() 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

765
        if ($this->/** @scrutinizer ignore-call */ beforeDelete()) {

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...
766
            /**
767
             * we do not check the return value of deleteAll() because it's possible the record is already deleted in
768 9
             * the database and thus the method will return 0
769
             */
770
            $condition = $this->getOldPrimaryKey(true);
771
            $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...
772
773
            if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
774
                $condition[$lock] = $this->$lock;
775
            }
776
777
            $result = static::deleteAll($condition);
778
779
            if ($lock !== null && !$result) {
780
                throw new StaleObjectException('The object being deleted is outdated.');
781
            }
782
783
            $this->oldAttributes = null;
784
            $this->afterDelete();
785
        }
786
787
        return $result;
788
    }
789
790
    /**
791
     * Returns a value indicating whether the current record is new.
792
     *
793
     * @return bool whether the record is new and should be inserted when calling {@see save()}.
794 3
     */
795
    public function getIsNewRecord(): bool
796 3
    {
797 3
        return $this->oldAttributes === null;
798 3
    }
799 3
800
    /**
801
     * Sets the value indicating whether the record is new.
802
     *
803
     * @param bool $value whether the record is new and should be inserted when calling {@see save()}.
804 3
     *
805
     * @see getIsNewRecord()
806
     */
807 3
    public function setIsNewRecord($value)
808
    {
809
        $this->oldAttributes = $value ? null : $this->attributes;
810
    }
811
812
    /**
813
     * Repopulates this active record with the latest data.
814
     *
815
     * If the refresh is successful, an {@see EVENT_AFTER_REFRESH} event will be triggered.
816
     *
817
     * @return bool whether the row still exists in the database. If `true`, the latest data will be populated to this
818
     * active record. Otherwise, this record will remain unchanged.
819
     */
820
    public function refresh(): bool
821
    {
822
        /* @var $record BaseActiveRecord */
823
        $record = static::findOne($this->getPrimaryKey(true));
824
825
        return $this->refreshInternal($record);
826
    }
827
828
    /**
829
     * Repopulates this active record with the latest data from a newly fetched instance.
830
     *
831
     * @param BaseActiveRecord $record the record to take attributes from.
832
     *
833
     * @return bool whether refresh was successful.
834
     *
835
     * {@see refresh()}
836
     */
837
    protected function refreshInternal($record): bool
838
    {
839
        if ($record === null) {
840
            return false;
841
        }
842
843
        foreach ($this->attributes() as $name) {
844
            $this->attributes[$name] = $record->attributes[$name] ?? null;
845
        }
846
847
        $this->oldAttributes = $record->oldAttributes;
848
        $this->related = [];
849
        $this->relationsDependencies = [];
850
851
        return true;
852
    }
853
854
    /**
855
     * Returns a value indicating whether the given active record is the same as the current one.
856
     *
857
     * The comparison is made by comparing the table names and the primary key values of the two active records.
858
     * If one of the records {@see isNewRecord|is new} they are also considered not equal.
859
     *
860
     * @param ActiveRecordInterface $record record to compare to.
861
     *
862
     * @return bool whether the two active records refer to the same row in the same database table.
863
     */
864
    public function equals(ActiveRecordInterface $record): bool
865
    {
866 30
        if ($this->getIsNewRecord() || $record->getIsNewRecord()) {
867
            return false;
868 30
        }
869
870
        return \get_class($this) === \get_class($record) && $this->getPrimaryKey() === $record->getPrimaryKey();
871
    }
872
873
    /**
874
     * Returns the primary key value(s).
875
     *
876
     * @param bool $asArray whether to return the primary key value as an array. If `true`, the return value will be an
877
     * array with column names as keys and column values as values. Note that for composite primary keys, an array will
878
     * always be returned regardless of this parameter value.
879
     * @property mixed The primary key value. An array (column name => column value) is returned if the primary key is
880
     * composite. A string is returned otherwise (null will be returned if the key value is null).
881
     *
882
     * @return mixed the primary key value. An array (column name => column value) is returned if the primary key is
883
     * composite or `$asArray` is `true`. A string is returned otherwise (null will be returned if the key value is
884
     * null).
885
     */
886
    public function getPrimaryKey(bool $asArray = false)
887
    {
888
        $keys = $this->primaryKey();
889
890
        if (!$asArray && \count($keys) === 1) {
891
            return $this->attributes[$keys[0]] ?? null;
892
        }
893
894
        $values = [];
895
896
        foreach ($keys as $name) {
897
            $values[$name] = $this->attributes[$name] ?? null;
898
        }
899
900
        return $values;
901
    }
902
903
    /**
904
     * Returns the old primary key value(s).
905
     *
906
     * This refers to the primary key value that is populated into the record after executing a find method
907
     * (e.g. find(), findOne()).
908
     * The value remains unchanged even if the primary key attribute is manually assigned with a different value.
909
     *
910 15
     * @param bool $asArray whether to return the primary key value as an array. If `true`, the return value will be an
911
     * array with column name as key and column value as value. If this is `false` (default), a scalar value will be
912 15
     * returned for non-composite primary key.
913
     * @property mixed The old primary key value. An array (column name => column value) is returned if the primary key
914
     * is composite. A string is returned otherwise (null will be returned if the key value is null).
915
     *
916 15
     * @throws Exception if the AR model does not have a primary key.
917 15
     *
918
     * @return mixed the old primary key value. An array (column name => column value) is returned if the primary key
919
     * is composite or `$asArray` is `true`. A string is returned otherwise (null will be returned if the key value is
920 15
     * null).
921 15
     */
922 15
    public function getOldPrimaryKey(bool $asArray = false)
923
    {
924 15
        $keys = $this->primaryKey();
925
926
        if (empty($keys)) {
927
            throw new Exception(
928
                \get_class($this) . ' does not have a primary key. You should either define a primary key for '
929
                . 'the corresponding table or override the primaryKey() method.'
930
            );
931
        }
932
933
        if (!$asArray && \count($keys) === 1) {
934
            return $this->oldAttributes[$keys[0]] ?? null;
935
        }
936
937
        $values = [];
938
939
        foreach ($keys as $name) {
940
            $values[$name] = $this->oldAttributes[$name] ?? null;
941
        }
942
943
        return $values;
944
    }
945
946
    /**
947
     * Populates an active record object using a row of data from the database/storage.
948
     *
949
     * This is an internal method meant to be called to create active record objects after fetching data from the
950
     * database. It is mainly used by {@see ActiveQuery} to populate the query results into active records.
951
     *
952
     * When calling this method manually you should call {@see afterFind()} on the created record to trigger the
953
     * {@see EVENT_AFTER_FIND|afterFind Event}.
954
     *
955
     * @param BaseActiveRecord $record the record to be populated. In most cases this will be an instance created by
956
     * {@see instantiate()} beforehand.
957
     * @param array $row attribute values (name => value).
958
     */
959 15
    public static function populateRecord($record, $row): void
960
    {
961 15
        $columns = \array_flip($record->attributes());
962
963 15
        foreach ($row as $name => $value) {
964
            if (isset($columns[$name])) {
965
                $record->attributes[$name] = $value;
966
            } elseif ($record->canSetProperty($name)) {
967 15
                $record->$name = $value;
968
            }
969 15
        }
970 15
971
        $record->oldAttributes = $record->attributes;
972
        $record->related = [];
973 15
        $record->relationsDependencies = [];
974
    }
975
976
    /**
977
     * Creates an active record instance.
978
     *
979
     * This method is called together with {@see populateRecord()} by {@see ActiveQuery}.
980
     *
981
     * It is not meant to be used for creating new records directly.
982
     *
983
     * You may override this method if the instance being created depends on the row data to be populated into the
984
     * record.
985
     *
986
     * For example, by creating a record based on the value of a column, you may implement the so-called single-table
987
     * inheritance mapping.
988
     *
989
     * @return ActiveRecordInterface the newly created active record
990
     */
991
    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

991
    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...
992
    {
993
        return new static();
994
    }
995 15
996
    /**
997 15
     * Establishes the relationship between two models.
998
     *
999 15
     * The relationship is established by setting the foreign key value(s) in one model to be the corresponding primary
1000
     * key value(s) in the other model.
1001
     *
1002
     * The model with the foreign key will be saved into database without performing validation.
1003
     *
1004
     * If the relationship involves a junction table, a new row will be inserted into the junction table which contains
1005
     * the primary key values from both models.
1006 15
     *
1007
     * Note that this method requires that the primary key value is not null.
1008
     *
1009
     * @param string $name the case sensitive name of the relationship, e.g. `orders` for a relation defined via
1010 15
     * `getOrders()` method.
1011
     * @param ActiveRecordInterface $model the model to be linked with the current one.
1012 15
     * @param array $extraColumns additional column values to be saved into the junction table. This parameter is only
1013 15
     * meaningful for a relationship involving a junction table (i.e., a relation set with
1014
     * {@see ActiveRelationTrait::via()} or {@see ActiveQuery::viaTable()}.)
1015
     *
1016 15
     * @throws \ReflectionException
1017
     * @throws Exception
1018
     * @throws InvalidArgumentException
1019
     * @throws InvalidCallException if the method is unable to link two models.
1020
     * @throws InvalidConfigException
1021
     * @throws NotSupportedException
1022
     */
1023
    public function link(string $name, ActiveRecordInterface $model, array $extraColumns = []): void
1024
    {
1025
        $relation = $this->getRelation($name);
1026
1027
        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

1027
        if ($relation->/** @scrutinizer ignore-call */ getVia() !== null) {
Loading history...
1028
            if ($this->getIsNewRecord() || $model->getIsNewRecord()) {
1029
                throw new InvalidCallException(
1030
                    'Unable to link models: the models being linked cannot be newly created.'
1031
                );
1032
            }
1033
1034 147
            if (\is_array($relation->getVia())) {
1035
                /** @var $viaRelation ActiveQuery */
1036 147
                [$viaName, $viaRelation] = $relation->getVia();
1037
                $viaClass = $viaRelation->getModelClass();
1038 147
                /* unset $viaName so that it can be reloaded to reflect the change */
1039 147
                unset($this->related[$viaName]);
1040 147
            } else {
1041 6
                $viaRelation = $relation->getVia();
1042 6
                $from = $relation->getVia()->getFrom();
1043
                $viaTable = \reset($from);
1044
            }
1045
1046 147
            $columns = [];
1047 147
1048 147
            foreach ($viaRelation->getLink() as $a => $b) {
1049 147
                $columns[$a] = $this->$b;
1050
            }
1051
1052
            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

1052
            foreach ($relation->/** @scrutinizer ignore-call */ getLink() as $a => $b) {
Loading history...
1053
                $columns[$b] = $model->$a;
1054
            }
1055
1056
            foreach ($extraColumns as $k => $v) {
1057
                $columns[$k] = $v;
1058
            }
1059
1060
            if (\is_array($relation->getVia())) {
1061
                /** @var $viaClass ActiveRecordInterface */
1062
                /** @var $record ActiveRecordInterface */
1063
                $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...
1064
1065
                foreach ($columns as $column => $value) {
1066
                    $record->$column = $value;
1067
                }
1068 141
1069
                $record->insert(false);
0 ignored issues
show
Bug introduced by
false of type false is incompatible with the type array|null expected by parameter $attributes of Yiisoft\ActiveRecord\Act...cordInterface::insert(). ( Ignorable by Annotation )

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

1069
                $record->insert(/** @scrutinizer ignore-type */ false);
Loading history...
1070 141
            } else {
1071
                /** @var $viaTable string */
1072
                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...
1073
            }
1074
        } else {
1075
            $p1 = $model->isPrimaryKey(\array_keys($relation->getLink()));
1076
            $p2 = static::isPrimaryKey(\array_values($relation->getLink()));
1077
1078
            if ($p1 && $p2) {
1079
                if ($this->getIsNewRecord() && $model->getIsNewRecord()) {
1080
                    throw new InvalidCallException('Unable to link models: at most one model can be newly created.');
1081
                }
1082
1083
                if ($this->getIsNewRecord()) {
1084
                    $this->bindModels(\array_flip($relation->getLink()), $this, $model);
1085
                } else {
1086
                    $this->bindModels($relation->getLink(), $model, $this);
1087
                }
1088
            } elseif ($p1) {
1089
                $this->bindModels(\array_flip($relation->getLink()), $this, $model);
1090
            } elseif ($p2) {
1091
                $this->bindModels($relation->getLink(), $model, $this);
1092
            } else {
1093
                throw new InvalidCallException(
1094
                    'Unable to link models: the link defining the relation does not involve any primary key.'
1095
                );
1096
            }
1097 3
        }
1098
1099 3
        // update lazily loaded related objects
1100
        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

1100
        if (!$relation->/** @scrutinizer ignore-call */ getMultiple()) {
Loading history...
1101 3
            $this->related[$name] = $model;
1102
        } elseif (isset($this->related[$name])) {
1103
            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

1103
            if ($relation->/** @scrutinizer ignore-call */ getIndexBy() !== null) {
Loading history...
1104
                if ($relation->getIndexBy() instanceof \Closure) {
1105
                    $index = \call_user_func($relation->getIndexBy(), $model);
1106
                } else {
1107
                    $index = $model->{$relation->getIndexBy()};
1108
                }
1109
                $this->related[$name][$index] = $model;
1110
            } else {
1111
                $this->related[$name][] = $model;
1112
            }
1113
        }
1114
    }
1115
1116
    /**
1117
     * Destroys the relationship between two models.
1118
     *
1119
     * The model with the foreign key of the relationship will be deleted if `$delete` is `true`. Otherwise, the
1120
     * foreign key will be set `null` and the model will be saved without validation.
1121
     *
1122
     * @param string $name the case sensitive name of the relationship, e.g. `orders` for a relation defined via
1123
     * `getOrders()` method.
1124
     * @param ActiveRecordInterface $model the model to be unlinked from the current one.
1125
     * You have to make sure that the model is really related with the current model as this method does not check this.
1126
     * @param bool $delete whether to delete the model that contains the foreign key. If `false`, the model's foreign
1127
     * key will be set `null` and saved. If `true`, the model containing the foreign key will be deleted.
1128
     *
1129
     * @throws \ReflectionException
1130
     * @throws Exception
1131
     * @throws InvalidCallException if the models cannot be unlinked.
1132
     * @throws StaleObjectException
1133
     */
1134
    public function unlink(string $name, ActiveRecordInterface $model, bool $delete = false): void
1135
    {
1136
        $relation = $this->getRelation($name);
1137
1138
        if ($relation->getVia() !== null) {
1139
            if (\is_array($relation->getVia())) {
1140
                /* @var $viaRelation ActiveQuery */
1141
                [$viaName, $viaRelation] = $relation->getVia();
1142
                $viaClass = $viaRelation->getModelClass();
1143
                unset($this->related[$viaName]);
1144
            } else {
1145
                $viaRelation = $relation->getVia();
1146
                $from = $relation->getVia()->getFrom();
1147
                $viaTable = \reset($from);
1148 3
            }
1149 3
1150
            $columns = [];
1151 3
            foreach ($viaRelation->getLink() as $a => $b) {
1152
                $columns[$a] = $this->$b;
1153
            }
1154
1155
            foreach ($relation->getLink() as $a => $b) {
1156
                $columns[$b] = $model->$a;
1157
            }
1158
            $nulls = [];
1159
1160
            foreach (\array_keys($columns) as $a) {
1161 3
                $nulls[$a] = null;
1162
            }
1163 3
1164 3
            if (\is_array($relation->getVia())) {
1165
                /* @var $viaClass ActiveRecordInterface */
1166
                if ($delete) {
1167
                    $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...
1168
                } else {
1169
                    $viaClass::updateAll($nulls, $columns);
1170
                }
1171
            } else {
1172
                /* @var $viaTable string */
1173 3
                /* @var $command Command */
1174
                $command = static::getConnection()->createCommand();
1175 3
                if ($delete) {
1176 3
                    $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...
1177 3
                } else {
1178
                    $command->update($viaTable, $nulls, $columns)->execute();
1179
                }
1180 3
            }
1181
        } else {
1182 3
            $p1 = $model->isPrimaryKey(\array_keys($relation->getLink()));
1183
            $p2 = static::isPrimaryKey(\array_values($relation->getLink()));
1184
            if ($p2) {
1185
                if ($delete) {
1186
                    $model->delete();
1187 3
                } else {
1188
                    foreach ($relation->getLink() as $a => $b) {
1189
                        $model->$a = null;
1190
                    }
1191
                    $model->save();
1192
                }
1193
            } elseif ($p1) {
1194
                foreach ($relation->getLink() as $a => $b) {
1195
                    if (\is_array($this->$b)) { // relation via array valued attribute
1196
                        if (($key = \array_search($model->$a, $this->$b, false)) !== false) {
1197
                            $values = $this->$b;
1198
                            unset($values[$key]);
1199
                            $this->$b = \array_values($values);
1200
                        }
1201
                    } else {
1202
                        $this->$b = null;
1203
                    }
1204
                }
1205
                $delete ? $this->delete() : $this->save();
1206
            } else {
1207
                throw new InvalidCallException('Unable to unlink models: the link does not involve any primary key.');
1208
            }
1209
        }
1210
1211
        if (!$relation->getMultiple()) {
1212
            unset($this->related[$name]);
1213
        } elseif (isset($this->related[$name])) {
1214
            /* @var $b ActiveRecordInterface */
1215
            foreach ($this->related[$name] as $a => $b) {
1216
                if ($model->getPrimaryKey() === $b->getPrimaryKey()) {
1217
                    unset($this->related[$name][$a]);
1218
                }
1219
            }
1220
        }
1221
    }
1222
1223
    /**
1224
     * Destroys the relationship in current model.
1225
     *
1226
     * The model with the foreign key of the relationship will be deleted if `$delete` is `true`.
1227
     * Otherwise, the foreign key will be set `null` and the model will be saved without validation.
1228
     *
1229
     * Note that to destroy the relationship without removing records make sure your keys can be set to null
1230
     *
1231
     * @param string $name the case sensitive name of the relationship, e.g. `orders` for a relation defined via
1232
     * `getOrders()` method.
1233
     * @param bool $delete whether to delete the model that contains the foreign key.
1234
     *
1235
     * Note that the deletion will be performed using {@see deleteAll()}, which will not trigger any events on the
1236
     * related models. If you need {@see EVENT_BEFORE_DELETE} or {@see EVENT_AFTER_DELETE} to be triggered, you need to
1237
     * {@see find()|find} the models first and then call {@see delete()} on each of them.
1238
     *
1239
     * @throws \ReflectionException
1240
     * @throws Exception
1241
     * @throws StaleObjectException */
1242
    public function unlinkAll(string $name, bool $delete = false): void
1243
    {
1244
        $relation = $this->getRelation($name);
1245
1246
        if ($relation->getVia() !== null) {
1247
            if (\is_array($relation->getVia())) {
1248
                /* @var $viaRelation ActiveQuery */
1249
                [$viaName, $viaRelation] = $relation->getVia();
1250
                $viaClass = $viaRelation->getModelClass();
1251
                unset($this->related[$viaName]);
1252
            } else {
1253
                $viaRelation = $relation->getVia();
1254
                $from = $relation->getVia()->getFrom();
1255
                $viaTable = \reset($from);
1256
            }
1257
1258
            $condition = [];
1259
            $nulls = [];
1260
            foreach ($viaRelation->getLink() as $a => $b) {
1261
                $nulls[$a] = null;
1262
                $condition[$a] = $this->$b;
1263
            }
1264
1265
            if (!empty($viaRelation->getWhere())) {
1266
                $condition = ['and', $condition, $viaRelation->getWhere()];
1267
            }
1268
1269
            if (!empty($viaRelation->getOn())) {
1270
                $condition = ['and', $condition, $viaRelation->getOn()];
1271
            }
1272
1273
            if (\is_array($relation->getVia())) {
1274
                /** @var $viaClass ActiveRecordInterface */
1275
                if ($delete) {
1276
                    $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...
1277
                } else {
1278
                    $viaClass::updateAll($nulls, $condition);
1279
                }
1280
            } else {
1281
                /** @var $viaTable string */
1282
                /** @var $command Command */
1283
                $command = static::getConnection()->createCommand();
1284
                if ($delete) {
1285
                    $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...
1286
                } else {
1287
                    $command->update($viaTable, $nulls, $condition)->execute();
1288
                }
1289
            }
1290
        } else {
1291
            /* @var $relatedModel ActiveRecordInterface */
1292
            $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

1292
            /** @scrutinizer ignore-call */ 
1293
            $relatedModel = $relation->getModelClass();
Loading history...
1293
1294
            $link = $relation->getLink();
1295
            if (!$delete && \count($link) === 1 && \is_array($this->{$b = \reset($link)})) {
1296
                /** relation via array valued attribute */
1297
                $this->$b = [];
1298
                $this->save();
1299
            } else {
1300
                $nulls = [];
1301
                $condition = [];
1302
1303
                foreach ($relation->getLink() as $a => $b) {
1304
                    $nulls[$a] = null;
1305
                    $condition[$a] = $this->$b;
1306
                }
1307
1308
                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

1308
                if (!empty($relation->/** @scrutinizer ignore-call */ getWhere())) {
Loading history...
1309
                    $condition = ['and', $condition, $relation->getWhere()];
1310
                }
1311
1312
                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

1312
                if (!empty($relation->/** @scrutinizer ignore-call */ getOn())) {
Loading history...
1313
                    $condition = ['and', $condition, $relation->getOn()];
1314
                }
1315
1316
                if ($delete) {
1317 9
                    $relatedModel::deleteAll($condition);
1318
                } else {
1319 9
                    $relatedModel::updateAll($nulls, $condition);
1320
                }
1321 9
            }
1322 6
        }
1323
1324 3
        unset($this->related[$name]);
1325 3
    }
1326 3
1327
    private function bindModels(
1328 3
        array $link,
1329 3
        ActiveRecordInterface $foreignModel,
1330 3
        ActiveRecordInterface $primaryModel
1331
    ): void {
1332
        foreach ($link as $fk => $pk) {
1333 6
            $value = $primaryModel->$pk;
1334 6
1335 6
            if ($value === null) {
1336 6
                throw new InvalidCallException(
1337 6
                    'Unable to link models: the primary key of ' . \get_class($primaryModel) . ' is null.'
1338
                );
1339
            }
1340 6
1341
            /** relation via array valued attribute */
1342
            if (\is_array($foreignModel->$fk)) {
1343
                $foreignModel->{$fk}[] = $value;
1344 6
            } else {
1345
                $foreignModel->{$fk} = $value;
1346
            }
1347
        }
1348 6
1349
        $foreignModel->save();
1350 3
    }
1351 3
1352
    /**
1353 3
     * Returns a value indicating whether the given set of attributes represents the primary key for this model.
1354
     *
1355
     * @param array $keys the set of attributes to check.
1356
     *
1357
     * @return bool whether the given set of attributes represents the primary key for this model.
1358 3
     */
1359 3
    public static function isPrimaryKey(array $keys): bool
1360 3
    {
1361
        $pks = static::primaryKey();
1362 6
1363
        if (\count($keys) === \count($pks)) {
1364
            return \count(\array_intersect($keys, $pks)) === \count($pks);
1365
        }
1366
1367 3
        return false;
1368
    }
1369 3
1370
    /**
1371
     * Returns the text label for the specified attribute.
1372
     *
1373
     * If the attribute looks like `relatedModel.attribute`, then the attribute will be received from the related model.
1374 3
     *
1375 3
     * @param string $attribute the attribute name.
1376
     *
1377 3
     * @throws \ReflectionException
1378 3
     * @throws InvalidArgumentException
1379 3
     *
1380
     * @return string the attribute label.
1381
     *
1382 3
     * {@see generateAttributeLabel()}
1383
     * {@see attributeLabels()}
1384
     */
1385
    public function getAttributeLabel(string $attribute): string
1386 3
    {
1387 3
        $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

1387
        /** @scrutinizer ignore-call */ 
1388
        $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...
1388
1389
        if (isset($labels[$attribute])) {
1390 3
            return $labels[$attribute];
1391 3
        }
1392
1393
        if (\strpos($attribute, '.')) {
1394
            $attributeParts = \explode('.', $attribute);
1395
            $neededAttribute = \array_pop($attributeParts);
1396
1397
            $relatedModel = $this;
1398 9
            foreach ($attributeParts as $relationName) {
1399 9
                if ($relatedModel->isRelationPopulated($relationName) && $relatedModel->$relationName instanceof self) {
1400
                    $relatedModel = $relatedModel->$relationName;
1401
                } else {
1402
                    try {
1403
                        $relation = $relatedModel->getRelation($relationName);
1404
                    } catch (InvalidParamException $e) {
1405
                        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

1405
                        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...
1406
                    }
1407
                    /* @var $modelClass ActiveRecordInterface */
1408 3
                    $modelClass = $relation->getModelClass();
1409
                    $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

1409
                    /** @scrutinizer ignore-call */ 
1410
                    $relatedModel = $modelClass::instance();
Loading history...
1410
                }
1411
            }
1412
1413 3
            $labels = $relatedModel->attributeLabels();
1414 3
1415
            if (isset($labels[$neededAttribute])) {
1416 3
                return $labels[$neededAttribute];
1417
            }
1418
        }
1419
1420
        return $this->generateAttributeLabel($attribute);
1421
    }
1422
1423 3
    /**
1424
     * Returns the text hint for the specified attribute.
1425
     *
1426 3
     * If the attribute looks like `relatedModel.attribute`, then the attribute will be received from the related model.
1427
     *
1428
     * @param string $attribute the attribute name
1429
     *
1430 3
     * @throws \ReflectionException
1431 3
     * @throws InvalidArgumentException
1432
     *
1433
     * @return string the attribute hint
1434
     *
1435
     * {@see attributeHints()}
1436
     */
1437
    public function getAttributeHint(string $attribute): string
1438
    {
1439
        $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

1439
        /** @scrutinizer ignore-call */ 
1440
        $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...
1440 6
1441
        if (isset($hints[$attribute])) {
1442 6
            return $hints[$attribute];
1443
        } elseif (\strpos($attribute, '.')) {
1444 6
            $attributeParts = \explode('.', $attribute);
1445 6
            $neededAttribute = \array_pop($attributeParts);
1446
            $relatedModel = $this;
1447
1448 6
            foreach ($attributeParts as $relationName) {
1449
                if ($relatedModel->isRelationPopulated($relationName) && $relatedModel->$relationName instanceof self) {
1450
                    $relatedModel = $relatedModel->$relationName;
1451
                } else {
1452
                    try {
1453
                        $relation = $relatedModel->getRelation($relationName);
1454
                    } catch (InvalidParamException $e) {
1455
                        return '';
1456
                    }
1457
                    /* @var $modelClass ActiveRecordInterface */
1458
                    $modelClass = $relation->getModelClass();
1459
                    $relatedModel = $modelClass::instance();
1460
                }
1461
            }
1462
1463
            $hints = $relatedModel->attributeHints();
1464
1465
            if (isset($hints[$neededAttribute])) {
1466
                return $hints[$neededAttribute];
1467
            }
1468
        }
1469
1470
        return '';
1471
    }
1472
1473
    public function fields(): array
1474
    {
1475
        $fields = \array_keys($this->attributes);
1476
1477
        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...
1478
    }
1479
1480
    public function extraFields(): array
1481
    {
1482
        $fields = \array_keys($this->getRelatedRecords());
1483
1484
        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...
1485
    }
1486
1487
    /**
1488
     * Resets dependent related models checking if their links contain specific attribute.
1489
     *
1490
     * @param string $attribute The changed attribute name.
1491
     */
1492
    private function resetDependentRelations(string $attribute): void
1493
    {
1494
        foreach ($this->relationsDependencies[$attribute] as $relation) {
1495
            unset($this->related[$relation]);
1496
        }
1497
1498
        unset($this->relationsDependencies[$attribute]);
1499
    }
1500
1501
    /**
1502
     * Sets relation dependencies for a property.
1503
     *
1504
     * @param string $name property name.
1505
     * @param ActiveQueryInterface $relation relation instance.
1506
     * @param string|null $viaRelationName intermediate relation.
1507
     */
1508
    private function setRelationDependencies(
1509
        string $name,
1510
        ActiveQueryInterface $relation,
1511
        ?string $viaRelationName = null
1512
    ): void {
1513
        if (empty($relation->getVia()) && $relation->getLink()) {
1514
            foreach ($relation->getLink() as $attribute) {
1515
                $this->relationsDependencies[$attribute][$name] = $name;
1516
                if ($viaRelationName !== null) {
1517
                    $this->relationsDependencies[$attribute][] = $viaRelationName;
1518
                }
1519
            }
1520
        } elseif ($relation->getVia() instanceof ActiveQueryInterface) {
1521
            $this->setRelationDependencies($name, $relation->getVia());
1522
        } elseif (\is_array($relation->getVia())) {
1523
            [$viaRelationName, $viaQuery] = $relation->getVia();
1524
            $this->setRelationDependencies($name, $viaQuery, $viaRelationName);
1525
        }
1526
    }
1527
1528
    /**
1529
     * Returns attribute values.
1530
     *
1531
     * @param array|null $names list of attributes whose value needs to be returned. Defaults to null, meaning all
1532
     * attributes listed in {@see attributes()} will be returned. If it is an array, only the attributes in the array
1533
     * will be returned.
1534
     * @param array $except list of attributes whose value should NOT be returned.
1535
     *
1536
     * @return array attribute values (name => value).
1537
     */
1538
    public function getAttributes(?array $names = null, $except = []): array
1539
    {
1540
        $values = [];
1541
1542
        if ($names === null) {
1543
            $names = $this->attributes();
1544
        }
1545
1546
        foreach ($names as $name) {
1547
            $values[$name] = $this->$name;
1548
        }
1549
1550
        foreach ($except as $name) {
1551
            unset($values[$name]);
1552
        }
1553
1554
        return $values;
1555
    }
1556
}
1557