diff --git a/src/Parsers/AlterOperations.php b/src/Parsers/AlterOperations.php index 7083b175..2cb54347 100644 --- a/src/Parsers/AlterOperations.php +++ b/src/Parsers/AlterOperations.php @@ -262,6 +262,7 @@ public static function parse(Parser $parser, TokensList $list, array $options = * Counts brackets. */ $brackets = 0; + $isFirstUnknownToken = true; /** * The state of the parser. @@ -355,6 +356,12 @@ public static function parse(Parser $parser, TokensList $list, array $options = $state = 2; } elseif ($state === 2) { + if ($isFirstUnknownToken && $ret->options?->has('ADD') === true && $token->keyword === 'VARCHAR') { + // Validate the column type while preserving the original tokens used to build ALTER. + DataTypes::parse($parser, clone $list); + } + + $isFirstUnknownToken = false; if (is_string($token->value) || is_int($token->value)) { $arrayKey = $token->value; } else { diff --git a/src/Parsers/DataTypes.php b/src/Parsers/DataTypes.php index 8b961bc0..e22bfcc2 100644 --- a/src/Parsers/DataTypes.php +++ b/src/Parsers/DataTypes.php @@ -11,6 +11,7 @@ use PhpMyAdmin\SqlParser\TokensList; use PhpMyAdmin\SqlParser\TokenType; +use function preg_match; use function strtoupper; /** @@ -79,7 +80,37 @@ public static function parse(Parser $parser, TokensList $list, array $options = $state = 1; } elseif ($state === 1) { if (($token->type === TokenType::Operator) && ($token->value === '(')) { + $parametersStart = $list->idx + 1; $parameters = ArrayObjs::parse($parser, $list); + if ($ret->name === 'VARCHAR') { + $hasLength = false; + for ($idx = $parametersStart; $idx < $list->idx; ++$idx) { + $parameter = $list->tokens[$idx]; + if ($parameter->type === TokenType::Whitespace || $parameter->type === TokenType::Comment) { + continue; + } + + // Check the original lexeme: conversion can turn 20.0 or '20' into 20. + if ( + $hasLength || $parameter->type !== TokenType::Number + || preg_match('/^[0-9]+$/D', $parameter->token) !== 1 + ) { + $parser->error('VARCHAR length must be a single nonnegative integer.', $parameter); + $hasLength = true; + break; + } + + $hasLength = true; + } + + if (! $hasLength) { + $parser->error( + 'VARCHAR length must be a single nonnegative integer.', + $list->tokens[$list->idx] ?? null, + ); + } + } + ++$list->idx; $ret->parameters = ($ret->name === 'ENUM') || ($ret->name === 'SET') ? $parameters->raw : $parameters->values; diff --git a/tests/Components/DataTypeTest.php b/tests/Components/DataTypeTest.php new file mode 100644 index 00000000..142c416a --- /dev/null +++ b/tests/Components/DataTypeTest.php @@ -0,0 +1,176 @@ +getTokensList($declaration . ' COLLATE utf8mb4_bin NULL')); + self::assertSame([], $parser->errors); + self::assertNotNull($type); + self::assertSame('VARCHAR', $type->name); + self::assertSame([$length], $type->parameters); + self::assertNotNull($type->options); + self::assertSame('utf8mb4_bin', $type->options->get('COLLATE')); + + $parser = new Parser('CREATE TABLE temp_users (phone ' . $declaration . ' COLLATE utf8mb4_bin NULL);'); + self::assertSame([], $parser->errors); + self::assertInstanceOf(CreateStatement::class, $parser->statements[0]); + self::assertIsArray($parser->statements[0]->fields); + $field = $parser->statements[0]->fields[0]; + self::assertNotNull($field->type); + self::assertSame([$length], $field->type->parameters); + self::assertNotNull($field->options); + self::assertTrue($field->options->has('NULL')); + + $parser = new Parser('ALTER TABLE temp_users ADD COLUMN phone ' . $declaration . ' NULL;'); + self::assertSame([], $parser->errors); + self::assertInstanceOf(AlterStatement::class, $parser->statements[0]); + self::assertStringContainsString($length, $parser->statements[0]->build()); + self::assertStringContainsString('NULL', $parser->statements[0]->build()); + } + + /** @return array */ + public static function validLengthProvider(): array + { + return [ + 'ordinary' => ['VARCHAR(20)', '20'], + 'zero' => ['VARCHAR(0)', '0'], + 'leading zeros' => ['VARCHAR(020)', '20'], + 'mixed case and whitespace' => ["vArChAr \n ( 20\t )", '20'], + 'comments' => ['varchar /* type */ ( /* before */ 20 /* after */ )', '20'], + 'line comments' => ["varchar( -- before\n 20 # after\n )", '20'], + ]; + } + + #[DataProvider('invalidLengthProvider')] + public function testInvalidLength(string $length, string $invalidToken): void + { + $parser = new Parser(); + DataTypes::parse($parser, $this->getTokensList('VARCHAR(' . $length . ') NULL')); + self::assertCount(1, $parser->errors); + self::assertSame(self::LENGTH_ERROR, $parser->errors[0]->getMessage()); + self::assertInstanceOf(ParserException::class, $parser->errors[0]); + self::assertNotNull($parser->errors[0]->token); + self::assertSame($invalidToken, $parser->errors[0]->token->token); + + foreach (self::columnQueries('VARCHAR(' . $length . ')') as $query) { + $parser = new Parser($query . ' SELECT 1;'); + self::assertCount(1, $parser->errors); + self::assertSame(self::LENGTH_ERROR, $parser->errors[0]->getMessage()); + self::assertInstanceOf(ParserException::class, $parser->errors[0]); + self::assertNotNull($parser->errors[0]->token); + self::assertSame($invalidToken, $parser->errors[0]->token->token); + self::assertCount(2, $parser->statements); + self::assertInstanceOf(SelectStatement::class, $parser->statements[1]); + + try { + new Parser($query, true); + self::fail('Strict mode must reject an invalid VARCHAR length.'); + } catch (ParserException $exception) { + self::assertSame(self::LENGTH_ERROR, $exception->getMessage()); + } + } + } + + /** @return array */ + public static function invalidLengthProvider(): array + { + return [ + 'identifier' => ['X', 'X'], + 'quoted string' => ["'20'", "'20'"], + 'double quoted string' => ['"20"', '"20"'], + 'quoted identifier' => ['`20`', '`20`'], + 'fraction' => ['20.0', '20.0'], + 'negative' => ['-20', '-20'], + 'positive sign' => ['+20', '+20'], + 'scientific' => ['2e1', '2e1'], + 'hexadecimal' => ['0x14', '0x14'], + 'binary' => ['0b10100', '0b10100'], + 'expression' => ['1+19', '+19'], + 'nested parentheses' => ['(20)', '('], + 'empty' => ['', ')'], + 'only comments' => [' /* empty */ ', ')'], + 'extra parameter' => ['20,30', ','], + 'trailing comma' => ['20,', ','], + 'separate numbers' => ['20 /* gap */ 30', '30'], + ]; + } + + /** @return list */ + private static function columnQueries(string $declaration): array + { + return [ + 'CREATE TABLE temp_users (phone ' . $declaration . ' NULL);', + 'ALTER TABLE temp_users ADD COLUMN phone ' . $declaration . ' NULL;', + 'ALTER TABLE temp_users ADD phone ' . $declaration . ' NULL;', + ]; + } + + /** @param list $parameters */ + #[DataProvider('otherTypeProvider')] + public function testOtherTypes(string $declaration, array $parameters): void + { + $parser = new Parser(); + $type = DataTypes::parse($parser, $this->getTokensList($declaration)); + self::assertSame([], $parser->errors); + self::assertNotNull($type); + self::assertSame($parameters, $type->parameters); + } + + /** @return array}> */ + public static function otherTypeProvider(): array + { + return [ + 'enum' => ["ENUM('a','b')", ["'a'", "'b'"]], + 'set' => ["SET('a','b')", ["'a'", "'b'"]], + 'decimal' => ['DECIMAL(10,2)', ['10', '2']], + 'lengthless varchar' => ['VARCHAR', []], + ]; + } + + public function testLengthlessRoutine(): void + { + $parser = new Parser('CREATE PROCEDURE p(IN phone VARCHAR) SELECT phone;'); + self::assertSame([], $parser->errors); + } + + public function testAlterContinuation(): void + { + $parser = new Parser( + 'ALTER TABLE temp_users ADD COLUMN phone VARCHAR(X) NULL, ADD COLUMN name VARCHAR(20) NOT NULL;', + ); + self::assertCount(1, $parser->errors); + self::assertSame(self::LENGTH_ERROR, $parser->errors[0]->getMessage()); + self::assertInstanceOf(AlterStatement::class, $parser->statements[0]); + self::assertNotNull($parser->statements[0]->altered); + self::assertCount(2, $parser->statements[0]->altered); + self::assertStringContainsString('VARCHAR(20) NOT NULL', $parser->statements[0]->build()); + } + + public function testAlterStringParameters(): void + { + $parser = new Parser("ALTER TABLE temp_users ADD COLUMN e ENUM('a','b'), ADD COLUMN s SET('a','b');"); + self::assertSame([], $parser->errors); + self::assertInstanceOf(AlterStatement::class, $parser->statements[0]); + self::assertNotNull($parser->statements[0]->altered); + self::assertCount(2, $parser->statements[0]->altered); + self::assertStringContainsString("ENUM('a','b')", $parser->statements[0]->build()); + } +} diff --git a/tests/Parser/AlterStatementTest.php b/tests/Parser/AlterStatementTest.php index c2f019de..743643a8 100644 --- a/tests/Parser/AlterStatementTest.php +++ b/tests/Parser/AlterStatementTest.php @@ -45,6 +45,7 @@ public static function alterProvider(): array ['parser/parseAlterTablePartitionByRange2'], ['parser/parseAlterTableCoalescePartition'], ['parser/parseAlterTableAddColumnWithCheck'], + ['parser/parseAlterTableAddColumnInvalidVarcharLength'], ['parser/parseAlterTableAddSpatialIndex1'], ['parser/parseAlterTableAddUniqueKey1'], ['parser/parseAlterTableAddUniqueKey2'], diff --git a/tests/data/parser/parseAlterTableAddColumnInvalidVarcharLength.in b/tests/data/parser/parseAlterTableAddColumnInvalidVarcharLength.in new file mode 100644 index 00000000..217c77ae --- /dev/null +++ b/tests/data/parser/parseAlterTableAddColumnInvalidVarcharLength.in @@ -0,0 +1 @@ +ALTER TABLE temp_users ADD COLUMN phone VARCHAR(X) NULL; \ No newline at end of file diff --git a/tests/data/parser/parseAlterTableAddColumnInvalidVarcharLength.out b/tests/data/parser/parseAlterTableAddColumnInvalidVarcharLength.out new file mode 100644 index 00000000..51cf069f --- /dev/null +++ b/tests/data/parser/parseAlterTableAddColumnInvalidVarcharLength.out @@ -0,0 +1,337 @@ +{ + "query": "ALTER TABLE temp_users ADD COLUMN phone VARCHAR(X) NULL;", + "lexer": { + "@type": "PhpMyAdmin\\SqlParser\\Lexer", + "strict": false, + "errors": [], + "str": "ALTER TABLE temp_users ADD COLUMN phone VARCHAR(X) NULL;", + "len": 56, + "last": 56, + "list": { + "@type": "PhpMyAdmin\\SqlParser\\TokensList", + "count": 20, + "idx": 20, + "tokens": [ + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": "ALTER", + "value": "ALTER", + "keyword": "ALTER", + "type": { + "@type": "PhpMyAdmin\\SqlParser\\TokenType", + "name": "Keyword", + "value": 1 + }, + "flags": 3, + "position": 0 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": " ", + "value": " ", + "keyword": null, + "type": { + "@type": "PhpMyAdmin\\SqlParser\\TokenType", + "name": "Whitespace", + "value": 3 + }, + "flags": 0, + "position": 5 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": "TABLE", + "value": "TABLE", + "keyword": "TABLE", + "type": { + "@type": "@3" + }, + "flags": 3, + "position": 6 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": " ", + "value": " ", + "keyword": null, + "type": { + "@type": "@5" + }, + "flags": 0, + "position": 11 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": "temp_users", + "value": "temp_users", + "keyword": null, + "type": { + "@type": "PhpMyAdmin\\SqlParser\\TokenType", + "name": "None", + "value": 0 + }, + "flags": 0, + "position": 12 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": " ", + "value": " ", + "keyword": null, + "type": { + "@type": "@5" + }, + "flags": 0, + "position": 22 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": "ADD", + "value": "ADD", + "keyword": "ADD", + "type": { + "@type": "@3" + }, + "flags": 3, + "position": 23 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": " ", + "value": " ", + "keyword": null, + "type": { + "@type": "@5" + }, + "flags": 0, + "position": 26 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": "COLUMN", + "value": "COLUMN", + "keyword": "COLUMN", + "type": { + "@type": "@3" + }, + "flags": 3, + "position": 27 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": " ", + "value": " ", + "keyword": null, + "type": { + "@type": "@5" + }, + "flags": 0, + "position": 33 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": "phone", + "value": "phone", + "keyword": null, + "type": { + "@type": "@9" + }, + "flags": 0, + "position": 34 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": " ", + "value": " ", + "keyword": null, + "type": { + "@type": "@5" + }, + "flags": 0, + "position": 39 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": "VARCHAR", + "value": "VARCHAR", + "keyword": "VARCHAR", + "type": { + "@type": "@3" + }, + "flags": 11, + "position": 40 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": "(", + "value": "(", + "keyword": null, + "type": { + "@type": "PhpMyAdmin\\SqlParser\\TokenType", + "name": "Operator", + "value": 2 + }, + "flags": 16, + "position": 47 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": "X", + "value": "X", + "keyword": "X", + "type": { + "@type": "@3" + }, + "flags": 33, + "position": 48 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": ")", + "value": ")", + "keyword": null, + "type": { + "@type": "@19" + }, + "flags": 16, + "position": 49 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": " ", + "value": " ", + "keyword": null, + "type": { + "@type": "@5" + }, + "flags": 0, + "position": 50 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": "NULL", + "value": "NULL", + "keyword": "NULL", + "type": { + "@type": "@3" + }, + "flags": 3, + "position": 51 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": ";", + "value": ";", + "keyword": null, + "type": { + "@type": "PhpMyAdmin\\SqlParser\\TokenType", + "name": "Delimiter", + "value": 9 + }, + "flags": 0, + "position": 55 + }, + { + "@type": "PhpMyAdmin\\SqlParser\\Token", + "token": "", + "value": "", + "keyword": null, + "type": { + "@type": "@25" + }, + "flags": 0, + "position": null + } + ] + }, + "delimiter": ";", + "delimiterLen": 1 + }, + "parser": { + "@type": "PhpMyAdmin\\SqlParser\\Parser", + "strict": false, + "errors": [], + "list": { + "@type": "@1" + }, + "statements": [ + { + "@type": "PhpMyAdmin\\SqlParser\\Statements\\AlterStatement", + "table": { + "@type": "PhpMyAdmin\\SqlParser\\Components\\Expression", + "database": null, + "table": "temp_users", + "column": null, + "expr": "temp_users", + "alias": null, + "function": null, + "subquery": null + }, + "altered": [ + { + "@type": "PhpMyAdmin\\SqlParser\\Components\\AlterOperation", + "options": { + "@type": "PhpMyAdmin\\SqlParser\\Components\\OptionsArray", + "options": { + "1": "ADD", + "2": "COLUMN" + } + }, + "field": { + "@type": "PhpMyAdmin\\SqlParser\\Components\\Expression", + "database": null, + "table": null, + "column": "phone", + "expr": "phone", + "alias": null, + "function": null, + "subquery": null + }, + "partitions": null, + "unknown": [ + { + "@type": "@17" + }, + { + "@type": "@18" + }, + { + "@type": "@20" + }, + { + "@type": "@21" + }, + { + "@type": "@22" + }, + { + "@type": "@23" + } + ] + } + ], + "options": { + "@type": "PhpMyAdmin\\SqlParser\\Components\\OptionsArray", + "options": { + "3": "TABLE" + } + }, + "first": 0, + "last": 18 + } + ], + "brackets": 0 + }, + "errors": { + "lexer": [], + "parser": [ + [ + "VARCHAR length must be a single nonnegative integer.", + { + "@type": "@20" + }, + 0 + ] + ] + } +} \ No newline at end of file