Issues (265)

src/Driver/Compiler.php (2 issues)

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