Passed
Pull Request — master (#295)
by Wilmer
15:21 queued 02:00
created

ActiveRecord   A

Complexity

Total Complexity 39

Size/Duplication

Total Lines 318
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 5
Bugs 2 Features 0
Metric Value
wmc 39
eloc 97
c 5
b 2
f 0
dl 0
loc 318
rs 9.28
ccs 67
cts 67
cp 1

15 Methods

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

212
    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...
213 108
    {
214 108
        return $this->transactions();
215 108
    }
216
217 108
    /**
218 8
     * Loads default values from database table schema.
219 8
     *
220 8
     * You may call this method to load default values after creating a new instance:
221
     *
222
     * ```php
223
     * // class Customer extends ActiveRecord
224 108
     * $customer = new Customer($db);
225
     * $customer->loadDefaultValues();
226
     * ```
227 28
     *
228
     * @param bool $skipIfSet Whether existing value should be preserved. This will only set defaults for attributes
229 28
     * that are `null`.
230
     *
231 28
     * @throws Exception
232 28
     * @throws InvalidConfigException
233
     *
234
     * @return self The active record instance itself.
235 28
     */
236 28
    public function loadDefaultValues(bool $skipIfSet = true): self
237
    {
238
        foreach ($this->getTableSchema()->getColumns() as $column) {
239 28
            if ($column->getDefaultValue() !== null && (!$skipIfSet || $this->getAttribute($column->getName()) === null)) {
240
                $this->setAttribute($column->getName(), $column->getDefaultValue());
241
            }
242 28
        }
243
244 28
        return $this;
245
    }
246
247
    public function populateRecord(array|object $row): void
248
    {
249
        $columns = $this->getTableSchema()->getColumns();
250
251
        /** @psalm-var array[][] $row */
252
        foreach ($row as $name => $value) {
253
            if (isset($columns[$name])) {
254
                $row[$name] = $columns[$name]->phpTypecast($value);
255
            }
256
        }
257
258
        parent::populateRecord($row);
259
    }
260
261
    public function primaryKey(): array
262
    {
263
        return $this->getTableSchema()->getPrimaryKey();
264
    }
265
266
    /**
267
     * @throws Exception
268
     * @throws InvalidArgumentException
269
     * @throws InvalidConfigException
270
     * @throws Throwable
271
     */
272
    public function refresh(): bool
273
    {
274
        $query = $this->instantiateQuery(static::class);
275
276
        $tableName = key($query->getTablesUsedInFrom());
277
        $pk = [];
278
279 46
        /** disambiguate column names in case ActiveQuery adds a JOIN */
280
        foreach ($this->getPrimaryKey(true) as $key => $value) {
281 46
            $pk[$tableName . '.' . $key] = $value;
282
        }
283 46
284
        $query->where($pk);
285 46
286
        return $this->refreshInternal($query->onePopulate());
287
    }
288
289
    /**
290
     * Declares which DB operations should be performed within a transaction in different scenarios.
291
     *
292
     * The supported DB operations are: {@see OP_INSERT}, {@see OP_UPDATE} and {@see OP_DELETE}, which correspond to the
293
     * {@see insert()}, {@see update()} and {@see delete()} methods, respectively.
294
     *
295
     * By default, these methods are NOT enclosed in a DB transaction.
296
     *
297
     * In some scenarios, to ensure data consistency, you may want to enclose some or all of them in transactions. You
298
     * can do so by overriding this method and returning the operations that need to be transactional. For example,
299
     *
300
     * ```php
301
     * return [
302
     *     'admin' => self::OP_INSERT,
303
     *     'api' => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE,
304
     *     // the above is equivalent to the following:
305
     *     // 'api' => self::OP_ALL,
306
     *
307
     * ];
308
     * ```
309
     *
310
     * The above declaration specifies that in the "admin" scenario, the insert operation ({@see insert()}) should be
311
     * done in a transaction; and in the "api" scenario, all the operations should be done in a transaction.
312 8
     *
313
     * @return array The declarations of transactional operations. The array keys are scenarios names, and the array
314 8
     * values are the corresponding transaction operations.
315
     */
316 8
    public function transactions(): array
317 8
    {
318 8
        return [];
319
    }
320
321 8
    public function update(array $attributeNames = null): int
322 8
    {
323
        if (!$this->isTransactional(self::OP_UPDATE)) {
324 8
            return $this->updateInternal($attributeNames);
325
        }
326
327
        $transaction = $this->db->beginTransaction();
328
329
        try {
330
            $result = $this->updateInternal($attributeNames);
331
            if ($result === 0) {
332
                $transaction->rollBack();
333
            } else {
334
                $transaction->commit();
335
            }
336
337
            return $result;
338
        } catch (Throwable $e) {
339
            $transaction->rollBack();
340
            throw $e;
341
        }
342
    }
343
344
    /**
345
     * Valid column names are table column names or column names prefixed with table name or table alias.
346
     *
347
     * @throws Exception
348
     * @throws InvalidConfigException
349
     */
350
    protected function filterValidColumnNames(array $aliases): array
351
    {
352
        $columnNames = [];
353
        $tableName = $this->getTableName();
354
        $quotedTableName = $this->db->getQuoter()->quoteTableName($tableName);
355
356
        foreach ($this->getTableSchema()->getColumnNames() as $columnName) {
357 20
            $columnNames[] = $columnName;
358
            $columnNames[] = $this->db->getQuoter()->quoteColumnName($columnName);
359 20
            $columnNames[] = "$tableName.$columnName";
360 20
            $columnNames[] = $this->db->getQuoter()->quoteSql("$quotedTableName.[[$columnName]]");
361
362 20
            foreach ($aliases as $tableAlias) {
363
                $columnNames[] = "$tableAlias.$columnName";
364
                $quotedTableAlias = $this->db->getQuoter()->quoteTableName($tableAlias);
365
                $columnNames[] = $this->db->getQuoter()->quoteSql("$quotedTableAlias.[[$columnName]]");
366
            }
367
        }
368
369
        return $columnNames;
370
    }
371
372
    /**
373
     * Inserts an ActiveRecord into DB without considering transaction.
374
     *
375 14
     * @param array|null $attributes List of attributes that need to be saved. Defaults to `null`, meaning all
376
     * attributes that are loaded from DB will be saved.
377 14
     *
378
     * @throws Exception
379 14
     * @throws InvalidArgumentException
380
     * @throws InvalidConfigException
381
     * @throws Throwable
382
     *
383
     * @return bool Whether the record is inserted successfully.
384
     */
385
    protected function insertInternal(array $attributes = null): bool
386
    {
387
        $values = $this->getDirtyAttributes($attributes);
388
389
        if (($primaryKeys = $this->db->createCommand()->insertWithReturningPks($this->getTableName(), $values)) === false) {
390 538
            return false;
391
        }
392 538
393
        foreach ($primaryKeys as $name => $value) {
394 538
            $id = $this->getTableSchema()->getColumn($name)?->phpTypecast($value);
395 4
            $this->setAttribute($name, $id);
396
            $values[$name] = $id;
397
        }
398 534
399
        $this->setOldAttributes($values);
400
401
        return true;
402
    }
403
}
404