Issues (43)

src/QueryBuilder/AbstractDQLQueryBuilder.php (2 issues)

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