Passed
Push — 2.x ( 18bce1...b13a23 )
by Maxim
17:24
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",
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("\nORDER BY", $this->orderBy($params, $q, $tokens['orderBy'])),
194
            $this->optional("\n", $this->limit($params, $q, $tokens['limit'], $tokens['offset'])),
195 1440
            $this->optional(' ', $tokens['forUpdate'] ? 'FOR UPDATE' : '')
196
        );
197 1440
    }
198 1440
199 202
    protected function distinct(QueryParameters $params, Quoter $q, string|bool|array $distinct): string
200 202
    {
201 202
        return $distinct === false ? '' : 'DISTINCT';
0 ignored issues
show
introduced by
The condition $distinct === false is always false.
Loading history...
202 202
    }
203
204
    protected function joins(QueryParameters $params, Quoter $q, array $joins): string
205 202
    {
206 74
        $statement = '';
207
        foreach ($joins as $join) {
208 74
            $statement .= sprintf(
209
                "\n%s JOIN %s",
210
                $join['type'],
211 202
                $this->nameWithAlias($params, $q, $join['outer'], $join['alias'], true)
212 202
            );
213 202
214
            $statement .= $this->optional(
215
                "\n    ON",
216
                $this->where($params, $q, $join['on'])
217 1440
            );
218
        }
219
220 1440
        return $statement;
221
    }
222 1440
223 1440
    protected function unions(QueryParameters $params, Quoter $q, array $unions): string
224
    {
225
        if ($unions === []) {
226 24
            return '';
227 24
        }
228 24
229
        $statement = '';
230 24
        foreach ($unions as $union) {
231
            $select = $this->fragment($params, $q, $union[1]);
232 16
233
            if ($union[0] !== '') {
234
                //First key is union type, second united query (no need to share compiler)
235 16
                $statement .= "\nUNION {$union[0]}\n{$select}";
236
            } else {
237
                //No extra space
238
                $statement .= "\nUNION \n{$select}";
239 24
            }
240
        }
241
242 1440
        return \ltrim($statement, "\n");
243
    }
244 1440
245 1440
    protected function orderBy(QueryParameters $params, Quoter $q, array $orderBy): string
246 108
    {
247
        $result = [];
248 108
        foreach ($orderBy as $order) {
249
            if (\is_string($order[0]) && $this->isJsonPath($order[0])) {
250
                $order[0] = $this->compileJsonOrderBy($order[0]);
251
            }
252 108
253
            if ($order[1] === null) {
254
                $result[] = $this->name($params, $q, $order[0]);
255 1440
                continue;
256
            }
257
258 1440
            $direction = \strtoupper($order[1]);
259
260 1440
            \in_array($direction, static::ORDER_OPTIONS) or throw new CompilerException(
261 1440
                \sprintf(
262 80
                    'Invalid sorting direction, only `%s` are allowed',
263
                    \implode('`, `', static::ORDER_OPTIONS),
264
                ),
265 1440
            );
266
267
            $result[] = $this->name($params, $q, $order[0]) . ' ' . $direction;
268
        }
269
270
        return \implode(', ', $result);
271
    }
272
273
    protected function groupBy(QueryParameters $params, Quoter $q, array $groupBy): string
274
    {
275 104
        $result = [];
276
        foreach ($groupBy as $identifier) {
277
            $result[] = $this->name($params, $q, $identifier);
278
        }
279
280 104
        return \implode(', ', $result);
281 104
    }
282 104
283 104
    abstract protected function limit(
284 104
        QueryParameters $params,
285 104
        Quoter $q,
286
        int $limit = null,
287
        int $offset = null
288
    ): string;
289 104
290 104
    protected function updateQuery(
291 104
        QueryParameters $parameters,
292 104
        Quoter $quoter,
293 104
        array $tokens
294
    ): string {
295
        $values = [];
296
        foreach ($tokens['values'] as $column => $value) {
297
            $values[] = sprintf(
298
                '%s = %s',
299
                $this->name($parameters, $quoter, $column),
300 56
                $this->value($parameters, $quoter, $value)
301
            );
302
        }
303
304
        return sprintf(
305 56
            "UPDATE %s\nSET %s%s",
306 56
            $this->name($parameters, $quoter, $tokens['table'], true),
307 56
            trim(implode(', ', $values)),
308 56
            $this->optional("\nWHERE", $this->where($parameters, $quoter, $tokens['where']))
309 56
        );
310 56
    }
311
312
    /**
313
     * @psalm-return non-empty-string
314
     */
315
    protected function deleteQuery(
316
        QueryParameters $parameters,
317
        Quoter $quoter,
318 1670
        array $tokens
319
    ): string {
320 1670
        return sprintf(
321 184
            'DELETE FROM %s%s',
322
            $this->name($parameters, $quoter, $tokens['table'], true),
323
            $this->optional(
324 1670
                "\nWHERE",
325 8
                $this->where($parameters, $quoter, $tokens['where'])
326
            )
327
        );
328 1670
    }
329
330
    /**
331
     * @psalm-return non-empty-string
332
     */
333
    protected function name(QueryParameters $params, Quoter $q, $name, bool $table = false): string
334 1546
    {
335
        if ($name instanceof FragmentInterface) {
336
            return $this->fragment($params, $q, $name);
337 1546
        }
338 1546
339 1546
        if ($name instanceof ParameterInterface) {
340 1546
            return $this->value($params, $q, $name);
341
        }
342
343
        return $q->quote($name, $table);
344 1546
    }
345
346
    /**
347
     * @psalm-return non-empty-string
348
     */
349
    protected function nameWithAlias(
350 338
        QueryParameters $params,
351
        Quoter $q,
352 338
        $name,
353 16
        ?string $alias = null,
354
        bool $table = false,
355
    ): string {
356 338
        $quotedName = $this->name($params, $q, $name, $table);
357 330
358
        if ($alias !== null) {
359
            $q->registerAlias($alias, (string) $name);
360 338
361 256
            $quotedName .= ' AS ' . $this->name($params, $q, $alias);
362 256
        }
363 256
364
        return $quotedName;
365
    }
366 256
367
    /**
368
     * @psalm-return non-empty-string
369 338
     */
370
    protected function columns(QueryParameters $params, Quoter $q, array $columns, int $maxLength = 180): string
371 338
    {
372
        // let's quote every identifier
373
        $columns = array_map(
374 1560
            function ($column) use ($params, $q) {
375
                return $this->name($params, $q, $column);
376 1560
            },
377 1496
            $columns
378
        );
379
380 1194
        return wordwrap(implode(', ', $columns), $maxLength);
381
    }
382 1194
383 1194
    /**
384
     * @psalm-return non-empty-string
385 1194
     */
386
    protected function value(QueryParameters $params, Quoter $q, $value): string
387
    {
388 1194
        if ($value instanceof FragmentInterface) {
389
            return $this->fragment($params, $q, $value);
390 1194
        }
391
392 480
        if (!$value instanceof ParameterInterface) {
393 480
            $value = new Parameter($value);
394
        }
395
396
        if ($value->isArray()) {
397
            $values = [];
398
            foreach ($value->getValue() as $child) {
399
                $values[] = $this->value($params, $q, $child);
400 1194
            }
401 240
402
            return '(' . implode(', ', $values) . ')';
403 240
        }
404
405
        $params->push($value);
406 240
407 240
        return '?';
408
    }
409
410 1186
    protected function where(QueryParameters $params, Quoter $q, array $tokens): string
411 8
    {
412 8
        if ($tokens === []) {
413 8
            return '';
414
        }
415
416
        $statement = '';
417 1186
418 1186
        $activeGroup = true;
419 1186
        foreach ($tokens as $condition) {
420 1186
            // OR/AND keyword
421
            [$boolean, $context] = $condition;
422
423 1194
            // first condition in group/query, no any AND, OR required
424
            if ($activeGroup) {
425 1194
                // next conditions require AND or OR
426 8
                $activeGroup = false;
427
            } else {
428
                $statement .= $boolean;
429 1186
                $statement .= ' ';
430
            }
431
432
            /*
433
             * When context is string it usually represent control keyword/syntax such as opening
434
             * or closing braces.
435 1186
             */
436
            if (\is_string($context)) {
437 1186
                if ($context === '(') {
438 1186
                    // new where group.
439
                    $activeGroup = true;
440 1186
                }
441 16
442 1170
                $statement .= $context;
443
                continue;
444
            }
445
446 1186
            if ($context instanceof FragmentInterface) {
447 308
                $statement .= $this->fragment($params, $q, $context);
448
                $statement .= ' ';
449
                continue;
450 1040
            }
451
452
            // identifier can be column name, expression or even query builder
453
            $statement .= $this->name($params, $q, $context[0]);
454 1040
            $statement .= ' ';
455 1040
            $statement .= $this->condition($params, $q, $context);
456 50
            $statement .= ' ';
457
        }
458 50
459
        $activeGroup and throw new CompilerException('Unable to build where statement, unclosed where group');
460
461
        if (trim($statement, ' ()') === '') {
462 50
            return '';
463 50
        }
464 1022
465 32
        return $statement;
466 8
    }
467 24
468 8
    /**
469
     * @psalm-return non-empty-string
470
     */
471 32
    protected function condition(QueryParameters $params, Quoter $q, array $context): string
472
    {
473 990
        $operator = $context[1];
474
        $value = $context[2];
475
476 1040
        if ($operator instanceof FragmentInterface) {
477 64
            $operator = $this->fragment($params, $q, $operator);
478
        } elseif (!\is_string($operator)) {
479
            throw new CompilerException('Invalid operator type, string or fragment is expected');
480 64
        }
481
482
        if ($value instanceof FragmentInterface) {
483 976
            return $operator . ' ' . $this->fragment($params, $q, $value);
484
        }
485
486
        if (!$value instanceof ParameterInterface) {
487
            throw new CompilerException('Invalid value format, fragment or parameter is expected');
488
        }
489
490 1560
        $placeholder = '?';
491
        if ($value->isArray()) {
492 1560
            return $this->arrayToInOperator($params, $q, $value->getValue(), match (\strtoupper($operator)) {
493 1496
                'IN', '=' => true,
494
                'NOT IN', '!=' => false,
495
                default => throw CompilerException\UnexpectedOperatorException::sequence($operator),
496 1292
            });
497 1236
        }
498
499
        if ($value->isNull()) {
500 1292
            if ($operator === '=') {
501
                $operator = 'IS';
502
            } elseif ($operator === '!=') {
503
                $operator = 'IS NOT';
504
            }
505
506
            $placeholder = 'NULL';
507
        } else {
508
            $params->push($value);
509
        }
510
511
        if ($operator === 'BETWEEN' || $operator === 'NOT BETWEEN') {
512
            $params->push($context[3]);
513
514
            // possibly support between nested queries
515
            return $operator . ' ? AND ?';
516
        }
517
518
        return $operator . ' ' . $placeholder;
519
    }
520
521
    /**
522
     * Combine expression with prefix/postfix (usually SQL keyword) but only if expression is not
523
     * empty.
524
     */
525
    protected function optional(string $prefix, string $expression, string $postfix = ''): string
526
    {
527
        if ($expression === '') {
528
            return '';
529
        }
530
531
        if ($prefix !== "\n" && $prefix !== ' ') {
532
            $prefix .= ' ';
533
        }
534
535
        return $prefix . $expression . $postfix;
536
    }
537
538
    protected function isJsonPath(string $column): bool
539
    {
540
        return \str_contains($column, '->');
541
    }
542
543
    /**
544
     * Each driver must override this method and implement sorting by JSON column.
545
     */
546
    protected function compileJsonOrderBy(string $path): string|FragmentInterface
547
    {
548
        return $path;
549
    }
550
551
    private function arrayToInOperator(QueryParameters $params, Quoter $q, array $values, bool $in): string
552
    {
553
        $operator = $in ? 'IN' : 'NOT IN';
554
555
        $placeholders = $simpleParams = [];
556
        foreach ($values as $value) {
557
            if ($value instanceof FragmentInterface) {
558
                $placeholders[] = $this->fragment($params, $q, $value);
559
            } else {
560
                $placeholders[] = '?';
561
                $simpleParams[] = $value;
562
            }
563
        }
564
        if ($simpleParams !== []) {
565
            $params->push(new Parameter($simpleParams));
566
        }
567
568
        return \sprintf('%s(%s)', $operator, \implode(',', $placeholders));
569
    }
570
}
571