Passed
Pull Request — master (#261)
by Wilmer
10:13 queued 06:53
created

ActiveRecord::deleteInternal()   B

Complexity

Conditions 8
Paths 8

Size

Total Lines 26
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 8

Importance

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

217
    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...
218 8
    {
219 8
        return $this->transactions();
220 8
    }
221
222
    /**
223
     * Loads default values from database table schema.
224 108
     *
225
     * You may call this method to load default values after creating a new instance:
226
     *
227 28
     * ```php
228
     * // class Customer extends ActiveRecord
229 28
     * $customer = new Customer($db);
230
     * $customer->loadDefaultValues();
231 28
     * ```
232 28
     *
233
     * @param bool $skipIfSet Whether existing value should be preserved. This will only set defaults for attributes
234
     * that are `null`.
235 28
     *
236 28
     * @throws Exception
237
     * @throws InvalidConfigException
238
     *
239 28
     * @return self The active record instance itself.
240
     */
241
    public function loadDefaultValues(bool $skipIfSet = true): self
242 28
    {
243
        foreach ($this->getTableSchema()->getColumns() as $column) {
244 28
            if ($column->getDefaultValue() !== null && (!$skipIfSet || $this->getAttribute($column->getName()) === null)) {
245
                $this->setAttribute($column->getName(), $column->getDefaultValue());
246
            }
247
        }
248
249
        return $this;
250
    }
251
252
    public function populateRecord(array|object $row): void
253
    {
254
        $columns = $this->getTableSchema()->getColumns();
255
256
        /** @psalm-var array[][] $row */
257
        foreach ($row as $name => $value) {
258
            if (isset($columns[$name])) {
259
                $row[$name] = $columns[$name]->phpTypecast($value);
260
            }
261
        }
262
263
        parent::populateRecord($row);
264
    }
265
266
    public function primaryKey(): array
267
    {
268
        return $this->getTableSchema()->getPrimaryKey();
269
    }
270
271
    /**
272
     * @throws Exception
273
     * @throws InvalidArgumentException
274
     * @throws InvalidConfigException
275
     * @throws Throwable
276
     */
277
    public function refresh(): bool
278
    {
279 46
        $query = $this->instantiateQuery(static::class);
280
281 46
        $tableName = key($query->getTablesUsedInFrom());
282
        $pk = [];
283 46
284
        /** disambiguate column names in case ActiveQuery adds a JOIN */
285 46
        foreach ($this->getPrimaryKey(true) as $key => $value) {
286
            $pk[$tableName . '.' . $key] = $value;
287
        }
288
289
        $query->where($pk);
290
291
        return $this->refreshInternal($query->onePopulate());
292
    }
293
294
    /**
295
     * Declares which DB operations should be performed within a transaction in different scenarios.
296
     *
297
     * The supported DB operations are: {@see OP_INSERT}, {@see OP_UPDATE} and {@see OP_DELETE}, which correspond to the
298
     * {@see insert()}, {@see update()} and {@see delete()} methods, respectively.
299
     *
300
     * By default, these methods are NOT enclosed in a DB transaction.
301
     *
302
     * In some scenarios, to ensure data consistency, you may want to enclose some or all of them in transactions. You
303
     * can do so by overriding this method and returning the operations that need to be transactional. For example,
304
     *
305
     * ```php
306
     * return [
307
     *     'admin' => self::OP_INSERT,
308
     *     'api' => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE,
309
     *     // the above is equivalent to the following:
310
     *     // 'api' => self::OP_ALL,
311
     *
312 8
     * ];
313
     * ```
314 8
     *
315
     * The above declaration specifies that in the "admin" scenario, the insert operation ({@see insert()}) should be
316 8
     * done in a transaction; and in the "api" scenario, all the operations should be done in a transaction.
317 8
     *
318 8
     * @return array The declarations of transactional operations. The array keys are scenarios names, and the array
319
     * values are the corresponding transaction operations.
320
     */
321 8
    public function transactions(): array
322 8
    {
323
        return [];
324 8
    }
325
326
    public function update(array $attributeNames = null): false|int
327
    {
328
        if (!$this->isTransactional(self::OP_UPDATE)) {
329
            return $this->updateInternal($attributeNames);
330
        }
331
332
        $transaction = $this->db->beginTransaction();
333
334
        try {
335
            $result = $this->updateInternal($attributeNames);
336
            if ($result === 0) {
337
                $transaction->rollBack();
338
            } else {
339
                $transaction->commit();
340
            }
341
342
            return $result;
343
        } catch (Throwable $e) {
344
            $transaction->rollBack();
345
            throw $e;
346
        }
347
    }
348
349
    /**
350
     * Deletes an ActiveRecord without considering transaction.
351
     *
352
     * Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful.
353
     *
354
     * @throws Exception
355
     * @throws StaleObjectException
356
     * @throws Throwable
357 20
     *
358
     * @return false|int The number of rows deleted, or `false` if the deletion is unsuccessful for some reason.
359 20
     */
360 20
    protected function deleteInternal(): false|int
361
    {
362 20
        $result = false;
363
364
        /**
365
         * We do not check the return value of deleteAll() because it's possible the record is already deleted in the
366
         * database and thus the method will return 0.
367
         */
368
        $condition = $this->getOldPrimaryKey(true);
369
        $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...
370
371
        if ($lock !== null && is_array($condition)) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
372
            $condition[$lock] = $this->$lock;
373
        }
374
375 14
        if ($condition !== null && is_array($condition) && count($condition) > 0) {
376
            $result = $this->deleteAll($condition);
377 14
        }
378
379 14
        if ($lock !== null && !$result) {
0 ignored issues
show
introduced by
The condition $lock !== null is always false.
Loading history...
Bug Best Practice introduced by
The expression $result of type false|integer is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
380
            throw new StaleObjectException('The object being deleted is outdated.');
381
        }
382
383
        $this->setOldAttributes();
384
385
        return $result;
386
    }
387
388
    /**
389
     * Valid column names are table column names or column names prefixed with table name or table alias.
390 538
     *
391
     * @throws Exception
392 538
     * @throws InvalidConfigException
393
     */
394 538
    protected function filterValidColumnNames(array $aliases): array
395 4
    {
396
        $columnNames = [];
397
        $tableName = $this->getTableName();
398 534
        $quotedTableName = $this->db->getQuoter()->quoteTableName($tableName);
399
400
        foreach ($this->getTableSchema()->getColumnNames() as $columnName) {
401
            $columnNames[] = $columnName;
402
            $columnNames[] = $this->db->getQuoter()->quoteColumnName($columnName);
403
            $columnNames[] = "$tableName.$columnName";
404
            $columnNames[] = $this->db->getQuoter()->quoteSql("$quotedTableName.[[$columnName]]");
405
406
            foreach ($aliases as $tableAlias) {
407
                $columnNames[] = "$tableAlias.$columnName";
408
                $quotedTableAlias = $this->db->getQuoter()->quoteTableName($tableAlias);
409
                $columnNames[] = $this->db->getQuoter()->quoteSql("$quotedTableAlias.[[$columnName]]");
410
            }
411
        }
412
413
        return $columnNames;
414
    }
415
416
    /**
417 276
     * Inserts an ActiveRecord into DB without considering transaction.
418
     *
419 276
     * @param array|null $attributes List of attributes that need to be saved. Defaults to `null`, meaning all
420
     * attributes that are loaded from DB will be saved.
421
     *
422
     * @throws Exception
423
     * @throws InvalidArgumentException
424
     * @throws InvalidConfigException
425
     * @throws Throwable
426
     *
427
     * @return bool Whether the record is inserted successfully.
428
     */
429
    protected function insertInternal(array $attributes = null): bool
430
    {
431
        $values = $this->getDirtyAttributes($attributes);
432
433 450
        if (($primaryKeys = $this->db->createCommand()->insertWithReturningPks($this->getTableName(), $values)) === false) {
434
            return false;
435 450
        }
436
437
        foreach ($primaryKeys as $name => $value) {
438
            $id = $this->getTableSchema()->getColumn($name)?->phpTypecast($value);
439
            $this->setAttribute($name, $id);
440
            $values[$name] = $id;
441
        }
442
443
        $this->setOldAttributes($values);
444
445
        return true;
446
    }
447
}
448