Passed
Push — master ( 2eb63c...789523 )
by Sergei
02:26
created

AbstractDQLQueryBuilder::buildGroupBy()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 17
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 8
nc 5
nop 2
dl 0
loc 17
rs 9.6111
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\QueryBuilder;
6
7
use Yiisoft\Db\Command\Param;
8
use Yiisoft\Db\Command\ParamBuilder;
9
use Yiisoft\Db\Exception\Exception;
10
use Yiisoft\Db\Exception\InvalidArgumentException;
11
use Yiisoft\Db\Exception\InvalidConfigException;
12
use Yiisoft\Db\Exception\NotSupportedException;
13
use Yiisoft\Db\Expression\Expression;
14
use Yiisoft\Db\Expression\ExpressionBuilderInterface;
15
use Yiisoft\Db\Expression\ExpressionInterface;
16
use Yiisoft\Db\QueryBuilder\Condition\HashCondition;
17
use Yiisoft\Db\QueryBuilder\Condition\Interface\ConditionInterface;
18
use Yiisoft\Db\QueryBuilder\Condition\SimpleCondition;
19
use Yiisoft\Db\Query\Query;
20
use Yiisoft\Db\Query\QueryExpressionBuilder;
21
use Yiisoft\Db\Query\QueryInterface;
22
use Yiisoft\Db\Schema\QuoterInterface;
23
24
use function array_filter;
25
use function array_merge;
26
use function array_shift;
27
use function ctype_digit;
28
use function implode;
29
use function is_array;
30
use function is_int;
31
use function is_string;
32
use function ltrim;
33
use function preg_match;
34
use function preg_split;
35
use function reset;
36
use function strtoupper;
37
use function trim;
38
39
/**
40
 * It's used to query data from a database.
41
 *
42
 * @link https://en.wikipedia.org/wiki/Data_query_language
43
 */
44
abstract class AbstractDQLQueryBuilder implements DQLQueryBuilderInterface
45
{
46
    protected string $separator = ' ';
47
    /**
48
     * @var array Map of condition aliases to condition classes. For example:
49
     *
50
     * ```php
51
     * return [
52
     *     'LIKE' => \Yiisoft\Db\Condition\LikeCondition::class,
53
     * ];
54
     * ```
55
     *
56
     * This property is used by {@see createConditionFromArray} method.
57
     *
58
     * See default condition classes list in {@see defaultConditionClasses()} method.
59
     *
60
     * In case you want to add custom conditions support, use the {@see setConditionClasses()} method.
61
     *
62
     * @see setConditonClasses()
63
     * @see defaultConditionClasses()
64
     */
65
    protected array $conditionClasses = [];
66
    /**
67
     * @var array Map of expression aliases to expression classes.
68
     *
69
     * For example:
70
     *
71
     * ```php
72
     * [
73
     *    Expression::class => ExpressionBuilder::class
74
     * ]
75
     * ```
76
     * This property is mainly used by {@see buildExpression()} to build SQL expressions form expression objects.
77
     * See default values in {@see defaultExpressionBuilders()} method.
78
     *
79
     * {@see setExpressionBuilders()}
80
     * {@see defaultExpressionBuilders()}
81
     *
82
     * @psalm-var array<string, class-string<ExpressionBuilderInterface>>
83
     */
84
    protected array $expressionBuilders = [];
85
86
    public function __construct(
87
        protected QueryBuilderInterface $queryBuilder,
88
        private QuoterInterface $quoter
89
    ) {
90
        $this->expressionBuilders = $this->defaultExpressionBuilders();
91
        $this->conditionClasses = $this->defaultConditionClasses();
92
    }
93
94
    public function build(QueryInterface $query, array $params = []): array
95
    {
96
        $query = $query->prepare($this->queryBuilder);
97
        $params = empty($params) ? $query->getParams() : array_merge($params, $query->getParams());
98
        $clauses = [
99
            $this->buildSelect($query->getSelect(), $params, $query->getDistinct(), $query->getSelectOption()),
100
            $this->buildFrom($query->getFrom(), $params),
101
            $this->buildJoin($query->getJoins(), $params),
102
            $this->buildWhere($query->getWhere(), $params),
103
            $this->buildGroupBy($query->getGroupBy(), $params),
104
            $this->buildHaving($query->getHaving(), $params),
105
        ];
106
        $sql = implode($this->separator, array_filter($clauses));
107
        $sql = $this->buildOrderByAndLimit($sql, $query->getOrderBy(), $query->getLimit(), $query->getOffset(), $params);
108
109
        $union = $this->buildUnion($query->getUnions(), $params);
110
111
        if ($union !== '') {
112
            $sql = "($sql)$this->separator$union";
113
        }
114
115
        $with = $this->buildWithQueries($query->getWithQueries(), $params);
116
117
        if ($with !== '') {
118
            $sql = "$with$this->separator$sql";
119
        }
120
121
        return [$sql, $params];
122
    }
123
124
    public function buildColumns(array|string $columns): string
125
    {
126
        if (!is_array($columns)) {
0 ignored issues
show
introduced by
The condition is_array($columns) is always true.
Loading history...
127
            if (str_contains($columns, '(')) {
128
                return $columns;
129
            }
130
131
            $columns = preg_split('/\s*,\s*/', $columns, -1, PREG_SPLIT_NO_EMPTY);
132
        }
133
134
        /** @psalm-var array<array-key, ExpressionInterface|string> $columns */
135
        foreach ($columns as $i => $column) {
136
            if ($column instanceof ExpressionInterface) {
137
                $columns[$i] = $this->buildExpression($column);
138
            } elseif (!str_contains($column, '(')) {
139
                $columns[$i] = $this->quoter->quoteColumnName($column);
140
            }
141
        }
142
143
        /** @psalm-var string[] $columns */
144
        return implode(', ', $columns);
145
    }
146
147
    public function buildCondition(array|string|ExpressionInterface|null $condition, array &$params = []): string
148
    {
149
        if (empty($condition)) {
150
            if ($condition === '0') {
151
                return '0';
152
            }
153
154
            return '';
155
        }
156
157
        if (is_array($condition)) {
0 ignored issues
show
introduced by
The condition is_array($condition) is always true.
Loading history...
158
            $condition = $this->createConditionFromArray($condition);
159
        } elseif (is_string($condition)) {
160
            $condition = new Expression($condition, $params);
161
            $params = [];
162
        }
163
164
        return $this->buildExpression($condition, $params);
165
    }
166
167
    public function buildExpression(ExpressionInterface $expression, array &$params = []): string
168
    {
169
        $builder = $this->queryBuilder->getExpressionBuilder($expression);
170
        /** @psalm-suppress MixedMethodCall */
171
        return (string) $builder->build($expression, $params);
172
    }
173
174
    public function buildFrom(array|null $tables, array &$params): string
175
    {
176
        if (empty($tables)) {
177
            return '';
178
        }
179
180
        /** @psalm-var string[] $tables */
181
        $tables = $this->quoteTableNames($tables, $params);
182
183
        return 'FROM ' . implode(', ', $tables);
184
    }
185
186
    public function buildGroupBy(array $columns, array &$params = []): string
187
    {
188
        if (empty($columns)) {
189
            return '';
190
        }
191
192
        /** @psalm-var array<string, ExpressionInterface|string> $columns */
193
        foreach ($columns as $i => $column) {
194
            if ($column instanceof ExpressionInterface) {
195
                $columns[$i] = $this->buildExpression($column, $params);
196
            } elseif (!str_contains($column, '(')) {
197
                $columns[$i] = $this->quoter->quoteColumnName($column);
198
            }
199
        }
200
201
        /** @psalm-var array<string, Expression|string> $columns */
202
        return 'GROUP BY ' . implode(', ', $columns);
203
    }
204
205
    public function buildHaving(array|ExpressionInterface|string|null $condition, array &$params = []): string
206
    {
207
        $having = $this->buildCondition($condition, $params);
208
209
        return ($having === '') ? '' : ('HAVING ' . $having);
210
    }
211
212
    public function buildJoin(array $joins, array &$params): string
213
    {
214
        if (empty($joins)) {
215
            return '';
216
        }
217
218
        /**
219
         * @psalm-var array<
220
         *   array-key,
221
         *   array{
222
         *     0?:string,
223
         *     1?:array<array-key, Query|string>|string,
224
         *     2?:array|ExpressionInterface|string|null
225
         *   }|null
226
         * > $joins
227
         */
228
        foreach ($joins as $i => $join) {
229
            if (!is_array($join) || !isset($join[0], $join[1])) {
230
                throw new Exception(
231
                    'A join clause must be specified as an array of join type, join table, and optionally join '
232
                    . 'condition.'
233
                );
234
            }
235
236
            /* 0:join type, 1:join table, 2:on-condition (optional) */
237
            [$joinType, $table] = $join;
238
239
            $tables = $this->quoteTableNames((array) $table, $params);
240
241
            /** @var string $table */
242
            $table = reset($tables);
243
            $joins[$i] = "$joinType $table";
244
245
            if (isset($join[2])) {
246
                $condition = $this->buildCondition($join[2], $params);
247
                if ($condition !== '') {
248
                    $joins[$i] .= ' ON ' . $condition;
249
                }
250
            }
251
        }
252
253
        /** @psalm-var array<string> $joins */
254
        return implode($this->separator, $joins);
255
    }
256
257
    public function buildLimit(ExpressionInterface|int|null $limit, ExpressionInterface|int|null $offset): string
258
    {
259
        $sql = '';
260
261
        if ($this->hasLimit($limit)) {
262
            $sql = 'LIMIT ' . ($limit instanceof ExpressionInterface ? $this->buildExpression($limit) : (string) $limit);
263
        }
264
265
        if ($this->hasOffset($offset)) {
266
            $sql .= ' OFFSET ' . ($offset instanceof ExpressionInterface ? $this->buildExpression($offset) : (string) $offset);
267
        }
268
269
        return ltrim($sql);
270
    }
271
272
    public function buildOrderBy(array $columns, array &$params = []): string
273
    {
274
        if (empty($columns)) {
275
            return '';
276
        }
277
278
        $orders = [];
279
280
        /** @psalm-var array<string, ExpressionInterface|int|string> $columns */
281
        foreach ($columns as $name => $direction) {
282
            if ($direction instanceof ExpressionInterface) {
283
                $orders[] = $this->buildExpression($direction, $params);
284
            } else {
285
                $orders[] = $this->quoter->quoteColumnName($name) . ($direction === SORT_DESC ? ' DESC' : '');
286
            }
287
        }
288
289
        return 'ORDER BY ' . implode(', ', $orders);
290
    }
291
292
    public function buildOrderByAndLimit(
293
        string $sql,
294
        array $orderBy,
295
        ExpressionInterface|int|null $limit,
296
        ExpressionInterface|int|null $offset,
297
        array &$params = []
298
    ): string {
299
        $orderBy = $this->buildOrderBy($orderBy, $params);
300
        if ($orderBy !== '') {
301
            $sql .= $this->separator . $orderBy;
302
        }
303
        $limit = $this->buildLimit($limit, $offset);
304
        if ($limit !== '') {
305
            $sql .= $this->separator . $limit;
306
        }
307
308
        return $sql;
309
    }
310
311
    public function buildSelect(
312
        array $columns,
313
        array &$params,
314
        bool|null $distinct = false,
315
        string $selectOption = null
316
    ): string {
317
        $select = $distinct ? 'SELECT DISTINCT' : 'SELECT';
318
319
        if ($selectOption !== null) {
320
            $select .= ' ' . $selectOption;
321
        }
322
323
        if (empty($columns)) {
324
            return $select . ' *';
325
        }
326
327
        /** @psalm-var array<array-key, ExpressionInterface|string> $columns */
328
        foreach ($columns as $i => $column) {
329
            if ($column instanceof ExpressionInterface) {
330
                if (is_int($i)) {
331
                    $columns[$i] = $this->buildExpression($column, $params);
332
                } else {
333
                    $columns[$i] = $this->buildExpression($column, $params) . ' AS '
334
                        . $this->quoter->quoteColumnName($i);
335
                }
336
            } elseif (is_string($i) && $i !== $column) {
337
                if (!str_contains($column, '(')) {
338
                    $column = $this->quoter->quoteColumnName($column);
339
                }
340
                $columns[$i] = "$column AS " . $this->quoter->quoteColumnName($i);
341
            } elseif (!str_contains($column, '(')) {
342
                if (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_.]+)$/', $column, $matches)) {
343
                    $columns[$i] = $this->quoter->quoteColumnName($matches[1])
344
                        . ' AS ' . $this->quoter->quoteColumnName($matches[2]);
345
                } else {
346
                    $columns[$i] = $this->quoter->quoteColumnName($column);
347
                }
348
            }
349
        }
350
351
        /** @psalm-var array<string, Expression|string> $columns */
352
        return $select . ' ' . implode(', ', $columns);
353
    }
354
355
    public function buildUnion(array $unions, array &$params): string
356
    {
357
        if (empty($unions)) {
358
            return '';
359
        }
360
361
        $result = '';
362
363
        /** @psalm-var array<array{query:string|Query, all:bool}> $unions */
364
        foreach ($unions as $union) {
365
            if ($union['query'] instanceof QueryInterface) {
366
                [$union['query'], $params] = $this->build($union['query'], $params);
367
            }
368
369
            $result .= 'UNION ' . ($union['all'] ? 'ALL ' : '') . '( ' . $union['query'] . ' ) ';
370
        }
371
372
        return trim($result);
373
    }
374
375
    public function buildWhere(
376
        array|string|ConditionInterface|ExpressionInterface|null $condition,
377
        array &$params = []
378
    ): string {
379
        $where = $this->buildCondition($condition, $params);
380
        return ($where === '') ? '' : ('WHERE ' . $where);
381
    }
382
383
    public function buildWithQueries(array $withs, array &$params): string
384
    {
385
        if (empty($withs)) {
386
            return '';
387
        }
388
389
        $recursive = false;
390
        $result = [];
391
392
        /** @psalm-var array{query:string|Query, alias:ExpressionInterface|string, recursive:bool}[] $withs */
393
        foreach ($withs as $with) {
394
            if ($with['recursive']) {
395
                $recursive = true;
396
            }
397
398
            if ($with['query'] instanceof QueryInterface) {
399
                [$with['query'], $params] = $this->build($with['query'], $params);
400
            }
401
402
            $quotedAlias = $this->quoteCteAlias($with['alias']);
403
404
            $result[] = $quotedAlias . ' AS (' . $with['query'] . ')';
405
        }
406
407
        return 'WITH ' . ($recursive ? 'RECURSIVE ' : '') . implode(', ', $result);
408
    }
409
410
    public function createConditionFromArray(array $condition): ConditionInterface
411
    {
412
        /** operator format: operator, operand 1, operand 2, ... */
413
        if (isset($condition[0])) {
414
            $operator = strtoupper((string) array_shift($condition));
415
416
            /** @var string $className */
417
            $className = $this->conditionClasses[$operator] ?? SimpleCondition::class;
418
419
            /** @var ConditionInterface $className */
420
            return $className::fromArrayDefinition($operator, $condition);
421
        }
422
423
        /** hash format: 'column1' => 'value1', 'column2' => 'value2', ... */
424
        return new HashCondition($condition);
425
    }
426
427
    public function getExpressionBuilder(ExpressionInterface $expression): object
428
    {
429
        $className = $expression::class;
430
431
        if (!isset($this->expressionBuilders[$className])) {
432
            throw new InvalidArgumentException(
433
                'Expression of class ' . $className . ' can not be built in ' . static::class
434
            );
435
        }
436
437
        return new $this->expressionBuilders[$className]($this->queryBuilder);
438
    }
439
440
    public function selectExists(string $rawSql): string
441
    {
442
        return 'SELECT EXISTS(' . $rawSql . ')';
443
    }
444
445
    public function setConditionClasses(array $classes): void
446
    {
447
        $this->conditionClasses = array_merge($this->conditionClasses, $classes);
448
    }
449
450
    public function setExpressionBuilders(array $builders): void
451
    {
452
        $this->expressionBuilders = array_merge($this->expressionBuilders, $builders);
453
    }
454
455
    /**
456
     * @param string $separator The separator between different fragments of an SQL statement.
457
     *
458
     * Defaults to an empty space. This is mainly used by {@see build()} when generating a SQL statement.
459
     */
460
    public function setSeparator(string $separator): void
461
    {
462
        $this->separator = $separator;
463
    }
464
465
    /**
466
     * Has an array of default condition classes.
467
     *
468
     * Extend this method if you want to change default condition classes for the query builder.
469
     *
470
     * See {@see conditionClasses} docs for details.
471
     */
472
    protected function defaultConditionClasses(): array
473
    {
474
        return [
475
            'NOT' => Condition\NotCondition::class,
476
            'AND' => Condition\AndCondition::class,
477
            'OR' => Condition\OrCondition::class,
478
            'BETWEEN' => Condition\BetweenCondition::class,
479
            'NOT BETWEEN' => Condition\BetweenCondition::class,
480
            'IN' => Condition\InCondition::class,
481
            'NOT IN' => Condition\InCondition::class,
482
            'LIKE' => Condition\LikeCondition::class,
483
            'NOT LIKE' => Condition\LikeCondition::class,
484
            'OR LIKE' => Condition\LikeCondition::class,
485
            'OR NOT LIKE' => Condition\LikeCondition::class,
486
            'EXISTS' => Condition\ExistsCondition::class,
487
            'NOT EXISTS' => Condition\ExistsCondition::class,
488
        ];
489
    }
490
491
    /**
492
     * Has an array of default expression builders.
493
     *
494
     * Extend this method and override it if you want to change default expression builders for this query builder.
495
     *
496
     * See {@see expressionBuilders} docs for details.
497
     *
498
     * @psalm-return array<string, class-string<ExpressionBuilderInterface>>
499
     */
500
    protected function defaultExpressionBuilders(): array
501
    {
502
        return [
503
            Query::class => QueryExpressionBuilder::class,
504
            Param::class => ParamBuilder::class,
505
            Condition\AbstractConjunctionCondition::class => Condition\Builder\ConjunctionConditionBuilder::class,
506
            Condition\NotCondition::class => Condition\Builder\NotConditionBuilder::class,
507
            Condition\AndCondition::class => Condition\Builder\ConjunctionConditionBuilder::class,
508
            Condition\OrCondition::class => Condition\Builder\ConjunctionConditionBuilder::class,
509
            Condition\BetweenCondition::class => Condition\Builder\BetweenConditionBuilder::class,
510
            Condition\InCondition::class => Condition\Builder\InConditionBuilder::class,
511
            Condition\LikeCondition::class => Condition\Builder\LikeConditionBuilder::class,
512
            Condition\ExistsCondition::class => Condition\Builder\ExistsConditionBuilder::class,
513
            Condition\SimpleCondition::class => Condition\Builder\SimpleConditionBuilder::class,
514
            Condition\HashCondition::class => Condition\Builder\HashConditionBuilder::class,
515
            Condition\BetweenColumnsCondition::class => Condition\Builder\BetweenColumnsConditionBuilder::class,
516
        ];
517
    }
518
519
    /**
520
     * Extracts table alias if there is one or returns false.
521
     *
522
     * @psalm-return string[]|bool
523
     */
524
    protected function extractAlias(string $table): array|bool
525
    {
526
        if (preg_match('/^(.*?)(?i:\s+as|)\s+([^ ]+)$/', $table, $matches)) {
527
            return $matches;
528
        }
529
530
        return false;
531
    }
532
533
    /**
534
     * Checks to see if the given limit is effective.
535
     *
536
     * @param mixed $limit The given limit.
537
     *
538
     * @return bool Whether the limit is effective.
539
     */
540
    protected function hasLimit(mixed $limit): bool
541
    {
542
        return ($limit instanceof ExpressionInterface) || ctype_digit((string) $limit);
543
    }
544
545
    /**
546
     * Checks to see if the given offset is effective.
547
     *
548
     * @param mixed $offset The given offset.
549
     *
550
     * @return bool Whether the offset is effective.
551
     */
552
    protected function hasOffset(mixed $offset): bool
553
    {
554
        return ($offset instanceof ExpressionInterface) || (ctype_digit((string)$offset) && (string)$offset !== '0');
555
    }
556
557
    /**
558
     * @throws Exception
559
     * @throws InvalidConfigException
560
     * @throws NotSupportedException
561
     *
562
     * @return array The list of table names with quote.
563
     */
564
    private function quoteTableNames(array $tables, array &$params): array
565
    {
566
        /** @psalm-var array<array-key, array|QueryInterface|string> $tables */
567
        foreach ($tables as $i => $table) {
568
            if ($table instanceof QueryInterface) {
569
                [$sql, $params] = $this->build($table, $params);
570
                $tables[$i] = "($sql) " . $this->quoter->quoteTableName((string) $i);
571
            } elseif (is_string($table) && is_string($i)) {
572
                if (!str_contains($table, '(')) {
573
                    $table = $this->quoter->quoteTableName($table);
574
                }
575
                $tables[$i] = "$table " . $this->quoter->quoteTableName($i);
576
            } elseif ($table instanceof ExpressionInterface && is_string($i)) {
577
                $table = $this->buildExpression($table, $params);
578
                $tables[$i] = "$table " . $this->quoter->quoteTableName($i);
579
            } elseif (is_string($table) && !str_contains($table, '(')) {
580
                $tableWithAlias = $this->extractAlias($table);
581
                if (is_array($tableWithAlias)) { // with alias
582
                    $tables[$i] = $this->quoter->quoteTableName($tableWithAlias[1]) . ' '
583
                        . $this->quoter->quoteTableName($tableWithAlias[2]);
584
                } else {
585
                    $tables[$i] = $this->quoter->quoteTableName($table);
586
                }
587
            }
588
        }
589
590
        return $tables;
591
    }
592
593
    /**
594
     * Quotes an alias of Common Table Expressions (CTE)
595
     *
596
     * @param ExpressionInterface|string $name The alias name with or without column names to quote.
597
     *
598
     * @return string The quoted alias.
599
     */
600
    private function quoteCteAlias(ExpressionInterface|string $name): string
601
    {
602
        if ($name instanceof ExpressionInterface) {
603
            return $this->buildExpression($name);
604
        }
605
606
        if (!str_contains($name, '(')) {
607
            return $this->quoter->quoteTableName($name);
608
        }
609
610
        if (!str_ends_with($name, ')')) {
611
            return $name;
612
        }
613
614
        /** @psalm-suppress PossiblyUndefinedArrayOffset */
615
        [$name, $columns] = explode('(', substr($name, 0, -1), 2);
616
        $name = trim($name);
617
618
        return $this->quoter->quoteTableName($name) . '(' . $this->buildColumns($columns) . ')';
619
    }
620
}
621