Passed
Push — master ( 6c6d7e...b48c4f )
by Marwan
01:39
created

Model   F

Complexity

Total Complexity 93

Size/Duplication

Total Lines 922
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 256
c 1
b 0
f 0
dl 0
loc 922
ccs 304
cts 304
cp 1
rs 2
wmc 93

48 Methods

Rating   Name   Duplication   Size   Complexity  
A __debugInfo() 0 3 1
A assertAttributeExists() 0 13 3
A getTable() 0 8 2
A find() 0 33 6
A __unset() 0 5 1
A offsetUnset() 0 2 1
A __isset() 0 5 1
A __construct() 0 9 2
A getPrimaryKey() 0 7 2
A get() 0 5 1
A toArray() 0 10 1
A count() 0 17 4
A getColumns() 0 7 2
A getAttributes() 0 3 1
A fetch() 0 15 2
A all() 0 29 6
A first() 0 3 1
A isMigrated() 0 8 1
A offsetGet() 0 2 1
A __get() 0 5 1
A offsetExists() 0 2 1
A offsetSet() 0 2 1
A __call() 0 10 2
A getIterator() 0 3 1
A __sleep() 0 3 1
A buildWhereClause() 0 13 2
A create() 0 6 1
A __toString() 0 3 1
A setAttributes() 0 7 2
A __set() 0 5 1
A migrate() 0 3 1
A one() 0 3 1
A validateCondition() 0 17 5
A toJson() 0 5 1
A last() 0 3 1
A delete() 0 11 1
A __clone() 0 3 1
A bootstrap() 0 2 1
B buildNestedQuery() 0 63 6
A where() 0 30 4
A __wakeup() 0 3 1
A set() 0 7 1
A update() 0 23 2
A destroy() 0 5 2
A validateOperator() 0 15 2
A getDatabase() 0 7 2
A hydrate() 0 8 2
A save() 0 34 6

How to fix   Complexity   

Complex Class

Complex classes like Model 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 Model, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * @author Marwan Al-Soltany <[email protected]>
5
 * @copyright Marwan Al-Soltany 2021
6
 * For the full copyright and license information, please view
7
 * the LICENSE file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace MAKS\Velox\Backend;
13
14
use MAKS\Velox\Backend\Database;
15
use MAKS\Velox\Helper\Misc;
16
17
/**
18
 * An abstract class that serves as a base model that can be extended to create models.
19
 *
20
 * Example:
21
 * ```
22
 * // Attributes has to match the attribute name (column name) unless otherwise specified.
23
 *
24
 * // creating/manipulating models
25
 * $model = new Model(); // set attributes later via setters or public assignment.
26
 * $model = new Model(['attribute_name' => $value]);
27
 * $model->get('attribute_name', $value);
28
 * $model->set('attribute_name', $value);
29
 * $model->getAttributeName(); // case will be changed to 'snake_case' automatically.
30
 * $model->setAttributeName($value); // case will be changed to 'snake_case' automatically.
31
 * $model->anotherAttribute; // case will be changed to 'snake_case' automatically.
32
 * $model->anotherAttribute = $value; // case will be changed to 'snake_case' automatically.
33
 * $attributes = $model->getAttributes(); // returns all attributes.
34
 * $model->save(); // persists the model in the database.
35
 * $model->update(['attribute_name' => $value]); // updates the model and save changes in the database.
36
 * $model->delete(); // deletes the model from the database.
37
 * Model::create($attributes); // creates a new model and saves it in the database.
38
 * Model::destroy($id); // destroys a model and deletes it from the database.
39
 *
40
 * // fetching models
41
 * $model = Model::first();
42
 * $model = Model::last();
43
 * $model = Model::one();
44
 * $models = Model::all(['name' => 'John'], 'age DESC', $offset, $limit);
45
 * $count = Model::count(); // returns the number of models in the database.
46
 * $model = Model::find($id); // $id is the primary key of the model.
47
 * $models = Model::find('age', 27, 'name', 'John', ...); // or Model::find(['name' => $value]);
48
 * $models = Model::where('name', '=', $name); // fetch using a where clause condition.
49
 * $models = Model::where('name', 'LIKE', 'John%', [['AND', 'age', '>', 27], ...]);
50
 * $models = Model::fetch('SELECT * FROM @table WHERE `name` = :name', ['name' => $name]); // fetch using raw SQL query.
51
 * ```
52
 *
53
 * @method mixed get*() Getter for model attribute, (`attribute_name` -> `getAttributeName()`).
54
 * @method mixed set*() Setter for model attribute, (`attribute_name` -> `setAttributeName($value)`).
55
 * @property mixed $* Public attribute for model attribute, (`attribute_name` -> `attributeName`).
56
 *
57
 * @since 1.3.0
58
 * @api
59
 */
60
abstract class Model implements \ArrayAccess, \Traversable, \IteratorAggregate
61
{
62
    /**
63
     * Model table name. If not set, an auto-generated name will be used instead.
64
     * For good practice, keep the model name in singular form and make the table name in plural form.
65
     */
66
    protected static ?string $table = null;
67
68
    /**
69
     * Model table columns. If not set, the model will fall back to the default primary key `['id']`.
70
     * For good practice, keep the table columns in `snake_case`. Model attribute names match table columns.
71
     */
72
    protected static ?array $columns = ['id'];
73
74
    /**
75
     * Model table primary key. If not set, `id` will be used by default.
76
     */
77
    protected static ?string $primaryKey = 'id';
78
79
    /**
80
     * The database instance/connection.
81
     */
82
    protected static ?Database $database;
83
84
85
    /**
86
     * Model attributes. Corresponds to table columns.
87
     */
88
    protected array $attributes;
89
90
91
    /**
92
     * The SQL code to create the model table from. Has to match `self::$table`, `self::$columns`, and `self::$primaryKey`.
93
     * Example: ```CREATE TABLE IF NOT EXISTS `table` (`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `text` VARCHAR(255));```
94
     *
95
     * @return string
96
     */
97
    abstract public static function schema(): string;
98
99
    /**
100
     * Migrates model table to the database.
101
     *
102
     * @return void
103
     */
104 30
    final public static function migrate(): void
105
    {
106 30
        static::getDatabase()->perform(trim(static::schema()));
107 30
    }
108
109
    /**
110
     * Checks whether the model table is migrated to the database or not.
111
     * You can override this method and return always `true` to disable auto migration.
112
     *
113
     * @return bool
114
     */
115 30
    public static function isMigrated(): bool
116
    {
117 30
        $table  = static::getTable();
118 30
        $tables = static::getDatabase()
119 30
            ->query('SHOW TABLES;')
120 30
            ->fetchAll(Database::FETCH_COLUMN);
121
122 30
        return in_array($table, $tables);
123
    }
124
125
    /**
126
     * Returns model database connection and sets `static::$database` with a default value if it's not set.
127
     *
128
     * @return Database
129
     */
130 30
    public static function getDatabase(): Database
131
    {
132 30
        if (empty(static::$database)) {
133 1
            static::$database = Database::instance();
134
        }
135
136 30
        return static::$database;
137
    }
138
139
    /**
140
     * Returns model table name and sets `static::$table` with a default value if it's not set.
141
     *
142
     * @return string
143
     */
144 30
    public static function getTable(): string
145
    {
146 30
        if (empty(static::$table)) {
147 1
            $class = (new \ReflectionClass(static::class))->getShortName();
148 1
            static::$table = Misc::transform($class . '_model_entries', 'snake');
149
        }
150
151 30
        return static::$table;
152
    }
153
154
    /**
155
     * Returns model table columns and sets `static::$columns` with a default value if it's not set.
156
     *
157
     * @return array
158
     */
159 30
    public static function getColumns(): array
160
    {
161 30
        if (empty(static::$columns)) {
162 1
            static::$columns = ['id'];
163
        }
164
165 30
        return static::$columns;
166
    }
167
168
    /**
169
     * Returns model table primary key and sets `static::$primaryKey` with a default value if it's not set.
170
     *
171
     * @return string
172
     */
173 30
    public static function getPrimaryKey(): string
174
    {
175 30
        if (empty(static::$primaryKey)) {
176 1
            static::$primaryKey = 'id';
177
        }
178
179 30
        return static::$primaryKey;
180
    }
181
182
    /**
183
     * Asserts that the model attribute name is valid.
184
     *
185
     * @param mixed $name The name to validate.
186
     *
187
     * @return void
188
     */
189 25
    private static function assertAttributeExists($name): void
190
    {
191 25
        static $columns = null;
192
193 25
        if ($columns === null) {
194 1
            $columns = static::getColumns();
195
        }
196
197 25
        if (!in_array((string)$name, $columns)) {
198 1
            throw new \Exception(sprintf(
199 1
                'Cannot find attribute with the name "%s". %s model table does not consist of this column',
200
                $name,
201 1
                static::class
202
            ));
203
        }
204 25
    }
205
206
    /**
207
     * Gets and validates a specific model attribute.
208
     *
209
     * @param string $name Attribute name.
210
     * @param mixed $value Attribute value.
211
     *
212
     * @return mixed Attribute value.
213
     */
214 24
    public function get(string $name)
215
    {
216 24
        $this->assertAttributeExists($name);
217
218 24
        return $this->attributes[$name];
219
    }
220
221
    /**
222
     * Sets and validates a specific model attribute.
223
     *
224
     * @param string $name Attribute name.
225
     * @param mixed $value Attribute value.
226
     *
227
     * @return $this
228
     */
229 25
    public function set(string $name, $value): Model
230
    {
231 25
        $this->assertAttributeExists($name);
232
233 25
        $this->attributes[$name] = $value;
234
235 25
        return $this;
236
    }
237
238
    /**
239
     * Gets all model attributes.
240
     *
241
     * @return array Model attributes.
242
     */
243 23
    public function getAttributes(): array
244
    {
245 23
        return $this->attributes;
246
    }
247
248
    /**
249
     * Sets all or a subset of model attributes.
250
     *
251
     * @param array $attributes Model attributes.
252
     *
253
     * @return $this
254
     */
255 25
    public function setAttributes(array $attributes): Model
256
    {
257 25
        foreach ($attributes as $key => $value) {
258 25
            $this->set($key, $value);
259
        }
260
261 25
        return $this;
262
    }
263
264
    /**
265
     * Creates an instance of the model. The model is not saved to the database unless `self::save()` is called.
266
     *
267
     * @param string $attributes The attributes of the model.
268
     *
269
     * @return static
270
     */
271 25
    public static function create(array $attributes): Model
272
    {
273 25
        $item = new static();
274 25
        $item->setAttributes($attributes);
275
276 25
        return $item;
277
    }
278
279
    /**
280
     * Creates/updates a model and saves it in database.
281
     *
282
     * @param array $attributes Model attributes.
283
     *
284
     * @return static
285
     */
286 23
    public function save(array $attributes = []): Model
287
    {
288 23
        $isNew = !$this->get($this->getPrimaryKey());
289
290 23
        $this->setAttributes($attributes);
291
292 23
        $attributes = $this->getAttributes();
293 23
        $variables  = [];
294
295 23
        foreach ($attributes as $key => $value) {
296 23
            if ($isNew && $key === $this->getPrimaryKey()) {
297 23
                unset($attributes[$key]);
298 23
                continue;
299
            }
300
301 23
            $variables[':' . $key] = $value;
302
        }
303
304 23
        $query = vsprintf('%s INTO `%s` (%s) VALUES(%s);', [
305 23
            $isNew ? 'INSERT' : 'REPLACE',
306 23
            $this->getTable(),
307 23
            implode(', ', array_keys($attributes)),
308 23
            implode(', ', array_keys($variables)),
309
        ]);
310
311 23
        $id = $this->getDatabase()->transactional(function () use ($query, $variables) {
312 23
            $this->perform($query, $variables);
0 ignored issues
show
Bug introduced by
The method perform() does not exist on MAKS\Velox\Backend\Model. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

312
            $this->/** @scrutinizer ignore-call */ 
313
                   perform($query, $variables);
Loading history...
313
314 23
            return $this->lastInsertId();
0 ignored issues
show
Bug introduced by
The method lastInsertId() does not exist on MAKS\Velox\Backend\Model. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

314
            return $this->/** @scrutinizer ignore-call */ lastInsertId();
Loading history...
315 23
        });
316
317 23
        $this->set($this->getPrimaryKey(), is_numeric($id) ? (int)$id : $id);
318
319 23
        return $this;
320
    }
321
322
    /**
323
     * Updates the given attributes of the model and saves them in the database.
324
     *
325
     * @param array $attributes The attributes to update.
326
     *
327
     * @return static
328
     */
329 1
    public function update(array $attributes): Model
330
    {
331 1
        $variables = [];
332
333 1
        foreach ($attributes as $key => $value) {
334 1
            $this->set($key, $value);
335
336 1
            $variables[':' . $key] = $value;
337
        }
338
339 1
        $variables[':id'] = $this->get($this->getPrimaryKey());
340
341 1
        $query = vsprintf('UPDATE `%s` SET %s WHERE `%s` = :id;', [
342 1
            $this->getTable(),
343 1
            implode(', ', array_map(fn ($key) => sprintf('`%s` = :%s', $key, $key), array_keys($attributes))),
344 1
            $this->getPrimaryKey(),
345
        ]);
346
347 1
        $this->getDatabase()->transactional(function () use ($query, $variables) {
348 1
            $this->perform($query, $variables);
349 1
        });
350
351 1
        return $this;
352
    }
353
354
    /**
355
     * Deletes the model from the database.
356
     *
357
     * @return int The number of affected rows during the SQL operation.
358
     */
359 2
    public function delete(): int
360
    {
361 2
        $query = vsprintf('DELETE FROM `%s` WHERE `%s` = :id LIMIT 1;', [
362 2
            $this->getTable(),
363 2
            $this->getPrimaryKey(),
364
        ]);
365
366 2
        $variables = [':id' => $this->get($this->getPrimaryKey())];
367
368 2
        return $this->getDatabase()->transactional(function () use ($query, $variables) {
369 2
            return $this->perform($query, $variables)->rowCount();
370 2
        });
371
    }
372
373
    /**
374
     * Destroys (deletes) a model from the database if it exists.
375
     *
376
     * @param string|int $primaryKey
377
     *
378
     * @return int The number of affected rows during the SQL operation.
379
     */
380 1
    public static function destroy($primaryKey): int
381
    {
382 1
        $model = static::find($primaryKey);
383
384 1
        return $model ? $model->delete() : 0;
385
    }
386
387
    /**
388
     * Hydrates models from an array of model attributes (row).
389
     *
390
     * @param array $data Array of objects data.
391
     *
392
     * @return static[] Array of hydrated objects.
393
     *
394
     * Example:
395
     * - `Model::hydrate([...$arrayOfModelAttributes])`
396
     */
397 1
    public static function hydrate(array $models): array
398
    {
399 1
        $objects = [];
400 1
        foreach ($models as $model) {
401 1
            $objects[] = static::create($model);
402
        }
403
404 1
        return $objects;
405
    }
406
407
    /**
408
     * Executes a query (a prepared statement) and returns the result.
409
     *
410
     * @param string $query The query to execute. The `@table` can be used to inject the current model table name into the query.
411
     * @param array|null $variables [optional] The variables needed for the query.
412
     * @param bool $raw [optional] Whether fetch the models as arrays (raw) or as hydrated objects.
413
     *
414
     * @return static[]|array[] The result as an array of objects or array of arrays depending on the passed parameters.
415
     *
416
     * Example:
417
     * - ```Model::raw('SELECT * FROM `users` WHERE `name` = :name OR `age` = :age', ['name' => 'Doe', 'age' => 27], true)```
418
     */
419 21
    public static function fetch(string $query, ?array $variables = [], bool $raw = false): array
420
    {
421 21
        $table     = sprintf('`%s`', static::getTable());
422 21
        $query     = str_ireplace(['@table', '`@table`'], $table, $query);
423 21
        $variables = $variables ?? [];
424
425 21
        $class = static::class;
426
427 21
        return static::getDatabase()->transactional(function () use ($query, $variables, $raw, $class) {
428 21
            $statement = $this->perform($query, $variables);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $this seems to be never defined.
Loading history...
429 21
            $result    = $raw
430 2
                ? $statement->fetchAll(\PDO::FETCH_ASSOC)
431 21
                : $statement->fetchAll(\PDO::FETCH_CLASS|\PDO::FETCH_PROPS_LATE, $class, [/* $class constructor arguments */]);
432
433 21
            return $result;
434 21
        });
435
    }
436
437
    /**
438
     * Fetches all models.
439
     *
440
     * @param array $conditions Fetch conditions (like: `['id' => $id, ...]`). Conditions are combined by logical `AND`.
441
     * @param string|null $order [optional] SQL order expression (like: `id` or `id ASC`).
442
     * @param int|null $limit [optional] To how many items the result should be limited.
443
     * @param int|null $offset [optional] From which item the result should start.
444
     *
445
     * @return static[]|array
446
     *
447
     * Examples:
448
     * - PHP: `Model::all(['name' => 'Doe', 'job' => 'Developer'],'age DESC', 3, 15)`.
449
     * - SQL: ```SELECT * FROM `users` WHERE `name` = "Doe" AND `job` = `Developer` ORDER BY age DESC LIMIT 3 OFFSET 15```.
450
     */
451 18
    public static function all(?array $conditions = [], ?string $order = null, ?int $limit = null, ?int $offset = null): array
452
    {
453 18
        $query = 'SELECT * FROM @table';
454
455 18
        if (!empty($conditions)) {
456 13
            $sqlConditions = [];
457 13
            foreach ($conditions as $key => $value) {
458 13
                static::assertAttributeExists($key);
459 13
                $sqlConditions[] = sprintf('`%s` = :%s', $key, $key);
460
            }
461
462 13
            $query .= ' WHERE ' . implode(' AND ', $sqlConditions);
463
        }
464
465 18
        if ($order) {
466 7
            $query .= ' ORDER BY ' . $order;
467
        }
468
469 18
        if ($limit) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $limit of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null 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...
470 18
            $query .= ' LIMIT ' . $limit;
471
        }
472
473 18
        if ($offset) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $offset of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null 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...
474 1
            $query .= ' OFFSET ' . $offset;
475
        }
476
477 18
        $query .= ';';
478
479 18
        return static::fetch($query, $conditions);
480
    }
481
482
    /**
483
     * Fetches a single model object.
484
     *
485
     * @param array $conditions [optional] Query conditions (like: `['id' => 1, ...]`). Conditions are combined by logical `AND`.
486
     *
487
     * @return static|null
488
     *
489
     * Examples:
490
     * - Fetching the first items: `Model::one()`.
491
     * - Fetching an item according to a condition: `Model::one(['name' => $name])`.
492
     */
493 13
    public static function one(?array $conditions = []): ?Model
494
    {
495 13
        return static::all($conditions, null, 1, null)[0] ?? null;
496
    }
497
498
    /**
499
     * Fetches the first model object.
500
     *
501
     * @return static|null
502
     *
503
     * Examples: `Model::first()`.
504
     */
505 2
    public static function first(): ?Model
506
    {
507 2
        return static::all(null, static::getPrimaryKey() . ' ASC', 1, 0)[0] ?? null;
508
    }
509
510
    /**
511
     * Fetches the last model object.
512
     *
513
     * @return static|null
514
     *
515
     * Example: `Model::last()`.
516
     */
517 4
    public static function last(): ?Model
518
    {
519 4
        return static::all(null, static::getPrimaryKey() . ' DESC', 1, 0)[0] ?? null;
520
    }
521
522
523
    /**
524
     * Finds a single or multiple models matching the passed condition.
525
     *
526
     * @param mixed|mixed[] ...$condition Can either be the primary key or a set of condition(s) (like: `id`, or `'name', 'Doe', 'age', 35`, or `['name' => $name]`).
527
     *
528
     * @return static|static[]|null|array Depends on the number of conditions (1 = single, >1 = multiple).
529
     *
530
     * Examples:
531
     * - Find by primary key (ID): `Model::find(1)`.
532
     * - Find by specific value: `Model::find('name', 'Doe', 'age', 35, ...)` or `Model::find(['name' => $name, 'age' => 35], ...)`.
533
     *
534
     */
535 11
    public static function find(...$condition)
536
    {
537
        // formats conditions to be consumed as `$name, $value, $name, $value, ...`
538 11
        $format = function ($array) use (&$format) {
539 11
            $pairs = array_map(function ($key, $value) use (&$format) {
540 11
                if (is_string($key)) {
541 1
                    return [$key, $value];
542
                }
543
544 11
                if (is_array($value)) {
545 1
                    return $format($value);
546
                }
547
548 11
                return [$value];
549 11
            }, array_keys($array), $array);
550
551 11
            return array_values((array)array_merge(...$pairs));
552 11
        };
553
554 11
        $pairs = $format($condition);
555
556 11
        if (count($pairs) === 1) {
557 11
            return static::one([static::getPrimaryKey() => current($condition)]);
558
        }
559
560 1
        $conditions = [];
561 1
        for ($i = 0; $i < count($pairs); $i++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
562 1
            if ($i % 2 === 0) {
563 1
                $conditions[$pairs[$i]] = $pairs[$i + 1];
564
            }
565
        }
566
567 1
        return static::all($conditions);
568
    }
569
570
    /**
571
     * Returns the count of models matching the passed condition (counting is done on the SQL end for better performance).
572
     *
573
     * @param array $conditions [optional] Query conditions (like: `['id' => 1, ...]`). Conditions are combined by logical `AND`.
574
     *
575
     * @return int
576
     */
577 1
    public static function count(?array $conditions = []): int
578
    {
579 1
        $query = 'SELECT COUNT(*) FROM @table';
580
581 1
        if (!empty($conditions)) {
582 1
            $sqlConditions = [];
583 1
            foreach ($conditions as $key => $value) {
584 1
                static::assertAttributeExists($key);
585 1
                $sqlConditions[] = sprintf('`%s` = :%s', $key, $key);
586
            }
587
588 1
            $query .= ' WHERE ' . implode(' AND ', $sqlConditions) . ';';
589
        }
590
591 1
        $data = static::fetch($query, $conditions, true);
592
593 1
        return $data ? $data[0]['COUNT(*)'] ?? 0 : 0;
594
    }
595
596
    /**
597
     * Finds a single or multiple models by the passed condition.
598
     *
599
     * @param string $column The column/attribute name.
600
     * @param string $operator Condition operator, can be: `=`, `<>`, `<`, `>`, `<=`, `>=`, `LIKE`, `NOT LIKE`, `IN`, `NOT IN`.
601
     * @param mixed $value The value to compare to.
602
     * @param array[] $additional [optional] Additional conditions. Can be used to add more conditions to the `WHERE` clause. Deep nesting can be achieved by simply using a child array.
603
     * @param string|null $order [optional] SQL order expression (like: `id` or `id ASC`).
604
     * @param int|null $limit [optional] To how many items the result should be limited.
605
     * @param int|null $offset [optional] From which item the result should start.
606
     *
607
     * @return static[]|array
608
     *
609
     * @throws \InvalidArgumentException If operator is not supported or a condition is invalid.
610
     *
611
     * Examples:
612
     * - `Model::where('name', '=', 'Doe')`.
613
     * - `Model::where('age', '>', 27, [['AND', 'name', 'LIKE', 'Doe%'], ..., [...,...]], $order, $limit, $offset)`.
614
     */
615 3
    public static function where(
616
        string $column,
617
        string $operator,
618
        $value,
619
        ?array $additional = null,
620
        ?string $order = null,
621
        ?int $limit = null,
622
        ?int $offset = null
623
    ): array {
624 3
        $conditions = array_merge([['', $column, $operator, $value]], $additional ?? []);
625
626 3
        $where     = static::buildWhereClause($conditions);
627 1
        $query     = sprintf('SELECT * FROM @table %s', $where['query']);
628 1
        $variables = $where['variables'];
629
630 1
        if ($order) {
631 1
            $query .= ' ORDER BY ' . $order;
632
        }
633
634 1
        if ($limit) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $limit of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null 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...
635 1
            $query .= ' LIMIT ' . $limit;
636
        }
637
638 1
        if ($offset) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $offset of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null 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...
639 1
            $query .= ' OFFSET ' . $offset;
640
        }
641
642 1
        $query .= ';';
643
644 1
        return static::fetch($query, $variables);
645
    }
646
647 3
    private static function buildWhereClause(array $conditions): array
648
    {
649 3
        $query     = 'WHERE';
650 3
        $variables = [];
651
652 3
        foreach ($conditions as $index => $condition) {
653 3
            $result = static::buildNestedQuery($condition, $index);
654
655 2
            $query     = $query     . $result['query'];
656 2
            $variables = $variables + $result['variables'];
657
        }
658
659 1
        return compact('query', 'variables');
660
    }
661
662
    /**
663
     * Builds a nested query.
664
     *
665
     * @param array $condition The condition in the form of `['OPERATOR1', 'COLUMN', 'OPERATOR2', 'VALUE', ], ..., [..., ...]`.
666
     * @param int|string $index The index of the condition.
667
     *
668
     * @return mixed[] An associative array containing the SQL `query` and its needed `variables`.
669
     */
670 3
    private static function buildNestedQuery(array $condition, $index): array
671
    {
672 3
        $query     = '';
673 3
        $variables = [];
674 3
        $nested    = 0;
675
676 3
        if (is_array($condition[$nested] ?? null)) {
677 1
            $nested = count($condition);
678 1
            $subConditions = $condition;
679
680 1
            foreach ($subConditions as $subIndex => $subCondition) {
681 1
                $result = null;
682
683 1
                if ($subIndex === 0) {
684 1
                    $query .= ' ' . $subCondition[0] . ' (';
685 1
                    $subCondition[0] = '';
686 1
                    $subIndex = sprintf('%s_%s', $index, $subIndex);
687
688 1
                    $result = static::buildNestedQuery($subCondition, $subIndex);
689
                } else {
690 1
                    $result = static::buildNestedQuery($subCondition, $subIndex);
691
                }
692
693 1
                $query     = $query     . $result['query'];
694 1
                $variables = $variables + $result['variables'];
695
696 1
                $nested--;
697
            }
698
699 1
            $query .= ' )';
700
701 1
            return compact('query', 'variables');
702
        }
703
704 3
        [$operator1, $column, $operator2, $value] = static::validateCondition($condition, $index);
705
706 3
        $operator1 = static::validateOperator($operator1, $index);
707 3
        $operator2 = static::validateOperator($operator2, $index);
708
709 2
        $placeholder = sprintf('%s_%s', $column, $index);
710
711 2
        if ($isInOperator = substr($operator2, -2) === 'IN') {
712 1
            $placeholders = array_map(function ($id) use ($placeholder) {
713 1
                return sprintf('%s_%s', $placeholder, $id);
714 1
            }, array_keys($value));
715
716 1
            $keys       = array_values($placeholders);
717 1
            $values     = array_values($value);
718 1
            $variables  = array_merge($variables, array_combine($keys, $values));
719
720 1
            $placeholders = implode(', ', array_map(fn ($id) => ':' . $id, $placeholders));
721
        } else {
722 2
            $variables[$placeholder] = $value;
723
        }
724
725 2
        $query .= ' ' . trim(vsprintf('%s `%s` %s %s', [
726 2
            $operator1,
727 2
            $column,
728 2
            $operator2,
729 2
            $isInOperator ? "({$placeholders})" : ":{$placeholder}"
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $placeholders does not seem to be defined for all execution paths leading up to this point.
Loading history...
730
        ]));
731
732 2
        return compact('query', 'variables');
733
    }
734
735
    /**
736
     * Validates the passed condition.
737
     *
738
     * @param string $condition The condition to validate. in the form of `['OPERATOR1', 'COLUMN', 'OPERATOR2', 'VALUE']`
739
     * @param int|string $index The index of the condition (used to make more user-friendly exception).
740
     *
741
     * @return array An array containing the validated condition.
742
     *
743
     * @throws \InvalidArgumentException If the condition is invalid.
744
     */
745 3
    private static function validateCondition(array $condition, $index): array
746
    {
747 3
        $condition = array_merge($condition, array_fill(0, 4, null));
748 3
        $condition = array_splice($condition, 0, 4);
749
750
        // $operator1, $column, $value, $operator2
751 3
        if (!is_string($condition[0]) || !is_string($condition[1]) || !is_string($condition[2]) || !isset($condition[3])) {
752 1
            throw new \InvalidArgumentException(sprintf(
753 1
                "The passed condition ['%s'] at index (%s), is invalid. Was expecting ['%s'], got ['%s']",
754 1
                implode("', '", $condition),
755
                $index,
756 1
                implode("', '", ['operator1:string', 'column:string', 'operator2:string', 'value:mixed']),
757 1
                implode("', '", array_map(fn ($var) => gettype($var), $condition))
758
            ));
759
        }
760
761 3
        return $condition;
762
    }
763
764
    /**
765
     * Validates the passed operator.
766
     *
767
     * @param string $operator The operator to validate.
768
     * @param int|string $index The index of the condition (used to make more user-friendly exception).
769
     *
770
     * @return string The validated operator.
771
     *
772
     * @throws \InvalidArgumentException If the operator is invalid.
773
     */
774 3
    private static function validateOperator(string $operator, $index): string
775
    {
776 3
        $operator  = strtoupper(trim($operator));
777 3
        $supported = ['', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'AND', 'OR', '=', '<', '>', '<=', '>=', '<>'];
778
779 3
        if (!in_array($operator, $supported)) {
780 1
            throw new \InvalidArgumentException(sprintf(
781 1
                "Got '%s' as an argument at index (%s), which is invalid or unsupported SQL operator. Supported operators are: ['%s']",
782
                $operator,
783
                $index,
784 1
                implode("', '", $supported),
785
            ));
786
        }
787
788 3
        return $operator;
789
    }
790
791
792
    /**
793
     * Returns array representation of the model. All attributes will be converted to `camelCase` form.
794
     */
795 4
    public function toArray(): array
796
    {
797 4
        $attributes = $this->getAttributes();
798
799 4
        return array_combine(
800 4
            array_map(
801 4
                fn ($key) => Misc::transform($key, 'camel'),
802 4
                array_keys($attributes)
803
            ),
804
            $attributes
805
        );
806
    }
807
808
    /**
809
     * Returns JSON representation of the model. All attributes will be converted to `camelCase` form.
810
     */
811 2
    public function toJson(): string
812
    {
813 2
        return json_encode(
814 2
            $this->toArray(),
815 2
            JSON_UNESCAPED_SLASHES|JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_AMP|JSON_HEX_QUOT
816
        );
817
    }
818
819
    /**
820
     * Override this method to add your own bootstrap code to the modal.
821
     *
822
     * @return void
823
     */
824 30
    protected function bootstrap(): void
825
    {
826
        //
827 30
    }
828
829
830
    /**
831
     * Class constructor.
832
     * Keep all constructor arguments optional when extending the class.
833
     * Or use `self::bootstrap()` instead.
834
     *
835
     * @param array $attributes [optional] The attributes to set on the model.
836
     */
837 30
    public function __construct(?array $attributes = [])
838
    {
839 30
        $this->attributes = array_merge(array_fill_keys($this->getColumns(), null), $attributes);
840
841 30
        if ($this->isMigrated() === false) {
842 30
            $this->migrate();
843
        }
844
845 30
        $this->bootstrap();
846 30
    }
847
848
    /**
849
     * Defines magic getters and setter for model attributes.
850
     * Examples: `model_id` has `getModelId()` and `setModelId()`
851
     */
852 2
    public function __call(string $method, array $arguments)
853
    {
854 2
        if (preg_match('/^([gs]et)([a-z0-9_]+)$/i', $method, $matches)) {
855 2
            $function = strtolower($matches[1]);
856 2
            $attribute = Misc::transform($matches[2], 'snake');
857
858 2
            return $this->{$function}($attribute, ...$arguments);
859
        }
860
861 1
        throw new \Exception(sprintf('Call to undefined method %s::%s()', static::class, $method));
862
    }
863
864
    /**
865
     * Makes attributes accessible via public property access notation.
866
     * Examples: `model_id` as `$model->modelId`
867
     */
868 9
    public function __get(string $name)
869
    {
870 9
        $name = Misc::transform($name, 'snake');
871
872 9
        return $this->get($name);
873
    }
874
875
    /**
876
     * Makes attributes accessible via public property assignment notation.
877
     * Examples: `model_id` as `$model->modelId`
878
     */
879 20
    public function __set(string $name, $value)
880
    {
881 20
        $name = Misc::transform($name, 'snake');
882
883 20
        return $this->set($name, $value);
884
    }
885
886
    /**
887
     * Makes attributes consumable via `isset()`.
888
     */
889 1
    public function __isset(string $name)
890
    {
891 1
        $name = Misc::transform($name, 'snake');
892
893 1
        return $this->get($name) !== null;
894
    }
895
896
    /**
897
     * Makes attributes consumable via `unset()`.
898
     */
899 1
    public function __unset(string $name)
900
    {
901 1
        $name = Misc::transform($name, 'snake');
902
903 1
        return $this->set($name, null);
904
    }
905
906
    /**
907
     * Makes the model safely cloneable via the `clone` keyword.
908
     */
909 1
    public function __clone()
910
    {
911 1
        $this->set($this->getPrimaryKey(), null);
912 1
    }
913
914
    /**
915
     * Makes the model safely consumable via `serialize()`.
916
     */
917 1
    public function __sleep()
918
    {
919 1
        return ['attributes'];
920
    }
921
922
    /**
923
     * Makes the model safely consumable via `unserialize()`.
924
     */
925 1
    public function __wakeup()
926
    {
927 1
        static::$database = static::getDatabase();
928 1
    }
929
930
    /**
931
     * Makes the model more friendly presented when exported via `var_dump()`.
932
     */
933 1
    public function __debugInfo()
934
    {
935 1
        return $this->toArray();
936
    }
937
938
    /**
939
     * Makes the object quickly available as a JSON string.
940
     */
941 1
    public function __toString()
942
    {
943 1
        return $this->toJson();
944
    }
945
946
947
    /**
948
     * `ArrayAccess::offsetGet()` interface implementation.
949
     */
950 1
    public function offsetGet($offset) {
951 1
        return $this->get($offset);
952
    }
953
954
    /**
955
     * `ArrayAccess::offsetSet()` interface implementation.
956
     */
957 1
    public function offsetSet($offset, $value): void {
958 1
        $this->set($offset, $value);
959 1
    }
960
961
    /**
962
     * `ArrayAccess::offsetExists()` interface implementation.
963
     */
964 1
    public function offsetExists($offset): bool {
965 1
        return $this->get($offset) !== null;
966
    }
967
968
    /**
969
     * `ArrayAccess::offsetUnset()` interface implementation.
970
     */
971 1
    public function offsetUnset($offset): void {
972 1
        $this->set($offset, null);
973 1
    }
974
975
976
    /**
977
     * `IteratorAggregate::getIterator()` interface implementation.
978
     */
979 1
    public function getIterator(): iterable
980
    {
981 1
        return new \ArrayIterator($this->attributes);
982
    }
983
}
984