Passed
Pull Request — master (#558)
by
unknown
12:44
created

Query::glueQueryPartsWithSpaces()   A

Complexity

Conditions 6
Paths 4

Size

Total Lines 20
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 11
c 0
b 0
f 0
nc 4
nop 1
dl 0
loc 20
rs 9.2222
ccs 1
cts 1
cp 1
crap 6
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 26
     * @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 22
75
        if ($statement->from !== []) {
76
            $flags->selectFrom = true;
77 26
        }
78 2
79
        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
            $flags->distinct = true;
81 26
        }
82 4
83
        if (! empty($statement->group) || ! empty($statement->having)) {
84
            $flags->isGroup = true;
85 26
        }
86 2
87
        if (! empty($statement->into) && ($statement->into->type === 'OUTFILE')) {
88
            $flags->isExport = true;
89 26
        }
90 26
91 2
        $expressions = $statement->expr;
92 2
        if (! empty($statement->join)) {
93
            foreach ($statement->join as $join) {
94
                $expressions[] = $join->expr;
95
            }
96 26
        }
97 26
98 2
        foreach ($expressions as $expr) {
99 2
            if (! empty($expr->function)) {
100 2
                if ($expr->function === 'COUNT') {
101 2
                    $flags->isCount = true;
102
                } elseif (in_array($expr->function, static::$functions)) {
103
                    $flags->isFunc = true;
104
                }
105 26
            }
106 24
107
            if (empty($expr->subquery)) {
108
                continue;
109 2
            }
110
111
            $flags->isSubQuery = true;
112 26
        }
113 2
114
        if (! empty($statement->procedure) && ($statement->procedure->name === 'ANALYSE')) {
115
            $flags->isAnalyse = true;
116 26
        }
117 2
118
        if (! empty($statement->group)) {
119
            $flags->group = true;
120 26
        }
121 2
122
        if (! empty($statement->having)) {
123
            $flags->having = true;
124 26
        }
125 2
126
        if ($statement->union !== []) {
127
            $flags->union = true;
128 26
        }
129 24
130
        if (empty($statement->join)) {
131
            return;
132 2
        }
133
134
        $flags->join = true;
135
    }
136
137
    /**
138
     * Gets an array with flags this statement has.
139
     *
140 64
     * @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 2
146 2
        if ($statement instanceof AlterStatement) {
147 62
            $flags->queryType = StatementType::Alter;
148 2
            $flags->reload = true;
149 2
        } elseif ($statement instanceof CreateStatement) {
150 60
            $flags->queryType = StatementType::Create;
151 2
            $flags->reload = true;
152 2
        } elseif ($statement instanceof AnalyzeStatement) {
153 58
            $flags->queryType = StatementType::Analyze;
154 2
            $flags->isMaint = true;
155 2
        } elseif ($statement instanceof CheckStatement) {
156 56
            $flags->queryType = StatementType::Check;
157 2
            $flags->isMaint = true;
158 2
        } elseif ($statement instanceof ChecksumStatement) {
159 54
            $flags->queryType = StatementType::Checksum;
160 2
            $flags->isMaint = true;
161 2
        } elseif ($statement instanceof OptimizeStatement) {
162 52
            $flags->queryType = StatementType::Optimize;
163 2
            $flags->isMaint = true;
164 2
        } elseif ($statement instanceof RepairStatement) {
165 50
            $flags->queryType = StatementType::Repair;
166 2
            $flags->isMaint = true;
167 2
        } elseif ($statement instanceof CallStatement) {
168 48
            $flags->queryType = StatementType::Call;
169 2
            $flags->isProcedure = true;
170
        } elseif ($statement instanceof DeleteStatement) {
171 2
            $flags->queryType = StatementType::Delete;
172 2
            /** @psalm-suppress DeprecatedProperty */
173 46
            $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 4
            $flags->isAffected = true;
175 4
        } elseif ($statement instanceof DropStatement) {
176
            $flags->queryType = StatementType::Drop;
177 4
            $flags->reload = true;
178 3
179
            if ($statement->options->has('DATABASE') || $statement->options->has('SCHEMA')) {
180 42
                $flags->dropDatabase = true;
181 2
            }
182
        } elseif ($statement instanceof ExplainStatement) {
183 2
            $flags->queryType = StatementType::Explain;
184 40
            /** @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 2
        } elseif ($statement instanceof InsertStatement) {
187 2
            $flags->queryType = StatementType::Insert;
188 38
            $flags->isAffected = true;
189 2
            $flags->isInsert = true;
190 2
        } elseif ($statement instanceof LoadStatement) {
191 2
            $flags->queryType = StatementType::Load;
192 36
            $flags->isAffected = true;
193 2
            $flags->isInsert = true;
194 2
        } elseif ($statement instanceof ReplaceStatement) {
195
            $flags->queryType = StatementType::Replace;
196 2
            $flags->isAffected = true;
197 2
            /** @psalm-suppress DeprecatedProperty */
198 34
            $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 26
            $flags->isInsert = true;
200 8
        } elseif ($statement instanceof SelectStatement) {
201 2
            self::getFlagsSelect($statement, $flags);
202
        } elseif ($statement instanceof ShowStatement) {
203 2
            $flags->queryType = StatementType::Show;
204 6
            /** @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 2
        } elseif ($statement instanceof UpdateStatement) {
207 4
            $flags->queryType = StatementType::Update;
208 2
            $flags->isAffected = true;
209
        } elseif ($statement instanceof SetStatement) {
210
            $flags->queryType = StatementType::Set;
211
        }
212 64
213 38
        if (
214 64
            ($statement instanceof SelectStatement)
215
            || ($statement instanceof UpdateStatement)
216 30
            || ($statement instanceof DeleteStatement)
217 4
        ) {
218
            if (! empty($statement->limit)) {
219
                $flags->limit = true;
220 30
            }
221 4
222
            if (! empty($statement->order)) {
223
                $flags->order = true;
224
            }
225 64
        }
226
227
        return $flags;
228
    }
229
230
    /**
231
     * Parses a query and gets all information about it.
232
     *
233 4
     * @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 2
239
        if ($parser->statements === []) {
240
            return new StatementInfo($parser, null, static::getFlags(null), [], []);
241 2
        }
242 2
243 2
        $statement = $parser->statements[0];
244 2
        $flags = static::getFlags($statement);
245
        $selectTables = [];
246 2
        $selectExpressions = [];
247
248 2
        if ($statement instanceof SelectStatement) {
249 2
            // Finding tables' aliases and their associated real names.
250 2
            $tableAliases = [];
251 2
            foreach ($statement->from as $expr) {
252
                if (! isset($expr->table, $expr->alias) || ($expr->table === '') || ($expr->alias === '')) {
253
                    continue;
254 2
                }
255 2
256 2
                $tableAliases[$expr->alias] = [
257 2
                    $expr->table,
258
                    $expr->database ?? null,
259
                ];
260
            }
261
262
            // Trying to find selected tables only from the select expression.
263 2
            // Sometimes, this is not possible because the tables aren't defined
264 2
            // 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
                    if (isset($tableAliases[$expr->table])) {
268 2
                        $arr = $tableAliases[$expr->table];
269 2
                    } else {
270 2
                        $arr = [
271 2
                            $expr->table,
272 2
                            isset($expr->database) && ($expr->database !== '') ?
273
                                $expr->database : null,
274
                        ];
275 2
                    }
276 2
277
                    if (! in_array($arr, $selectTables)) {
278
                        $selectTables[] = $arr;
279 2
                    }
280
                } else {
281
                    $selectExpressions[] = $expr->expr;
282
                }
283
            }
284
285
            // If no tables names were found in the SELECT clause or if there
286 2
            // are expressions like * or COUNT(*), etc. tables names should be
287 2
            // extracted from the FROM clause.
288 2
            if ($selectTables === []) {
289
                foreach ($statement->from as $expr) {
290
                    if (! isset($expr->table) || ($expr->table === '')) {
291
                        continue;
292 2
                    }
293 2
294 2
                    $arr = [
295 2
                        $expr->table,
296 2
                        isset($expr->database) && ($expr->database !== '') ?
297 2
                            $expr->database : null,
298
                    ];
299
                    if (in_array($arr, $selectTables)) {
300
                        continue;
301 2
                    }
302
303
                    $selectTables[] = $arr;
304
                }
305
            }
306 2
        }
307
308
        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 20
     * @return array<int, string>
317
     */
318 20
    public static function getTables(Statement $statement): array
319
    {
320 20
        $expressions = [];
321 4
322 16
        if (($statement instanceof InsertStatement) || ($statement instanceof ReplaceStatement)) {
323 4
            $expressions = [$statement->into->dest];
324 12
        } elseif ($statement instanceof UpdateStatement) {
325 4
            $expressions = $statement->tables;
326 8
        } elseif (($statement instanceof SelectStatement) || ($statement instanceof DeleteStatement)) {
327 2
            $expressions = $statement->from;
328 6
        } elseif (($statement instanceof AlterStatement) || ($statement instanceof TruncateStatement)) {
329 4
            $expressions = [$statement->table];
330
        } elseif ($statement instanceof DropStatement) {
331 2
            if (! $statement->options->has('TABLE')) {
332
                // No tables are dropped.
333
                return [];
334 2
            }
335 2
336 2
            $expressions = $statement->fields;
337 2
        } elseif ($statement instanceof RenameStatement) {
338
            foreach ($statement->renames as $rename) {
339
                $expressions[] = $rename->old;
340
            }
341 18
        }
342 18
343 18
        $ret = [];
344
        foreach ($expressions as $expr) {
345
            if (empty($expr->table)) {
346
                continue;
347 18
            }
348 18
349 18
            $expr->expr = null; // Force rebuild.
350
            $expr->alias = null; // Aliases are not required.
351
            $ret[] = $expr->build();
352 18
        }
353
354
        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 10
     * @param bool       $skipFirst whether to skip the first keyword in clause
371
     */
372
    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 10
         * The index of the current clause.
381
         */
382
        $currIdx = 0;
383
384
        /**
385
         * The count of brackets.
386 10
         * We keep track of them so we won't insert the clause in a subquery.
387
         */
388
        $brackets = 0;
389
390
        /**
391 10
         * The string to be returned.
392
         */
393
        $ret = '';
394
395
        /**
396 10
         * The clauses of this type of statement and their index.
397
         */
398
        $clauses = array_flip(array_keys($statement->getClauses()));
399
400
        /**
401 10
         * Lexer used for lexing the clause.
402
         */
403
        $lexer = new Lexer($clause);
404
405
        /**
406 10
         * The type of this clause.
407
         */
408
        $clauseType = $lexer->list->getNextOfType(TokenType::Keyword)->keyword;
409
410
        /**
411 10
         * The index of this clause.
412
         */
413 10
        $clauseIdx = $clauses[$clauseType] ?? -1;
414 10
415
        $firstClauseIdx = $clauseIdx;
416
        $lastClauseIdx = $clauseIdx;
417 10
418 8
        // Determining the behavior of this function.
419 8
        if ($type === -1) {
420 10
            $firstClauseIdx = -1; // Something small enough.
421 8
            $lastClauseIdx = $clauseIdx - 1;
422 8
        } elseif ($type === 1) {
423 6
            $firstClauseIdx = $clauseIdx + 1;
424 4
            $lastClauseIdx = 10000; // Something big enough.
425 4
        } elseif (is_string($type) && isset($clauses[$type])) {
426 4
            if ($clauses[$type] > $clauseIdx) {
427
                $firstClauseIdx = $clauseIdx + 1;
428 2
                $lastClauseIdx = $clauses[$type] - 1;
429 2
            } else {
430
                $firstClauseIdx = $clauses[$type] + 1;
431
                $lastClauseIdx = $clauseIdx - 1;
432
            }
433
        }
434 10
435 10
        // This option is unavailable for multiple clauses.
436
        if ($type !== 0) {
437
            $skipFirst = false;
438 10
        }
439 10
440
        for ($i = $statement->first; $i <= $statement->last; ++$i) {
441 10
            $token = $list->tokens[$i];
442 2
443
            if ($token->type === TokenType::Comment) {
444
                continue;
445 10
            }
446 8
447 8
            if ($token->type === TokenType::Operator) {
448 8
                if ($token->value === '(') {
449 8
                    ++$brackets;
450
                } elseif ($token->value === ')') {
451
                    --$brackets;
452
                }
453 10
            }
454
455
            if ($brackets === 0) {
456 10
                // Checking if the section was changed.
457 10
                if (
458 10
                    ($token->type === TokenType::Keyword)
459
                    && isset($clauses[$token->keyword])
460 8
                    && ($clauses[$token->keyword] >= $currIdx)
461 8
                ) {
462
                    $currIdx = $clauses[$token->keyword];
463
                    if ($skipFirst && ($currIdx === $clauseIdx)) {
464 4
                        // This token is skipped (not added to the old
465
                        // clause) because it will be replaced.
466
                        continue;
467
                    }
468
                }
469 10
            }
470 10
471
            if (($firstClauseIdx > $currIdx) || ($currIdx > $lastClauseIdx)) {
472
                continue;
473 10
            }
474
475
            $ret .= $token->token;
476 10
        }
477
478
        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
    private static function glueQueryPartsWithSpaces(array $parts): string
483
    {
484
        $statement = '';
485
        foreach ($parts as $part) {
486
            if ($part === '') {
487
                continue;
488
            }
489
490
            if (
491
                $statement !== '' &&
492
                ! ctype_space(mb_substr($statement, -1)) &&
493
                ! ctype_space(mb_substr($part, 0, 1))
494 8
            ) {
495
                $statement .= ' ';
496
            }
497
498
            $statement .= $part;
499
        }
500
501
        return $statement;
502
    }
503 8
504 4
    /**
505
     * Builds a query by rebuilding the statement from the tokens list supplied
506
     * and replaces a clause.
507 8
     *
508 2
     * It is a very basic version of a query builder.
509 2
     *
510 2
     * @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 6
     *                              replaced. This can be an entire clause.
514 6
     * @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
    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 2
528
        $parts = [
529 2
            static::getClause($statement, $list, $old, -1, false),
530
            $new ?? $old,
531
        ];
532 2
        if ($onlyType) {
533 2
            $parts[] = static::getClause($statement, $list, $old, 0);
534
        }
535
536
        $parts[] = static::getClause($statement, $list, $old, 1, false);
537
538
        return self::glueQueryPartsWithSpaces($parts);
539 2
    }
540
541
    /**
542 2
     * Builds a query by rebuilding the statement from the tokens list supplied
543 2
     * 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 2
     * @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 2
     */
551 2
    public static function replaceClauses(Statement $statement, TokensList $list, array $ops): string
552
    {
553
        $count = count($ops);
554 2
555 2
        // Nothing to do.
556
        if ($count === 0) {
557
            return '';
558 2
        }
559
560
        // If there is only one clause, `replaceClause()` should be used.
561
        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
        $parts = [static::getClause($statement, $list, $ops[0][0], -1)];
567
568
        // Doing replacements.
569
        foreach ($ops as $i => $clause) {
570
            $parts[] = $clause[1];
571
572
            // Adding everything between this and next replacement.
573
            if ($i + 1 === $count) {
574
                continue;
575 2
            }
576
577 2
            $parts[] = static::getClause($statement, $list, $clause[0], $ops[$i + 1][0]);
578 2
        }
579
580
        // Adding everything after the last replacement.
581
        $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 2
     *
589
     * @param string $query     the query to be analyzed
590 2
     * @param string $delimiter the delimiter to be used
591 2
     *
592
     * @return array<int, string|null> array containing the first full query,
593 2
     *                                 the remaining part of the query and the last delimiter
594 2
     * @psalm-return array{string|null, string, string|null}
595
     */
596
    public static function getFirstStatement(string $query, string|null $delimiter = null): array
597 2
    {
598
        $lexer = new Lexer($query, false, $delimiter);
599 2
        $list = $lexer->list;
600 2
601 2
        /**
602 2
         * Whether a full statement was found.
603
         */
604
        $fullStatement = false;
605
606
        /**
607
         * The first full statement.
608 2
         */
609 2
        $statement = '';
610 2
611 2
        for ($list->idx = 0; $list->idx < $list->count; ++$list->idx) {
612 2
            $token = $list->tokens[$list->idx];
613 2
614
            if ($token->type === TokenType::Comment) {
615
                continue;
616
            }
617
618 2
            $statement .= $token->token;
619 2
620 2
            if (($token->type === TokenType::Delimiter) && ! empty($token->token)) {
621
                $delimiter = $token->token;
622
                $fullStatement = true;
623 2
                break;
624 2
            }
625 2
        }
626 2
627 2
        // No statement was found so we return the entire query as being the
628
        // remaining part.
629
        if (! $fullStatement) {
630
            return [
631
                null,
632
                $query,
633
                $delimiter,
634
            ];
635
        }
636
637 520
        // At least one query was found so we have to build the rest of the
638
        // remaining query.
639
        $query = '';
640
        for (++$list->idx; $list->idx < $list->count; ++$list->idx) {
641
            $query .= $list->tokens[$list->idx]->token;
642
        }
643 520
644
        return [
645
            trim($statement),
646
            $query,
647
            $delimiter,
648 520
        ];
649
    }
650 520
651 520
    /**
652
     * Gets a starting offset of a specific clause.
653 520
     *
654 64
     * @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 520
     */
658 414
    public static function getClauseStartOffset(Statement $statement, TokensList $list, string $clause): int
659 168
    {
660 412
        /**
661 168
         * The count of brackets.
662
         * We keep track of them so we won't insert the clause in a subquery.
663
         */
664
        $brackets = 0;
665 520
666 172
        /**
667
         * The clauses of this type of statement and their index.
668
         */
669
        $clauses = array_flip(array_keys($statement->getClauses()));
670 518
671 518
        for ($i = $statement->first; $i <= $statement->last; ++$i) {
672 518
            $token = $list->tokens[$i];
673
674 512
            if ($token->type === TokenType::Comment) {
675
                continue;
676
            }
677 518
678 10
            if ($token->type === TokenType::Operator) {
679
                if ($token->value === '(') {
680
                    ++$brackets;
681
                } elseif ($token->value === ')') {
682 520
                    --$brackets;
683
                }
684
            }
685
686
            if ($brackets !== 0) {
687
                continue;
688
            }
689
690
            if (
691
                ($token->type === TokenType::Keyword)
692
                && isset($clauses[$token->keyword])
693
                && ($clause === $token->keyword)
694
            ) {
695
                return $i;
696
            }
697
698
            if ($token->keyword === 'UNION') {
699
                return -1;
700
            }
701
        }
702
703
        return -1;
704
    }
705
}
706