Passed
Push — 2.x ( e6f7bd...b3cc5a )
by Aleksei
26:07 queued 06:51
created

Compiler::excepts()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4

Importance

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