Passed
Pull Request — master (#558)
by
unknown
05:06 queued 02:01
created

Query::getTables()   C

Complexity

Conditions 14
Paths 22

Size

Total Lines 37
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 24
CRAP Score 14.0125

Importance

Changes 0
Metric Value
cc 14
eloc 24
c 0
b 0
f 0
nc 22
nop 1
dl 0
loc 37
ccs 24
cts 25
cp 0.96
crap 14.0125
rs 6.2666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace PhpMyAdmin\SqlParser\Utils;
6
7
use PhpMyAdmin\SqlParser\Lexer;
8
use PhpMyAdmin\SqlParser\Parser;
9
use PhpMyAdmin\SqlParser\Statement;
10
use PhpMyAdmin\SqlParser\Statements\AlterStatement;
11
use PhpMyAdmin\SqlParser\Statements\AnalyzeStatement;
12
use PhpMyAdmin\SqlParser\Statements\CallStatement;
13
use PhpMyAdmin\SqlParser\Statements\CheckStatement;
14
use PhpMyAdmin\SqlParser\Statements\ChecksumStatement;
15
use PhpMyAdmin\SqlParser\Statements\CreateStatement;
16
use PhpMyAdmin\SqlParser\Statements\DeleteStatement;
17
use PhpMyAdmin\SqlParser\Statements\DropStatement;
18
use PhpMyAdmin\SqlParser\Statements\ExplainStatement;
19
use PhpMyAdmin\SqlParser\Statements\InsertStatement;
20
use PhpMyAdmin\SqlParser\Statements\LoadStatement;
21
use PhpMyAdmin\SqlParser\Statements\OptimizeStatement;
22
use PhpMyAdmin\SqlParser\Statements\RenameStatement;
23
use PhpMyAdmin\SqlParser\Statements\RepairStatement;
24
use PhpMyAdmin\SqlParser\Statements\ReplaceStatement;
25
use PhpMyAdmin\SqlParser\Statements\SelectStatement;
26
use PhpMyAdmin\SqlParser\Statements\SetStatement;
27
use PhpMyAdmin\SqlParser\Statements\ShowStatement;
28
use PhpMyAdmin\SqlParser\Statements\TruncateStatement;
29
use PhpMyAdmin\SqlParser\Statements\UpdateStatement;
30
use PhpMyAdmin\SqlParser\TokensList;
31
use PhpMyAdmin\SqlParser\TokenType;
32
33
use function array_flip;
34
use function array_keys;
35
use function count;
36
use function ctype_space;
37
use function in_array;
38
use function is_string;
39
use function mb_substr;
40
use function trim;
41
42
/**
43
 * Statement utilities.
44
 */
45
class Query
46
{
47
    /**
48
     * Functions that set the flag `is_func`.
49
     *
50
     * @var string[]
51
     */
52
    public static array $functions = [
53
        'SUM',
54
        'AVG',
55
        'STD',
56
        'STDDEV',
57
        'MIN',
58
        'MAX',
59
        'BIT_OR',
60
        'BIT_AND',
61
    ];
62
63
    /**
64
     * Gets an array with flags select statement has.
65
     *
66
     * @param SelectStatement $statement the statement to be processed
67
     * @param StatementFlags  $flags     flags set so far
68
     */
69 26
    private static function getFlagsSelect(SelectStatement $statement, StatementFlags $flags): void
70
    {
71 26
        $flags->queryType = StatementType::Select;
72
        /** @psalm-suppress DeprecatedProperty */
73 26
        $flags->isSelect = true;
0 ignored issues
show
Deprecated Code introduced by
The property PhpMyAdmin\SqlParser\Uti...atementFlags::$isSelect has been deprecated: Use {@see self::$queryType} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

73
        /** @scrutinizer ignore-deprecated */ $flags->isSelect = true;

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
74
75 26
        if ($statement->from !== []) {
76 22
            $flags->selectFrom = true;
77
        }
78
79 26
        if ($statement->options->has('DISTINCT')) {
0 ignored issues
show
Bug introduced by
The method has() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

79
        if ($statement->options->/** @scrutinizer ignore-call */ has('DISTINCT')) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
80 2
            $flags->distinct = true;
81
        }
82
83 26
        if (! empty($statement->group) || ! empty($statement->having)) {
84 4
            $flags->isGroup = true;
85
        }
86
87 26
        if (! empty($statement->into) && ($statement->into->type === 'OUTFILE')) {
88 2
            $flags->isExport = true;
89
        }
90
91 26
        $expressions = $statement->expr;
92 26
        if (! empty($statement->join)) {
93 2
            foreach ($statement->join as $join) {
94 2
                $expressions[] = $join->expr;
95
            }
96
        }
97
98 26
        foreach ($expressions as $expr) {
99 26
            if (! empty($expr->function)) {
100 2
                if ($expr->function === 'COUNT') {
101 2
                    $flags->isCount = true;
102 2
                } elseif (in_array($expr->function, static::$functions)) {
103 2
                    $flags->isFunc = true;
104
                }
105
            }
106
107 26
            if (empty($expr->subquery)) {
108 24
                continue;
109
            }
110
111 2
            $flags->isSubQuery = true;
112
        }
113
114 26
        if (! empty($statement->procedure) && ($statement->procedure->name === 'ANALYSE')) {
115 2
            $flags->isAnalyse = true;
116
        }
117
118 26
        if (! empty($statement->group)) {
119 2
            $flags->group = true;
120
        }
121
122 26
        if (! empty($statement->having)) {
123 2
            $flags->having = true;
124
        }
125
126 26
        if ($statement->union !== []) {
127 2
            $flags->union = true;
128
        }
129
130 26
        if (empty($statement->join)) {
131 24
            return;
132
        }
133
134 2
        $flags->join = true;
135
    }
136
137
    /**
138
     * Gets an array with flags this statement has.
139
     *
140
     * @param Statement|null $statement the statement to be processed
141
     */
142 64
    public static function getFlags(Statement|null $statement): StatementFlags
143
    {
144 64
        $flags = new StatementFlags();
145
146 64
        if ($statement instanceof AlterStatement) {
147 2
            $flags->queryType = StatementType::Alter;
148 2
            $flags->reload = true;
149 62
        } elseif ($statement instanceof CreateStatement) {
150 2
            $flags->queryType = StatementType::Create;
151 2
            $flags->reload = true;
152 60
        } elseif ($statement instanceof AnalyzeStatement) {
153 2
            $flags->queryType = StatementType::Analyze;
154 2
            $flags->isMaint = true;
155 58
        } elseif ($statement instanceof CheckStatement) {
156 2
            $flags->queryType = StatementType::Check;
157 2
            $flags->isMaint = true;
158 56
        } elseif ($statement instanceof ChecksumStatement) {
159 2
            $flags->queryType = StatementType::Checksum;
160 2
            $flags->isMaint = true;
161 54
        } elseif ($statement instanceof OptimizeStatement) {
162 2
            $flags->queryType = StatementType::Optimize;
163 2
            $flags->isMaint = true;
164 52
        } elseif ($statement instanceof RepairStatement) {
165 2
            $flags->queryType = StatementType::Repair;
166 2
            $flags->isMaint = true;
167 50
        } elseif ($statement instanceof CallStatement) {
168 2
            $flags->queryType = StatementType::Call;
169 2
            $flags->isProcedure = true;
170 48
        } elseif ($statement instanceof DeleteStatement) {
171 2
            $flags->queryType = StatementType::Delete;
172
            /** @psalm-suppress DeprecatedProperty */
173 2
            $flags->isDelete = true;
0 ignored issues
show
Deprecated Code introduced by
The property PhpMyAdmin\SqlParser\Uti...atementFlags::$isDelete has been deprecated: Use {@see self::$queryType} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

173
            /** @scrutinizer ignore-deprecated */ $flags->isDelete = true;

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
174 2
            $flags->isAffected = true;
175 46
        } elseif ($statement instanceof DropStatement) {
176 4
            $flags->queryType = StatementType::Drop;
177 4
            $flags->reload = true;
178
179 4
            if ($statement->options->has('DATABASE') || $statement->options->has('SCHEMA')) {
180 3
                $flags->dropDatabase = true;
181
            }
182 42
        } elseif ($statement instanceof ExplainStatement) {
183 2
            $flags->queryType = StatementType::Explain;
184
            /** @psalm-suppress DeprecatedProperty */
185 2
            $flags->isExplain = true;
0 ignored issues
show
Deprecated Code introduced by
The property PhpMyAdmin\SqlParser\Uti...tementFlags::$isExplain has been deprecated: Use {@see self::$queryType} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

185
            /** @scrutinizer ignore-deprecated */ $flags->isExplain = true;

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
186 40
        } elseif ($statement instanceof InsertStatement) {
187 2
            $flags->queryType = StatementType::Insert;
188 2
            $flags->isAffected = true;
189 2
            $flags->isInsert = true;
190 38
        } elseif ($statement instanceof LoadStatement) {
191 2
            $flags->queryType = StatementType::Load;
192 2
            $flags->isAffected = true;
193 2
            $flags->isInsert = true;
194 36
        } elseif ($statement instanceof ReplaceStatement) {
195 2
            $flags->queryType = StatementType::Replace;
196 2
            $flags->isAffected = true;
197
            /** @psalm-suppress DeprecatedProperty */
198 2
            $flags->isReplace = true;
0 ignored issues
show
Deprecated Code introduced by
The property PhpMyAdmin\SqlParser\Uti...tementFlags::$isReplace has been deprecated: Use {@see self::$queryType} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

198
            /** @scrutinizer ignore-deprecated */ $flags->isReplace = true;

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
199 2
            $flags->isInsert = true;
200 34
        } elseif ($statement instanceof SelectStatement) {
201 26
            self::getFlagsSelect($statement, $flags);
202 8
        } elseif ($statement instanceof ShowStatement) {
203 2
            $flags->queryType = StatementType::Show;
204
            /** @psalm-suppress DeprecatedProperty */
205 2
            $flags->isShow = true;
0 ignored issues
show
Deprecated Code introduced by
The property PhpMyAdmin\SqlParser\Utils\StatementFlags::$isShow has been deprecated: Use {@see self::$queryType} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

205
            /** @scrutinizer ignore-deprecated */ $flags->isShow = true;

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
206 6
        } elseif ($statement instanceof UpdateStatement) {
207 2
            $flags->queryType = StatementType::Update;
208 2
            $flags->isAffected = true;
209 4
        } elseif ($statement instanceof SetStatement) {
210 2
            $flags->queryType = StatementType::Set;
211
        }
212
213
        if (
214 64
            ($statement instanceof SelectStatement)
215 38
            || ($statement instanceof UpdateStatement)
216 64
            || ($statement instanceof DeleteStatement)
217
        ) {
218 30
            if (! empty($statement->limit)) {
219 4
                $flags->limit = true;
220
            }
221
222 30
            if (! empty($statement->order)) {
223 4
                $flags->order = true;
224
            }
225
        }
226
227 64
        return $flags;
228
    }
229
230
    /**
231
     * Parses a query and gets all information about it.
232
     *
233
     * @param string $query the query to be parsed
234
     */
235 4
    public static function getAll(string $query): StatementInfo
236
    {
237 4
        $parser = new Parser($query);
238
239 4
        if ($parser->statements === []) {
240 2
            return new StatementInfo($parser, null, static::getFlags(null), [], []);
241
        }
242
243 2
        $statement = $parser->statements[0];
244 2
        $flags = static::getFlags($statement);
245 2
        $selectTables = [];
246 2
        $selectExpressions = [];
247
248 2
        if ($statement instanceof SelectStatement) {
249
            // Finding tables' aliases and their associated real names.
250 2
            $tableAliases = [];
251 2
            foreach ($statement->from as $expr) {
252 2
                if (! isset($expr->table, $expr->alias) || ($expr->table === '') || ($expr->alias === '')) {
253 2
                    continue;
254
                }
255
256 2
                $tableAliases[$expr->alias] = [
257 2
                    $expr->table,
258 2
                    $expr->database ?? null,
259 2
                ];
260
            }
261
262
            // Trying to find selected tables only from the select expression.
263
            // Sometimes, this is not possible because the tables aren't defined
264
            // explicitly (e.g. SELECT * FROM film, SELECT film_id FROM film).
265 2
            foreach ($statement->expr as $expr) {
266 2
                if (isset($expr->table) && ($expr->table !== '')) {
267 2
                    if (isset($tableAliases[$expr->table])) {
268 2
                        $arr = $tableAliases[$expr->table];
269
                    } else {
270 2
                        $arr = [
271 2
                            $expr->table,
272 2
                            isset($expr->database) && ($expr->database !== '') ?
273 2
                                $expr->database : null,
274 2
                        ];
275
                    }
276
277 2
                    if (! in_array($arr, $selectTables)) {
278 2
                        $selectTables[] = $arr;
279
                    }
280
                } else {
281 2
                    $selectExpressions[] = $expr->expr;
282
                }
283
            }
284
285
            // If no tables names were found in the SELECT clause or if there
286
            // are expressions like * or COUNT(*), etc. tables names should be
287
            // extracted from the FROM clause.
288 2
            if ($selectTables === []) {
289 2
                foreach ($statement->from as $expr) {
290 2
                    if (! isset($expr->table) || ($expr->table === '')) {
291
                        continue;
292
                    }
293
294 2
                    $arr = [
295 2
                        $expr->table,
296 2
                        isset($expr->database) && ($expr->database !== '') ?
297 2
                            $expr->database : null,
298 2
                    ];
299 2
                    if (in_array($arr, $selectTables)) {
300
                        continue;
301
                    }
302
303 2
                    $selectTables[] = $arr;
304
                }
305
            }
306
        }
307
308 2
        return new StatementInfo($parser, $statement, $flags, $selectTables, $selectExpressions);
309
    }
310
311
    /**
312
     * Gets a list of all tables used in this statement.
313
     *
314
     * @param Statement $statement statement to be scanned
315
     *
316
     * @return array<int, string>
317
     */
318 20
    public static function getTables(Statement $statement): array
319
    {
320 20
        $expressions = [];
321
322 20
        if (($statement instanceof InsertStatement) || ($statement instanceof ReplaceStatement)) {
323 4
            $expressions = [$statement->into->dest];
324 16
        } elseif ($statement instanceof UpdateStatement) {
325 4
            $expressions = $statement->tables;
326 12
        } elseif (($statement instanceof SelectStatement) || ($statement instanceof DeleteStatement)) {
327 4
            $expressions = $statement->from;
328 8
        } elseif (($statement instanceof AlterStatement) || ($statement instanceof TruncateStatement)) {
329 2
            $expressions = [$statement->table];
330 6
        } elseif ($statement instanceof DropStatement) {
331 4
            if (! $statement->options->has('TABLE')) {
332
                // No tables are dropped.
333 2
                return [];
334
            }
335
336 2
            $expressions = $statement->fields;
337 2
        } elseif ($statement instanceof RenameStatement) {
338 2
            foreach ($statement->renames as $rename) {
339 2
                $expressions[] = $rename->old;
340
            }
341
        }
342
343 18
        $ret = [];
344 18
        foreach ($expressions as $expr) {
345 18
            if (empty($expr->table)) {
346
                continue;
347
            }
348
349 18
            $expr->expr = null; // Force rebuild.
350 18
            $expr->alias = null; // Aliases are not required.
351 18
            $ret[] = $expr->build();
352
        }
353
354 18
        return $ret;
355
    }
356
357
    /**
358
     * Gets a specific clause.
359
     *
360
     * @param Statement  $statement the parsed query that has to be modified
361
     * @param TokensList $list      the list of tokens
362
     * @param string     $clause    the clause to be returned
363
     * @param int|string $type      The type of the search.
364
     *                              If int,
365
     *                              -1 for everything that was before
366
     *                              0 only for the clause
367
     *                              1 for everything after
368
     *                              If string, the name of the first clause that
369
     *                              should not be included.
370
     * @param bool       $skipFirst whether to skip the first keyword in clause
371
     */
372 10
    public static function getClause(
373
        Statement $statement,
374
        TokensList $list,
375
        string $clause,
376
        int|string $type = 0,
377
        bool $skipFirst = true,
378
    ): string {
379
        /**
380
         * The index of the current clause.
381
         */
382 10
        $currIdx = 0;
383
384
        /**
385
         * The count of brackets.
386
         * We keep track of them so we won't insert the clause in a subquery.
387
         */
388 10
        $brackets = 0;
389
390
        /**
391
         * The string to be returned.
392
         */
393 10
        $ret = '';
394
395
        /**
396
         * The clauses of this type of statement and their index.
397
         */
398 10
        $clauses = array_flip(array_keys($statement->getClauses()));
399
400
        /**
401
         * Lexer used for lexing the clause.
402
         */
403 10
        $lexer = new Lexer($clause);
404
405
        /**
406
         * The type of this clause.
407
         */
408 10
        $clauseType = $lexer->list->getNextOfType(TokenType::Keyword)->keyword;
409
410
        /**
411
         * The index of this clause.
412
         */
413 10
        $clauseIdx = $clauses[$clauseType] ?? -1;
414
415 10
        $firstClauseIdx = $clauseIdx;
416 10
        $lastClauseIdx = $clauseIdx;
417
418
        // Determining the behavior of this function.
419 10
        if ($type === -1) {
420 8
            $firstClauseIdx = -1; // Something small enough.
421 8
            $lastClauseIdx = $clauseIdx - 1;
422 10
        } elseif ($type === 1) {
423 8
            $firstClauseIdx = $clauseIdx + 1;
424 8
            $lastClauseIdx = 10000; // Something big enough.
425 6
        } elseif (is_string($type) && isset($clauses[$type])) {
426 4
            if ($clauses[$type] > $clauseIdx) {
427 4
                $firstClauseIdx = $clauseIdx + 1;
428 4
                $lastClauseIdx = $clauses[$type] - 1;
429
            } else {
430 2
                $firstClauseIdx = $clauses[$type] + 1;
431 2
                $lastClauseIdx = $clauseIdx - 1;
432
            }
433
        }
434
435
        // This option is unavailable for multiple clauses.
436 10
        if ($type !== 0) {
437 10
            $skipFirst = false;
438
        }
439
440 10
        for ($i = $statement->first; $i <= $statement->last; ++$i) {
441 10
            $token = $list->tokens[$i];
442
443 10
            if ($token->type === TokenType::Comment) {
444 2
                continue;
445
            }
446
447 10
            if ($token->type === TokenType::Operator) {
448 8
                if ($token->value === '(') {
449 8
                    ++$brackets;
450 8
                } elseif ($token->value === ')') {
451 8
                    --$brackets;
452
                }
453
            }
454
455 10
            if ($brackets === 0) {
456
                // Checking if the section was changed.
457
                if (
458 10
                    ($token->type === TokenType::Keyword)
459 10
                    && isset($clauses[$token->keyword])
460 10
                    && ($clauses[$token->keyword] >= $currIdx)
461
                ) {
462 8
                    $currIdx = $clauses[$token->keyword];
463 8
                    if ($skipFirst && ($currIdx === $clauseIdx)) {
464
                        // This token is skipped (not added to the old
465
                        // clause) because it will be replaced.
466 4
                        continue;
467
                    }
468
                }
469
            }
470
471 10
            if (($firstClauseIdx > $currIdx) || ($currIdx > $lastClauseIdx)) {
472 10
                continue;
473
            }
474
475 10
            $ret .= $token->token;
476
        }
477
478 10
        return trim($ret);
479
    }
480
481
    /** @param list<string> $parts */
0 ignored issues
show
Bug introduced by
The type PhpMyAdmin\SqlParser\Utils\list 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...
482 8
    private static function glueQueryPartsWithSpaces(array $parts): string
483
    {
484 8
        $statement = '';
485 8
        foreach ($parts as $part) {
486 8
            if ($part === '') {
487 8
                continue;
488
            }
489
490
            if (
491 8
                $statement !== '' &&
492 8
                ! ctype_space(mb_substr($statement, -1)) &&
493 8
                ! ctype_space(mb_substr($part, 0, 1))
494
            ) {
495 6
                $statement .= ' ';
496
            }
497
498 8
            $statement .= $part;
499
        }
500
501 8
        return $statement;
502
    }
503
504
    /**
505
     * Builds a query by rebuilding the statement from the tokens list supplied
506
     * and replaces a clause.
507
     *
508
     * It is a very basic version of a query builder.
509
     *
510
     * @param Statement  $statement the parsed query that has to be modified
511
     * @param TokensList $list      the list of tokens
512
     * @param string     $old       The type of the clause that should be
513
     *                              replaced. This can be an entire clause.
514
     * @param string     $new       The new clause. If this parameter is omitted
515
     *                              it is considered to be equal with `$old`.
516
     * @param bool       $onlyType  whether only the type of the clause should
517
     *                              be replaced or the entire clause
518
     */
519 8
    public static function replaceClause(
520
        Statement $statement,
521
        TokensList $list,
522
        string $old,
523
        string|null $new = null,
524
        bool $onlyType = false,
525
    ): string {
526
        // TODO: Update the tokens list and the statement.
527
528 8
        $parts = [
529 8
            static::getClause($statement, $list, $old, -1, false),
530 8
            $new ?? $old,
531 8
        ];
532 8
        if ($onlyType) {
533 2
            $parts[] = static::getClause($statement, $list, $old, 0);
534
        }
535
536 8
        $parts[] = static::getClause($statement, $list, $old, 1, false);
537
538 8
        return self::glueQueryPartsWithSpaces($parts);
539
    }
540
541
    /**
542
     * Builds a query by rebuilding the statement from the tokens list supplied
543
     * and replaces multiple clauses.
544
     *
545
     * @param Statement                      $statement the parsed query that has to be modified
546
     * @param TokensList                     $list      the list of tokens
547
     * @param array<int, array<int, string>> $ops       Clauses to be replaced. Contains multiple
548
     *                              arrays having two values: [$old, $new].
549
     *                              Clauses must be sorted.
550
     */
551 2
    public static function replaceClauses(Statement $statement, TokensList $list, array $ops): string
552
    {
553 2
        $count = count($ops);
554
555
        // Nothing to do.
556 2
        if ($count === 0) {
557 2
            return '';
558
        }
559
560
        // If there is only one clause, `replaceClause()` should be used.
561 2
        if ($count === 1) {
562 2
            return static::replaceClause($statement, $list, $ops[0][0], $ops[0][1]);
563
        }
564
565
        // Adding everything before first replacement.
566 2
        $parts = [static::getClause($statement, $list, $ops[0][0], -1)];
567
568
        // Doing replacements.
569 2
        foreach ($ops as $i => $clause) {
570 2
            $parts[] = $clause[1];
571
572
            // Adding everything between this and next replacement.
573 2
            if ($i + 1 === $count) {
574 2
                continue;
575
            }
576
577 2
            $parts[] = static::getClause($statement, $list, $clause[0], $ops[$i + 1][0]);
578
        }
579
580
        // Adding everything after the last replacement.
581 2
        $parts[] = static::getClause($statement, $list, $ops[$count - 1][0], 1);
582
583 2
        return self::glueQueryPartsWithSpaces($parts);
584
    }
585
586
    /**
587
     * Gets the first full statement in the query.
588
     *
589
     * @param string $query     the query to be analyzed
590
     * @param string $delimiter the delimiter to be used
591
     *
592
     * @return array<int, string|null> array containing the first full query,
593
     *                                 the remaining part of the query and the last delimiter
594
     * @psalm-return array{string|null, string, string|null}
595
     */
596 2
    public static function getFirstStatement(string $query, string|null $delimiter = null): array
597
    {
598 2
        $lexer = new Lexer($query, false, $delimiter);
599 2
        $list = $lexer->list;
600
601
        /**
602
         * Whether a full statement was found.
603
         */
604 2
        $fullStatement = false;
605
606
        /**
607
         * The first full statement.
608
         */
609 2
        $statement = '';
610
611 2
        for ($list->idx = 0; $list->idx < $list->count; ++$list->idx) {
612 2
            $token = $list->tokens[$list->idx];
613
614 2
            if ($token->type === TokenType::Comment) {
615 2
                continue;
616
            }
617
618 2
            $statement .= $token->token;
619
620 2
            if (($token->type === TokenType::Delimiter) && ! empty($token->token)) {
621 2
                $delimiter = $token->token;
622 2
                $fullStatement = true;
623 2
                break;
624
            }
625
        }
626
627
        // No statement was found so we return the entire query as being the
628
        // remaining part.
629 2
        if (! $fullStatement) {
630 2
            return [
631 2
                null,
632 2
                $query,
633 2
                $delimiter,
634 2
            ];
635
        }
636
637
        // At least one query was found so we have to build the rest of the
638
        // remaining query.
639 2
        $query = '';
640 2
        for (++$list->idx; $list->idx < $list->count; ++$list->idx) {
641 2
            $query .= $list->tokens[$list->idx]->token;
642
        }
643
644 2
        return [
645 2
            trim($statement),
646 2
            $query,
647 2
            $delimiter,
648 2
        ];
649
    }
650
651
    /**
652
     * Gets a starting offset of a specific clause.
653
     *
654
     * @param Statement  $statement the parsed query that has to be modified
655
     * @param TokensList $list      the list of tokens
656
     * @param string     $clause    the clause to be returned
657
     */
658 520
    public static function getClauseStartOffset(Statement $statement, TokensList $list, string $clause): int
659
    {
660
        /**
661
         * The count of brackets.
662
         * We keep track of them so we won't insert the clause in a subquery.
663
         */
664 520
        $brackets = 0;
665
666
        /**
667
         * The clauses of this type of statement and their index.
668
         */
669 520
        $clauses = array_flip(array_keys($statement->getClauses()));
670
671 520
        for ($i = $statement->first; $i <= $statement->last; ++$i) {
672 520
            $token = $list->tokens[$i];
673
674 520
            if ($token->type === TokenType::Comment) {
675 64
                continue;
676
            }
677
678 520
            if ($token->type === TokenType::Operator) {
679 414
                if ($token->value === '(') {
680 168
                    ++$brackets;
681 412
                } elseif ($token->value === ')') {
682 168
                    --$brackets;
683
                }
684
            }
685
686 520
            if ($brackets !== 0) {
687 172
                continue;
688
            }
689
690
            if (
691 518
                ($token->type === TokenType::Keyword)
692 518
                && isset($clauses[$token->keyword])
693 518
                && ($clause === $token->keyword)
694
            ) {
695 512
                return $i;
696
            }
697
698 518
            if ($token->keyword === 'UNION') {
699 10
                return -1;
700
            }
701
        }
702
703 520
        return -1;
704
    }
705
}
706