diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b7c98d59..f1813f8f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -591,7 +591,7 @@ parameters: path: src/Components/Key.php - - message: "#^Property PhpMyAdmin\\\\SqlParser\\\\Components\\\\Key\\:\\:\\$columns \\(array\\\\) does not accept non\\-empty\\-array\\\\>\\.$#" + message: "#^Property PhpMyAdmin\\\\SqlParser\\\\Components\\\\Key\\:\\:\\$columns \\(array\\\\) does not accept non\\-empty\\-array\\\\>\\.$#" count: 1 path: src/Components/Key.php diff --git a/src/Components/Key.php b/src/Components/Key.php index 9c0ff839..7f10c351 100644 --- a/src/Components/Key.php +++ b/src/Components/Key.php @@ -10,6 +10,7 @@ use PhpMyAdmin\SqlParser\Token; use PhpMyAdmin\SqlParser\TokensList; +use function count; use function implode; use function trim; @@ -77,7 +78,7 @@ class Key extends Component * The key columns * * @var array> - * @phpstan-var array{name?: string, length?: int, order?: string}[] + * @phpstan-var array{name?: string, expr?: string, length?: int, order?: string}[] */ public $columns; @@ -107,7 +108,7 @@ class Key extends Component * @param array> $columns the columns covered by this key * @param string $type the type of this key * @param OptionsArray $options the options of this key - * @phpstan-param array{name?: string, length?: int, order?: string}[] $columns + * @phpstan-param array{name?: string, expr?: string, length?: int, order?: string}[] $columns */ public function __construct( $name = null, @@ -148,17 +149,23 @@ public static function parse(Parser $parser, TokensList $list, array $options = * * 1 ---------------------[ name ]---------------------------> 1 * 1 ---------------------[ columns ]------------------------> 2 - * 1 ---------------------[ expression ]---------------------> 5 * + * 2 ---------------------[ expression ]---------------------> 2 * 2 ---------------------[ column length ]------------------> 3 * 3 ---------------------[ column length ]------------------> 2 * 2 ---------------------[ options ]------------------------> 4 - * 5 ---------------------[ expression ]---------------------> 4 * * @var int */ $state = 0; + /** + * Parentheses depth while skipping an invalid key part, null when not skipping. + * + * @var int|null + */ + $skipDepth = null; + for (; $list->idx < $list->count; ++$list->idx) { /** * Token parsed at this moment. @@ -180,23 +187,41 @@ public static function parse(Parser $parser, TokensList $list, array $options = $state = 1; } elseif ($state === 1) { if (($token->type === Token::TYPE_OPERATOR) && ($token->value === '(')) { - $positionBeforeSearch = $list->idx; - $list->idx++;// Ignore the current token "(" or the search condition will always be true - $nextToken = $list->getNext(); - $list->idx = $positionBeforeSearch;// Restore the position - - if ($nextToken !== null && $nextToken->value === '(') { - // Switch to expression mode - $state = 5; - } else { - $state = 2; - } + $state = 2; } else { $ret->name = $token->value; } } elseif ($state === 2) { + if ($skipDepth !== null) { + // Skips the rest of an invalid key part. + if ($token->type === Token::TYPE_OPERATOR && $token->value === '(') { + ++$skipDepth; + continue; + } + + if ($token->type === Token::TYPE_OPERATOR && $token->value === ')' && $skipDepth > 0) { + --$skipDepth; + continue; + } + + $isPartEnd = $token->type === Token::TYPE_OPERATOR + && ($token->value === ',' || $token->value === ')'); + if ($skipDepth > 0 || ! $isPartEnd) { + continue; + } + + $skipDepth = null; + } + if ($token->type === Token::TYPE_OPERATOR) { - if ($token->value === '(') { + if ($token->value === '(' && $lastColumn === []) { + // A functional key part, e.g. `(col * 2)` in `KEY (a, (col * 2), b)`. + $lastColumn['expr'] = (string) Expression::parse( + $parser, + $list, + ['parenthesesDelimited' => true] + ); + } elseif ($token->value === '(' && ! isset($lastColumn['expr'])) { $state = 3; } elseif (($token->value === ',') || ($token->value === ')')) { $state = $token->value === ',' ? 2 : 4; @@ -204,6 +229,14 @@ public static function parse(Parser $parser, TokensList $list, array $options = $ret->columns[] = $lastColumn; $lastColumn = []; } + + if ($state === 4) { + static::moveExpressionsOnlyKeyToExpr($ret); + } + } elseif (isset($lastColumn['expr'])) { + $parser->error('Unexpected token.', $token); + // An unexpected "(" is already consumed, so its ")" must not end the key. + $skipDepth = $token->value === '(' ? 1 : 0; } } elseif ( ( @@ -215,6 +248,9 @@ public static function parse(Parser $parser, TokensList $list, array $options = ) ) { $lastColumn['order'] = $token->keyword; + } elseif (isset($lastColumn['expr'])) { + $parser->error('Unexpected token.', $token); + $skipDepth = 0; } else { $lastColumn['name'] = $token->value; } @@ -228,41 +264,34 @@ public static function parse(Parser $parser, TokensList $list, array $options = $ret->options = OptionsArray::parse($parser, $list, static::$KEY_OPTIONS); ++$list->idx; break; - } elseif ($state === 5) { - if ($token->type === Token::TYPE_OPERATOR) { - // This got back to here and we reached the end of the expression - if ($token->value === ')') { - $state = 4;// go back to state 4 to fetch options - continue; - } - - // The expression is not finished, adding a separator for the next expression - if ($token->value === ',') { - $ret->expr .= ', '; - continue; - } + } + } - // Start of the expression - if ($token->value === '(') { - // This is the first expression, set to empty - if ($ret->expr === null) { - $ret->expr = ''; - } + --$list->idx; - $ret->expr .= Expression::parse($parser, $list, ['parenthesesDelimited' => true]); - continue; - } - // Another unexpected operator was found - } + return $ret; + } - // Something else than an operator was found - $parser->error('Unexpected token.', $token); + /** + * Keeps the `expr` representation for keys made only of expressions. + */ + private static function moveExpressionsOnlyKeyToExpr(Key $key): void + { + $expressions = []; + foreach ($key->columns as $column) { + if (! isset($column['expr']) || count($column) !== 1) { + return; } + + $expressions[] = $column['expr']; } - --$list->idx; + if ($expressions === []) { + return; + } - return $ret; + $key->expr = implode(', ', $expressions); + $key->columns = []; } /** @@ -289,6 +318,10 @@ public static function build($component, array $options = []) $tmp .= Context::escape($column['name']); } + if (isset($column['expr'])) { + $tmp .= $column['expr']; + } + if (isset($column['length'])) { $tmp .= '(' . $column['length'] . ')'; } diff --git a/tests/Builder/CreateStatementTest.php b/tests/Builder/CreateStatementTest.php index 693bad1b..eff7d2be 100644 --- a/tests/Builder/CreateStatementTest.php +++ b/tests/Builder/CreateStatementTest.php @@ -895,4 +895,22 @@ public function testBuildCreateTableWithUppercaseHexDefault(): void $this->assertEmpty($parser->errors); $this->assertStringContainsString("DEFAULT X'FF'", $parser->statements[0]->build()); } + + public function testBuildCreateTableWithColumnsAndExpressionsInIndexes(): void + { + $sql = <<<'SQL' +CREATE TABLE `t` ( + `a` int NOT NULL, + `b` int NOT NULL, + `c` varchar(20) DEFAULT NULL, + UNIQUE KEY `u` (`a`,(ifnull(`c`,_utf8mb4'x'))) USING BTREE, + KEY `k` (`a`,`b` DESC,((((`a` % 7) * 3) + `b`)),`c`), + KEY `e` (((`b` * 2)) DESC,`a`) +) ENGINE=InnoDB +SQL; + + $parser = new Parser($sql); + self::assertEmpty($parser->errors); + self::assertEquals($sql, $parser->statements[0]->build()); + } } diff --git a/tests/Components/KeyTest.php b/tests/Components/KeyTest.php index 421a05d5..0bb0671b 100644 --- a/tests/Components/KeyTest.php +++ b/tests/Components/KeyTest.php @@ -275,14 +275,9 @@ public function testParseKeyExpressionWithOptionsError(): void $t ), ], $parser->errors); - $expr = new Expression('(convert_tz(`cache_updated`,_utf8mb4\'GMT\',_utf8mb4\'GB\'))'); - $expr->function = 'convert_tz'; - $this->assertEquals('()(`cache_updated`,_utf8mb4\'GMT\',_utf8mb4\'GB\')', $component->expr); + $this->assertEquals('()', $component->expr); $this->assertSame([], $component->columns); - $this->assertSame( - 'KEY `updated_tz_ind2` (()(`cache_updated`,_utf8mb4\'GMT\',_utf8mb4\'GB\')) ', - Key::build($component) - ); + $this->assertSame('KEY `updated_tz_ind2` (()) ', Key::build($component)); } public function testParseKeyOneExpressionWithOptions(): void @@ -371,4 +366,86 @@ public function testParseKeyMultipleExpressionsWithOptions(): void Key::build($component) ); } + + public function testParseKeyWithColumnsAndExpressions(): void + { + $parser = new Parser(); + $component = Key::parse( + $parser, + $this->getTokensList('KEY `k` (`a`,`b` DESC,((((`a` % 7) * 3) + `b`)),`c`),') + ); + self::assertSame([], $parser->errors); + self::assertEquals('KEY', $component->type); + self::assertEquals('k', $component->name); + self::assertNull($component->expr); + self::assertSame([ + ['name' => 'a'], + ['name' => 'b', 'order' => 'DESC'], + ['expr' => '((((`a` % 7) * 3) + `b`))'], + ['name' => 'c'], + ], $component->columns); + self::assertSame( + 'KEY `k` (`a`,`b` DESC,((((`a` % 7) * 3) + `b`)),`c`)', + Key::build($component) + ); + } + + public function testParseKeyWithExpressionBeforeColumns(): void + { + $parser = new Parser(); + $component = Key::parse( + $parser, + $this->getTokensList('UNIQUE KEY `u` ((ifnull(`a`,0)) DESC,`b`) USING BTREE,') + ); + self::assertSame([], $parser->errors); + self::assertEquals('UNIQUE KEY', $component->type); + self::assertNull($component->expr); + self::assertSame([ + ['expr' => '(ifnull(`a`,0))', 'order' => 'DESC'], + ['name' => 'b'], + ], $component->columns); + self::assertSame( + 'UNIQUE KEY `u` ((ifnull(`a`,0)) DESC,`b`) USING BTREE', + Key::build($component) + ); + } + + public function testParseKeyExpressionWithOrder(): void + { + $parser = new Parser(); + $component = Key::parse($parser, $this->getTokensList('KEY `expr_desc` (((`a` + 1)) DESC),')); + self::assertSame([], $parser->errors); + self::assertNull($component->expr); + self::assertSame([['expr' => '((`a` + 1))', 'order' => 'DESC']], $component->columns); + self::assertSame('KEY `expr_desc` (((`a` + 1)) DESC)', Key::build($component)); + } + + public function testParseKeyWithColumnsAndExpressionsError(): void + { + $parser = new Parser(); + $component = Key::parse($parser, $this->getTokensList('KEY `k` (`a`,((`b` * 2)) `c`(5),`d`),')); + $t = new Token('`c`', Token::TYPE_SYMBOL, Token::FLAG_SYMBOL_BACKTICK); + $t->value = 'c'; + $t->position = 25; + self::assertEquals([new ParserException('Unexpected token.', $t)], $parser->errors); + self::assertSame([ + ['name' => 'a'], + ['expr' => '((`b` * 2))'], + ['name' => 'd'], + ], $component->columns); + } + + public function testParseKeyWithUnexpectedParenthesisAfterExpression(): void + { + $parser = new Parser(); + $component = Key::parse($parser, $this->getTokensList('KEY `k` (`a`,((`b` + 1))(5),`d`),')); + $t = new Token('(', Token::TYPE_OPERATOR, Token::FLAG_OPERATOR_SQL); + $t->position = 24; + self::assertEquals([new ParserException('Unexpected token.', $t)], $parser->errors); + self::assertSame([ + ['name' => 'a'], + ['expr' => '((`b` + 1))'], + ['name' => 'd'], + ], $component->columns); + } }