Passed
Pull Request — master (#386)
by William
03:14
created

Statement::before()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 0
nc 1
nop 3
dl 0
loc 2
ccs 0
cts 0
cp 0
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace PhpMyAdmin\SqlParser;
6
7
use PhpMyAdmin\SqlParser\Components\FunctionCall;
8
use PhpMyAdmin\SqlParser\Components\OptionsArray;
9
use Stringable;
10
11
use function array_flip;
12
use function array_keys;
13
use function count;
14
use function in_array;
15
use function stripos;
16
use function trim;
17
18
/**
19
 * The result of the parser is an array of statements are extensions of the class defined here.
20
 *
21
 * A statement represents the result of parsing the lexemes.
22
 *
23
 * Abstract statement definition.
24
 */
25
#[\AllowDynamicProperties]
26
abstract class Statement implements Stringable
27
{
28
    /**
29
     * Options for this statement.
30
     *
31
     * The option would be the key and the value can be an integer or an array.
32
     *
33
     * The integer represents only the index used.
34
     *
35
     * The array may have two keys: `0` is used to represent the index used and
36
     * `1` is the type of the option (which may be 'var' or 'var='). Both
37
     * options mean they expect a value after the option (e.g. `A = B` or `A B`,
38
     * in which case `A` is the key and `B` is the value). The only difference
39
     * is in the building process. `var` options are built as `A B` and  `var=`
40
     * options are built as `A = B`
41
     *
42
     * Two options that can be used together must have different values for
43
     * indexes, else, when they will be used together, an error will occur.
44
     *
45
     * @var array<string, int|array<int, int|string>>
46
     * @psalm-var array<string, (positive-int|array{positive-int, ('var'|'var='|'expr'|'expr=')})>
47
     */
48
    public static $OPTIONS = [];
49
50
    /**
51
     * The clauses of this statement, in order.
52
     *
53
     * The value attributed to each clause is used by the builder and it may
54
     * have one of the following values:
55
     *
56
     *     - 1 = 01 - add the clause only
57
     *     - 2 = 10 - add the keyword
58
     *     - 3 = 11 - add both the keyword and the clause
59
     *
60
     * @var array<string, array<int, int|string>>
61
     * @psalm-var array<string, array{non-empty-string, (1|2|3)}>
62
     */
63
    public static $CLAUSES = [];
64
65
    /**
66
     * @var array<string, int|array<int, int|string>>
67
     * @psalm-var array<string, (positive-int|array{positive-int, ('var'|'var='|'expr'|'expr=')})>
68
     */
69
    public static $END_OPTIONS = [];
70
71
    /**
72
     * The options of this query.
73
     *
74
     * @see static::$OPTIONS
75
     *
76
     * @var OptionsArray|null
77
     */
78
    public $options;
79
80
    /**
81
     * The index of the first token used in this statement.
82
     *
83
     * @var int|null
84
     */
85
    public $first;
86
87
    /**
88
     * The index of the last token used in this statement.
89
     *
90
     * @var int|null
91
     */
92
    public $last;
93
94
    /**
95
     * @param Parser|null     $parser the instance that requests parsing
96
     * @param TokensList|null $list   the list of tokens to be parsed
97
     */
98 1708
    public function __construct(?Parser $parser = null, ?TokensList $list = null)
99
    {
100 1708
        if (($parser === null) || ($list === null)) {
101 12
            return;
102
        }
103
104 1700
        $this->parse($parser, $list);
105
    }
106
107
    /**
108
     * Builds the string representation of this statement.
109
     *
110
     * @return string
111
     */
112 108
    public function build()
113
    {
114
        /**
115
         * Query to be returned.
116
         *
117
         * @var string
118
         */
119 108
        $query = '';
120
121
        /**
122
         * Clauses which were built already.
123
         *
124
         * It is required to keep track of built clauses because some fields,
125
         * for example `join` is used by multiple clauses (`JOIN`, `LEFT JOIN`,
126
         * `LEFT OUTER JOIN`, etc.). The same happens for `VALUE` and `VALUES`.
127
         *
128
         * A clause is considered built just after fields' value
129
         * (`$this->field`) was used in building.
130
         */
131 108
        $built = [];
132
133
        /**
134
         * Statement's clauses.
135
         */
136 108
        $clauses = $this->getClauses();
137
138 108
        foreach ($clauses as $clause) {
139
            /**
140
             * The name of the clause.
141
             *
142
             * @var string
143
             */
144 104
            $name = $clause[0];
145
146
            /**
147
             * The type of the clause.
148
             *
149
             * @see self::$CLAUSES
150
             *
151
             * @var int
152
             */
153 104
            $type = $clause[1];
154
155
            /**
156
             * The builder (parser) of this clause.
157
             *
158
             * @var Component
159
             */
160 104
            $class = Parser::$KEYWORD_PARSERS[$name]['class'];
161
162
            /**
163
             * The name of the field that is used as source for the builder.
164
             * Same field is used to store the result of parsing.
165
             *
166
             * @var string
167
             */
168 104
            $field = Parser::$KEYWORD_PARSERS[$name]['field'];
169
170
            // The field is empty, there is nothing to be built.
171 104
            if (empty($this->$field)) {
172 104
                continue;
173
            }
174
175
            // Checking if this field was already built.
176 104
            if ($type & 1) {
177 104
                if (! empty($built[$field])) {
178 52
                    continue;
179
                }
180
181 104
                $built[$field] = true;
182
            }
183
184
            // Checking if the name of the clause should be added.
185 104
            if ($type & 2) {
186 104
                $query = trim($query) . ' ' . $name;
187
            }
188
189
            // Checking if the result of the builder should be added.
190 104
            if (! ($type & 1)) {
191 104
                continue;
192
            }
193
194 104
            $query = trim($query) . ' ' . $class::build($this->$field);
195
        }
196
197 108
        return $query;
198
    }
199
200
    /**
201
     * Parses the statements defined by the tokens list.
202
     *
203
     * @param Parser     $parser the instance that requests parsing
204
     * @param TokensList $list   the list of tokens to be parsed
205
     *
206
     * @return void
207
     *
208
     * @throws Exceptions\ParserException
209
     */
210 852
    public function parse(Parser $parser, TokensList $list)
211
    {
212
        /**
213
         * Array containing all list of clauses parsed.
214
         * This is used to check for duplicates.
215
         */
216 852
        $parsedClauses = [];
217
218
        // This may be corrected by the parser.
219 852
        $this->first = $list->idx;
220
221
        /**
222
         * Whether options were parsed or not.
223
         * For statements that do not have any options this is set to `true` by
224
         * default.
225
         */
226 852
        $parsedOptions = empty(static::$OPTIONS);
227
228 852
        for (; $list->idx < $list->count; ++$list->idx) {
229
            /**
230
             * Token parsed at this moment.
231
             */
232 852
            $token = $list->tokens[$list->idx];
233
234
            // End of statement.
235 852
            if ($token->type === Token::TYPE_DELIMITER) {
236 812
                break;
237
            }
238
239
            // Checking if this closing bracket is the pair for a bracket
240
            // outside the statement.
241 852
            if (($token->value === ')') && ($parser->brackets > 0)) {
242 28
                --$parser->brackets;
243 28
                continue;
244
            }
245
246
            // Only keywords are relevant here. Other parts of the query are
247
            // processed in the functions below.
248 852
            if ($token->type !== Token::TYPE_KEYWORD) {
249 68
                if (($token->type !== Token::TYPE_COMMENT) && ($token->type !== Token::TYPE_WHITESPACE)) {
250 36
                    $parser->error('Unexpected token.', $token);
251
                }
252
253 68
                continue;
254
            }
255
256
            // Unions are parsed by the parser because they represent more than
257
            // one statement.
258
            if (
259 852
                ($token->keyword === 'UNION') ||
260 852
                ($token->keyword === 'UNION ALL') ||
261 852
                ($token->keyword === 'UNION DISTINCT') ||
262 852
                ($token->keyword === 'EXCEPT') ||
263 852
                ($token->keyword === 'INTERSECT')
264
            ) {
265 80
                break;
266
            }
267
268 852
            $lastIdx = $list->idx;
269
270
            // ON DUPLICATE KEY UPDATE ...
271
            // has to be parsed in parent statement (INSERT or REPLACE)
272
            // so look for it and break
273 852
            if ($this instanceof Statements\SelectStatement && $token->value === 'ON') {
274 12
                ++$list->idx; // Skip ON
275
276
                // look for ON DUPLICATE KEY UPDATE
277 12
                $first = $list->getNextOfType(Token::TYPE_KEYWORD);
278 12
                $second = $list->getNextOfType(Token::TYPE_KEYWORD);
279 12
                $third = $list->getNextOfType(Token::TYPE_KEYWORD);
280
281
                if (
282
                    $first && $second && $third
283 12
                    && $first->value === 'DUPLICATE'
284 12
                    && $second->value === 'KEY'
285 12
                    && $third->value === 'UPDATE'
286
                ) {
287 12
                    $list->idx = $lastIdx;
288 12
                    break;
289
                }
290
            }
291
292 852
            $list->idx = $lastIdx;
293
294
            /**
295
             * The name of the class that is used for parsing.
296
             *
297
             * @var Component
298
             */
299 852
            $class = null;
300
301
            /**
302
             * The name of the field where the result of the parsing is stored.
303
             *
304
             * @var string
305
             */
306 852
            $field = null;
307
308
            /**
309
             * Parser's options.
310
             */
311 852
            $options = [];
312
313
            // Looking for duplicated clauses.
314
            if (
315 852
                ! empty(Parser::$KEYWORD_PARSERS[$token->value])
316 852
                || ! empty(Parser::$STATEMENT_PARSERS[$token->value])
317
            ) {
318 852
                if (! empty($parsedClauses[$token->value])) {
319 28
                    $parser->error('This type of clause was previously parsed.', $token);
320 28
                    break;
321
                }
322
323 852
                $parsedClauses[$token->value] = true;
324
            }
325
326
            // Checking if this is the beginning of a clause.
327
            // Fix Issue #221: As `truncate` is not a keyword
328
            // but it might be the beginning of a statement of truncate,
329
            // so let the value use the keyword field for truncate type.
330 852
            $tokenValue = in_array($token->keyword, ['TRUNCATE']) ? $token->keyword : $token->value;
331 852
            if (! empty(Parser::$KEYWORD_PARSERS[$tokenValue]) && $list->idx < $list->count) {
332 848
                $class = Parser::$KEYWORD_PARSERS[$tokenValue]['class'];
333 848
                $field = Parser::$KEYWORD_PARSERS[$tokenValue]['field'];
334 848
                if (! empty(Parser::$KEYWORD_PARSERS[$tokenValue]['options'])) {
335 656
                    $options = Parser::$KEYWORD_PARSERS[$tokenValue]['options'];
336
                }
337
            }
338
339
            // Checking if this is the beginning of the statement.
340 852
            if (! empty(Parser::$STATEMENT_PARSERS[$token->keyword])) {
341
                if (
342 852
                    ! empty(static::$CLAUSES) // Undefined for some statements.
343 852
                    && empty(static::$CLAUSES[$token->value])
344
                ) {
345
                    // Some keywords (e.g. `SET`) may be the beginning of a
346
                    // statement and a clause.
347
                    // If such keyword was found and it cannot be a clause of
348
                    // this statement it means it is a new statement, but no
349
                    // delimiter was found between them.
350 12
                    $parser->error(
351
                        'A new statement was found, but no delimiter between it and the previous one.',
352
                        $token
353
                    );
354 12
                    break;
355
                }
356
357 852
                if (! $parsedOptions) {
358 796
                    if (empty(static::$OPTIONS[$token->value])) {
359
                        // Skipping keyword because if it is not a option.
360 792
                        ++$list->idx;
361
                    }
362
363 796
                    $this->options = OptionsArray::parse($parser, $list, static::$OPTIONS);
364 852
                    $parsedOptions = true;
365
                }
366 572
            } elseif ($class === null) {
367
                if (
368
                    $this instanceof Statements\SelectStatement
369 48
                    && ($token->value === 'FOR UPDATE'
370 56
                        || $token->value === 'LOCK IN SHARE MODE')
371
                ) {
372
                    // Handle special end options in Select statement
373
                    // See Statements\SelectStatement::$END_OPTIONS
374 16
                    $this->end_options = OptionsArray::parse($parser, $list, static::$END_OPTIONS);
375
                } elseif (
376
                    $this instanceof Statements\SetStatement
377 4
                    && ($token->value === 'COLLATE'
378 40
                        || $token->value === 'DEFAULT')
379
                ) {
380
                    // Handle special end options in SET statement
381
                    // See Statements\SetStatement::$END_OPTIONS
382 4
                    $this->end_options = OptionsArray::parse($parser, $list, static::$END_OPTIONS);
383
                } else {
384
                    // There is no parser for this keyword and isn't the beginning
385
                    // of a statement (so no options) either.
386 36
                    $parser->error('Unrecognized keyword.', $token);
387 36
                    continue;
388
                }
389
            }
390
391 852
            $this->before($parser, $list, $token);
392
393
            // Parsing this keyword.
394 852
            if ($class !== null) {
395
                // We can't parse keyword at the end of statement
396 848
                if ($list->idx >= $list->count) {
397 4
                    $parser->error('Keyword at end of statement.', $token);
398 4
                    continue;
399
                }
400
401 844
                ++$list->idx; // Skipping keyword or last option.
402 844
                $this->$field = $class::parse($parser, $list, $options);
403
            }
404
405 848
            $this->after($parser, $list, $token);
406
407
            // #223 Here may make a patch, if last is delimiter, back one
408 848
            if ($class !== FunctionCall::class || $list->offsetGet($list->idx)->type !== Token::TYPE_DELIMITER) {
409 844
                continue;
410
            }
411
412 4
            --$list->idx;
413
        }
414
415
        // This may be corrected by the parser.
416 852
        $this->last = --$list->idx; // Go back to last used token.
417
    }
418
419
    /**
420
     * Function called before the token is processed.
421
     *
422
     * @param Parser     $parser the instance that requests parsing
423
     * @param TokensList $list   the list of tokens to be parsed
424
     * @param Token      $token  the token that is being parsed
425
     *
426
     * @return void
427
     */
428
    public function before(Parser $parser, TokensList $list, Token $token)
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

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

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...
429
    {
430
    }
431
432
    /**
433
     * Function called after the token was processed.
434
     *
435
     * @param Parser     $parser the instance that requests parsing
436
     * @param TokensList $list   the list of tokens to be parsed
437
     * @param Token      $token  the token that is being parsed
438
     *
439
     * @return void
440
     */
441
    public function after(Parser $parser, TokensList $list, Token $token)
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

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

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...
442
    {
443
    }
444
445
    /**
446
     * Gets the clauses of this statement.
447
     *
448
     * @return array<string, array<int, int|string>>
449
     * @psalm-return array<string, array{non-empty-string, (1|2|3)}>
450
     */
451 1220
    public function getClauses()
452
    {
453 1220
        return static::$CLAUSES;
454
    }
455
456
    /**
457
     * Builds the string representation of this statement.
458
     *
459
     * @see static::build
460
     *
461
     * @return string
462
     */
463 32
    public function __toString()
464
    {
465 32
        return $this->build();
466
    }
467
468
    /**
469
     * Validates the order of the clauses in parsed statement
470
     * Ideally this should be called after successfully
471
     * completing the parsing of each statement.
472
     *
473
     * @param Parser     $parser the instance that requests parsing
474
     * @param TokensList $list   the list of tokens to be parsed
475
     *
476
     * @return bool
477
     *
478
     * @throws Exceptions\ParserException
479
     */
480 1700
    public function validateClauseOrder($parser, $list)
481
    {
482 1700
        $clauses = array_flip(array_keys($this->getClauses()));
483
484 1700
        if (empty($clauses) || count($clauses) === 0) {
485 972
            return true;
486
        }
487
488 812
        $minIdx = -1;
489
490
        /**
491
         * For tracking JOIN clauses in a query
492
         *   = 0 - JOIN not found till now
493
         *   > 0 - Index of first JOIN clause in the statement.
494
         *
495
         * @var int
496
         */
497 812
        $minJoin = 0;
498
499
        /**
500
         * For tracking JOIN clauses in a query
501
         *   = 0 - JOIN not found till now
502
         *   > 0 - Index of last JOIN clause
503
         *         (which appears together with other JOINs)
504
         *         in the statement.
505
         *
506
         * @var int
507
         */
508 812
        $maxJoin = 0;
509
510 812
        $error = 0;
511 812
        $lastIdx = 0;
512 812
        foreach ($clauses as $clauseType => $index) {
513 812
            $clauseStartIdx = Utils\Query::getClauseStartOffset($this, $list, $clauseType);
514
515
            if (
516
                $clauseStartIdx !== -1
517
                && $this instanceof Statements\SelectStatement
518
                && ($clauseType === 'FORCE'
519
                    || $clauseType === 'IGNORE'
520
                    || $clauseType === 'USE')
521
            ) {
522
                // TODO: ordering of clauses in a SELECT statement with
523
                // Index hints is not supported
524 28
                return true;
525
            }
526
527
            // Handle ordering of Multiple Joins in a query
528 812
            if ($clauseStartIdx !== -1) {
529 796
                if ($minJoin === 0 && stripos($clauseType, 'JOIN')) {
530
                    // First JOIN clause is detected
531 80
                    $minJoin = $maxJoin = $clauseStartIdx;
532 796
                } elseif ($minJoin !== 0 && ! stripos($clauseType, 'JOIN')) {
533
                    // After a previous JOIN clause, a non-JOIN clause has been detected
534 40
                    $maxJoin = $lastIdx;
535 796
                } elseif ($maxJoin < $clauseStartIdx && stripos($clauseType, 'JOIN')) {
536 4
                    $error = 1;
537
                }
538
            }
539
540 812
            if ($clauseStartIdx !== -1 && $clauseStartIdx < $minIdx) {
541 24
                if ($minJoin === 0 || $error === 1) {
542 20
                    $token = $list->tokens[$clauseStartIdx];
543 20
                    $parser->error('Unexpected ordering of clauses.', $token);
544
545 20
                    return false;
546
                }
547
548 4
                $minIdx = $clauseStartIdx;
549 812
            } elseif ($clauseStartIdx !== -1) {
550 796
                $minIdx = $clauseStartIdx;
551
            }
552
553 812
            $lastIdx = $clauseStartIdx !== -1 ? $clauseStartIdx : $lastIdx;
554
        }
555
556 768
        return true;
557
    }
558
}
559