Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ parameters:
path: src/Components/Key.php

-
message: "#^Property PhpMyAdmin\\\\SqlParser\\\\Components\\\\Key\\:\\:\\$columns \\(array\\<array\\{name\\?\\: string, length\\?\\: int, order\\?\\: string\\}\\>\\) does not accept non\\-empty\\-array\\<array\\<string, mixed\\>\\>\\.$#"
message: "#^Property PhpMyAdmin\\\\SqlParser\\\\Components\\\\Key\\:\\:\\$columns \\(array\\<array\\{name\\?\\: string, expr\\?\\: string, length\\?\\: int, order\\?\\: string\\}\\>\\) does not accept non\\-empty\\-array\\<array\\<string, mixed\\>\\>\\.$#"
count: 1
path: src/Components/Key.php

Expand Down
121 changes: 77 additions & 44 deletions src/Components/Key.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use PhpMyAdmin\SqlParser\Token;
use PhpMyAdmin\SqlParser\TokensList;

use function count;
use function implode;
use function trim;

Expand Down Expand Up @@ -77,7 +78,7 @@ class Key extends Component
* The key columns
*
* @var array<int, array<string, int|string>>
* @phpstan-var array{name?: string, length?: int, order?: string}[]
* @phpstan-var array{name?: string, expr?: string, length?: int, order?: string}[]
*/
public $columns;

Expand Down Expand Up @@ -107,7 +108,7 @@ class Key extends Component
* @param array<int, array<string, int|string>> $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,
Expand Down Expand Up @@ -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.
Expand All @@ -180,30 +187,56 @@ 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;
if (! empty($lastColumn)) {
$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 (
(
Expand All @@ -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;
}
Expand All @@ -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 = [];
}

/**
Expand All @@ -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'] . ')';
}
Expand Down
18 changes: 18 additions & 0 deletions tests/Builder/CreateStatementTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
91 changes: 84 additions & 7 deletions tests/Components/KeyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}