Passed
Push — master ( de2d27...d28f1e )
by Maurício
10:00 queued 06:30
created

Statement::parse()   D

Complexity

Conditions 36
Paths 16

Size

Total Lines 163
Code Lines 68

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 57
CRAP Score 40.313

Importance

Changes 2
Bugs 0 Features 1
Metric Value
cc 36
eloc 68
c 2
b 0
f 1
nc 16
nop 2
dl 0
loc 163
ccs 57
cts 67
cp 0.8507
crap 40.313
rs 4.1666

How to fix   Long Method    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;
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 1150
    public function __construct(Parser|null $parser = null, TokensList|null $list = null)
91
    {
92 1150
        if (($parser === null) || ($list === null)) {
93 6
            return;
94
        }
95
96 1146
        $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 169
                    $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 866
    public function getClauses(): array
364
    {
365 866
        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 1146
    public function validateClauseOrder(Parser $parser, TokensList $list): bool
389
    {
390 1146
        $clauses = array_flip(array_keys($this->getClauses()));
391
392 1146
        if ($clauses === []) {
393 694
            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