DBAL::buildNestedQuery()   B
last analyzed

Complexity

Conditions 6
Paths 5

Size

Total Lines 64
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 37
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 41
c 1
b 0
f 0
nc 5
nop 2
dl 0
loc 64
ccs 37
cts 37
cp 1
crap 6
rs 8.6417

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\Model;
13
14
use MAKS\Velox\Backend\Exception;
15
use MAKS\Velox\Backend\Database;
16
use MAKS\Velox\Helper\Misc;
17
18
/**
19
 * An abstract class that serves as a DBAL for models.
20
 * NOTE: This class is not meant to be used directly.
21
 *
22
 * @package Velox\Backend\Model
23
 * @since 1.5.1
24
 */
25
abstract class DBAL
26
{
27
    /**
28
     * Model table name. If not set, an auto-generated name will be used instead.
29
     * For good practice, keep the model name in singular form and make the table name in plural form.
30
     */
31
    protected static ?string $table = null;
32
33
    /**
34
     * Model table columns. If not set, the model will fall back to the default primary key `['id']`.
35
     * For good practice, keep the table columns in `snake_case`. Model attribute names match table columns.
36
     */
37
    protected static ?array $columns = ['id'];
38
39
    /**
40
     * Model table primary key. If not set, `id` will be used by default.
41
     */
42
    protected static ?string $primaryKey = 'id';
43
44
    /**
45
     * The database instance/connection.
46
     */
47
    protected static ?Database $database;
48
49
50
    /**
51
     * Returns model database connection and sets `static::$database` with a default value if it's not set.
52
     *
53
     * @return Database
54
     */
55 41
    public static function getDatabase(): Database
56
    {
57 41
        if (empty(static::$database)) {
58 1
            static::$database = Database::instance();
59
        }
60
61 41
        return static::$database;
62
    }
63
64
    /**
65
     * Returns model table name and sets `static::$table` with a default value and returns it if it's not set.
66
     *
67
     * @return string
68
     */
69 41
    public static function getTable(): string
70
    {
71 41
        if (empty(static::$table)) {
72 1
            $class = (new \ReflectionClass(static::class))->getShortName();
73 1
            static::$table = Misc::transform($class . '_model_entries', 'snake');
74
        }
75
76 41
        return static::$table;
77
    }
78
79
    /**
80
     * Returns model table columns and sets `static::$columns` with a default value and returns it if it's not set.
81
     *
82
     * @return array
83
     */
84 41
    public static function getColumns(): array
85
    {
86 41
        if (empty(static::$columns)) {
87 1
            static::$columns = ['id'];
88
        }
89
90 41
        return static::$columns;
91
    }
92
93
    /**
94
     * Returns model table primary key and sets `static::$primaryKey` with a default value and returns it if it's not set.
95
     *
96
     * @return string
97
     */
98 39
    public static function getPrimaryKey(): string
99
    {
100 39
        if (empty(static::$primaryKey)) {
101 1
            static::$primaryKey = 'id';
102
        }
103
104 39
        return static::$primaryKey;
105
    }
106
107
    /**
108
     * The SQL code to create the model table from. Has to match `self::$table`, `self::$columns`, and `self::$primaryKey`.
109
     * Example: ```CREATE TABLE IF NOT EXISTS `table` (`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `text` VARCHAR(255));```
110
     *
111
     * @return string
112
     */
113
    abstract public static function schema(): string;
114
115
    /**
116
     * Migrates model table to the database.
117
     *
118
     * @return void
119
     *
120
     * @throws \BadMethodCallException If called in an abstract class context.
121
     */
122 40
    final public static function migrate(): void
123
    {
124 40
        if (self::class === static::class || (new \ReflectionClass(static::class))->isAbstract()) {
0 ignored issues
show
introduced by
The condition self::class === static::class is always true.
Loading history...
125 1
            Exception::throw('CannotMigrateException:BadMethodCallException', sprintf(
126
                'Cannot migrate an abstract class, "%s" methods should be used by extension only',
127
                self::class
128
            ));
129
        }
130
131 40
        static::getDatabase()->perform(trim(static::schema()));
132
    }
133
134
    /**
135
     * Checks whether the model table is migrated to the database or not.
136
     * You can override this method and return always `true` to disable auto migration.
137
     *
138
     * NOTE: For compatibility reasons, the return value of this method true if it fails to connect to the database.
139
     *
140
     * @return bool
141
     */
142 41
    public static function isMigrated(): bool
143
    {
144 41
        $table  = static::getTable();
145 41
        $tables = [$table];
0 ignored issues
show
Unused Code introduced by
The assignment to $tables is dead and can be removed.
Loading history...
146
147
        try {
148 41
            $tables = static::getDatabase()
149 41
                ->query('SHOW TABLES;')
150 41
                ->fetchAll(\PDO::FETCH_COLUMN);
151
        } catch (\Exception $e) {
152
            // ignore silently
153
        }
154
155 41
        return in_array($table, $tables);
156
    }
157
158
159
    /**
160
     * Executes a query (a prepared statement) and returns the result.
161
     *
162
     * @param string $query The query to execute. The `@table` can be used to inject the current model table name into the query.
163
     * @param array|null $variables [optional] The variables needed for the query.
164
     * @param bool $raw [optional] Whether fetch the models as arrays (raw) or as hydrated objects.
165
     *
166
     * @return static[]|array[] The result as an array of objects or array of arrays depending on the passed parameters.
167
     *
168
     * @throws \BadMethodCallException If called in an abstract class context.
169
     *
170
     * Example:
171
     * - ```Model::fetch('SELECT * FROM `users` WHERE `name` = :name OR `age` = :age', ['name' => 'Doe', 'age' => 27], true)```
172
     */
173 29
    public static function fetch(string $query, ?array $variables = [], bool $raw = false): array
174
    {
175 29
        if (static::class === self::class || (new \ReflectionClass(static::class))->isAbstract()) {
0 ignored issues
show
introduced by
The condition static::class === self::class is always true.
Loading history...
176 1
            Exception::throw('CannotFetchException:BadMethodCallException', sprintf(
177
                'Cannot fetch for an abstract class, "%s" methods should be used by extension only',
178
                self::class
179
            ));
180
        }
181
182 28
        $table     = sprintf('`%s`', static::getTable());
183 28
        $query     = str_ireplace(['@table', '`@table`'], $table, $query);
184 28
        $variables = $variables ?? [];
185
186 28
        $class = static::class;
187
188 28
        return static::getDatabase()->transactional(function () use ($query, $variables, $raw, $class) {
189
            /** @var Database $this */
190 28
            $statement = $this->perform($query, $variables);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $this seems to be never defined.
Loading history...
191 28
            $result    = $raw
192 2
                ? $statement->fetchAll(\PDO::FETCH_ASSOC)
193 28
                : $statement->fetchAll(\PDO::FETCH_CLASS|\PDO::FETCH_PROPS_LATE, $class, [/* $class constructor arguments */]);
194
195 28
            return $result;
196
        });
197
    }
198
199
    /**
200
     * Finds a single or multiple models by the passed condition.
201
     *
202
     * @param string $column The column/attribute name.
203
     * @param string $operator Condition operator, can be: `=`, `!=`, `<>`, `<`, `>`, `<=`, `>=`, `LIKE`, `NOT LIKE`, `IN`, `NOT IN`.
204
     * @param mixed $value The value to compare to.
205
     * @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.
206
     * @param string|null $order [optional] SQL order expression (like: `id` or `id ASC`).
207
     * @param int|null $limit [optional] To how many items the result should be limited.
208
     * @param int|null $offset [optional] From which item the result should start.
209
     *
210
     * @return static[]|array[]
211
     *
212
     * @throws \InvalidArgumentException If operator is not supported or a condition is invalid.
213
     * @throws \BadMethodCallException If called in an abstract class context.
214
     *
215
     * Examples:
216
     * - `Model::where('name', '=', 'Doe')`.
217
     * - `Model::where('age', '>', 27, [['AND', 'name', 'LIKE', 'Doe%'], $query, ..., [$subQuery, ...]], $order, $limit, $offset)`.
218
     */
219 3
    public static function where(
220
        string $column,
221
        string $operator,
222
        $value,
223
        ?array $additional = null,
224
        ?string $order = null,
225
        ?int $limit = null,
226
        ?int $offset = null
227
    ): array {
228 3
        $conditions = array_merge([['', $column, $operator, $value]], $additional ?? []);
229
230 3
        $where     = static::buildWhereClause($conditions);
231 1
        $query     = sprintf('SELECT * FROM @table %s', $where['query']);
232 1
        $variables = $where['variables'];
233
234 1
        if ($order !== null) {
235 1
            $query .= ' ORDER BY ' . $order;
236
        }
237
238 1
        if ($limit !== null) {
239 1
            $query .= ' LIMIT ' . $limit;
240
        }
241
242 1
        if ($offset !== null) {
243 1
            $query .= ' OFFSET ' . $offset;
244
        }
245
246 1
        $query .= ';';
247
248 1
        return static::fetch($query, $variables);
249
    }
250
251 3
    private static function buildWhereClause(array $conditions): array
252
    {
253 3
        $query     = 'WHERE';
254 3
        $variables = [];
255
256 3
        foreach ($conditions as $index => $condition) {
257 3
            $result = static::buildNestedQuery($condition, $index);
258
259 2
            $query     = $query . $result['query'];
260 2
            $variables = $variables + $result['variables'];
261
        }
262
263
        return [
264 1
            'query'     => (string)$query,
265 1
            'variables' => (array)$variables,
266
        ];
267
    }
268
269
    /**
270
     * Builds a nested query.
271
     *
272
     * @param array $condition The condition in the form of `['OPERATOR1', 'COLUMN', 'OPERATOR2', 'VALUE', ], ..., [..., ...]`.
273
     * @param int|string $index The index of the condition.
274
     *
275
     * @return mixed[] An associative array containing the SQL `query` and its needed `variables`.
276
     */
277 3
    private static function buildNestedQuery(array $condition, $index): array
278
    {
279 3
        $query     = '';
280 3
        $variables = [];
281 3
        $nested    = 0;
282
283 3
        if (is_array($condition[$nested] ?? null)) {
284 1
            $nested = count($condition);
285 1
            $subConditions = $condition;
286
287 1
            foreach ($subConditions as $subIndex => $subCondition) {
288 1
                $result = null;
289
290 1
                if ($subIndex === 0) {
291 1
                    $query .= ' ' . $subCondition[0] . ' (';
292 1
                    $subCondition[0] = '';
293 1
                    $subIndex = sprintf('%s_%s', $index, $subIndex);
294
295 1
                    $result = static::buildNestedQuery($subCondition, $subIndex);
296
                } else {
297 1
                    $result = static::buildNestedQuery($subCondition, $subIndex);
298
                }
299
300 1
                $query     = $query . $result['query'];
301 1
                $variables = $variables + $result['variables'];
302
303 1
                $nested--;
304
            }
305
306 1
            $query .= ' )';
307
308 1
            return compact('query', 'variables');
309
        }
310
311 3
        [$operator1, $column, $operator2, $value] = static::validateCondition($condition, $index);
312
313 3
        $operator1 = static::validateOperator($operator1, $index);
314 3
        $operator2 = static::validateOperator($operator2, $index);
315
316 2
        $placeholder  = sprintf('%s_%s', $column, $index);
317 2
        $placeholders = '';
318
319 2
        if ($isInOperator = substr($operator2, -2) === 'IN') {
320 1
            $placeholders = array_map(function ($id) use ($placeholder) {
321 1
                return sprintf('%s_%s', $placeholder, $id);
322 1
            }, array_keys($value));
323
324 1
            $keys       = array_values($placeholders);
325 1
            $values     = array_values($value);
326 1
            $variables  = array_merge($variables, array_combine($keys, $values));
327
328 1
            $placeholders = implode(', ', array_map(fn ($id) => ':' . $id, $placeholders));
329
        } else {
330 2
            $variables[$placeholder] = $value;
331
        }
332
333 2
        $query .= ' ' . trim(vsprintf('%s `%s` %s %s', [
334
            $operator1,
335
            $column,
336
            $operator2,
337 2
            $isInOperator ? "({$placeholders})" : ":{$placeholder}"
338
        ]));
339
340 2
        return compact('query', 'variables');
341
    }
342
343
    /**
344
     * Validates the passed condition.
345
     *
346
     * @param array $condition The condition to validate. in the form of `['OPERATOR1', 'COLUMN', 'OPERATOR2', 'VALUE']`
347
     * @param int|string $index The index of the condition (used to make more user-friendly exception).
348
     *
349
     * @return array An array containing the validated condition.
350
     *
351
     * @throws \InvalidArgumentException If the condition is invalid.
352
     */
353 3
    private static function validateCondition(array $condition, $index): array
354
    {
355 3
        $condition = array_merge($condition, array_fill(0, 4, null));
356 3
        $condition = array_splice($condition, 0, 4);
357
358
        // $operator1, $column, $value, $operator2
359 3
        if (!is_string($condition[0]) || !is_string($condition[1]) || !is_string($condition[2]) || !isset($condition[3])) {
360 1
            $formatter = fn ($name, $type) => sprintf('%s (%s)', $name, $type);
361
            $variables = [
362
                'linkingOperator'    => 'string',
363
                'column'             => 'string',
364
                'comparisonOperator' => 'string',
365
                'value'              => 'mixed',
366
            ];
367
368 1
            $message = sprintf(
369
                "The passed condition ['%s'] in query at index (%s) is invalid. Was expecting ['%s'], got ['%s']",
370 1
                implode("', '", $condition),
371
                $index,
372 1
                implode("', '", array_map($formatter, array_keys($variables), array_values($variables))),
373 1
                implode("', '", array_map($formatter, array_keys($variables), array_map('gettype', $condition)))
374
            );
375
376 1
            Exception::throw('QueryBuilderException:InvalidArgumentException', $message);
377
        }
378
379 3
        return $condition;
380
    }
381
382
    /**
383
     * Validates the passed operator.
384
     *
385
     * @param string $operator The operator to validate.
386
     * @param int|string $index The index of the condition (used to make more user-friendly exception).
387
     *
388
     * @return string The validated operator.
389
     *
390
     * @throws \InvalidArgumentException If the operator is invalid.
391
     */
392 3
    private static function validateOperator(string $operator, $index): string
393
    {
394 3
        $operator  = strtoupper(trim($operator));
395 3
        $supported = ['', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'AND', 'OR', '=', '!=', '<>', '<', '>', '<=', '>='];
396
397 3
        if (!in_array($operator, $supported)) {
398 1
            $message = sprintf(
399 1
                "Got '%s' as an argument in query at index (%s), " .
400
                "which is an invalid or unsupported SQL operator. Supported operators are: ['%s']",
401
                $operator,
402
                $index,
403 1
                implode("', '", array_filter($supported)),
404
            );
405
406 1
            Exception::throw('QueryBuilderException:InvalidArgumentException', $message);
407
        }
408
409 3
        return $operator;
410
    }
411
}
412