Compiler::nameWithAlias()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 16
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 5
dl 0
loc 16
ccs 8
cts 8
cp 1
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This file is part of Cycle ORM package.
5
 *
6
 * For the full copyright and license information, please view the LICENSE
7
 * file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace Cycle\Database\Driver;
13
14
use Cycle\Database\Exception\CompilerException;
15
use Cycle\Database\Injection\FragmentInterface;
16
use Cycle\Database\Injection\Parameter;
17
use Cycle\Database\Injection\ParameterInterface;
18
use Cycle\Database\Query\QueryParameters;
19
20
abstract class Compiler implements CompilerInterface
21
{
22
    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
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
                \str_contains($join['type'], 'JOIN') ? "\n%s %s" : "\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
     * @param mixed $name
379
     */
380 1194
    protected function name(QueryParameters $params, Quoter $q, $name, bool $table = false): string
381
    {
382 1194
        if ($name instanceof FragmentInterface) {
383 1194
            return $this->fragment($params, $q, $name);
384
        }
385 1194
386
        if ($name instanceof ParameterInterface) {
387
            return $this->value($params, $q, $name);
388 1194
        }
389
390 1194
        return $q->quote($name, $table);
391
    }
392 480
393 480
    /**
394
     * @psalm-return non-empty-string
395
     * @param mixed $name
396
     */
397
    protected function nameWithAlias(
398
        QueryParameters $params,
399
        Quoter $q,
400 1194
        $name,
401 240
        ?string $alias = null,
402
        bool $table = false,
403 240
    ): string {
404
        $quotedName = $this->name($params, $q, $name, $table);
405
406 240
        if ($alias !== null) {
407 240
            $q->registerAlias($alias, (string) $name);
408
409
            $quotedName .= ' AS ' . $this->name($params, $q, $alias);
410 1186
        }
411 8
412 8
        return $quotedName;
413 8
    }
414
415
    /**
416
     * @psalm-return non-empty-string
417 1186
     */
418 1186
    protected function columns(QueryParameters $params, Quoter $q, array $columns, int $maxLength = 180): string
419 1186
    {
420 1186
        // let's quote every identifier
421
        $columns = \array_map(
422
            function ($column) use ($params, $q) {
423 1194
                return $this->name($params, $q, $column);
424
            },
425 1194
            $columns,
426 8
        );
427
428
        return \wordwrap(\implode(', ', $columns), $maxLength);
429 1186
    }
430
431
    /**
432
     * @psalm-return non-empty-string
433
     * @param mixed $value
434
     */
435 1186
    protected function value(QueryParameters $params, Quoter $q, $value): string
436
    {
437 1186
        if ($value instanceof FragmentInterface) {
438 1186
            return $this->fragment($params, $q, $value);
439
        }
440 1186
441 16
        if (!$value instanceof ParameterInterface) {
442 1170
            $value = new Parameter($value);
443
        }
444
445
        if ($value->isArray()) {
446 1186
            $values = [];
447 308
            foreach ($value->getValue() as $child) {
448
                $values[] = $this->value($params, $q, $child);
449
            }
450 1040
451
            return '(' . \implode(', ', $values) . ')';
452
        }
453
454 1040
        $params->push($value);
455 1040
456 50
        return '?';
457
    }
458 50
459
    protected function where(QueryParameters $params, Quoter $q, array $tokens): string
460
    {
461
        if ($tokens === []) {
462 50
            return '';
463 50
        }
464 1022
465 32
        $statement = '';
466 8
467 24
        $activeGroup = true;
468 8
        foreach ($tokens as $condition) {
469
            // OR/AND keyword
470
            [$boolean, $context] = $condition;
471 32
472
            // first condition in group/query, no any AND, OR required
473 990
            if ($activeGroup) {
474
                // first condition can have a `NOT` keyword (WHERE NOT ...)
475
                if (\str_contains(\strtoupper($boolean), 'NOT')) {
476 1040
                    $statement .= 'NOT';
477 64
                    $statement .= ' ';
478
                }
479
480 64
                // next conditions require AND or OR
481
                $activeGroup = false;
482
            } else {
483 976
                $statement .= $boolean;
484
                $statement .= ' ';
485
            }
486
487
            /*
488
             * When context is string it usually represent control keyword/syntax such as opening
489
             * or closing braces.
490 1560
             */
491
            if (\is_string($context)) {
492 1560
                if ($context === '(') {
493 1496
                    // new where group.
494
                    $activeGroup = true;
495
                }
496 1292
497 1236
                $statement .= $context;
498
                continue;
499
            }
500 1292
501
            if ($context instanceof FragmentInterface) {
502
                $statement .= $this->fragment($params, $q, $context);
503
                $statement .= ' ';
504
                continue;
505
            }
506
507
            // identifier can be column name, expression or even query builder
508
            $statement .= $this->name($params, $q, $context[0]);
509
            $statement .= ' ';
510
            $statement .= $this->condition($params, $q, $context);
511
            $statement .= ' ';
512
        }
513
514
        $activeGroup and throw new CompilerException('Unable to build where statement, unclosed where group');
515
516
        if (\trim($statement, ' ()') === '') {
517
            return '';
518
        }
519
520
        return $statement;
521
    }
522
523
    /**
524
     * @psalm-return non-empty-string
525
     */
526
    protected function condition(QueryParameters $params, Quoter $q, array $context): string
527
    {
528
        $operator = $context[1];
529
        $value = $context[2];
530
531
        if ($operator instanceof FragmentInterface) {
532
            $operator = $this->fragment($params, $q, $operator);
533
        } elseif (!\is_string($operator)) {
534
            throw new CompilerException('Invalid operator type, string or fragment is expected');
535
        }
536
537
        if ($value instanceof FragmentInterface) {
538
            return $operator . ' ' . $this->fragment($params, $q, $value);
539
        }
540
541
        if (!$value instanceof ParameterInterface) {
542
            throw new CompilerException('Invalid value format, fragment or parameter is expected');
543
        }
544
545
        $placeholder = '?';
546
        if ($value->isArray()) {
547
            return $this->arrayToInOperator($params, $q, $value->getValue(), match (\strtoupper($operator)) {
548
                'IN', '=' => true,
549
                'NOT IN', '!=' => false,
550
                default => throw CompilerException\UnexpectedOperatorException::sequence($operator),
551
            });
552
        }
553
554
        if ($value->isNull()) {
555
            if ($operator === '=') {
556
                $operator = 'IS';
557
            } elseif ($operator === '!=') {
558
                $operator = 'IS NOT';
559
            }
560
561
            $placeholder = 'NULL';
562
        } else {
563
            $params->push($value);
564
        }
565
566
        if ($operator === 'BETWEEN' || $operator === 'NOT BETWEEN') {
567
            $params->push($context[3]);
568
569
            // possibly support between nested queries
570
            return $operator . ' ? AND ?';
571
        }
572
573
        return $operator . ' ' . $placeholder;
574
    }
575
576
    /**
577
     * Combine expression with prefix/postfix (usually SQL keyword) but only if expression is not
578
     * empty.
579
     */
580
    protected function optional(string $prefix, string $expression, string $postfix = ''): string
581
    {
582
        if ($expression === '') {
583
            return '';
584
        }
585
586
        if ($prefix !== "\n" && $prefix !== ' ') {
587
            $prefix .= ' ';
588
        }
589
590
        return $prefix . $expression . $postfix;
591
    }
592
593
    protected function isJsonPath(string $column): bool
594
    {
595
        return \str_contains($column, '->');
596
    }
597
598
    /**
599
     * Each driver must override this method and implement sorting by JSON column.
600
     */
601
    protected function compileJsonOrderBy(string $path): string|FragmentInterface
602
    {
603
        return $path;
604
    }
605
606
    private function arrayToInOperator(QueryParameters $params, Quoter $q, array $values, bool $in): string
607
    {
608
        $operator = $in ? 'IN' : 'NOT IN';
609
610
        $placeholders = $simpleParams = [];
611
        foreach ($values as $value) {
612
            if ($value instanceof FragmentInterface) {
613
                $placeholders[] = $this->fragment($params, $q, $value);
614
            } else {
615
                $placeholders[] = '?';
616
                $simpleParams[] = $value;
617
            }
618
        }
619
        if ($simpleParams !== []) {
620
            $params->push(new Parameter($simpleParams));
621
        }
622
623
        return \sprintf('%s(%s)', $operator, \implode(',', $placeholders));
624
    }
625
}
626