Passed
Push — 2.x ( 18bce1...b13a23 )
by Maxim
17:24
created

Compiler::compileJsonOrderBy()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 0
cts 0
cp 0
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This file is part of Cycle ORM package.
5
 *
6
 * For the full copyright and license information, please view the LICENSE
7
 * file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace Cycle\Database\Driver;
13
14
use Cycle\Database\Exception\CompilerException;
15
use Cycle\Database\Injection\FragmentInterface;
16
use Cycle\Database\Injection\Parameter;
17
use Cycle\Database\Injection\ParameterInterface;
18
use Cycle\Database\Query\QueryParameters;
19
20
abstract class Compiler implements CompilerInterface
21
{
22
    private Quoter $quoter;
23
24
    protected const ORDER_OPTIONS = ['ASC', 'DESC'];
25
26
    /**
27 82
     * @psalm-param non-empty-string $quotes
28
     */
29 82
    public function __construct(string $quotes = '""')
30 82
    {
31
        $this->quoter = new Quoter('', $quotes);
32
    }
33
34
    /**
35
     * @psalm-param non-empty-string $identifier
36
     *
37 3460
     * @psalm-return non-empty-string
38
     */
39 3460
    public function quoteIdentifier(string $identifier): string
40
    {
41
        return $this->quoter->identifier($identifier);
42
    }
43
44
    /**
45 2254
     * @psalm-return non-empty-string
46
     */
47
    public function compile(
48
        QueryParameters $params,
49
        string $prefix,
50 2254
        FragmentInterface $fragment
51
    ): string {
52 2254
        return $this->fragment(
53
            $params,
54 2254
            $this->quoter->withPrefix($prefix),
55
            $fragment,
56
            false
57
        );
58
    }
59
60
    /**
61 1344
     * @psalm-return non-empty-string
62
     */
63 1344
    public function hashLimit(QueryParameters $params, array $tokens): string
64 66
    {
65
        if ($tokens['limit'] !== null) {
66
            $params->push(new Parameter($tokens['limit']));
67 1344
        }
68 48
69
        if ($tokens['offset'] !== null) {
70
            $params->push(new Parameter($tokens['offset']));
71 1344
        }
72
73
        return '_' . ($tokens['limit'] === null) . '_' . ($tokens['offset'] === null);
74
    }
75
76
    /**
77 2254
     * @psalm-return non-empty-string
78
     */
79
    protected function fragment(
80
        QueryParameters $params,
81
        Quoter $q,
82
        FragmentInterface $fragment,
83 2254
        bool $nestedQuery = true
84
    ): string {
85 2254
        $tokens = $fragment->getTokens();
86 2254
87 662
        switch ($fragment->getType()) {
88 16
            case self::FRAGMENT:
89
                foreach ($tokens['parameters'] as $param) {
90
                    $params->push($param);
91 662
                }
92
93 1674
                return $tokens['fragment'];
94 342
95 26
            case self::EXPRESSION:
96
                foreach ($tokens['parameters'] as $param) {
97
                    $params->push($param);
98 342
                }
99
100 1670
                return $q->quote($tokens['expression']);
101 272
102
            case self::JSON_EXPRESSION:
103 1560
                foreach ($tokens['parameters'] as $param) {
104 1440
                    $params->push($param);
105 112
                }
106 72
107 72
                return $tokens['expression'];
108 72
109
            case self::INSERT_QUERY:
110
                return $this->insertQuery($params, $q, $tokens);
111
112 112
            case self::SELECT_QUERY:
113 112
                if ($nestedQuery) {
114 112
                    if ($fragment->getPrefix() !== null) {
0 ignored issues
show
Bug introduced by
The method getPrefix() does not exist on Cycle\Database\Injection\FragmentInterface. It seems like you code against a sub-type of Cycle\Database\Injection\FragmentInterface such as Cycle\Database\Query\QueryInterface. ( Ignorable by Annotation )

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

114
                    if ($fragment->/** @scrutinizer ignore-call */ getPrefix() !== null) {
Loading history...
115
                        $q = $q->withPrefix(
116
                            $fragment->getPrefix(),
117
                            true
118 1432
                        );
119
                    }
120 160
121 104
                    return sprintf(
122
                        '(%s)',
123 56
                        $this->selectQuery($params, $q, $tokens)
124 56
                    );
125
                }
126
127
                return $this->selectQuery($params, $q, $tokens);
128
129
            case self::UPDATE_QUERY:
130
                return $this->updateQuery($params, $q, $tokens);
131
132
            case self::DELETE_QUERY:
133
                return $this->deleteQuery($params, $q, $tokens);
134
        }
135
136
        throw new CompilerException(
137
            sprintf(
138 236
                'Unknown fragment type %s',
139
                $fragment->getType()
140 236
            )
141 236
        );
142 228
    }
143
144
    /**
145 236
     * @psalm-return non-empty-string
146 8
     */
147 8
    protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens): string
148 8
    {
149
        $values = [];
150
        foreach ($tokens['values'] as $value) {
151
            $values[] = $this->value($params, $q, $value);
152 228
        }
153 228
154 228
        if ($tokens['columns'] === []) {
155 228
            return sprintf(
156 228
                'INSERT INTO %s DEFAULT VALUES',
157
                $this->name($params, $q, $tokens['table'], true)
158
            );
159
        }
160
161
        return sprintf(
162
            'INSERT INTO %s (%s) VALUES %s',
163 990
            $this->name($params, $q, $tokens['table'], true),
164
            $this->columns($params, $q, $tokens['columns']),
165
            implode(', ', $values)
166 990
        );
167 990
    }
168 990
169
    /**
170 990
     * @psalm-return non-empty-string
171 132
     */
172
    protected function selectQuery(QueryParameters $params, Quoter $q, array $tokens): string
173
    {
174 990
        // This statement(s) parts should be processed first to define set of table and column aliases
175 990
        $tables = [];
176 990
        foreach ($tokens['from'] as $table) {
177 990
            $tables[] = $this->name($params, $q, $table, true);
178 990
        }
179 990
        foreach ($tokens['join'] as $join) {
180 990
            $this->nameWithAlias(new QueryParameters(), $q, $join['outer'], $join['alias'], true);
181 990
        }
182 990
183 990
        return sprintf(
184 990
            "SELECT%s %s\nFROM %s%s%s%s%s%s%s%s%s",
185 990
            $this->optional(' ', $this->distinct($params, $q, $tokens['distinct'])),
186 990
            $this->columns($params, $q, $tokens['columns']),
187
            \implode(', ', $tables),
188
            $this->optional(' ', $this->joins($params, $q, $tokens['join']), ' '),
189
            $this->optional("\nWHERE", $this->where($params, $q, $tokens['where'])),
190 1102
            $this->optional("\nGROUP BY", $this->groupBy($params, $q, $tokens['groupBy']), ' '),
191
            $this->optional("\nHAVING", $this->where($params, $q, $tokens['having'])),
192 1102
            $this->optional("\n", $this->unions($params, $q, $tokens['union'])),
193
            $this->optional("\nORDER BY", $this->orderBy($params, $q, $tokens['orderBy'])),
194
            $this->optional("\n", $this->limit($params, $q, $tokens['limit'], $tokens['offset'])),
195 1440
            $this->optional(' ', $tokens['forUpdate'] ? 'FOR UPDATE' : '')
196
        );
197 1440
    }
198 1440
199 202
    protected function distinct(QueryParameters $params, Quoter $q, string|bool|array $distinct): string
200 202
    {
201 202
        return $distinct === false ? '' : 'DISTINCT';
0 ignored issues
show
introduced by
The condition $distinct === false is always false.
Loading history...
202 202
    }
203
204
    protected function joins(QueryParameters $params, Quoter $q, array $joins): string
205 202
    {
206 74
        $statement = '';
207
        foreach ($joins as $join) {
208 74
            $statement .= sprintf(
209
                "\n%s JOIN %s",
210
                $join['type'],
211 202
                $this->nameWithAlias($params, $q, $join['outer'], $join['alias'], true)
212 202
            );
213 202
214
            $statement .= $this->optional(
215
                "\n    ON",
216
                $this->where($params, $q, $join['on'])
217 1440
            );
218
        }
219
220 1440
        return $statement;
221
    }
222 1440
223 1440
    protected function unions(QueryParameters $params, Quoter $q, array $unions): string
224
    {
225
        if ($unions === []) {
226 24
            return '';
227 24
        }
228 24
229
        $statement = '';
230 24
        foreach ($unions as $union) {
231
            $select = $this->fragment($params, $q, $union[1]);
232 16
233
            if ($union[0] !== '') {
234
                //First key is union type, second united query (no need to share compiler)
235 16
                $statement .= "\nUNION {$union[0]}\n{$select}";
236
            } else {
237
                //No extra space
238
                $statement .= "\nUNION \n{$select}";
239 24
            }
240
        }
241
242 1440
        return \ltrim($statement, "\n");
243
    }
244 1440
245 1440
    protected function orderBy(QueryParameters $params, Quoter $q, array $orderBy): string
246 108
    {
247
        $result = [];
248 108
        foreach ($orderBy as $order) {
249
            if (\is_string($order[0]) && $this->isJsonPath($order[0])) {
250
                $order[0] = $this->compileJsonOrderBy($order[0]);
251
            }
252 108
253
            if ($order[1] === null) {
254
                $result[] = $this->name($params, $q, $order[0]);
255 1440
                continue;
256
            }
257
258 1440
            $direction = \strtoupper($order[1]);
259
260 1440
            \in_array($direction, static::ORDER_OPTIONS) or throw new CompilerException(
261 1440
                \sprintf(
262 80
                    'Invalid sorting direction, only `%s` are allowed',
263
                    \implode('`, `', static::ORDER_OPTIONS),
264
                ),
265 1440
            );
266
267
            $result[] = $this->name($params, $q, $order[0]) . ' ' . $direction;
268
        }
269
270
        return \implode(', ', $result);
271
    }
272
273
    protected function groupBy(QueryParameters $params, Quoter $q, array $groupBy): string
274
    {
275 104
        $result = [];
276
        foreach ($groupBy as $identifier) {
277
            $result[] = $this->name($params, $q, $identifier);
278
        }
279
280 104
        return \implode(', ', $result);
281 104
    }
282 104
283 104
    abstract protected function limit(
284 104
        QueryParameters $params,
285 104
        Quoter $q,
286
        int $limit = null,
287
        int $offset = null
288
    ): string;
289 104
290 104
    protected function updateQuery(
291 104
        QueryParameters $parameters,
292 104
        Quoter $quoter,
293 104
        array $tokens
294
    ): string {
295
        $values = [];
296
        foreach ($tokens['values'] as $column => $value) {
297
            $values[] = sprintf(
298
                '%s = %s',
299
                $this->name($parameters, $quoter, $column),
300 56
                $this->value($parameters, $quoter, $value)
301
            );
302
        }
303
304
        return sprintf(
305 56
            "UPDATE %s\nSET %s%s",
306 56
            $this->name($parameters, $quoter, $tokens['table'], true),
307 56
            trim(implode(', ', $values)),
308 56
            $this->optional("\nWHERE", $this->where($parameters, $quoter, $tokens['where']))
309 56
        );
310 56
    }
311
312
    /**
313
     * @psalm-return non-empty-string
314
     */
315
    protected function deleteQuery(
316
        QueryParameters $parameters,
317
        Quoter $quoter,
318 1670
        array $tokens
319
    ): string {
320 1670
        return sprintf(
321 184
            'DELETE FROM %s%s',
322
            $this->name($parameters, $quoter, $tokens['table'], true),
323
            $this->optional(
324 1670
                "\nWHERE",
325 8
                $this->where($parameters, $quoter, $tokens['where'])
326
            )
327
        );
328 1670
    }
329
330
    /**
331
     * @psalm-return non-empty-string
332
     */
333
    protected function name(QueryParameters $params, Quoter $q, $name, bool $table = false): string
334 1546
    {
335
        if ($name instanceof FragmentInterface) {
336
            return $this->fragment($params, $q, $name);
337 1546
        }
338 1546
339 1546
        if ($name instanceof ParameterInterface) {
340 1546
            return $this->value($params, $q, $name);
341
        }
342
343
        return $q->quote($name, $table);
344 1546
    }
345
346
    /**
347
     * @psalm-return non-empty-string
348
     */
349
    protected function nameWithAlias(
350 338
        QueryParameters $params,
351
        Quoter $q,
352 338
        $name,
353 16
        ?string $alias = null,
354
        bool $table = false,
355
    ): string {
356 338
        $quotedName = $this->name($params, $q, $name, $table);
357 330
358
        if ($alias !== null) {
359
            $q->registerAlias($alias, (string) $name);
360 338
361 256
            $quotedName .= ' AS ' . $this->name($params, $q, $alias);
362 256
        }
363 256
364
        return $quotedName;
365
    }
366 256
367
    /**
368
     * @psalm-return non-empty-string
369 338
     */
370
    protected function columns(QueryParameters $params, Quoter $q, array $columns, int $maxLength = 180): string
371 338
    {
372
        // let's quote every identifier
373
        $columns = array_map(
374 1560
            function ($column) use ($params, $q) {
375
                return $this->name($params, $q, $column);
376 1560
            },
377 1496
            $columns
378
        );
379
380 1194
        return wordwrap(implode(', ', $columns), $maxLength);
381
    }
382 1194
383 1194
    /**
384
     * @psalm-return non-empty-string
385 1194
     */
386
    protected function value(QueryParameters $params, Quoter $q, $value): string
387
    {
388 1194
        if ($value instanceof FragmentInterface) {
389
            return $this->fragment($params, $q, $value);
390 1194
        }
391
392 480
        if (!$value instanceof ParameterInterface) {
393 480
            $value = new Parameter($value);
394
        }
395
396
        if ($value->isArray()) {
397
            $values = [];
398
            foreach ($value->getValue() as $child) {
399
                $values[] = $this->value($params, $q, $child);
400 1194
            }
401 240
402
            return '(' . implode(', ', $values) . ')';
403 240
        }
404
405
        $params->push($value);
406 240
407 240
        return '?';
408
    }
409
410 1186
    protected function where(QueryParameters $params, Quoter $q, array $tokens): string
411 8
    {
412 8
        if ($tokens === []) {
413 8
            return '';
414
        }
415
416
        $statement = '';
417 1186
418 1186
        $activeGroup = true;
419 1186
        foreach ($tokens as $condition) {
420 1186
            // OR/AND keyword
421
            [$boolean, $context] = $condition;
422
423 1194
            // first condition in group/query, no any AND, OR required
424
            if ($activeGroup) {
425 1194
                // next conditions require AND or OR
426 8
                $activeGroup = false;
427
            } else {
428
                $statement .= $boolean;
429 1186
                $statement .= ' ';
430
            }
431
432
            /*
433
             * When context is string it usually represent control keyword/syntax such as opening
434
             * or closing braces.
435 1186
             */
436
            if (\is_string($context)) {
437 1186
                if ($context === '(') {
438 1186
                    // new where group.
439
                    $activeGroup = true;
440 1186
                }
441 16
442 1170
                $statement .= $context;
443
                continue;
444
            }
445
446 1186
            if ($context instanceof FragmentInterface) {
447 308
                $statement .= $this->fragment($params, $q, $context);
448
                $statement .= ' ';
449
                continue;
450 1040
            }
451
452
            // identifier can be column name, expression or even query builder
453
            $statement .= $this->name($params, $q, $context[0]);
454 1040
            $statement .= ' ';
455 1040
            $statement .= $this->condition($params, $q, $context);
456 50
            $statement .= ' ';
457
        }
458 50
459
        $activeGroup and throw new CompilerException('Unable to build where statement, unclosed where group');
460
461
        if (trim($statement, ' ()') === '') {
462 50
            return '';
463 50
        }
464 1022
465 32
        return $statement;
466 8
    }
467 24
468 8
    /**
469
     * @psalm-return non-empty-string
470
     */
471 32
    protected function condition(QueryParameters $params, Quoter $q, array $context): string
472
    {
473 990
        $operator = $context[1];
474
        $value = $context[2];
475
476 1040
        if ($operator instanceof FragmentInterface) {
477 64
            $operator = $this->fragment($params, $q, $operator);
478
        } elseif (!\is_string($operator)) {
479
            throw new CompilerException('Invalid operator type, string or fragment is expected');
480 64
        }
481
482
        if ($value instanceof FragmentInterface) {
483 976
            return $operator . ' ' . $this->fragment($params, $q, $value);
484
        }
485
486
        if (!$value instanceof ParameterInterface) {
487
            throw new CompilerException('Invalid value format, fragment or parameter is expected');
488
        }
489
490 1560
        $placeholder = '?';
491
        if ($value->isArray()) {
492 1560
            return $this->arrayToInOperator($params, $q, $value->getValue(), match (\strtoupper($operator)) {
493 1496
                'IN', '=' => true,
494
                'NOT IN', '!=' => false,
495
                default => throw CompilerException\UnexpectedOperatorException::sequence($operator),
496 1292
            });
497 1236
        }
498
499
        if ($value->isNull()) {
500 1292
            if ($operator === '=') {
501
                $operator = 'IS';
502
            } elseif ($operator === '!=') {
503
                $operator = 'IS NOT';
504
            }
505
506
            $placeholder = 'NULL';
507
        } else {
508
            $params->push($value);
509
        }
510
511
        if ($operator === 'BETWEEN' || $operator === 'NOT BETWEEN') {
512
            $params->push($context[3]);
513
514
            // possibly support between nested queries
515
            return $operator . ' ? AND ?';
516
        }
517
518
        return $operator . ' ' . $placeholder;
519
    }
520
521
    /**
522
     * Combine expression with prefix/postfix (usually SQL keyword) but only if expression is not
523
     * empty.
524
     */
525
    protected function optional(string $prefix, string $expression, string $postfix = ''): string
526
    {
527
        if ($expression === '') {
528
            return '';
529
        }
530
531
        if ($prefix !== "\n" && $prefix !== ' ') {
532
            $prefix .= ' ';
533
        }
534
535
        return $prefix . $expression . $postfix;
536
    }
537
538
    protected function isJsonPath(string $column): bool
539
    {
540
        return \str_contains($column, '->');
541
    }
542
543
    /**
544
     * Each driver must override this method and implement sorting by JSON column.
545
     */
546
    protected function compileJsonOrderBy(string $path): string|FragmentInterface
547
    {
548
        return $path;
549
    }
550
551
    private function arrayToInOperator(QueryParameters $params, Quoter $q, array $values, bool $in): string
552
    {
553
        $operator = $in ? 'IN' : 'NOT IN';
554
555
        $placeholders = $simpleParams = [];
556
        foreach ($values as $value) {
557
            if ($value instanceof FragmentInterface) {
558
                $placeholders[] = $this->fragment($params, $q, $value);
559
            } else {
560
                $placeholders[] = '?';
561
                $simpleParams[] = $value;
562
            }
563
        }
564
        if ($simpleParams !== []) {
565
            $params->push(new Parameter($simpleParams));
566
        }
567
568
        return \sprintf('%s(%s)', $operator, \implode(',', $placeholders));
569
    }
570
}
571