Passed
Pull Request — 2.x (#184)
by Maxim
19:02
created

Compiler   F

Complexity

Total Complexity 87

Size/Duplication

Total Lines 550
Duplicated Lines 0 %

Test Coverage

Coverage 95.98%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 236
dl 0
loc 550
ccs 215
cts 224
cp 0.9598
rs 2
c 1
b 0
f 0
wmc 87

24 Methods

Rating   Name   Duplication   Size   Complexity  
A compile() 0 10 1
A quoteIdentifier() 0 3 1
A __construct() 0 3 1
A nameWithAlias() 0 16 2
A joins() 0 17 2
A name() 0 11 3
A columns() 0 11 1
A isJsonSelector() 0 3 1
A hashLimit() 0 11 3
A groupBy() 0 8 2
A orderBy() 0 26 6
A deleteQuery() 0 11 1
A selectQuery() 0 24 4
C fragment() 0 61 13
A unions() 0 20 4
A optional() 0 11 4
A arrayToInOperator() 0 18 5
A distinct() 0 3 2
A compileJsonOrderBy() 0 3 1
B where() 0 56 9
B condition() 0 48 11
A insertQuery() 0 19 3
A value() 0 22 5
A updateQuery() 0 19 2

How to fix   Complexity   

Complex Class

Complex classes like Compiler often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Compiler, and based on these observations, apply Extract Interface, too.

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

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