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

Statement::validateClauseOrder()   D

Complexity

Conditions 21
Paths 38

Size

Total Lines 73
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 34
CRAP Score 21

Importance

Changes 0
Metric Value
cc 21
eloc 34
c 0
b 0
f 0
nc 38
nop 2
dl 0
loc 73
ccs 34
cts 34
cp 1
crap 21
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