Passed
Pull Request — master (#246)
by Wilmer
03:06
created

BaseActiveRecord::toArray()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3.2098

Importance

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