Passed
Pull Request — master (#485)
by
unknown
05:55 queued 02:24
created

AlterOperation::build()   A

Complexity

Conditions 6
Paths 8

Size

Total Lines 23
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 13
nc 8
nop 1
dl 0
loc 23
ccs 14
cts 14
cp 1
crap 6
rs 9.2222
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace PhpMyAdmin\SqlParser\Components;
6
7
use PhpMyAdmin\SqlParser\Component;
8
use PhpMyAdmin\SqlParser\Parser;
9
use PhpMyAdmin\SqlParser\Token;
10
use PhpMyAdmin\SqlParser\TokensList;
11
12
use function array_key_exists;
13
use function in_array;
14
use function is_numeric;
15
use function is_string;
16
use function trim;
17
18
/**
19
 * Parses an alter operation.
20
 */
21
final class AlterOperation implements Component
22
{
23
    /**
24
     * All database options.
25
     *
26
     * @var array<string, int|array<int, int|string>>
27
     * @psalm-var array<string, (positive-int|array{positive-int, ('var'|'var='|'expr'|'expr=')})>
28
     */
29
    public static $databaseOptions = [
30
        'CHARACTER SET' => [
31
            1,
32
            'var',
33
        ],
34
        'CHARSET' => [
35
            1,
36
            'var',
37
        ],
38
        'DEFAULT CHARACTER SET' => [
39
            1,
40
            'var',
41
        ],
42
        'DEFAULT CHARSET' => [
43
            1,
44
            'var',
45
        ],
46
        'UPGRADE' => [
47
            1,
48
            'var',
49
        ],
50
        'COLLATE' => [
51
            2,
52
            'var',
53
        ],
54
        'DEFAULT COLLATE' => [
55
            2,
56
            'var',
57
        ],
58
    ];
59
60
    /**
61
     * All table options.
62
     *
63
     * @var array<string, int|array<int, int|string>>
64
     * @psalm-var array<string, (positive-int|array{positive-int, ('var'|'var='|'expr'|'expr=')})>
65
     */
66
    public static $tableOptions = [
67
        'ENGINE' => [
68
            1,
69
            'var=',
70
        ],
71
        'ALGORITHM' => [
72
            1,
73
            'var=',
74
        ],
75
        'AUTO_INCREMENT' => [
76
            1,
77
            'var=',
78
        ],
79
        'AVG_ROW_LENGTH' => [
80
            1,
81
            'var',
82
        ],
83
        'COALESCE PARTITION' => [
84
            1,
85
            'var',
86
        ],
87
        'LOCK' => [
88
            1,
89
            'var=',
90
        ],
91
        'MAX_ROWS' => [
92
            1,
93
            'var',
94
        ],
95
        'ROW_FORMAT' => [
96
            1,
97
            'var',
98
        ],
99
        'COMMENT' => [
100
            1,
101
            'var',
102
        ],
103
        'ADD' => 1,
104
        'ALTER' => 1,
105
        'ANALYZE' => 1,
106
        'CHANGE' => 1,
107
        'CHARSET' => 1,
108
        'CHECK' => 1,
109
        'CONVERT' => 1,
110
        'DEFAULT CHARSET' => 1,
111
        'DISABLE' => 1,
112
        'DISCARD' => 1,
113
        'DROP' => 1,
114
        'ENABLE' => 1,
115
        'IMPORT' => 1,
116
        'MODIFY' => 1,
117
        'OPTIMIZE' => 1,
118
        'ORDER' => 1,
119
        'REBUILD' => 1,
120
        'REMOVE' => 1,
121
        'RENAME' => 1,
122
        'REORGANIZE' => 1,
123
        'REPAIR' => 1,
124
        'UPGRADE' => 1,
125
126
        'COLUMN' => 2,
127
        'CONSTRAINT' => 2,
128
        'DEFAULT' => 2,
129
        'BY' => 2,
130
        'FOREIGN' => 2,
131
        'FULLTEXT' => 2,
132
        'KEY' => 2,
133
        'KEYS' => 2,
134
        'PARTITION' => 2,
135
        'PARTITION BY' => 2,
136
        'PARTITIONING' => 2,
137
        'PRIMARY KEY' => 2,
138
        'SPATIAL' => 2,
139
        'TABLESPACE' => 2,
140
        'INDEX' => [
141
            2,
142
            'var',
143
        ],
144
145
        'CHARACTER SET' => 3,
146
        'TO' => [
147
            3,
148
            'var',
149
        ],
150
    ];
151
152
    /**
153
     * All user options.
154
     *
155
     * @var array<string, int|array<int, int|string>>
156
     * @psalm-var array<string, (positive-int|array{positive-int, ('var'|'var='|'expr'|'expr=')})>
157
     */
158
    public static $userOptions = [
159
        'ATTRIBUTE' => [
160
            1,
161
            'var',
162
        ],
163
        'COMMENT' => [
164
            1,
165
            'var',
166
        ],
167
        'REQUIRE' => [
168
            1,
169
            'var',
170
        ],
171
172
        'IDENTIFIED VIA' => [
173
            2,
174
            'var',
175
        ],
176
        'IDENTIFIED WITH' => [
177
            2,
178
            'var',
179
        ],
180
        'PASSWORD' => [
181
            2,
182
            'var',
183
        ],
184
        'WITH' => [
185
            2,
186
            'var',
187
        ],
188
189
        'BY' => [
190
            4,
191
            'expr',
192
        ],
193
194
        'ACCOUNT' => 1,
195
        'DEFAULT' => 1,
196
197
        'LOCK' => 2,
198
        'UNLOCK' => 2,
199
200
        'IDENTIFIED' => 3,
201
    ];
202
203
    /**
204
     * All view options.
205
     *
206
     * @var array<string, int|array<int, int|string>>
207
     * @psalm-var array<string, (positive-int|array{positive-int, ('var'|'var='|'expr'|'expr=')})>
208
     */
209
    public static $viewOptions = ['AS' => 1];
210
211
    /**
212
     * All event options.
213
     *
214
     * @var array<string, int|array<int, int|string>>
215
     * @psalm-var array<string, (positive-int|array{positive-int, ('var'|'var='|'expr'|'expr=')})>
216
     */
217
    public static $eventOptions = [
218
        'ON SCHEDULE' => 1,
219
        'EVERY' => [
220
            2,
221
            'expr',
222
        ],
223
        'AT' => [
224
            2,
225
            'expr',
226
        ],
227
        'STARTS' => [
228
            3,
229
            'expr',
230
        ],
231
        'ENDS' => [
232
            4,
233
            'expr',
234
        ],
235
        'ON COMPLETION PRESERVE' => 5,
236
        'ON COMPLETION NOT PRESERVE' => 5,
237
        'RENAME' => 6,
238
        'TO' => [
239
            7,
240
            'expr',
241
            ['parseField' => 'table'],
242
        ],
243
        'ENABLE' => 8,
244
        'DISABLE' => 8,
245
        'DISABLE ON SLAVE' => 8,
246
        'COMMENT' => [
247
            9,
248
            'var',
249
        ],
250
        'DO' => 10,
251
    ];
252
253
    /**
254
     * Options of this operation.
255
     *
256
     * @var OptionsArray
257
     */
258
    public $options;
259
260
    /**
261
     * The altered field.
262
     *
263
     * @var Expression|string|null
264
     */
265
    public $field;
266
267
    /**
268
     * The partitions.
269
     *
270
     * @var Component[]|ArrayObj|null
271
     */
272
    public $partitions;
273
274
    /**
275
     * Unparsed tokens.
276
     *
277
     * @var Token[]|string
278
     */
279
    public $unknown = [];
280
281
    /**
282
     * @param OptionsArray              $options    options of alter operation
283
     * @param Expression|string|null    $field      altered field
284
     * @param Component[]|ArrayObj|null $partitions partitions definition found in the operation
285
     * @param Token[]                   $unknown    unparsed tokens found at the end of operation
286
     */
287 174
    public function __construct(
288
        $options = null,
289
        $field = null,
290
        $partitions = null,
291
        $unknown = []
292
    ) {
293 174
        $this->partitions = $partitions;
294 174
        $this->options = $options;
295 174
        $this->field = $field;
296 174
        $this->unknown = $unknown;
297
    }
298
299
    /**
300
     * @param Parser               $parser  the parser that serves as context
301
     * @param TokensList           $list    the list of tokens that are being parsed
302
     * @param array<string, mixed> $options parameters for parsing
303
     *
304
     * @return AlterOperation
305
     */
306 174
    public static function parse(Parser $parser, TokensList $list, array $options = [])
307
    {
308 174
        $ret = new static();
309
310
        /**
311
         * Counts brackets.
312
         *
313
         * @var int
314
         */
315 174
        $brackets = 0;
316
317
        /**
318
         * The state of the parser.
319
         *
320
         * Below are the states of the parser.
321
         *
322
         *      0 ---------------------[ options ]---------------------> 1
323
         *
324
         *      1 ----------------------[ field ]----------------------> 2
325
         *
326
         *      1 -------------[ PARTITION / PARTITION BY ]------------> 3
327
         *
328
         *      2 -------------------------[ , ]-----------------------> 0
329
         *
330
         * @var int
331
         */
332 174
        $state = 0;
333
334
        /**
335
         * partition state.
336
         *
337
         * @var int
338
         */
339 174
        $partitionState = 0;
340
341 174
        for (; $list->idx < $list->count; ++$list->idx) {
342
            /**
343
             * Token parsed at this moment.
344
             */
345 174
            $token = $list->tokens[$list->idx];
346
347
            // End of statement.
348 174
            if ($token->type === Token::TYPE_DELIMITER) {
349 158
                break;
350
            }
351
352
            // Skipping comments.
353 174
            if ($token->type === Token::TYPE_COMMENT) {
354 2
                continue;
355
            }
356
357
            // Skipping whitespaces.
358 174
            if ($token->type === Token::TYPE_WHITESPACE) {
359 64
                if ($state === 2) {
360
                    // When parsing the unknown part, the whitespaces are
361
                    // included to not break anything.
362 56
                    $ret->unknown[] = $token;
363 56
                    continue;
364
                }
365
            }
366
367 174
            if ($state === 0) {
368 174
                $ret->options = OptionsArray::parse($parser, $list, $options);
369
370
                // Not only when aliasing but also when parsing the body of an event, we just list the tokens of the
371
                // body in the unknown tokens list, as they define their own statements.
372 174
                if ($ret->options->has('AS') || $ret->options->has('DO')) {
373 6
                    for (; $list->idx < $list->count; ++$list->idx) {
374 6
                        if ($list->tokens[$list->idx]->type === Token::TYPE_DELIMITER) {
375 6
                            break;
376
                        }
377
378 6
                        $ret->unknown[] = $list->tokens[$list->idx];
379
                    }
380
381 6
                    break;
382
                }
383
384 168
                $state = 1;
385 168
                if ($ret->options->has('PARTITION') || $token->value === 'PARTITION BY') {
386 8
                    $state = 3;
387 168
                    $list->getPrevious(); // in order to check whether it's partition or partition by.
388
                }
389 110
            } elseif ($state === 1) {
390 102
                $ret->field = Expression::parse(
391 102
                    $parser,
392 102
                    $list,
393 102
                    [
394 102
                        'breakOnAlias' => true,
395 102
                        'parseField' => 'column',
396 102
                    ]
397 102
                );
398 102
                if ($ret->field === null) {
399
                    // No field was read. We go back one token so the next
400
                    // iteration will parse the same token, but in state 2.
401 44
                    --$list->idx;
402
                }
403
404
                // If the operation is a RENAME COLUMN, now we have detected the field to rename, we need to parse
405
                // again the options to get the new name of the column.
406 102
                if ($ret->options->has('RENAME') && $ret->options->has('COLUMN')) {
407 12
                    $nextOptions = OptionsArray::parse($parser, $list, $options);
408 12
                    $ret->options->merge($nextOptions);
409
                }
410
411 102
                $state = 2;
412 96
            } elseif ($state === 2) {
413 88
                if (is_string($token->value) || is_numeric($token->value)) {
414 88
                    $arrayKey = $token->value;
415
                } else {
416 2
                    $arrayKey = $token->token;
417
                }
418
419 88
                if ($token->type === Token::TYPE_OPERATOR) {
420 62
                    if ($token->value === '(') {
421 42
                        ++$brackets;
422 62
                    } elseif ($token->value === ')') {
423 42
                        --$brackets;
424 44
                    } elseif (($token->value === ',') && ($brackets === 0)) {
425 62
                        break;
426
                    }
427 76
                } elseif (! self::checkIfTokenQuotedSymbol($token)) {
428 68
                    if (! empty(Parser::$statementParsers[$token->value])) {
429 12
                        $list->idx++; // Ignore the current token
430 12
                        $nextToken = $list->getNext();
431
432
                        if (
433 12
                            in_array($token->value, ['SET', 'ENUM'], true)
434 12
                            && $nextToken !== null
435 12
                            && $nextToken->value === '('
436
                        ) {
437
                            // To avoid adding the tokens between the SET() or ENUM() parentheses to the unknown tokens
438 4
                            $list->getNextOfTypeAndValue(Token::TYPE_OPERATOR, ')');
439
                        } elseif (
440 8
                            in_array($token->value, ['SET', 'ENUM'], true)
441 8
                            && $nextToken !== null
442 8
                            && $nextToken->value === 'DEFAULT'
443
                        ) {
444
                            // to avoid adding the `DEFAULT` token to the unknown tokens.
445 4
                            ++$list->idx;
446
                        } else {
447
                            // We have reached the end of ALTER operation and suddenly found
448
                            // a start to new statement, but have not find a delimiter between them
449 4
                            $parser->error(
450 4
                                'A new statement was found, but no delimiter between it and the previous one.',
451 4
                                $token
452 4
                            );
453 12
                            break;
454
                        }
455
                    } elseif (
456 62
                        (array_key_exists($arrayKey, self::$databaseOptions)
457 62
                        || array_key_exists($arrayKey, self::$tableOptions))
458 62
                        && ! self::checkIfColumnDefinitionKeyword($arrayKey)
459
                    ) {
460
                        // This alter operation has finished, which means a comma
461
                        // was missing before start of new alter operation
462 4
                        $parser->error('Missing comma before start of a new alter operation.', $token);
463 4
                        break;
464
                    }
465
                }
466
467 74
                $ret->unknown[] = $token;
468 8
            } elseif ($state === 3) {
469 8
                if ($partitionState === 0) {
470 8
                    $list->idx++; // Ignore the current token
471 8
                    $nextToken = $list->getNext();
472
                    if (
473 8
                        ($token->type === Token::TYPE_KEYWORD)
474 8
                        && (($token->keyword === 'PARTITION BY')
475 8
                        || ($token->keyword === 'PARTITION' && $nextToken && $nextToken->value !== '('))
476
                    ) {
477 8
                        $partitionState = 1;
478 2
                    } elseif (($token->type === Token::TYPE_KEYWORD) && ($token->keyword === 'PARTITION')) {
479 2
                        $partitionState = 2;
480
                    }
481
482 8
                    --$list->idx; // to decrease the idx by one, because the last getNext returned and increased it.
483
484
                    // reverting the effect of the getNext
485 8
                    $list->getPrevious();
486 8
                    $list->getPrevious();
487
488 8
                    ++$list->idx; // to index the idx by one, because the last getPrevious returned and decreased it.
489 8
                } elseif ($partitionState === 1) {
490
                    // Fetch the next token in a way the current index is reset to manage whitespaces in "field".
491 8
                    $currIdx = $list->idx;
492 8
                    ++$list->idx;
493 8
                    $nextToken = $list->getNext();
494 8
                    $list->idx = $currIdx;
495
                    // Building the expression used for partitioning.
496 8
                    if (empty($ret->field)) {
497 8
                        $ret->field = '';
498
                    }
499
500
                    if (
501 8
                        $token->type === Token::TYPE_OPERATOR
502 8
                        && $token->value === '('
503
                        && $nextToken
504 8
                        && $nextToken->keyword === 'PARTITION'
505
                    ) {
506 6
                        $partitionState = 2;
507 6
                        --$list->idx; // Current idx is on "(". We need a step back for ArrayObj::parse incoming.
508
                    } else {
509 8
                        $ret->field .= $token->type === Token::TYPE_WHITESPACE ? ' ' : $token->token;
510
                    }
511 6
                } elseif ($partitionState === 2) {
512 6
                    $ret->partitions = ArrayObj::parse(
513 6
                        $parser,
514 6
                        $list,
515 6
                        ['type' => PartitionDefinition::class]
516 6
                    );
517
                }
518
            }
519
        }
520
521 174
        if ($ret->options->isEmpty()) {
522 2
            $parser->error('Unrecognized alter operation.', $list->tokens[$list->idx]);
523
        }
524
525 174
        --$list->idx;
526
527 174
        return $ret;
528
    }
529
530
    /**
531
     * @param AlterOperation $component the component to be built
532
     */
533 24
    public static function build($component): string
534
    {
535
        // Specific case of RENAME COLUMN that insert the field between 2 options.
536 24
        $afterFieldsOptions = new OptionsArray();
537 24
        if ($component->options->has('RENAME') && $component->options->has('COLUMN')) {
538 8
            $afterFieldsOptions = clone $component->options;
539 8
            $afterFieldsOptions->remove('RENAME');
540 8
            $afterFieldsOptions->remove('COLUMN');
541 8
            $component->options->remove('TO');
542
        }
543
544 24
        $ret = $component->options . ' ';
545 24
        if (isset($component->field) && ($component->field !== '')) {
546 18
            $ret .= $component->field . ' ';
547
        }
548
549 24
        $ret .= $afterFieldsOptions . TokensList::build($component->unknown);
550
551 24
        if (isset($component->partitions)) {
552 2
            $ret .= PartitionDefinition::build($component->partitions);
0 ignored issues
show
Bug introduced by
It seems like $component->partitions can also be of type PhpMyAdmin\SqlParser\Components\ArrayObj; however, parameter $component of PhpMyAdmin\SqlParser\Com...tionDefinition::build() does only seem to accept PhpMyAdmin\SqlParser\Com...nts\PartitionDefinition, maybe add an additional type check? ( Ignorable by Annotation )

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

552
            $ret .= PartitionDefinition::build(/** @scrutinizer ignore-type */ $component->partitions);
Loading history...
553
        }
554
555 24
        return trim($ret);
556
    }
557
558
    /**
559
     * Check if token's value is one of the common keywords
560
     * between column and table alteration
561
     *
562
     * @param string $tokenValue Value of current token
563
     */
564 32
    private static function checkIfColumnDefinitionKeyword($tokenValue): bool
565
    {
566 32
        $commonOptions = [
567 32
            'AUTO_INCREMENT',
568 32
            'COMMENT',
569 32
            'DEFAULT',
570 32
            'CHARACTER SET',
571 32
            'COLLATE',
572 32
            'PRIMARY',
573 32
            'UNIQUE',
574 32
            'PRIMARY KEY',
575 32
            'UNIQUE KEY',
576 32
        ];
577
578
        // Since these options can be used for
579
        // both table as well as a specific column in the table
580 32
        return in_array($tokenValue, $commonOptions);
581
    }
582
583
    /**
584
     * Check if token is symbol and quoted with backtick
585
     *
586
     * @param Token $token token to check
587
     */
588 76
    private static function checkIfTokenQuotedSymbol($token): bool
589
    {
590 76
        return $token->type === Token::TYPE_SYMBOL && $token->flags === Token::FLAG_SYMBOL_BACKTICK;
591
    }
592
593
    public function __toString(): string
594
    {
595
        return static::build($this);
596
    }
597
}
598