Statement::getClauses()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace PhpMyAdmin\SqlParser;
6
7
use AllowDynamicProperties;
0 ignored issues
show
Bug introduced by
The type AllowDynamicProperties 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...
8
use PhpMyAdmin\SqlParser\Components\OptionsArray;
9
use PhpMyAdmin\SqlParser\Exceptions\ParserException;
10
use PhpMyAdmin\SqlParser\Parsers\OptionsArrays;
11
use PhpMyAdmin\SqlParser\Statements\SelectStatement;
12
use PhpMyAdmin\SqlParser\Statements\SetStatement;
13
use PhpMyAdmin\SqlParser\Utils\Query;
14
use Stringable;
15
16
use function array_flip;
17
use function array_key_exists;
18
use function array_keys;
19
use function is_array;
20
use function is_string;
21
use function str_contains;
22
use function strtoupper;
23
use function trim;
24
25
/**
26
 * The result of the parser is an array of statements are extensions of the class defined here.
27
 *
28
 * A statement represents the result of parsing the lexemes.
29
 *
30
 * Abstract statement definition.
31
 */
32
#[AllowDynamicProperties]
33
abstract class Statement implements Stringable
34
{
35
    /**
36
     * Options for this statement.
37
     *
38
     * The option would be the key and the value can be an integer or an array.
39
     *
40
     * The integer represents only the index used.
41
     *
42
     * The array may have two keys: `0` is used to represent the index used and
43
     * `1` is the type of the option (which may be 'var' or 'var='). Both
44
     * options mean they expect a value after the option (e.g. `A = B` or `A B`,
45
     * in which case `A` is the key and `B` is the value). The only difference
46
     * is in the building process. `var` options are built as `A B` and  `var=`
47
     * options are built as `A = B`
48
     *
49
     * Two options that can be used together must have different values for
50
     * indexes, else, when they will be used together, an error will occur.
51
     *
52
     * @var array<string, int|array<int, int|string>>
53
     * @psalm-var array<string, (positive-int|array{positive-int, ('var'|'var='|'expr'|'expr=')})>
54
     */
55
    public static array $statementOptions = [];
56
57
    protected const ADD_CLAUSE = 1;
58
    protected const ADD_KEYWORD = 2;
59
60
    /**
61
     * The clauses of this statement, in order.
62
     *
63
     * @var array<string, array{non-empty-string, int-mask-of<self::ADD_*>}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, array{non-...-mask-of<self::ADD_*>}> at position 6 could not be parsed: Expected ':' at position 6, but found 'non-empty-string'.
Loading history...
64
     */
65
    public static array $clauses = [];
66
67
    /**
68
     * The options of this query.
69
     *
70
     * @see Statement::$statementOptions
71
     */
72
    public OptionsArray|null $options = null;
73
74
    /**
75
     * The index of the first token used in this statement.
76
     */
77
    public int|null $first = null;
78
79
    /**
80
     * The index of the last token used in this statement.
81
     */
82
    public int|null $last = null;
83
84
    /**
85
     * @param Parser|null     $parser the instance that requests parsing
86
     * @param TokensList|null $list   the list of tokens to be parsed
87
     *
88
     * @throws ParserException
89
     */
90 1200
    public function __construct(Parser|null $parser = null, TokensList|null $list = null)
91
    {
92 1200
        if (($parser === null) || ($list === null)) {
93 6
            return;
94
        }
95
96 1196
        $this->parse($parser, $list);
97
    }
98
99
    /**
100
     * Builds the string representation of this statement.
101
     */
102 66
    public function build(): string
103
    {
104
        /**
105
         * Query to be returned.
106
         */
107 66
        $query = '';
108
109
        /**
110
         * Clauses which were built already.
111
         *
112
         * It is required to keep track of built clauses because some fields,
113
         * for example `join` is used by multiple clauses (`JOIN`, `LEFT JOIN`,
114
         * `LEFT OUTER JOIN`, etc.). The same happens for `VALUE` and `VALUES`.
115
         *
116
         * A clause is considered built just after fields' value
117
         * (`$this->field`) was used in building.
118
         */
119 66
        $built = [];
120
121 66
        foreach ($this->getClauses() as [$name, $type]) {
122
            /**
123
             * The name of the field that is used as source for the builder.
124
             * Same field is used to store the result of parsing.
125
             */
126 66
            $field = Parser::KEYWORD_PARSERS[$name]['field'];
127
128
            // The field is empty, there is nothing to be built.
129 66
            if (empty($this->$field)) {
130 66
                continue;
131
            }
132
133
            // Checking if this field was already built.
134 66
            if ($type & self::ADD_CLAUSE) {
135 66
                if (! empty($built[$field])) {
136 28
                    continue;
137
                }
138
139 66
                $built[$field] = true;
140
            }
141
142
            // Checking if the name of the clause should be added.
143 66
            if ($type & self::ADD_KEYWORD) {
144 66
                $query = trim($query) . ' ' . $name;
145
            }
146
147
            // Checking if the result of the builder should be added.
148 66
            if (! ($type & self::ADD_CLAUSE)) {
149 66
                continue;
150
            }
151
152 66
            if (is_array($this->$field)) {
153 66
                $class = Parser::KEYWORD_PARSERS[$name]['class'];
154 66
                $query = trim($query) . ' ' . $class::buildAll($this->$field);
155
            } else {
156 66
                $query = trim($query) . ' ' . $this->$field->build();
157
            }
158
        }
159
160 66
        return $query;
161
    }
162
163
    /**
164
     * Parses the statements defined by the tokens list.
165
     *
166
     * @param Parser     $parser the instance that requests parsing
167
     * @param TokensList $list   the list of tokens to be parsed
168
     *
169
     * @throws ParserException
170
     */
171 192
    public function parse(Parser $parser, TokensList $list): void
172
    {
173
        /**
174
         * Array containing all list of clauses parsed.
175
         * This is used to check for duplicates.
176
         */
177 192
        $parsedClauses = [];
178
179
        // This may be corrected by the parser.
180 192
        $this->first = $list->idx;
181
182
        /**
183
         * Whether options were parsed or not.
184
         * For statements that do not have any options this is set to `true` by
185
         * default.
186
         */
187 192
        $parsedOptions = static::$statementOptions === [];
188
189 192
        for (; $list->idx < $list->count; ++$list->idx) {
190
            /**
191
             * Token parsed at this moment.
192
             */
193 192
            $token = $list->tokens[$list->idx];
194
195
            // End of statement.
196 192
            if ($token->type === TokenType::Delimiter) {
197 190
                break;
198
            }
199
200
            // Checking if this closing bracket is the pair for a bracket
201
            // outside the statement.
202 192
            if (($token->value === ')') && ($parser->brackets > 0)) {
203
                --$parser->brackets;
204
                continue;
205
            }
206
207
            // Only keywords are relevant here. Other parts of the query are
208
            // processed in the functions below.
209 192
            if ($token->type !== TokenType::Keyword) {
210 6
                if (($token->type !== TokenType::Comment) && ($token->type !== TokenType::Whitespace)) {
211 4
                    $parser->error('Unexpected token.', $token);
212
                }
213
214 6
                continue;
215
            }
216
217
            // Unions are parsed by the parser because they represent more than
218
            // one statement.
219
            if (
220 192
                ($token->keyword === 'UNION') ||
221 192
                ($token->keyword === 'UNION ALL') ||
222 192
                ($token->keyword === 'UNION DISTINCT') ||
223 192
                ($token->keyword === 'EXCEPT') ||
224 192
                ($token->keyword === 'INTERSECT')
225
            ) {
226
                break;
227
            }
228
229
            /**
230
             * The name of the class that is used for parsing.
231
             */
232 192
            $class = null;
233
234
            /**
235
             * The name of the field where the result of the parsing is stored.
236
             */
237 192
            $field = null;
238
239
            /**
240
             * Parser's options.
241
             */
242 192
            $options = [];
243
244
            // Looking for duplicated clauses.
245
            if (
246 192
                is_string($token->value)
247
                && (
248 192
                    isset(Parser::KEYWORD_PARSERS[$token->value])
249 192
                    || (
250 192
                        isset(Parser::STATEMENT_PARSERS[$token->value])
251 192
                        && Parser::STATEMENT_PARSERS[$token->value] !== ''
252 192
                    )
253
                )
254
            ) {
255 190
                if (array_key_exists($token->value, $parsedClauses)) {
256
                    $parser->error('This type of clause was previously parsed.', $token);
257
                    break;
258
                }
259
260 190
                $parsedClauses[$token->value] = true;
261
            }
262
263
            // Checking if this is the beginning of a clause.
264
            // Fix Issue #221: As `truncate` is not a keyword,
265
            // but it might be the beginning of a statement of truncate,
266
            // so let the value use the keyword field for truncate type.
267 192
            $tokenValue = $token->keyword === 'TRUNCATE' ? $token->keyword : $token->value;
268 192
            if (is_string($tokenValue) && isset(Parser::KEYWORD_PARSERS[$tokenValue]) && $list->idx < $list->count) {
269 180
                $class = Parser::KEYWORD_PARSERS[$tokenValue]['class'];
270 180
                $field = Parser::KEYWORD_PARSERS[$tokenValue]['field'];
271 180
                if (isset(Parser::KEYWORD_PARSERS[$tokenValue]['options'])) {
272 80
                    $options = Parser::KEYWORD_PARSERS[$tokenValue]['options'];
273
                }
274
            }
275
276
            // Checking if this is the beginning of the statement.
277
            if (
278 192
                isset(Parser::STATEMENT_PARSERS[$token->keyword])
279 192
                && Parser::STATEMENT_PARSERS[$token->keyword] !== ''
280
            ) {
281 192
                if (static::$clauses !== [] && is_string($token->value) && ! isset(static::$clauses[$token->value])) {
282
                    // Some keywords (e.g. `SET`) may be the beginning of a
283
                    // statement and a clause.
284
                    // If such keyword was found, and it cannot be a clause of
285
                    // this statement it means it is a new statement, but no
286
                    // delimiter was found between them.
287
                    $parser->error(
288
                        'A new statement was found, but no delimiter between it and the previous one.',
289
                        $token,
290
                    );
291
                    break;
292
                }
293
294 192
                if (! $parsedOptions) {
295 146
                    if (! array_key_exists((string) $token->value, static::$statementOptions)) {
296
                        // Skipping keyword because if it is not a option.
297 136
                        ++$list->idx;
298
                    }
299
300 146
                    $this->options = OptionsArrays::parse($parser, $list, static::$statementOptions);
301 146
                    $parsedOptions = true;
302
                }
303 40
            } elseif ($class === null) {
304 10
                if (! ($this instanceof SetStatement) || ($token->value !== 'COLLATE' && $token->value !== 'DEFAULT')) {
305
                    // There is no parser for this keyword and isn't the beginning
306
                    // of a statement (so no options) either.
307 8
                    $parser->error('Unrecognized keyword.', $token);
308 8
                    continue;
309
                }
310
311
                // Handle special end options in SET statement
312 2
                $this->endOptions = OptionsArrays::parse($parser, $list, SetStatement::STATEMENT_END_OPTIONS);
313
            }
314
315 192
            $this->before($parser, $list, $token);
316
317
            // Parsing this keyword.
318 192
            if ($class !== null) {
319
                // We can't parse keyword at the end of statement
320 180
                if ($list->idx >= $list->count) {
321 2
                    $parser->error('Keyword at end of statement.', $token);
322 2
                    continue;
323
                }
324
325 178
                ++$list->idx; // Skipping keyword or last option.
326 178
                $this->$field = $class::parse($parser, $list, $options);
327
            }
328
329 190
            $this->after($parser, $list, $token);
330
        }
331
332
        // This may be corrected by the parser.
333 192
        $this->last = --$list->idx; // Go back to last used token.
334
    }
335
336
    /**
337
     * Function called before the token is processed.
338
     *
339
     * @param Parser     $parser the instance that requests parsing
340
     * @param TokensList $list   the list of tokens to be parsed
341
     * @param Token      $token  the token that is being parsed
342
     */
343 174
    public function before(Parser $parser, TokensList $list, Token $token): void
0 ignored issues
show
Unused Code introduced by
The parameter $token is not used and could be removed. ( Ignorable by Annotation )

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

343
    public function before(Parser $parser, TokensList $list, /** @scrutinizer ignore-unused */ Token $token): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
344
    {
345 174
    }
346
347
    /**
348
     * Function called after the token was processed.
349
     *
350
     * @param Parser     $parser the instance that requests parsing
351
     * @param TokensList $list   the list of tokens to be parsed
352
     * @param Token      $token  the token that is being parsed
353
     */
354 182
    public function after(Parser $parser, TokensList $list, Token $token): void
0 ignored issues
show
Unused Code introduced by
The parameter $token is not used and could be removed. ( Ignorable by Annotation )

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

354
    public function after(Parser $parser, TokensList $list, /** @scrutinizer ignore-unused */ Token $token): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
355
    {
356 182
    }
357
358
    /**
359
     * Gets the clauses of this statement.
360
     *
361
     * @return array<string, array{non-empty-string, int-mask-of<Statement::ADD_*>}>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, array{non-...-of<Statement::ADD_*>}> at position 6 could not be parsed: Expected ':' at position 6, but found 'non-empty-string'.
Loading history...
362
     */
363 916
    public function getClauses(): array
364
    {
365 916
        return static::$clauses;
366
    }
367
368
    /**
369
     * Builds the string representation of this statement.
370
     *
371
     * @see static::build
372
     */
373 18
    public function __toString(): string
374
    {
375 18
        return $this->build();
376
    }
377
378
    /**
379
     * Validates the order of the clauses in parsed statement
380
     * Ideally this should be called after successfully
381
     * completing the parsing of each statement.
382
     *
383
     * @param Parser     $parser the instance that requests parsing
384
     * @param TokensList $list   the list of tokens to be parsed
385
     *
386
     * @throws ParserException
387
     */
388 1196
    public function validateClauseOrder(Parser $parser, TokensList $list): bool
389
    {
390 1196
        $clauses = array_flip(array_keys($this->getClauses()));
391
392 1196
        if ($clauses === []) {
393 744
            return true;
394
        }
395
396 520
        $minIdx = -1;
397
398
        /**
399
         * For tracking JOIN clauses in a query
400
         *   = 0 - JOIN not found till now
401
         *   > 0 - Index of first JOIN clause in the statement.
402
         */
403 520
        $minJoin = 0;
404
405
        /**
406
         * For tracking JOIN clauses in a query
407
         *   = 0 - JOIN not found till now
408
         *   > 0 - Index of last JOIN clause
409
         *         (which appears together with other JOINs)
410
         *         in the statement.
411
         */
412 520
        $maxJoin = 0;
413
414 520
        $error = 0;
415 520
        $lastIdx = 0;
416 520
        foreach (array_keys($clauses) as $clauseType) {
417 520
            $clauseStartIdx = Query::getClauseStartOffset($this, $list, $clauseType);
418
419
            if (
420 520
                $clauseStartIdx !== -1
421 520
                && $this instanceof SelectStatement
422 520
                && ($clauseType === 'FORCE'
423 520
                    || $clauseType === 'IGNORE'
424 520
                    || $clauseType === 'USE')
425
            ) {
426
                // TODO: ordering of clauses in a SELECT statement with
427
                // Index hints is not supported
428 14
                return true;
429
            }
430
431
            // Handle ordering of Multiple Joins in a query
432 520
            if ($clauseStartIdx !== -1) {
433 512
                $containsJoinClause = str_contains(strtoupper($clauseType), 'JOIN');
434 512
                if ($minJoin === 0 && $containsJoinClause) {
435
                    // First JOIN clause is detected
436 62
                    $minJoin = $maxJoin = $clauseStartIdx;
437 512
                } elseif ($minJoin !== 0 && ! $containsJoinClause) {
438
                    // After a previous JOIN clause, a non-JOIN clause has been detected
439 38
                    $maxJoin = $lastIdx;
440 512
                } elseif ($maxJoin < $clauseStartIdx && $containsJoinClause) {
441 2
                    $error = 1;
442
                }
443
            }
444
445 520
            if ($clauseStartIdx !== -1 && $clauseStartIdx < $minIdx) {
446 14
                if ($minJoin === 0 || $error === 1) {
447 10
                    $token = $list->tokens[$clauseStartIdx];
448 10
                    $parser->error('Unexpected ordering of clauses.', $token);
449
450 10
                    return false;
451
                }
452
453 4
                $minIdx = $clauseStartIdx;
454 520
            } elseif ($clauseStartIdx !== -1) {
455 512
                $minIdx = $clauseStartIdx;
456
            }
457
458 520
            $lastIdx = $clauseStartIdx !== -1 ? $clauseStartIdx : $lastIdx;
459
        }
460
461 498
        return true;
462
    }
463
}
464