Passed
Pull Request — 2.x (#224)
by
unknown
19:26
created

Compiler::upsertQuery()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 31
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 4

Importance

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