Passed
Pull Request — master (#203)
by Alexander
05:12 queued 02:19
created

ActiveRecord::tableName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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

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