Passed
Pull Request — master (#546)
by Maurício
03:22
created

Statement::parse()   D

Complexity

Conditions 31
Paths 16

Size

Total Lines 158
Code Lines 64

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 54
CRAP Score 34.6624

Importance

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

333
    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...
334
    {
335 174
    }
336
337
    /**
338
     * Function called after the token was processed.
339
     *
340
     * @param Parser     $parser the instance that requests parsing
341
     * @param TokensList $list   the list of tokens to be parsed
342
     * @param Token      $token  the token that is being parsed
343
     */
344 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

344
    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...
345
    {
346 182
    }
347
348
    /**
349
     * Gets the clauses of this statement.
350
     *
351
     * @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...
352
     */
353 866
    public function getClauses(): array
354
    {
355 866
        return static::$clauses;
356
    }
357
358
    /**
359
     * Builds the string representation of this statement.
360
     *
361
     * @see static::build
362
     */
363 18
    public function __toString(): string
364
    {
365 18
        return $this->build();
366
    }
367
368
    /**
369
     * Validates the order of the clauses in parsed statement
370
     * Ideally this should be called after successfully
371
     * completing the parsing of each statement.
372
     *
373
     * @param Parser     $parser the instance that requests parsing
374
     * @param TokensList $list   the list of tokens to be parsed
375
     *
376
     * @throws Exceptions\ParserException
377
     */
378 1146
    public function validateClauseOrder(Parser $parser, TokensList $list): bool
379
    {
380 1146
        $clauses = array_flip(array_keys($this->getClauses()));
381
382 1146
        if ($clauses === []) {
383 694
            return true;
384
        }
385
386 520
        $minIdx = -1;
387
388
        /**
389
         * For tracking JOIN clauses in a query
390
         *   = 0 - JOIN not found till now
391
         *   > 0 - Index of first JOIN clause in the statement.
392
         */
393 520
        $minJoin = 0;
394
395
        /**
396
         * For tracking JOIN clauses in a query
397
         *   = 0 - JOIN not found till now
398
         *   > 0 - Index of last JOIN clause
399
         *         (which appears together with other JOINs)
400
         *         in the statement.
401
         */
402 520
        $maxJoin = 0;
403
404 520
        $error = 0;
405 520
        $lastIdx = 0;
406 520
        foreach ($clauses as $clauseType => $index) {
407 520
            $clauseStartIdx = Utils\Query::getClauseStartOffset($this, $list, $clauseType);
408
409
            if (
410 520
                $clauseStartIdx !== -1
411 520
                && $this instanceof Statements\SelectStatement
412 520
                && ($clauseType === 'FORCE'
413 520
                    || $clauseType === 'IGNORE'
414 520
                    || $clauseType === 'USE')
415
            ) {
416
                // TODO: ordering of clauses in a SELECT statement with
417
                // Index hints is not supported
418 14
                return true;
419
            }
420
421
            // Handle ordering of Multiple Joins in a query
422 520
            if ($clauseStartIdx !== -1) {
423 512
                if ($minJoin === 0 && stripos($clauseType, 'JOIN')) {
424
                    // First JOIN clause is detected
425 52
                    $minJoin = $maxJoin = $clauseStartIdx;
426 512
                } elseif ($minJoin !== 0 && ! stripos($clauseType, 'JOIN')) {
427
                    // After a previous JOIN clause, a non-JOIN clause has been detected
428 32
                    $maxJoin = $lastIdx;
429 512
                } elseif ($maxJoin < $clauseStartIdx && stripos($clauseType, 'JOIN')) {
430 2
                    $error = 1;
431
                }
432
            }
433
434 520
            if ($clauseStartIdx !== -1 && $clauseStartIdx < $minIdx) {
435 14
                if ($minJoin === 0 || $error === 1) {
436 10
                    $token = $list->tokens[$clauseStartIdx];
437 10
                    $parser->error('Unexpected ordering of clauses.', $token);
438
439 10
                    return false;
440
                }
441
442 4
                $minIdx = $clauseStartIdx;
443 520
            } elseif ($clauseStartIdx !== -1) {
444 512
                $minIdx = $clauseStartIdx;
445
            }
446
447 520
            $lastIdx = $clauseStartIdx !== -1 ? $clauseStartIdx : $lastIdx;
448
        }
449
450 498
        return true;
451
    }
452
}
453