Passed
Pull Request — 2.x (#231)
by
unknown
20:48
created

Compiler::upsertQuery()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 31
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 19
nc 4
nop 3
dl 0
loc 31
ccs 21
cts 21
cp 1
crap 4
rs 9.6333
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
    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::UPSERT_QUERY:
113 112
                return $this->upsertQuery($params, $q, $tokens);
114 112
115
            case self::SELECT_QUERY:
116
                if ($nestedQuery) {
117
                    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

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