Completed
Push — 2.x ( be80d1...0b4d06 )
by Aleksei
17s queued 15s
created

Compiler::unions()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 10
nc 4
nop 3
dl 0
loc 20
ccs 9
cts 9
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
    /**
25
     * @psalm-param non-empty-string $quotes
26
     */
27 82
    public function __construct(string $quotes = '""')
28
    {
29 82
        $this->quoter = new Quoter('', $quotes);
30 82
    }
31
32
    /**
33
     * @psalm-param non-empty-string $identifier
34
     *
35
     * @psalm-return non-empty-string
36
     */
37 3460
    public function quoteIdentifier(string $identifier): string
38
    {
39 3460
        return $this->quoter->identifier($identifier);
40
    }
41
42
    /**
43
     * @psalm-return non-empty-string
44
     */
45 2254
    public function compile(
46
        QueryParameters $params,
47
        string $prefix,
48
        FragmentInterface $fragment
49
    ): string {
50 2254
        return $this->fragment(
51
            $params,
52 2254
            $this->quoter->withPrefix($prefix),
53
            $fragment,
54 2254
            false
55
        );
56
    }
57
58
    /**
59
     * @psalm-return non-empty-string
60
     */
61 1344
    public function hashLimit(QueryParameters $params, array $tokens): string
62
    {
63 1344
        if ($tokens['limit'] !== null) {
64 66
            $params->push(new Parameter($tokens['limit']));
65
        }
66
67 1344
        if ($tokens['offset'] !== null) {
68 48
            $params->push(new Parameter($tokens['offset']));
69
        }
70
71 1344
        return '_' . ($tokens['limit'] === null) . '_' . ($tokens['offset'] === null);
72
    }
73
74
    /**
75
     * @psalm-return non-empty-string
76
     */
77 2254
    protected function fragment(
78
        QueryParameters $params,
79
        Quoter $q,
80
        FragmentInterface $fragment,
81
        bool $nestedQuery = true
82
    ): string {
83 2254
        $tokens = $fragment->getTokens();
84
85 2254
        switch ($fragment->getType()) {
86 2254
            case self::FRAGMENT:
87 662
                foreach ($tokens['parameters'] as $param) {
88 16
                    $params->push($param);
89
                }
90
91 662
                return $tokens['fragment'];
92
93 1674
            case self::EXPRESSION:
94 342
                foreach ($tokens['parameters'] as $param) {
95 26
                    $params->push($param);
96
                }
97
98 342
                return $q->quote($tokens['expression']);
99
100 1670
            case self::JSON_EXPRESSION:
101 272
                foreach ($tokens['parameters'] as $param) {
102
                    $params->push($param);
103 1560
                }
104 1440
105 112
                return $tokens['expression'];
106 72
107 72
            case self::INSERT_QUERY:
108 72
                return $this->insertQuery($params, $q, $tokens);
109
110
            case self::SELECT_QUERY:
111
                if ($nestedQuery) {
112 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

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