Passed
Pull Request — master (#201)
by
unknown
26:59 queued 23:43
created

ActiveRecord::loadDefaultValues()   A

Complexity

Conditions 5
Paths 3

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
cc 5
eloc 4
nc 3
nop 1
dl 0
loc 9
ccs 0
cts 0
cp 0
crap 30
rs 9.6111
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord;
6
7
use Throwable;
8
use Yiisoft\Db\Exception\Exception;
9
use Yiisoft\Db\Exception\InvalidArgumentException;
10
use Yiisoft\Db\Exception\InvalidConfigException;
11
use Yiisoft\Db\Exception\StaleObjectException;
12
use Yiisoft\Db\Expression\Expression;
13
use Yiisoft\Db\Query\Query;
14
use Yiisoft\Db\Schema\TableSchemaInterface;
15
use Yiisoft\Strings\Inflector;
16
use Yiisoft\Strings\StringHelper;
17
18
use function array_diff;
19
use function array_keys;
20
use function array_map;
21
use function array_values;
22
use function in_array;
23
use function is_array;
24
use function is_string;
25
use function key;
26
use function preg_replace;
27
28
/**
29
 * ActiveRecord is the base class for classes representing relational data in terms of objects.
30
 *
31
 * Active Record implements the [Active Record design pattern](http://en.wikipedia.org/wiki/Active_record).
32
 *
33
 * The premise behind Active Record is that an individual {@see ActiveRecord} object is associated with a specific row
34
 * in a database table. The object's attributes are mapped to the columns of the corresponding table.
35
 *
36
 * Referencing an Active Record attribute is equivalent to accessing the corresponding table column for that record.
37
 *
38
 * As an example, say that the `Customer` ActiveRecord class is associated with the `customer` table.
39
 *
40
 * This would mean that the class's `name` attribute is automatically mapped to the `name` column in `customer` table.
41
 * Thanks to Active Record, assuming the variable `$customer` is an object of type `Customer`, to get the value of the
42
 * `name` column for the table row, you can use the expression `$customer->name`.
43
 *
44
 * In this example, Active Record is providing an object-oriented interface for accessing data stored in the database.
45
 * But Active Record provides much more functionality than this.
46
 *
47
 * To declare an ActiveRecord class you need to extend {@see ActiveRecord} and implement the `tableName` method:
48
 *
49
 * ```php
50
 * <?php
51
 *
52
 * class Customer extends ActiveRecord
53
 * {
54
 *     public static function tableName(): string
55
 *     {
56
 *         return 'customer';
57
 *     }
58
 * }
59
 * ```
60
 *
61
 * The `tableName` method only has to return the name of the database table associated with the class.
62
 *
63
 * Class instances are obtained in one of two ways:
64
 *
65
 * Using the `new` operator to create a new, empty object.
66
 * Using a method to fetch an existing record (or records) from the database.
67
 *
68
 * Below is an example showing some typical usage of ActiveRecord:
69
 *
70
 * ```php
71
 * $user = new User($db);
72
 * $user->name = 'Qiang';
73
 * $user->save();  // a new row is inserted into user table
74
 *
75
 * // the following will retrieve the user 'CeBe' from the database
76
 * $userQuery = new ActiveQuery(User::class, $db);
77
 * $user = $userQuery->where(['name' => 'CeBe'])->one();
78
 *
79
 * // this will get related records from orders table when relation is defined
80
 * $orders = $user->orders;
81
 * ```
82
 *
83
 * For more details and usage information on ActiveRecord,
84
 * {@see the [guide article on ActiveRecord](guide:db-active-record)}
85
 *
86
 * @method ActiveQuery hasMany($class, array $link) {@see BaseActiveRecord::hasMany()} for more info.
87
 * @method ActiveQuery hasOne($class, array $link) {@see BaseActiveRecord::hasOne()} for more info.
88
 */
89
class ActiveRecord extends BaseActiveRecord
90
{
91
    /**
92
     * The insert operation. This is mainly used when overriding {@see transactions()} to specify which operations are
93
     * transactional.
94
     */
95
    public const OP_INSERT = 0x01;
96
97
    /**
98
     * The update operation. This is mainly used when overriding {@see transactions()} to specify which operations are
99
     * transactional.
100
     */
101
    public const OP_UPDATE = 0x02;
102
103
    /**
104
     * The delete operation. This is mainly used when overriding {@see transactions()} to specify which operations are
105
     * transactional.
106
     */
107
    public const OP_DELETE = 0x04;
108
109
    /**
110
     * All three operations: insert, update, delete.
111
     *
112
     * This is a shortcut of the expression: OP_INSERT | OP_UPDATE | OP_DELETE.
113
     */
114
    public const OP_ALL = 0x07;
115
116
    public function attributes(): array
117
    {
118
        return array_keys($this->getTableSchema()->getColumns());
119
    }
120
121
    public function delete(): false|int
122
    {
123
        if (!$this->isTransactional(self::OP_DELETE)) {
124
            return $this->deleteInternal();
125
        }
126
127
        $transaction = $this->db->beginTransaction();
128
129
        try {
130
            $result = $this->deleteInternal();
131
            if ($result === false) {
0 ignored issues
show
introduced by
The condition $result === false is always false.
Loading history...
132
                $transaction->rollBack();
133
            } else {
134
                $transaction->commit();
135 5
            }
136
137 5
            return $result;
138 5
        } catch (Throwable $e) {
139 5
            $transaction->rollBack();
140
            throw $e;
141
        }
142
    }
143 5
144
    public function deleteAll(array $condition = [], array $params = []): int
145
    {
146
        $command = $this->db->createCommand();
147
        $command->delete(static::tableName(), $condition, $params);
148
149
        return $command->execute();
150
    }
151
152
    public function filterCondition(array $condition, array $aliases = []): array
153
    {
154
        $result = [];
155 132
156
        $columnNames = $this->filterValidColumnNames($aliases);
157 132
158
        foreach ($condition as $key => $value) {
159 132
            if (is_string($key) && !in_array($this->db->getQuoter()->quoteSql($key), $columnNames, true)) {
160
                throw new InvalidArgumentException(
161 132
                    'Key "' . $key . '" is not a column name and can not be used as a filter'
162 20
                );
163 132
            }
164
            $result[$key] = is_array($value) ? array_values($value) : $value;
165
        }
166
167
        return $result;
168
    }
169
170
    public function filterValidAliases(ActiveQuery $query): array
171
    {
172
        $tables = $query->getTablesUsedInFrom();
173
174
        $aliases = array_diff(array_keys($tables), $tables);
175
176
        return array_map(static fn ($alias) => preg_replace('/{{([\w]+)}}/', '$1', $alias), array_values($aliases));
177
    }
178 108
179
    /**
180 108
     * Returns the schema information of the DB table associated with this AR class.
181
     *
182 108
     * @throws Exception
183
     * @throws InvalidConfigException If the table for the AR class does not exist.
184 108
     *
185 108
     * @return TableSchemaInterface The schema information of the DB table associated with this AR class.
186 32
     */
187 32
    public function getTableSchema(): TableSchemaInterface
188
    {
189
        $tableSchema = $this->db->getSchema()->getTableSchema(static::tableName());
190 76
191
        if ($tableSchema === null) {
192
            throw new InvalidConfigException('The table does not exist: ' . static::tableName());
193 76
        }
194
195
        return $tableSchema;
196
    }
197
198
    public function insert(array $attributes = null): bool
199
    {
200
        if (!$this->isTransactional(self::OP_INSERT)) {
201
            return $this->insertInternal($attributes);
202
        }
203
204
        $transaction = $this->db->beginTransaction();
205 108
206
        try {
207 108
            $result = $this->insertInternal($attributes);
208 108
            if ($result === false) {
209 108
                $transaction->rollBack();
210
            } else {
211 108
                $transaction->commit();
212 108
            }
213 108
214 108
            return $result;
215 108
        } catch (Throwable $e) {
216
            $transaction->rollBack();
217 108
            throw $e;
218 8
        }
219 8
    }
220 8
221
    /**
222
     * Returns a value indicating whether the specified operation is transactional.
223
     *
224 108
     * @param int $operation The operation to check. Possible values are {@see OP_INSERT}, {@see OP_UPDATE} and
225
     * {@see OP_DELETE}.
226
     *
227 28
     * @return array|bool Whether the specified operation is transactional.
228
     */
229 28
    public function isTransactional(int $operation): array|bool
0 ignored issues
show
Unused Code introduced by
The parameter $operation 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

229
    public function isTransactional(/** @scrutinizer ignore-unused */ int $operation): array|bool

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...
230
    {
231 28
        return $this->transactions();
232 28
    }
233
234
    /**
235 28
     * Loads default values from database table schema.
236 28
     *
237
     * You may call this method to load default values after creating a new instance:
238
     *
239 28
     * ```php
240
     * // class Customer extends ActiveRecord
241
     * $customer = new Customer($db);
242 28
     * $customer->loadDefaultValues();
243
     * ```
244 28
     *
245
     * @param bool $skipIfSet Whether existing value should be preserved. This will only set defaults for attributes
246
     * that are `null`.
247
     *
248
     * @throws Exception
249
     * @throws InvalidConfigException
250
     *
251
     * @return self The active record instance itself.
252
     */
253
    public function loadDefaultValues(bool $skipIfSet = true): self
254
    {
255
        foreach ($this->getTableSchema()->getColumns() as $column) {
256
            if ($column->getDefaultValue() !== null && (!$skipIfSet || $this->{$column->getName()} === null)) {
257
                $this->{$column->getName()} = $column->getDefaultValue();
258
            }
259
        }
260
261
        return $this;
262
    }
263
264
    /**
265
     * Populates an active record object using a row of data from the database/storage.
266
     *
267
     * This is an internal method meant to be called to create active record objects after fetching data from the
268
     * database. It is mainly used by {@see ActiveQuery} to populate the query results into active records.
269
     *
270
     * @param array|object $row Attribute values (name => value).
271
     *
272
     * @throws Exception
273
     * @throws InvalidConfigException
274
     */
275
    public function populateRecord(array|object $row): void
276
    {
277
        $columns = $this->getTableSchema()->getColumns();
278
279 46
        foreach ($row as $name => $value) {
280
            if (isset($columns[$name])) {
281 46
                $row[$name] = $columns[$name]->phpTypecast($value);
282
            }
283 46
        }
284
285 46
        parent::populateRecord($row);
286
    }
287
288
    public function primaryKey(): array
289
    {
290
        return $this->getTableSchema()->getPrimaryKey();
291
    }
292
293
    /**
294
     * @throws Exception
295
     * @throws InvalidArgumentException
296
     * @throws InvalidConfigException
297
     * @throws Throwable
298
     */
299
    public function refresh(): bool
300
    {
301
        $query = $this->instantiateQuery(static::class);
302
303
        $tableName = key($query->getTablesUsedInFrom());
304
        $pk = [];
305
306
        /** disambiguate column names in case ActiveQuery adds a JOIN */
307
        foreach ($this->getPrimaryKey(true) as $key => $value) {
308
            $pk[$tableName . '.' . $key] = $value;
309
        }
310
311
        $query->where($pk);
312 8
313
        return $this->refreshInternal($query->one());
314 8
    }
315
316 8
    /**
317 8
     * Declares which DB operations should be performed within a transaction in different scenarios.
318 8
     *
319
     * The supported DB operations are: {@see OP_INSERT}, {@see OP_UPDATE} and {@see OP_DELETE}, which correspond to the
320
     * {@see insert()}, {@see update()} and {@see delete()} methods, respectively.
321 8
     *
322 8
     * By default, these methods are NOT enclosed in a DB transaction.
323
     *
324 8
     * In some scenarios, to ensure data consistency, you may want to enclose some or all of them in transactions. You
325
     * can do so by overriding this method and returning the operations that need to be transactional. For example,
326
     *
327
     * ```php
328
     * return [
329
     *     'admin' => self::OP_INSERT,
330
     *     'api' => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE,
331
     *     // the above is equivalent to the following:
332
     *     // 'api' => self::OP_ALL,
333
     *
334
     * ];
335
     * ```
336
     *
337
     * The above declaration specifies that in the "admin" scenario, the insert operation ({@see insert()}) should be
338
     * done in a transaction; and in the "api" scenario, all the operations should be done in a transaction.
339
     *
340
     * @return array The declarations of transactional operations. The array keys are scenarios names, and the array
341
     * values are the corresponding transaction operations.
342
     */
343
    public function transactions(): array
344
    {
345
        return [];
346
    }
347
348
    public function update(array $attributeNames = null): false|int
349
    {
350
        if (!$this->isTransactional(self::OP_UPDATE)) {
351
            return $this->updateInternal($attributeNames);
352
        }
353
354
        $transaction = $this->db->beginTransaction();
355
356
        try {
357 20
            $result = $this->updateInternal($attributeNames);
358
            if ($result === 0) {
359 20
                $transaction->rollBack();
360 20
            } else {
361
                $transaction->commit();
362 20
            }
363
364
            return $result;
365
        } catch (Throwable $e) {
366
            $transaction->rollBack();
367
            throw $e;
368
        }
369
    }
370
371
    public function updateAll(array $attributes, array|string $condition = [], array $params = []): int
372
    {
373
        $command = $this->db->createCommand();
374
375 14
        $command->update(static::tableName(), $attributes, $condition, $params);
376
377 14
        return $command->execute();
378
    }
379 14
380
    /**
381
     * Updates the whole table using the provided counter changes and conditions.
382
     *
383
     * For example, to increment all customers' age by 1,
384
     *
385
     * ```php
386
     * $customer = new Customer($db);
387
     * $customer->updateAllCounters(['age' => 1]);
388
     * ```
389
     *
390 538
     * Note that this method will not trigger any events.
391
     *
392 538
     * @param array $counters The counters to be updated (attribute name => increment value).
393
     * Use negative values if you want to decrement the counters.
394 538
     * @param array|string $condition The conditions that will be put in the WHERE part of the UPDATE SQL. Please refer
395 4
     * to {@see Query::where()} on how to specify this parameter.
396
     * @param array $params The parameters (name => value) to be bound to the query.
397
     *
398 534
     * Do not name the parameters as `:bp0`, `:bp1`, etc., because they are used internally by this method.
399
     *
400
     * @throws Exception
401
     * @throws InvalidConfigException
402
     * @throws Throwable
403
     *
404
     * @return int The number of rows updated.
405
     */
406
    public function updateAllCounters(array $counters, array|string $condition = '', array $params = []): int
407
    {
408
        $n = 0;
409
410
        foreach ($counters as $name => $value) {
411
            $counters[$name] = new Expression("[[$name]]+:bp{$n}", [":bp{$n}" => $value]);
412
            $n++;
413
        }
414
415
        $command = $this->db->createCommand();
416
        $command->update(static::tableName(), $counters, $condition, $params);
417 276
418
        return $command->execute();
419 276
    }
420
421
    public static function tableName(): string
422
    {
423
        $inflector = new Inflector();
424
425
        return '{{%' . $inflector->pascalCaseToId(StringHelper::baseName(static::class), '_') . '}}';
426
    }
427
428
    /**
429
     * Deletes an ActiveRecord without considering transaction.
430
     *
431
     * Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful.
432
     *
433 450
     * @throws Exception
434
     * @throws StaleObjectException
435 450
     * @throws Throwable
436
     *
437
     * @return false|int The number of rows deleted, or `false` if the deletion is unsuccessful for some reason.
438
     */
439
    protected function deleteInternal(): false|int
440
    {
441
        /**
442
         * We do not check the return value of deleteAll() because it's possible the record is already deleted in the
443
         * database and thus the method will return 0.
444
         */
445
        $condition = $this->getOldPrimaryKey(true);
446
447
        $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...
448
449
        if ($lock !== null) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
450
            $condition[$lock] = $this->$lock;
451
        }
452
453
        $result = $this->deleteAll($condition);
454
455
        if ($lock !== null && !$result) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
456
            throw new StaleObjectException('The object being deleted is outdated.');
457
        }
458
459
        $this->setOldAttributes();
460
461
        return $result;
462
    }
463
464
    /**
465 85
     * Valid column names are table column names or column names prefixed with table name or table alias.
466
     *
467 85
     * @throws Exception
468
     * @throws InvalidConfigException
469
     */
470
    protected function filterValidColumnNames(array $aliases): array
471
    {
472
        $columnNames = [];
473
        $tableName = static::tableName();
474
        $quotedTableName = $this->db->getQuoter()->quoteTableName($tableName);
475
476
        foreach ($this->getTableSchema()->getColumnNames() as $columnName) {
477
            $columnNames[] = $columnName;
478
            $columnNames[] = $this->db->getQuoter()->quoteColumnName($columnName);
479
            $columnNames[] = "$tableName.$columnName";
480 397
            $columnNames[] = $this->db->getQuoter()->quoteSql("$quotedTableName.[[$columnName]]");
481
482 397
            foreach ($aliases as $tableAlias) {
483
                $columnNames[] = "$tableAlias.$columnName";
484 397
                $quotedTableAlias = $this->db->getQuoter()->quoteTableName($tableAlias);
485 397
                $columnNames[] = $this->db->getQuoter()->quoteSql("$quotedTableAlias.[[$columnName]]");
486 397
            }
487
        }
488
489
        return $columnNames;
490 397
    }
491 397
492
    /**
493
     * Inserts an ActiveRecord into DB without considering transaction.
494
     *
495
     * @param array|null $attributes List of attributes that need to be saved. Defaults to `null`, meaning all
496
     * attributes that are loaded from DB will be saved.
497
     *
498
     * @throws Exception
499
     * @throws InvalidArgumentException
500
     * @throws InvalidConfigException
501
     * @throws Throwable
502
     *
503
     * @return bool Whether the record is inserted successfully.
504
     */
505
    protected function insertInternal(array $attributes = null): bool
506
    {
507
        $values = $this->getDirtyAttributes($attributes);
508
509
        if (($primaryKeys = $this->db->createCommand()->insertEx(static::tableName(), $values)) === false) {
510
            return false;
511
        }
512
513
        foreach ($primaryKeys as $name => $value) {
514
            $id = $this->getTableSchema()->getColumn($name)?->phpTypecast($value);
515
            $this->setAttribute($name, $id);
516
            $values[$name] = $id;
517 65
        }
518
519 65
        $this->setOldAttributes($values);
520 65
521
        return true;
522
    }
523
}
524