Passed
Push — master ( 2e3094...33361f )
by Wilmer
05:02 queued 02:09
created

ActiveRecord   B

Complexity

Total Complexity 45

Size/Duplication

Total Lines 375
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 4
Bugs 1 Features 0
Metric Value
eloc 111
dl 0
loc 375
ccs 71
cts 71
cp 1
rs 8.8
c 4
b 1
f 0
wmc 45

17 Methods

Rating   Name   Duplication   Size   Complexity  
A delete() 0 20 4
A attributes() 0 3 1
A populateRecord() 0 11 3
A insertInternal() 0 17 3
A refresh() 0 15 2
A isTransactional() 0 3 1
A transactions() 0 3 1
A loadDefaultValues() 0 9 5
A filterValidAliases() 0 7 1
A insert() 0 20 4
A deleteInternal() 0 23 4
A tableName() 0 5 1
A primaryKey() 0 3 1
A update() 0 20 4
A filterValidColumnNames() 0 20 3
A filterCondition() 0 16 5
A getTableSchema() 0 9 2

How to fix   Complexity   

Complex Class

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

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

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

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord;
6
7
use 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 filterCondition(array $condition, array $aliases = []): array
145
    {
146
        $result = [];
147
148
        $columnNames = $this->filterValidColumnNames($aliases);
149
150
        foreach ($condition as $key => $value) {
151
            if (is_string($key) && !in_array($this->db->getQuoter()->quoteSql($key), $columnNames, true)) {
152
                throw new InvalidArgumentException(
153
                    'Key "' . $key . '" is not a column name and can not be used as a filter'
154
                );
155 132
            }
156
            $result[$key] = is_array($value) ? array_values($value) : $value;
157 132
        }
158
159 132
        return $result;
160
    }
161 132
162 20
    public function filterValidAliases(ActiveQuery $query): array
163 132
    {
164
        $tables = $query->getTablesUsedInFrom();
165
166
        $aliases = array_diff(array_keys($tables), $tables);
167
168
        return array_map(static fn ($alias) => preg_replace('/{{([\w]+)}}/', '$1', $alias), array_values($aliases));
169
    }
170
171
    /**
172
     * Returns the schema information of the DB table associated with this AR class.
173
     *
174
     * @throws Exception
175
     * @throws InvalidConfigException If the table for the AR class does not exist.
176
     *
177
     * @return TableSchemaInterface The schema information of the DB table associated with this AR class.
178 108
     */
179
    public function getTableSchema(): TableSchemaInterface
180 108
    {
181
        $tableSchema = $this->db->getSchema()->getTableSchema(static::tableName());
182 108
183
        if ($tableSchema === null) {
184 108
            throw new InvalidConfigException('The table does not exist: ' . static::tableName());
185 108
        }
186 32
187 32
        return $tableSchema;
188
    }
189
190 76
    public function insert(array $attributes = null): bool
191
    {
192
        if (!$this->isTransactional(self::OP_INSERT)) {
193 76
            return $this->insertInternal($attributes);
194
        }
195
196
        $transaction = $this->db->beginTransaction();
197
198
        try {
199
            $result = $this->insertInternal($attributes);
200
            if ($result === false) {
201
                $transaction->rollBack();
202
            } else {
203
                $transaction->commit();
204
            }
205 108
206
            return $result;
207 108
        } catch (Throwable $e) {
208 108
            $transaction->rollBack();
209 108
            throw $e;
210
        }
211 108
    }
212 108
213 108
    /**
214 108
     * Returns a value indicating whether the specified operation is transactional.
215 108
     *
216
     * @param int $operation The operation to check. Possible values are {@see OP_INSERT}, {@see OP_UPDATE} and
217 108
     * {@see OP_DELETE}.
218 8
     *
219 8
     * @return array|bool Whether the specified operation is transactional.
220 8
     */
221
    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

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