Passed
Pull Request — master (#806)
by Sergei
11:39
created

AbstractDQLQueryBuilder::buildOrderBy()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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