Passed
Pull Request — master (#460)
by Wilmer
04:14 queued 01:49
created

AbstractDQLQueryBuilder::buildExpression()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 2
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 2
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
use Yiisoft\Db\Schema\SchemaInterface;
25
26
use function array_filter;
27
use function array_merge;
28
use function array_shift;
29
use function ctype_digit;
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
abstract class AbstractDQLQueryBuilder implements DQLQueryBuilderInterface
42
{
43
    protected string $separator = ' ';
44
45
    /**
46
     * @var array map of condition aliases to condition classes. For example:
47
     *
48
     * ```php
49
     * return [
50
     *     'LIKE' => \Yiisoft\Db\Condition\LikeCondition::class,
51
     * ];
52
     * ```
53
     *
54
     * This property is used by {@see createConditionFromArray} method.
55
     * See default condition classes list in {@see defaultConditionClasses()} method.
56
     *
57
     * In case you want to add custom conditions support, use the {@see setConditionClasses()} method.
58
     *
59
     * @see setConditonClasses()
60
     * @see defaultConditionClasses()
61
     */
62
    protected array $conditionClasses = [];
63
64
    /**
65
     * @psalm-var array<string, class-string<ExpressionBuilderInterface>> maps expression class to expression builder
66
     * class.
67
     *
68
     * For example:
69
     *
70
     * ```php
71
     * [
72
     *    Expression::class => ExpressionBuilder::class
73
     * ]
74
     * ```
75
     * This property is mainly used by {@see buildExpression()} to build SQL expressions form expression objects.
76
     * See default values in {@see defaultExpressionBuilders()} method.
77
     *
78
     * {@see setExpressionBuilders()}
79
     * {@see defaultExpressionBuilders()}
80
     */
81
    protected array $expressionBuilders = [];
82
83
    public function __construct(
84
        private QueryBuilderInterface $queryBuilder,
85
        private QuoterInterface $quoter,
86
        private SchemaInterface $schema
87
    ) {
88
        $this->expressionBuilders = $this->defaultExpressionBuilders();
89
        $this->conditionClasses = $this->defaultConditionClasses();
90
    }
91
92
    public function build(QueryInterface $query, array $params = []): array
93
    {
94
        $query = $query->prepare($this->queryBuilder);
95
        $params = empty($params) ? $query->getParams() : array_merge($params, $query->getParams());
96
        $clauses = [
97
            $this->buildSelect($query->getSelect(), $params, $query->getDistinct(), $query->getSelectOption()),
98
            $this->buildFrom($query->getFrom(), $params),
99
            $this->buildJoin($query->getJoin(), $params),
100
            $this->buildWhere($query->getWhere(), $params),
101
            $this->buildGroupBy($query->getGroupBy(), $params),
102
            $this->buildHaving($query->getHaving(), $params),
103
        ];
104
        $sql = implode($this->separator, array_filter($clauses));
105
        $sql = $this->buildOrderByAndLimit($sql, $query->getOrderBy(), $query->getLimit(), $query->getOffset());
106
107
        if (!empty($query->getOrderBy())) {
108
            /** @psalm-var array<string, ExpressionInterface|string> */
109
            foreach ($query->getOrderBy() as $expression) {
110
                if ($expression instanceof ExpressionInterface) {
111
                    $this->buildExpression($expression, $params);
112
                }
113
            }
114
        }
115
116
        if (!empty($query->getGroupBy())) {
117
            /** @psalm-var array<string, ExpressionInterface|string> */
118
            foreach ($query->getGroupBy() as $expression) {
119
                if ($expression instanceof ExpressionInterface) {
120
                    $this->buildExpression($expression, $params);
121
                }
122
            }
123
        }
124
125
        $union = $this->buildUnion($query->getUnion(), $params);
126
127
        if ($union !== '') {
128
            $sql = "($sql)$this->separator$union";
129
        }
130
131
        $with = $this->buildWithQueries($query->getWithQueries(), $params);
132
133
        if ($with !== '') {
134
            $sql = "$with$this->separator$sql";
135
        }
136
137
        return [$sql, $params];
138
    }
139
140
    public function buildColumns(array|string $columns): string
141
    {
142
        if (!is_array($columns)) {
0 ignored issues
show
introduced by
The condition is_array($columns) is always true.
Loading history...
143
            if (str_contains($columns, '(')) {
144
                return $columns;
145
            }
146
147
            $rawColumns = $columns;
148
            $columns = preg_split('/\s*,\s*/', $columns, -1, PREG_SPLIT_NO_EMPTY);
149
150
            if ($columns === false) {
151
                throw new InvalidArgumentException("$rawColumns is not valid columns.");
152
            }
153
        }
154
155
        /** @psalm-var array<array-key, ExpressionInterface|string> $columns */
156
        foreach ($columns as $i => $column) {
157
            if ($column instanceof ExpressionInterface) {
158
                $columns[$i] = $this->buildExpression($column);
159
            } elseif (!str_contains($column, '(')) {
160
                $columns[$i] = $this->quoter->quoteColumnName($column);
161
            }
162
        }
163
164
        /** @psalm-var string[] $columns */
165
        return implode(', ', $columns);
166
    }
167
168
    public function buildCondition(array|string|ExpressionInterface|null $condition, array &$params = []): string
169
    {
170
        if (is_array($condition)) {
0 ignored issues
show
introduced by
The condition is_array($condition) is always true.
Loading history...
171
            if (empty($condition)) {
172
                return '';
173
            }
174
175
            $condition = $this->createConditionFromArray($condition);
176
        }
177
178
        if ($condition instanceof ExpressionInterface) {
0 ignored issues
show
introduced by
$condition is always a sub-type of Yiisoft\Db\Expression\ExpressionInterface.
Loading history...
179
            return $this->buildExpression($condition, $params);
180
        }
181
182
        return $condition ?? '';
183
    }
184
185
    /**
186
     * @throws InvalidArgumentException
187
     *
188
     * @psalm-suppress UndefinedInterfaceMethod
189
     * @psalm-suppress MixedMethodCall
190
     */
191
    public function buildExpression(ExpressionInterface $expression, array &$params = []): string
192
    {
193
        $builder = $this->queryBuilder->getExpressionBuilder($expression);
194
        return (string) $builder->build($expression, $params);
195
    }
196
197
    public function buildFrom(array|null $tables, array &$params): string
198
    {
199
        if (empty($tables)) {
200
            return '';
201
        }
202
203
        /** @psalm-var string[] */
204
        $tables = $this->quoteTableNames($tables, $params);
205
206
        return 'FROM ' . implode(', ', $tables);
207
    }
208
209
    public function buildGroupBy(array $columns, array &$params = []): string
210
    {
211
        if (empty($columns)) {
212
            return '';
213
        }
214
215
        /** @psalm-var array<string, Expression|string> $columns */
216
        foreach ($columns as $i => $column) {
217
            if ($column instanceof Expression) {
218
                $columns[$i] = $this->buildExpression($column);
219
                $params = array_merge($params, $column->getParams());
220
            } elseif (!str_contains($column, '(')) {
221
                $columns[$i] = $this->quoter->quoteColumnName($column);
222
            }
223
        }
224
225
        return 'GROUP BY ' . implode(', ', $columns);
226
    }
227
228
    public function buildHaving(array|ExpressionInterface|string|null $condition, array &$params = []): string
229
    {
230
        $having = $this->buildCondition($condition, $params);
231
232
        return ($having === '') ? '' : ('HAVING ' . $having);
233
    }
234
235
    public function buildJoin(array $joins, array &$params): string
236
    {
237
        if (empty($joins)) {
238
            return '';
239
        }
240
241
        /**
242
         * @psalm-var array<
243
         *   array-key,
244
         *   array{
245
         *     0?:string,
246
         *     1?:array<array-key, Query|string>|string,
247
         *     2?:array|ExpressionInterface|string|null
248
         *   }|null
249
         * > $joins
250
         */
251
        foreach ($joins as $i => $join) {
252
            if (!is_array($join) || !isset($join[0], $join[1])) {
253
                throw new Exception(
254
                    'A join clause must be specified as an array of join type, join table, and optionally join '
255
                    . 'condition.'
256
                );
257
            }
258
259
            /* 0:join type, 1:join table, 2:on-condition (optional) */
260
            [$joinType, $table] = $join;
261
262
            $tables = $this->quoteTableNames((array) $table, $params);
263
264
            /** @var string $table */
265
            $table = reset($tables);
266
            $joins[$i] = "$joinType $table";
267
268
            if (isset($join[2])) {
269
                $condition = $this->buildCondition($join[2], $params);
270
                if ($condition !== '') {
271
                    $joins[$i] .= ' ON ' . $condition;
272
                }
273
            }
274
        }
275
276
        /** @psalm-var array<string> $joins */
277
        return implode($this->separator, $joins);
278
    }
279
280
    public function buildLimit(Expression|int|null $limit, Expression|int|null $offset): string
281
    {
282
        $sql = '';
283
284
        if ($this->hasLimit($limit)) {
285
            $sql = 'LIMIT ' . (string) $limit;
286
        }
287
288
        if ($this->hasOffset($offset)) {
289
            $sql .= ' OFFSET ' . (string) $offset;
290
        }
291
292
        return ltrim($sql);
293
    }
294
295
    public function buildOrderBy(array $columns, array &$params = []): string
296
    {
297
        if (empty($columns)) {
298
            return '';
299
        }
300
301
        $orders = [];
302
303
        /** @psalm-var array<string, Expression|int|string> $columns */
304
        foreach ($columns as $name => $direction) {
305
            if ($direction instanceof Expression) {
306
                $orders[] = $this->buildExpression($direction);
307
                $params = array_merge($params, $direction->getParams());
308
            } else {
309
                $orders[] = $this->quoter->quoteColumnName($name) . ($direction === SORT_DESC ? ' DESC' : '');
310
            }
311
        }
312
313
        return 'ORDER BY ' . implode(', ', $orders);
314
    }
315
316
    public function buildOrderByAndLimit(
317
        string $sql,
318
        array $orderBy,
319
        Expression|int|null $limit,
320
        Expression|int|null $offset,
321
        array &$params = []
322
    ): string {
323
        $orderBy = $this->buildOrderBy($orderBy, $params);
324
        if ($orderBy !== '') {
325
            $sql .= $this->separator . $orderBy;
326
        }
327
        $limit = $this->buildLimit($limit, $offset);
328
        if ($limit !== '') {
329
            $sql .= $this->separator . $limit;
330
        }
331
332
        return $sql;
333
    }
334
335
    public function buildSelect(
336
        array $columns,
337
        array &$params,
338
        bool|null $distinct = false,
339
        string $selectOption = null
340
    ): string {
341
        $select = $distinct ? 'SELECT DISTINCT' : 'SELECT';
342
343
        if ($selectOption !== null) {
344
            $select .= ' ' . $selectOption;
345
        }
346
347
        if (empty($columns)) {
348
            return $select . ' *';
349
        }
350
351
        /** @psalm-var array<array-key, ExpressionInterface|string> $columns */
352
        foreach ($columns as $i => $column) {
353
            if ($column instanceof ExpressionInterface) {
354
                if (is_int($i)) {
355
                    $columns[$i] = $this->buildExpression($column, $params);
356
                } else {
357
                    $columns[$i] = $this->buildExpression($column, $params) . ' AS '
358
                        . $this->quoter->quoteColumnName($i);
359
                }
360
            } elseif (is_string($i) && $i !== $column) {
361
                if (!str_contains($column, '(')) {
362
                    $column = $this->quoter->quoteColumnName($column);
363
                }
364
                $columns[$i] = "$column AS " . $this->quoter->quoteColumnName($i);
365
            } elseif (!str_contains($column, '(')) {
366
                if (preg_match('/^(.*?)(?i:\s+as\s+|\s+)([\w\-_.]+)$/', $column, $matches)) {
367
                    $columns[$i] = $this->quoter->quoteColumnName($matches[1])
368
                        . ' AS ' . $this->quoter->quoteColumnName($matches[2]);
369
                } else {
370
                    $columns[$i] = $this->quoter->quoteColumnName($column);
371
                }
372
            }
373
        }
374
375
        return $select . ' ' . implode(', ', $columns);
376
    }
377
378
    public function buildUnion(array $unions, array &$params): string
379
    {
380
        if (empty($unions)) {
381
            return '';
382
        }
383
384
        $result = '';
385
386
        /** @psalm-var array<array{query:Query|string, all:bool}> $unions */
387
        foreach ($unions as $i => $union) {
388
            $query = $union['query'];
389
            if ($query instanceof QueryInterface) {
390
                [$unions[$i]['query'], $params] = $this->build($query, $params);
391
            }
392
393
            $result .= 'UNION ' . ($union['all'] ? 'ALL ' : '') . '( ' . $unions[$i]['query'] . ' ) ';
394
        }
395
396
        return trim($result);
397
    }
398
399
    public function buildWhere(
400
        array|string|ConditionInterface|ExpressionInterface|null $condition,
401
        array &$params = []
402
    ): string {
403
        $where = $this->buildCondition($condition, $params);
404
        return ($where === '') ? '' : ('WHERE ' . $where);
405
    }
406
407
    public function buildWithQueries(array $withs, array &$params): string
408
    {
409
        if (empty($withs)) {
410
            return '';
411
        }
412
413
        $recursive = false;
414
        $result = [];
415
416
        /** @psalm-var array<array-key, array{query:string|Query, alias:string, recursive:bool}> $withs */
417
        foreach ($withs as $with) {
418
            if ($with['recursive']) {
419
                $recursive = true;
420
            }
421
422
            $query = $with['query'];
423
            if ($query instanceof QueryInterface) {
424
                [$with['query'], $params] = $this->build($query, $params);
425
            }
426
427
            $result[] = $with['alias'] . ' AS (' . $with['query'] . ')';
428
        }
429
430
        return 'WITH ' . ($recursive ? 'RECURSIVE ' : '') . implode(', ', $result);
431
    }
432
433
    public function createConditionFromArray(array $condition): ConditionInterface
434
    {
435
        /** operator format: operator, operand 1, operand 2, ... */
436
        if (isset($condition[0])) {
437
            $operator = strtoupper((string) array_shift($condition));
438
439
            /** @var string $className */
440
            $className = $this->conditionClasses[$operator] ?? SimpleCondition::class;
441
442
            /** @var ConditionInterface $className */
443
            return $className::fromArrayDefinition($operator, $condition);
444
        }
445
446
        /** hash format: 'column1' => 'value1', 'column2' => 'value2', ... */
447
        return new HashCondition($condition);
448
    }
449
450
    public function getExpressionBuilder(ExpressionInterface $expression): object
451
    {
452
        $className = $expression::class;
453
454
        if (!isset($this->expressionBuilders[$className])) {
455
            throw new InvalidArgumentException(
456
                'Expression of class ' . $className . ' can not be built in ' . static::class
457
            );
458
        }
459
460
        return new $this->expressionBuilders[$className]($this->queryBuilder);
461
    }
462
463
    public function selectExists(string $rawSql): string
464
    {
465
        return 'SELECT EXISTS(' . $rawSql . ')';
466
    }
467
468
    public function setConditionClasses(array $classes): void
469
    {
470
        $this->conditionClasses = array_merge($this->conditionClasses, $classes);
471
    }
472
473
    public function setExpressionBuilders(array $builders): void
474
    {
475
        $this->expressionBuilders = array_merge($this->expressionBuilders, $builders);
476
    }
477
478
    /**
479
     * @param string the separator between different fragments of a SQL statement.
0 ignored issues
show
Bug introduced by
The type Yiisoft\Db\QueryBuilder\the was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
480
     *
481
     * Defaults to an empty space. This is mainly used by {@see build()} when generating a SQL statement.
482
     */
483
    public function setSeparator(string $separator): void
484
    {
485
        $this->separator = $separator;
486
    }
487
488
    /**
489
     * Contains array of default condition classes. Extend this method, if you want to change default condition classes
490
     * for the query builder.
491
     *
492
     * See {@see conditionClasses} docs for details.
493
     */
494
    protected function defaultConditionClasses(): array
495
    {
496
        return [
497
            'NOT' => Condition\NotCondition::class,
498
            'AND' => Condition\AndCondition::class,
499
            'OR' => Condition\OrCondition::class,
500
            'BETWEEN' => Condition\BetweenCondition::class,
501
            'NOT BETWEEN' => Condition\BetweenCondition::class,
502
            'IN' => Condition\InCondition::class,
503
            'NOT IN' => Condition\InCondition::class,
504
            'LIKE' => Condition\LikeCondition::class,
505
            'NOT LIKE' => Condition\LikeCondition::class,
506
            'OR LIKE' => Condition\LikeCondition::class,
507
            'OR NOT LIKE' => Condition\LikeCondition::class,
508
            'EXISTS' => Condition\ExistsCondition::class,
509
            'NOT EXISTS' => Condition\ExistsCondition::class,
510
        ];
511
    }
512
513
    /**
514
     * Contains array of default expression builders. Extend this method and override it, if you want to change default
515
     * expression builders for this query builder.
516
     *
517
     * See {@see expressionBuilders} docs for details.
518
     *
519
     * @psalm-return array<string, class-string<ExpressionBuilderInterface>>
520
     */
521
    protected function defaultExpressionBuilders(): array
522
    {
523
        return [
524
            Query::class => QueryExpressionBuilder::class,
525
            Param::class => ParamBuilder::class,
526
            Expression::class => ExpressionBuilder::class,
527
            Condition\ConjunctionCondition::class => Condition\Builder\ConjunctionConditionBuilder::class,
0 ignored issues
show
Bug introduced by
The type Yiisoft\Db\QueryBuilder\...on\ConjunctionCondition was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
528
            Condition\NotCondition::class => Condition\Builder\NotConditionBuilder::class,
529
            Condition\AndCondition::class => Condition\Builder\ConjunctionConditionBuilder::class,
530
            Condition\OrCondition::class => Condition\Builder\ConjunctionConditionBuilder::class,
531
            Condition\BetweenCondition::class => Condition\Builder\BetweenConditionBuilder::class,
532
            Condition\InCondition::class => Condition\Builder\InConditionBuilder::class,
533
            Condition\LikeCondition::class => Condition\Builder\LikeConditionBuilder::class,
534
            Condition\ExistsCondition::class => Condition\Builder\ExistsConditionBuilder::class,
535
            Condition\SimpleCondition::class => Condition\Builder\SimpleConditionBuilder::class,
536
            Condition\HashCondition::class => Condition\Builder\HashConditionBuilder::class,
537
            Condition\BetweenColumnsCondition::class => Condition\Builder\BetweenColumnsConditionBuilder::class,
538
        ];
539
    }
540
541
    /**
542
     * Extracts table alias if there is one or returns false.
543
     *
544
     * @psalm-return string[]|bool
545
     */
546
    protected function extractAlias(string $table): array|bool
547
    {
548
        if (preg_match('/^(.*?)(?i:\s+as|)\s+([^ ]+)$/', $table, $matches)) {
549
            return $matches;
550
        }
551
552
        return false;
553
    }
554
555
    /**
556
     * Checks to see if the given limit is effective.
557
     *
558
     * @param mixed $limit the given limit.
559
     *
560
     * @return bool whether the limit is effective.
561
     */
562
    protected function hasLimit(mixed $limit): bool
563
    {
564
        return ($limit instanceof ExpressionInterface) || ctype_digit((string) $limit);
565
    }
566
567
    /**
568
     * Checks to see if the given offset is effective.
569
     *
570
     * @param mixed $offset the given offset.
571
     *
572
     * @return bool whether the offset is effective.
573
     */
574
    protected function hasOffset(mixed $offset): bool
575
    {
576
        return ($offset instanceof ExpressionInterface) || (ctype_digit((string)$offset) && (string)$offset !== '0');
577
    }
578
579
    /**
580
     * Quotes table names passed.
581
     *
582
     * @throws Exception|InvalidConfigException|NotSupportedException
583
     */
584
    private function quoteTableNames(array $tables, array &$params): array
585
    {
586
        /** @psalm-var array<array-key, array|QueryInterface|string> $tables */
587
        foreach ($tables as $i => $table) {
588
            if ($table instanceof QueryInterface) {
589
                [$sql, $params] = $this->build($table, $params);
590
                $tables[$i] = "($sql) " . $this->quoter->quoteTableName((string) $i);
591
            } elseif (is_string($table) && is_string($i)) {
592
                if (!str_contains($table, '(')) {
593
                    $table = $this->quoter->quoteTableName($table);
594
                }
595
                $tables[$i] = "$table " . $this->quoter->quoteTableName($i);
596
            } elseif ($table instanceof ExpressionInterface && is_string($i)) {
597
                $table = $this->buildExpression($table, $params);
598
                $tables[$i] = "$table " . $this->quoter->quoteTableName($i);
599
            } elseif (is_string($table) && !str_contains($table, '(')) {
600
                $tableWithAlias = $this->extractAlias($table);
601
                if (is_array($tableWithAlias)) { // with alias
602
                    $tables[$i] = $this->quoter->quoteTableName($tableWithAlias[1]) . ' '
603
                        . $this->quoter->quoteTableName($tableWithAlias[2]);
604
                } else {
605
                    $tables[$i] = $this->quoter->quoteTableName($table);
606
                }
607
            }
608
        }
609
610
        return $tables;
611
    }
612
}
613