Passed
Push — master ( 33c45f...ca5a76 )
by Marwan
10:38
created

Model::isMigrated()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2.0078

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
c 2
b 0
f 0
dl 0
loc 14
ccs 7
cts 8
cp 0.875
crap 2.0078
rs 10
eloc 8
nc 2
nop 0
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');
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], ...], 'age DESC', $limit, $offset);
50
 * $models = Model::fetch('SELECT * FROM @table WHERE `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 = [$table];
0 ignored issues
show
Unused Code introduced by
The assignment to $tables is dead and can be removed.
Loading history...
119
120
        try {
121 30
            $tables = static::getDatabase()
122 30
                ->query('SHOW TABLES;')
123 30
                ->fetchAll(\PDO::FETCH_COLUMN);
124
        } catch (\Exception $e) {
125
            // ignore silently
126
        }
127
128 30
        return in_array($table, $tables);
129
    }
130
131
    /**
132
     * Returns model database connection and sets `static::$database` with a default value if it's not set.
133
     *
134
     * @return Database
135
     */
136 30
    public static function getDatabase(): Database
137
    {
138 30
        if (empty(static::$database)) {
139 1
            static::$database = Database::instance();
140
        }
141
142 30
        return static::$database;
143
    }
144
145
    /**
146
     * Returns model table name and sets `static::$table` with a default value if it's not set.
147
     *
148
     * @return string
149
     */
150 30
    public static function getTable(): string
151
    {
152 30
        if (empty(static::$table)) {
153 1
            $class = (new \ReflectionClass(static::class))->getShortName();
154 1
            static::$table = Misc::transform($class . '_model_entries', 'snake');
155
        }
156
157 30
        return static::$table;
158
    }
159
160
    /**
161
     * Returns model table columns and sets `static::$columns` with a default value if it's not set.
162
     *
163
     * @return array
164
     */
165 30
    public static function getColumns(): array
166
    {
167 30
        if (empty(static::$columns)) {
168 1
            static::$columns = ['id'];
169
        }
170
171 30
        return static::$columns;
172
    }
173
174
    /**
175
     * Returns model table primary key and sets `static::$primaryKey` with a default value if it's not set.
176
     *
177
     * @return string
178
     */
179 30
    public static function getPrimaryKey(): string
180
    {
181 30
        if (empty(static::$primaryKey)) {
182 1
            static::$primaryKey = 'id';
183
        }
184
185 30
        return static::$primaryKey;
186
    }
187
188
    /**
189
     * Asserts that the model attribute name is valid.
190
     *
191
     * @param mixed $name The name to validate.
192
     *
193
     * @return void
194
     */
195 25
    private static function assertAttributeExists($name): void
196
    {
197 25
        static $columns = null;
198
199 25
        if ($columns === null) {
200 1
            $columns = static::getColumns();
201
        }
202
203 25
        if (!in_array((string)$name, $columns)) {
204 1
            throw new \Exception(sprintf(
205 1
                'Cannot find attribute with the name "%s". %s model table does not consist of this column',
206
                $name,
207 1
                static::class
208
            ));
209
        }
210 25
    }
211
212
    /**
213
     * Gets and validates a specific model attribute.
214
     *
215
     * @param string $name Attribute name.
216
     * @param mixed $value Attribute value.
217
     *
218
     * @return mixed Attribute value.
219
     */
220 24
    public function get(string $name)
221
    {
222 24
        $this->assertAttributeExists($name);
223
224 24
        return $this->attributes[$name];
225
    }
226
227
    /**
228
     * Sets and validates a specific model attribute.
229
     *
230
     * @param string $name Attribute name.
231
     * @param mixed $value Attribute value.
232
     *
233
     * @return $this
234
     */
235 25
    public function set(string $name, $value): Model
236
    {
237 25
        $this->assertAttributeExists($name);
238
239 25
        $this->attributes[$name] = $value;
240
241 25
        return $this;
242
    }
243
244
    /**
245
     * Gets all model attributes.
246
     *
247
     * @return array Model attributes.
248
     */
249 23
    public function getAttributes(): array
250
    {
251 23
        return $this->attributes;
252
    }
253
254
    /**
255
     * Sets all or a subset of model attributes.
256
     *
257
     * @param array $attributes Model attributes.
258
     *
259
     * @return $this
260
     */
261 25
    public function setAttributes(array $attributes): Model
262
    {
263 25
        foreach ($attributes as $key => $value) {
264 25
            $this->set($key, $value);
265
        }
266
267 25
        return $this;
268
    }
269
270
    /**
271
     * Creates an instance of the model. The model is not saved to the database unless `self::save()` is called.
272
     *
273
     * @param string $attributes The attributes of the model.
274
     *
275
     * @return static
276
     */
277 25
    public static function create(array $attributes): Model
278
    {
279 25
        $item = new static();
280 25
        $item->setAttributes($attributes);
281
282 25
        return $item;
283
    }
284
285
    /**
286
     * Creates/updates a model and saves it in database.
287
     *
288
     * @param array $attributes Model attributes.
289
     *
290
     * @return static
291
     */
292 23
    public function save(array $attributes = []): Model
293
    {
294 23
        $isNew = !$this->get($this->getPrimaryKey());
295
296 23
        $this->setAttributes($attributes);
297
298 23
        $attributes = $this->getAttributes();
299 23
        $variables  = [];
300
301 23
        foreach ($attributes as $key => $value) {
302 23
            if ($isNew && $key === $this->getPrimaryKey()) {
303 23
                unset($attributes[$key]);
304 23
                continue;
305
            }
306
307 23
            $variables[':' . $key] = $value;
308
        }
309
310 23
        $query = vsprintf('%s INTO `%s` (%s) VALUES(%s);', [
311 23
            $isNew ? 'INSERT' : 'REPLACE',
312 23
            $this->getTable(),
313 23
            implode(', ', array_keys($attributes)),
314 23
            implode(', ', array_keys($variables)),
315
        ]);
316
317 23
        $id = $this->getDatabase()->transactional(function () use ($query, $variables) {
318
            /** @var Database $this */
319 23
            $this->perform($query, $variables);
320 23
            return $this->lastInsertId();
321 23
        });
322
323 23
        $this->set($this->getPrimaryKey(), is_numeric($id) ? (int)$id : $id);
324
325 23
        return $this;
326
    }
327
328
    /**
329
     * Updates the given attributes of the model and saves them in the database.
330
     *
331
     * @param array $attributes The attributes to update.
332
     *
333
     * @return static
334
     */
335 1
    public function update(array $attributes): Model
336
    {
337 1
        $variables = [];
338
339 1
        foreach ($attributes as $key => $value) {
340 1
            $this->set($key, $value);
341
342 1
            $variables[':' . $key] = $value;
343
        }
344
345 1
        $variables[':id'] = $this->get($this->getPrimaryKey());
346
347 1
        $query = vsprintf('UPDATE `%s` SET %s WHERE `%s` = :id;', [
348 1
            $this->getTable(),
349 1
            implode(', ', array_map(fn ($key) => sprintf('`%s` = :%s', $key, $key), array_keys($attributes))),
350 1
            $this->getPrimaryKey(),
351
        ]);
352
353 1
        $this->getDatabase()->transactional(function () use ($query, $variables) {
354
            /** @var Database $this */
355 1
            $this->perform($query, $variables);
356 1
        });
357
358 1
        return $this;
359
    }
360
361
    /**
362
     * Deletes the model from the database.
363
     *
364
     * @return int The number of affected rows during the SQL operation.
365
     */
366 2
    public function delete(): int
367
    {
368 2
        $query = vsprintf('DELETE FROM `%s` WHERE `%s` = :id LIMIT 1;', [
369 2
            $this->getTable(),
370 2
            $this->getPrimaryKey(),
371
        ]);
372
373 2
        $variables = [':id' => $this->get($this->getPrimaryKey())];
374
375 2
        return $this->getDatabase()->transactional(function () use ($query, $variables) {
376
            /** @var Database $this */
377 2
            return $this->perform($query, $variables)->rowCount();
378 2
        });
379
    }
380
381
    /**
382
     * Destroys (deletes) a model from the database if it exists.
383
     *
384
     * @param string|int $primaryKey
385
     *
386
     * @return int The number of affected rows during the SQL operation.
387
     */
388 1
    public static function destroy($primaryKey): int
389
    {
390 1
        $model = static::find($primaryKey);
391
392 1
        return $model ? $model->delete() : 0;
393
    }
394
395
    /**
396
     * Hydrates models from an array of model attributes (row).
397
     *
398
     * @param array $data Array of objects data.
399
     *
400
     * @return static[] Array of hydrated objects.
401
     *
402
     * Example:
403
     * - `Model::hydrate([...$arrayOfModelAttributes])`
404
     */
405 1
    public static function hydrate(array $models): array
406
    {
407 1
        $objects = [];
408 1
        foreach ($models as $model) {
409 1
            $objects[] = static::create($model);
410
        }
411
412 1
        return $objects;
413
    }
414
415
    /**
416
     * Executes a query (a prepared statement) and returns the result.
417
     *
418
     * @param string $query The query to execute. The `@table` can be used to inject the current model table name into the query.
419
     * @param array|null $variables [optional] The variables needed for the query.
420
     * @param bool $raw [optional] Whether fetch the models as arrays (raw) or as hydrated objects.
421
     *
422
     * @return static[]|array[] The result as an array of objects or array of arrays depending on the passed parameters.
423
     *
424
     * Example:
425
     * - ```Model::raw('SELECT * FROM `users` WHERE `name` = :name OR `age` = :age', ['name' => 'Doe', 'age' => 27], true)```
426
     */
427 21
    public static function fetch(string $query, ?array $variables = [], bool $raw = false): array
428
    {
429 21
        $table     = sprintf('`%s`', static::getTable());
430 21
        $query     = str_ireplace(['@table', '`@table`'], $table, $query);
431 21
        $variables = $variables ?? [];
432
433 21
        $class = static::class;
434
435 21
        return static::getDatabase()->transactional(function () use ($query, $variables, $raw, $class) {
436
            /** @var Database $this */
437 21
            $statement = $this->perform($query, $variables);
0 ignored issues
show
Bug introduced by
It seems like $query can also be of type array; however, parameter $query of MAKS\Velox\Backend\Database::perform() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

437
            $statement = $this->perform(/** @scrutinizer ignore-type */ $query, $variables);
Loading history...
Comprehensibility Best Practice introduced by
The variable $this seems to be never defined.
Loading history...
438 21
            $result    = $raw
439 2
                ? $statement->fetchAll(\PDO::FETCH_ASSOC)
440 21
                : $statement->fetchAll(\PDO::FETCH_CLASS|\PDO::FETCH_PROPS_LATE, $class, [/* $class constructor arguments */]);
441 21
            return $result;
442 21
        });
443
    }
444
445
    /**
446
     * Fetches all models.
447
     *
448
     * @param array $conditions Fetch conditions (like: `['id' => $id, ...]`). Conditions are combined by logical `AND`.
449
     * @param string|null $order [optional] SQL order expression (like: `id` or `id ASC`).
450
     * @param int|null $limit [optional] To how many items the result should be limited.
451
     * @param int|null $offset [optional] From which item the result should start.
452
     *
453
     * @return static[]|array
454
     *
455
     * Examples:
456
     * - PHP: `Model::all(['name' => 'Doe', 'job' => 'Developer'],'age DESC', 3, 15)`.
457
     * - SQL: ```SELECT * FROM `users` WHERE `name` = "Doe" AND `job` = `Developer` ORDER BY age DESC LIMIT 3 OFFSET 15```.
458
     */
459 18
    public static function all(?array $conditions = [], ?string $order = null, ?int $limit = null, ?int $offset = null): array
460
    {
461 18
        $query = 'SELECT * FROM @table';
462
463 18
        if (!empty($conditions)) {
464 13
            $sqlConditions = [];
465 13
            foreach ($conditions as $key => $value) {
466 13
                static::assertAttributeExists($key);
467 13
                $sqlConditions[] = sprintf('`%s` = :%s', $key, $key);
468
            }
469
470 13
            $query .= ' WHERE ' . implode(' AND ', $sqlConditions);
471
        }
472
473 18
        if ($order !== null) {
474 7
            $query .= ' ORDER BY ' . $order;
475
        }
476
477 18
        if ($limit !== null) {
478 18
            $query .= ' LIMIT ' . $limit;
479
        }
480
481 18
        if ($offset !== null) {
482 7
            $query .= ' OFFSET ' . $offset;
483
        }
484
485 18
        $query .= ';';
486
487 18
        return static::fetch($query, $conditions);
488
    }
489
490
    /**
491
     * Fetches a single model object.
492
     *
493
     * @param array $conditions [optional] Query conditions (like: `['id' => 1, ...]`). Conditions are combined by logical `AND`.
494
     *
495
     * @return static|null
496
     *
497
     * Examples:
498
     * - Fetching the first items: `Model::one()`.
499
     * - Fetching an item according to a condition: `Model::one(['name' => $name])`.
500
     */
501 13
    public static function one(?array $conditions = []): ?Model
502
    {
503 13
        return static::all($conditions, null, 1, null)[0] ?? null;
504
    }
505
506
    /**
507
     * Fetches the first model object.
508
     *
509
     * @return static|null
510
     *
511
     * Examples: `Model::first()`.
512
     */
513 2
    public static function first(): ?Model
514
    {
515 2
        return static::all(null, static::getPrimaryKey() . ' ASC', 1, 0)[0] ?? null;
516
    }
517
518
    /**
519
     * Fetches the last model object.
520
     *
521
     * @return static|null
522
     *
523
     * Example: `Model::last()`.
524
     */
525 4
    public static function last(): ?Model
526
    {
527 4
        return static::all(null, static::getPrimaryKey() . ' DESC', 1, 0)[0] ?? null;
528
    }
529
530
531
    /**
532
     * Finds a single or multiple models matching the passed condition.
533
     *
534
     * @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]`).
535
     *
536
     * @return static|static[]|null|array Depends on the number of conditions (1 = single, >1 = multiple).
537
     *
538
     * Examples:
539
     * - Find by primary key (ID): `Model::find(1)`.
540
     * - Find by specific value: `Model::find('name', 'Doe', 'age', 35, ...)` or `Model::find(['name' => $name, 'age' => 35], ...)`.
541
     *
542
     */
543 11
    public static function find(...$condition)
544
    {
545
        // formats conditions to be consumed as `$name, $value, $name, $value, ...`
546 11
        $format = function ($array) use (&$format) {
547 11
            $pairs = array_map(function ($key, $value) use (&$format) {
548 11
                if (is_string($key)) {
549 1
                    return [$key, $value];
550
                }
551
552 11
                if (is_array($value)) {
553 1
                    return $format($value);
554
                }
555
556 11
                return [$value];
557 11
            }, array_keys($array), $array);
558
559 11
            return array_values((array)array_merge(...$pairs));
560 11
        };
561
562 11
        $pairs = $format($condition);
563 11
        $count = count($pairs);
564
565 11
        if ($count === 1) {
566 11
            return static::one([static::getPrimaryKey() => current($condition)]);
567
        }
568
569 1
        $conditions = [];
570 1
        for ($i = 0; $i < $count; $i++) {
571 1
            if ($i % 2 === 0) {
572 1
                $conditions[$pairs[$i]] = $pairs[$i + 1];
573
            }
574
        }
575
576 1
        return static::all($conditions);
577
    }
578
579
    /**
580
     * Returns the count of models matching the passed condition (counting is done on the SQL end for better performance).
581
     *
582
     * @param array $conditions [optional] Query conditions (like: `['id' => 1, ...]`). Conditions are combined by logical `AND`.
583
     *
584
     * @return int
585
     */
586 1
    public static function count(?array $conditions = []): int
587
    {
588 1
        $query = 'SELECT COUNT(*) FROM @table';
589
590 1
        if (!empty($conditions)) {
591 1
            $sqlConditions = [];
592 1
            foreach ($conditions as $key => $value) {
593 1
                static::assertAttributeExists($key);
594 1
                $sqlConditions[] = sprintf('`%s` = :%s', $key, $key);
595
            }
596
597 1
            $query .= ' WHERE ' . implode(' AND ', $sqlConditions) . ';';
598
        }
599
600 1
        $data = static::fetch($query, $conditions, true);
601
602 1
        return $data ? $data[0]['COUNT(*)'] ?? 0 : 0;
603
    }
604
605
    /**
606
     * Finds a single or multiple models by the passed condition.
607
     *
608
     * @param string $column The column/attribute name.
609
     * @param string $operator Condition operator, can be: `=`, `<>`, `<`, `>`, `<=`, `>=`, `LIKE`, `NOT LIKE`, `IN`, `NOT IN`.
610
     * @param mixed $value The value to compare to.
611
     * @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.
612
     * @param string|null $order [optional] SQL order expression (like: `id` or `id ASC`).
613
     * @param int|null $limit [optional] To how many items the result should be limited.
614
     * @param int|null $offset [optional] From which item the result should start.
615
     *
616
     * @return static[]|array
617
     *
618
     * @throws \InvalidArgumentException If operator is not supported or a condition is invalid.
619
     *
620
     * Examples:
621
     * - `Model::where('name', '=', 'Doe')`.
622
     * - `Model::where('age', '>', 27, [['AND', 'name', 'LIKE', 'Doe%'], ..., [...,...]], $order, $limit, $offset)`.
623
     */
624 3
    public static function where(
625
        string $column,
626
        string $operator,
627
        $value,
628
        ?array $additional = null,
629
        ?string $order = null,
630
        ?int $limit = null,
631
        ?int $offset = null
632
    ): array {
633 3
        $conditions = array_merge([['', $column, $operator, $value]], $additional ?? []);
634
635 3
        $where     = static::buildWhereClause($conditions);
636 1
        $query     = sprintf('SELECT * FROM @table %s', $where['query']);
637 1
        $variables = $where['variables'];
638
639 1
        if ($order !== null) {
640 1
            $query .= ' ORDER BY ' . $order;
641
        }
642
643 1
        if ($limit !== null) {
644 1
            $query .= ' LIMIT ' . $limit;
645
        }
646
647 1
        if ($offset !== null) {
648 1
            $query .= ' OFFSET ' . $offset;
649
        }
650
651 1
        $query .= ';';
652
653 1
        return static::fetch($query, $variables);
654
    }
655
656 3
    private static function buildWhereClause(array $conditions): array
657
    {
658 3
        $query     = 'WHERE';
659 3
        $variables = [];
660
661 3
        foreach ($conditions as $index => $condition) {
662 3
            $result = static::buildNestedQuery($condition, $index);
663
664 2
            $query     = $query . $result['query'];
665 2
            $variables = $variables + $result['variables'];
666
        }
667
668 1
        return compact('query', 'variables');
669
    }
670
671
    /**
672
     * Builds a nested query.
673
     *
674
     * @param array $condition The condition in the form of `['OPERATOR1', 'COLUMN', 'OPERATOR2', 'VALUE', ], ..., [..., ...]`.
675
     * @param int|string $index The index of the condition.
676
     *
677
     * @return mixed[] An associative array containing the SQL `query` and its needed `variables`.
678
     */
679 3
    private static function buildNestedQuery(array $condition, $index): array
680
    {
681 3
        $query     = '';
682 3
        $variables = [];
683 3
        $nested    = 0;
684
685 3
        if (is_array($condition[$nested] ?? null)) {
686 1
            $nested = count($condition);
687 1
            $subConditions = $condition;
688
689 1
            foreach ($subConditions as $subIndex => $subCondition) {
690 1
                $result = null;
691
692 1
                if ($subIndex === 0) {
693 1
                    $query .= ' ' . $subCondition[0] . ' (';
694 1
                    $subCondition[0] = '';
695 1
                    $subIndex = sprintf('%s_%s', $index, $subIndex);
696
697 1
                    $result = static::buildNestedQuery($subCondition, $subIndex);
698
                } else {
699 1
                    $result = static::buildNestedQuery($subCondition, $subIndex);
700
                }
701
702 1
                $query     = $query . $result['query'];
703 1
                $variables = $variables + $result['variables'];
704
705 1
                $nested--;
706
            }
707
708 1
            $query .= ' )';
709
710 1
            return compact('query', 'variables');
711
        }
712
713 3
        [$operator1, $column, $operator2, $value] = static::validateCondition($condition, $index);
714
715 3
        $operator1 = static::validateOperator($operator1, $index);
716 3
        $operator2 = static::validateOperator($operator2, $index);
717
718 2
        $placeholder  = sprintf('%s_%s', $column, $index);
719 2
        $placeholders = '';
720
721 2
        if ($isInOperator = substr($operator2, -2) === 'IN') {
722 1
            $placeholders = array_map(function ($id) use ($placeholder) {
723 1
                return sprintf('%s_%s', $placeholder, $id);
724 1
            }, array_keys($value));
725
726 1
            $keys       = array_values($placeholders);
727 1
            $values     = array_values($value);
728 1
            $variables  = array_merge($variables, array_combine($keys, $values));
729
730 1
            $placeholders = implode(', ', array_map(fn ($id) => ':' . $id, $placeholders));
731
        } else {
732 2
            $variables[$placeholder] = $value;
733
        }
734
735 2
        $query .= ' ' . trim(vsprintf('%s `%s` %s %s', [
736 2
            $operator1,
737 2
            $column,
738 2
            $operator2,
739 2
            $isInOperator ? "({$placeholders})" : ":{$placeholder}"
740
        ]));
741
742 2
        return compact('query', 'variables');
743
    }
744
745
    /**
746
     * Validates the passed condition.
747
     *
748
     * @param string $condition The condition to validate. in the form of `['OPERATOR1', 'COLUMN', 'OPERATOR2', 'VALUE']`
749
     * @param int|string $index The index of the condition (used to make more user-friendly exception).
750
     *
751
     * @return array An array containing the validated condition.
752
     *
753
     * @throws \InvalidArgumentException If the condition is invalid.
754
     */
755 3
    private static function validateCondition(array $condition, $index): array
756
    {
757 3
        $condition = array_merge($condition, array_fill(0, 4, null));
758 3
        $condition = array_splice($condition, 0, 4);
759
760
        // $operator1, $column, $value, $operator2
761 3
        if (!is_string($condition[0]) || !is_string($condition[1]) || !is_string($condition[2]) || !isset($condition[3])) {
762 1
            throw new \InvalidArgumentException(sprintf(
763 1
                "The passed condition ['%s'] at index (%s), is invalid. Was expecting ['%s'], got ['%s']",
764 1
                implode("', '", $condition),
765
                $index,
766 1
                implode("', '", ['operator1:string', 'column:string', 'operator2:string', 'value:mixed']),
767 1
                implode("', '", array_map(fn ($var) => gettype($var), $condition))
768
            ));
769
        }
770
771 3
        return $condition;
772
    }
773
774
    /**
775
     * Validates the passed operator.
776
     *
777
     * @param string $operator The operator to validate.
778
     * @param int|string $index The index of the condition (used to make more user-friendly exception).
779
     *
780
     * @return string The validated operator.
781
     *
782
     * @throws \InvalidArgumentException If the operator is invalid.
783
     */
784 3
    private static function validateOperator(string $operator, $index): string
785
    {
786 3
        $operator  = strtoupper(trim($operator));
787 3
        $supported = ['', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'AND', 'OR', '=', '<', '>', '<=', '>=', '<>'];
788
789 3
        if (!in_array($operator, $supported)) {
790 1
            throw new \InvalidArgumentException(sprintf(
791 1
                "Got '%s' as an argument at index (%s), which is invalid or unsupported SQL operator. Supported operators are: ['%s']",
792
                $operator,
793
                $index,
794 1
                implode("', '", $supported),
795
            ));
796
        }
797
798 3
        return $operator;
799
    }
800
801
802
    /**
803
     * Returns array representation of the model. All attributes will be converted to `camelCase` form.
804
     */
805 4
    public function toArray(): array
806
    {
807 4
        $attributes = $this->getAttributes();
808
809 4
        return array_combine(
810 4
            array_map(
811 4
                fn ($key) => Misc::transform($key, 'camel'),
812 4
                array_keys($attributes)
813
            ),
814
            $attributes
815
        );
816
    }
817
818
    /**
819
     * Returns JSON representation of the model. All attributes will be converted to `camelCase` form.
820
     */
821 2
    public function toJson(): string
822
    {
823 2
        return json_encode(
824 2
            $this->toArray(),
825 2
            JSON_UNESCAPED_SLASHES|JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_AMP|JSON_HEX_QUOT
826
        );
827
    }
828
829
    /**
830
     * Override this method to add your own bootstrap code to the modal.
831
     *
832
     * @return void
833
     */
834 30
    protected function bootstrap(): void
835
    {
836
        //
837 30
    }
838
839
840
    /**
841
     * Class constructor.
842
     * Keep all constructor arguments optional when extending the class.
843
     * Or use `self::bootstrap()` instead.
844
     *
845
     * @param array $attributes [optional] The attributes to set on the model.
846
     */
847 30
    public function __construct(?array $attributes = [])
848
    {
849 30
        $this->attributes = array_merge(array_fill_keys($this->getColumns(), null), $attributes);
850
851 30
        if ($this->isMigrated() === false) {
852 30
            $this->migrate();
853
        }
854
855 30
        $this->bootstrap();
856 30
    }
857
858
    /**
859
     * Defines magic getters and setter for model attributes.
860
     * Examples: `model_id` has `getModelId()` and `setModelId()`
861
     */
862 2
    public function __call(string $method, array $arguments)
863
    {
864 2
        if (preg_match('/^([gs]et)([a-z0-9_]+)$/i', $method, $matches)) {
865 2
            $function = strtolower($matches[1]);
866 2
            $attribute = Misc::transform($matches[2], 'snake');
867
868 2
            return $this->{$function}($attribute, ...$arguments);
869
        }
870
871 1
        throw new \Exception(sprintf('Call to undefined method %s::%s()', static::class, $method));
872
    }
873
874
    /**
875
     * Makes attributes accessible via public property access notation.
876
     * Examples: `model_id` as `$model->modelId`
877
     */
878 9
    public function __get(string $name)
879
    {
880 9
        $name = Misc::transform($name, 'snake');
881
882 9
        return $this->get($name);
883
    }
884
885
    /**
886
     * Makes attributes accessible via public property assignment notation.
887
     * Examples: `model_id` as `$model->modelId`
888
     */
889 20
    public function __set(string $name, $value)
890
    {
891 20
        $name = Misc::transform($name, 'snake');
892
893 20
        return $this->set($name, $value);
894
    }
895
896
    /**
897
     * Makes attributes consumable via `isset()`.
898
     */
899 1
    public function __isset(string $name)
900
    {
901 1
        $name = Misc::transform($name, 'snake');
902
903 1
        return $this->get($name) !== null;
904
    }
905
906
    /**
907
     * Makes attributes consumable via `unset()`.
908
     */
909 1
    public function __unset(string $name)
910
    {
911 1
        $name = Misc::transform($name, 'snake');
912
913 1
        return $this->set($name, null);
914
    }
915
916
    /**
917
     * Makes the model safely cloneable via the `clone` keyword.
918
     */
919 1
    public function __clone()
920
    {
921 1
        $this->set($this->getPrimaryKey(), null);
922 1
    }
923
924
    /**
925
     * Makes the model safely consumable via `serialize()`.
926
     */
927 1
    public function __sleep()
928
    {
929 1
        return ['attributes'];
930
    }
931
932
    /**
933
     * Makes the model safely consumable via `unserialize()`.
934
     */
935 1
    public function __wakeup()
936
    {
937 1
        static::$database = static::getDatabase();
938 1
    }
939
940
    /**
941
     * Makes the model more friendly presented when exported via `var_dump()`.
942
     */
943 1
    public function __debugInfo()
944
    {
945 1
        return $this->toArray();
946
    }
947
948
    /**
949
     * Makes the object quickly available as a JSON string.
950
     */
951 1
    public function __toString()
952
    {
953 1
        return $this->toJson();
954
    }
955
956
957
    /**
958
     * `ArrayAccess::offsetGet()` interface implementation.
959
     */
960 1
    public function offsetGet($offset)
961
    {
962 1
        return $this->get($offset);
963
    }
964
965
    /**
966
     * `ArrayAccess::offsetSet()` interface implementation.
967
     */
968 1
    public function offsetSet($offset, $value): void
969
    {
970 1
        $this->set($offset, $value);
971 1
    }
972
973
    /**
974
     * `ArrayAccess::offsetExists()` interface implementation.
975
     */
976 1
    public function offsetExists($offset): bool
977
    {
978 1
        return $this->get($offset) !== null;
979
    }
980
981
    /**
982
     * `ArrayAccess::offsetUnset()` interface implementation.
983
     */
984 1
    public function offsetUnset($offset): void
985
    {
986 1
        $this->set($offset, null);
987 1
    }
988
989
990
    /**
991
     * `IteratorAggregate::getIterator()` interface implementation.
992
     */
993 1
    public function getIterator(): iterable
994
    {
995 1
        return new \ArrayIterator($this->attributes);
996
    }
997
}
998