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

Compiler::fragment()   C

Complexity

Conditions 13
Paths 13

Size

Total Lines 61
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 13.3627

Importance

Changes 0
Metric Value
cc 13
eloc 34
c 0
b 0
f 0
nc 13
nop 4
dl 0
loc 61
rs 6.6166
ccs 27
cts 31
cp 0.871
crap 13.3627

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * This file is part of Cycle ORM package.
5
 *
6
 * For the full copyright and license information, please view the LICENSE
7
 * file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace Cycle\Database\Driver;
13
14
use Cycle\Database\Exception\CompilerException;
15
use Cycle\Database\Injection\FragmentInterface;
16
use Cycle\Database\Injection\Parameter;
17
use Cycle\Database\Injection\ParameterInterface;
18
use Cycle\Database\Query\QueryParameters;
19
20
abstract class Compiler implements CompilerInterface
21
{
22
    private Quoter $quoter;
23
24
    protected const ORDER_OPTIONS = ['ASC', 'DESC'];
25
26
    /**
27 82
     * @psalm-param non-empty-string $quotes
28
     */
29 82
    public function __construct(string $quotes = '""')
30 82
    {
31
        $this->quoter = new Quoter('', $quotes);
32
    }
33
34
    /**
35
     * @psalm-param non-empty-string $identifier
36
     *
37 3460
     * @psalm-return non-empty-string
38
     */
39 3460
    public function quoteIdentifier(string $identifier): string
40
    {
41
        return $this->quoter->identifier($identifier);
42
    }
43
44
    /**
45 2254
     * @psalm-return non-empty-string
46
     */
47
    public function compile(
48
        QueryParameters $params,
49
        string $prefix,
50 2254
        FragmentInterface $fragment
51
    ): string {
52 2254
        return $this->fragment(
53
            $params,
54 2254
            $this->quoter->withPrefix($prefix),
55
            $fragment,
56
            false
57
        );
58
    }
59
60
    /**
61 1344
     * @psalm-return non-empty-string
62
     */
63 1344
    public function hashLimit(QueryParameters $params, array $tokens): string
64 66
    {
65
        if ($tokens['limit'] !== null) {
66
            $params->push(new Parameter($tokens['limit']));
67 1344
        }
68 48
69
        if ($tokens['offset'] !== null) {
70
            $params->push(new Parameter($tokens['offset']));
71 1344
        }
72
73
        return '_' . ($tokens['limit'] === null) . '_' . ($tokens['offset'] === null);
74
    }
75
76
    /**
77 2254
     * @psalm-return non-empty-string
78
     */
79
    protected function fragment(
80
        QueryParameters $params,
81
        Quoter $q,
82
        FragmentInterface $fragment,
83 2254
        bool $nestedQuery = true
84
    ): string {
85 2254
        $tokens = $fragment->getTokens();
86 2254
87 662
        switch ($fragment->getType()) {
88 16
            case self::FRAGMENT:
89
                foreach ($tokens['parameters'] as $param) {
90
                    $params->push($param);
91 662
                }
92
93 1674
                return $tokens['fragment'];
94 342
95 26
            case self::EXPRESSION:
96
                foreach ($tokens['parameters'] as $param) {
97
                    $params->push($param);
98 342
                }
99
100 1670
                return $q->quote($tokens['expression']);
101 272
102
            case self::JSON_EXPRESSION:
103 1560
                foreach ($tokens['parameters'] as $param) {
104 1440
                    $params->push($param);
105 112
                }
106 72
107 72
                return $tokens['expression'];
108 72
109
            case self::INSERT_QUERY:
110
                return $this->insertQuery($params, $q, $tokens);
111
112 112
            case self::SELECT_QUERY:
113 112
                if ($nestedQuery) {
114 112
                    if ($fragment->getPrefix() !== null) {
0 ignored issues
show
Bug introduced by
The method getPrefix() does not exist on Cycle\Database\Injection\FragmentInterface. It seems like you code against a sub-type of Cycle\Database\Injection\FragmentInterface such as Cycle\Database\Query\QueryInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

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