From 36f306f6cc739db38d15ae357f9cda166ca0cbc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Kr=C3=B6g?= Date: Tue, 27 Oct 2026 11:21:35 +0100 Subject: [PATCH 1/3] Add a test for each CREATE TABLE option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most table options were not covered by any test. The data provider replaces testBuilderCompressed and checks that each option is rebuilt and does not consume the tokens following it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Maximilian Krög --- phpstan-baseline.neon | 2 +- tests/Builder/CreateStatementTest.php | 53 ++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b7c98d59..571dbdb1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2152,7 +2152,7 @@ parameters: - message: "#^Dynamic call to static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertEquals\\(\\)\\.$#" - count: 35 + count: 34 path: tests/Builder/CreateStatementTest.php - diff --git a/tests/Builder/CreateStatementTest.php b/tests/Builder/CreateStatementTest.php index 693bad1b..36457da5 100644 --- a/tests/Builder/CreateStatementTest.php +++ b/tests/Builder/CreateStatementTest.php @@ -84,13 +84,54 @@ public function testBuilderWithComments(): void ); } - public function testBuilderCompressed(): void + /** + * @return string[][] + */ + public static function tableOptionsProvider(): array { - $parser = new Parser('CREATE TABLE users ( user_id int ) PAGE_COMPRESSED=1 PAGE_COMPRESSION_LEVEL=9;'); - $stmt = $parser->statements[0]; - $this->assertEquals( - "CREATE TABLE users (\n `user_id` int\n) PAGE_COMPRESSED=1 PAGE_COMPRESSION_LEVEL=9", - $stmt->build() + return [ + ['AUTO_INCREMENT=5', 'AUTO_INCREMENT=5'], + ['AVG_ROW_LENGTH=100', 'AVG_ROW_LENGTH=100'], + ['AVG_ROW_LENGTH 100', 'AVG_ROW_LENGTH 100'], + ['CHARACTER SET=utf8mb4', 'CHARACTER SET=utf8mb4'], + ['CHARACTER SET utf8mb4', 'CHARACTER SET=utf8mb4'], + ['CHARSET=utf8mb4', 'CHARSET=utf8mb4'], + ['DEFAULT CHARACTER SET=utf8mb4', 'DEFAULT CHARACTER SET=utf8mb4'], + ['DEFAULT CHARSET=utf8mb4', 'DEFAULT CHARSET=utf8mb4'], + ['CHECKSUM=1', 'CHECKSUM=1'], + ['DEFAULT COLLATE=utf8mb4_bin', 'DEFAULT COLLATE=utf8mb4_bin'], + ['COLLATE=utf8mb4_bin', 'COLLATE=utf8mb4_bin'], + ["COMMENT='foo'", "COMMENT='foo'"], + ["CONNECTION='mysql://user@host/db/t'", "CONNECTION='mysql://user@host/db/t'"], + ["DATA DIRECTORY = '/tmp/data'", "DATA DIRECTORY='/tmp/data'"], + ['DELAY_KEY_WRITE=1', 'DELAY_KEY_WRITE=1'], + ["INDEX DIRECTORY='/tmp/index'", "INDEX DIRECTORY='/tmp/index'"], + ['INSERT_METHOD=LAST', 'INSERT_METHOD=LAST'], + ['KEY_BLOCK_SIZE=8', 'KEY_BLOCK_SIZE=8'], + ['MAX_ROWS=1000', 'MAX_ROWS=1000'], + ['MIN_ROWS=10', 'MIN_ROWS=10'], + ['PACK_KEYS=DEFAULT', 'PACK_KEYS=DEFAULT'], + ["PASSWORD='secret'", "PASSWORD='secret'"], + ['ROW_FORMAT=DYNAMIC', 'ROW_FORMAT=DYNAMIC'], + ['TABLESPACE `ts` STORAGE DISK', 'TABLESPACE `ts` STORAGE DISK'], + ['UNION = (`t1`, `t2`)', 'UNION=(`t1`, `t2`)'], + ['PAGE_COMPRESSED=1', 'PAGE_COMPRESSED=1'], + ['PAGE_COMPRESSION_LEVEL=9', 'PAGE_COMPRESSION_LEVEL=9'], + ]; + } + + /** + * @dataProvider tableOptionsProvider + */ + #[DataProvider('tableOptionsProvider')] + public function testBuilderTableOption(string $option, string $expected): void + { + // The trailing ENGINE option checks that the option does not consume the following tokens. + $parser = new Parser('CREATE TABLE `t` ( `id` int ) ' . $option . ' ENGINE=InnoDB;'); + self::assertEmpty($parser->errors); + self::assertSame( + "CREATE TABLE `t` (\n `id` int\n) ENGINE=InnoDB " . $expected, + $parser->statements[0]->build() ); } From 1f10029c148cb2798566d0b783ae0ccce36f1c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Kr=C3=B6g?= Date: Tue, 27 Oct 2026 11:21:46 +0100 Subject: [PATCH 2/3] Add missing CREATE TABLE options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Options not in the list were dropped when building the statement, together with all the options following them. Added options documented by MySQL and MariaDB: PAGE_CHECKSUM, TRANSACTIONAL, ENCRYPTED, ENCRYPTION_KEY_ID, ENCRYPTION, COMPRESSION, STATS_AUTO_RECALC, STATS_PERSISTENT, STATS_SAMPLE_PAGES, AUTOEXTEND_SIZE, IETF_QUOTES, SEQUENCE, ENGINE_ATTRIBUTE, SECONDARY_ENGINE_ATTRIBUTE and ADAPTIVE_HASH_INDEX. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Maximilian Krög --- src/Statements/CreateStatement.php | 60 +++++++++++++++++++++++++++ tests/Builder/CreateStatementTest.php | 15 +++++++ 2 files changed, 75 insertions(+) diff --git a/src/Statements/CreateStatement.php b/src/Statements/CreateStatement.php index 06100373..2fa95c1c 100644 --- a/src/Statements/CreateStatement.php +++ b/src/Statements/CreateStatement.php @@ -219,6 +219,66 @@ class CreateStatement extends Statement 23, 'var', ], + 'PAGE_CHECKSUM' => [ + 24, + 'var', + ], + 'TRANSACTIONAL' => [ + 25, + 'var', + ], + 'ENCRYPTED' => [ + 26, + 'var', + ], + 'ENCRYPTION_KEY_ID' => [ + 27, + 'var', + ], + 'ENCRYPTION' => [ + 28, + 'var', + ], + 'COMPRESSION' => [ + 29, + 'var', + ], + 'STATS_AUTO_RECALC' => [ + 30, + 'var', + ], + 'STATS_PERSISTENT' => [ + 31, + 'var', + ], + 'STATS_SAMPLE_PAGES' => [ + 32, + 'var', + ], + 'AUTOEXTEND_SIZE' => [ + 33, + 'var', + ], + 'IETF_QUOTES' => [ + 34, + 'var', + ], + 'SEQUENCE' => [ + 35, + 'var', + ], + 'ENGINE_ATTRIBUTE' => [ + 36, + 'var', + ], + 'SECONDARY_ENGINE_ATTRIBUTE' => [ + 37, + 'var', + ], + 'ADAPTIVE_HASH_INDEX' => [ + 38, + 'var', + ], ]; /** diff --git a/tests/Builder/CreateStatementTest.php b/tests/Builder/CreateStatementTest.php index 36457da5..92546bf8 100644 --- a/tests/Builder/CreateStatementTest.php +++ b/tests/Builder/CreateStatementTest.php @@ -117,6 +117,21 @@ public static function tableOptionsProvider(): array ['UNION = (`t1`, `t2`)', 'UNION=(`t1`, `t2`)'], ['PAGE_COMPRESSED=1', 'PAGE_COMPRESSED=1'], ['PAGE_COMPRESSION_LEVEL=9', 'PAGE_COMPRESSION_LEVEL=9'], + ['PAGE_CHECKSUM=1', 'PAGE_CHECKSUM=1'], + ['TRANSACTIONAL=1', 'TRANSACTIONAL=1'], + ['ENCRYPTED=YES', 'ENCRYPTED=YES'], + ['ENCRYPTION_KEY_ID=2', 'ENCRYPTION_KEY_ID=2'], + ["ENCRYPTION='Y'", "ENCRYPTION='Y'"], + ["COMPRESSION='zlib'", "COMPRESSION='zlib'"], + ['STATS_AUTO_RECALC=1', 'STATS_AUTO_RECALC=1'], + ['STATS_PERSISTENT=0', 'STATS_PERSISTENT=0'], + ['STATS_SAMPLE_PAGES=25', 'STATS_SAMPLE_PAGES=25'], + ['AUTOEXTEND_SIZE=4M', 'AUTOEXTEND_SIZE=4M'], + ['IETF_QUOTES=YES', 'IETF_QUOTES=YES'], + ['SEQUENCE=1', 'SEQUENCE=1'], + ["ENGINE_ATTRIBUTE='{}'", "ENGINE_ATTRIBUTE='{}'"], + ["SECONDARY_ENGINE_ATTRIBUTE='{}'", "SECONDARY_ENGINE_ATTRIBUTE='{}'"], + ['ADAPTIVE_HASH_INDEX=YES', 'ADAPTIVE_HASH_INDEX=YES'], ]; } From 45312e6a4ca770afe195beb1f48773dc0ddad289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Kr=C3=B6g?= Date: Tue, 27 Oct 2026 11:22:16 +0100 Subject: [PATCH 3/3] Accept quoted CREATE TABLE option names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MariaDB quotes engine-defined table options in SHOW CREATE TABLE, e.g. `PAGE_COMPRESSED`='ON'. These were not recognized, so they were dropped together with all the options following them. phpMyAdmin rebuilds the statement when exporting the table structure, losing these options. Quoted names are only accepted where the options array enables it with `_ALLOW_QUOTED`, so quoted identifiers like SELECT `DISTINCT` are not mistaken for options. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Maximilian Krög --- src/Components/OptionsArray.php | 12 ++++++++++++ src/Statements/CreateStatement.php | 8 ++++++-- tests/Builder/CreateStatementTest.php | 28 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/Components/OptionsArray.php b/src/Components/OptionsArray.php index 5c0815bc..ed6b6742 100644 --- a/src/Components/OptionsArray.php +++ b/src/Components/OptionsArray.php @@ -14,6 +14,7 @@ use function count; use function implode; use function is_array; +use function is_string; use function ksort; use function sprintf; use function strcasecmp; @@ -118,6 +119,17 @@ public static function parse(Parser $parser, TokensList $list, array $options = if ($lastOption === null) { $upper = strtoupper($token->token); + if ( + isset($options['_ALLOW_QUOTED']) + && $token->type === Token::TYPE_SYMBOL + && ($token->flags & Token::FLAG_SYMBOL_BACKTICK) !== 0 + && is_string($token->value) + ) { + // The name of the option is quoted (e.g. MariaDB quotes + // engine-defined table options like `PAGE_COMPRESSED`). + $upper = strtoupper($token->value); + } + if (! isset($options[$upper])) { // There is no option to be processed. break; diff --git a/src/Statements/CreateStatement.php b/src/Statements/CreateStatement.php index 2fa95c1c..bd042253 100644 --- a/src/Statements/CreateStatement.php +++ b/src/Statements/CreateStatement.php @@ -107,10 +107,14 @@ class CreateStatement extends Statement /** * All table options. * - * @var array> - * @psalm-var array true, + 'ENGINE' => [ 1, 'var=', diff --git a/tests/Builder/CreateStatementTest.php b/tests/Builder/CreateStatementTest.php index 92546bf8..c5eec867 100644 --- a/tests/Builder/CreateStatementTest.php +++ b/tests/Builder/CreateStatementTest.php @@ -84,6 +84,25 @@ public function testBuilderWithComments(): void ); } + public function testBuilderQuotedTableOptions(): void + { + // MariaDB quotes engine-defined table options in SHOW CREATE TABLE. + $parser = new Parser( + 'CREATE TABLE `t` ( `id` int(11) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + . " `PAGE_COMPRESSED`='ON' `PAGE_COMPRESSION_LEVEL`=9 `ENCRYPTED`=YES `ENCRYPTION_KEY_ID`=2;" + ); + self::assertEmpty($parser->errors); + $stmt = $parser->statements[0]; + self::assertInstanceOf(CreateStatement::class, $stmt); + self::assertNotNull($stmt->entityOptions); + self::assertSame("'ON'", $stmt->entityOptions->has('PAGE_COMPRESSED', true)); + self::assertEquals( + "CREATE TABLE `t` (\n `id` int(11) NOT NULL\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + . " PAGE_COMPRESSED='ON' PAGE_COMPRESSION_LEVEL=9 ENCRYPTED=YES ENCRYPTION_KEY_ID=2", + $stmt->build() + ); + } + /** * @return string[][] */ @@ -116,6 +135,7 @@ public static function tableOptionsProvider(): array ['TABLESPACE `ts` STORAGE DISK', 'TABLESPACE `ts` STORAGE DISK'], ['UNION = (`t1`, `t2`)', 'UNION=(`t1`, `t2`)'], ['PAGE_COMPRESSED=1', 'PAGE_COMPRESSED=1'], + ["`PAGE_COMPRESSED`='ON'", "PAGE_COMPRESSED='ON'"], ['PAGE_COMPRESSION_LEVEL=9', 'PAGE_COMPRESSION_LEVEL=9'], ['PAGE_CHECKSUM=1', 'PAGE_CHECKSUM=1'], ['TRANSACTIONAL=1', 'TRANSACTIONAL=1'], @@ -150,6 +170,14 @@ public function testBuilderTableOption(string $option, string $expected): void ); } + public function testBuilderQuotedNameIsNotAnOption(): void + { + // Quoted option names are only accepted for table options. + $parser = new Parser('SELECT `DISTINCT` FROM `t`'); + self::assertEmpty($parser->errors); + self::assertSame('SELECT `DISTINCT` FROM `t`', $parser->statements[0]->build()); + } + public function testBuilderCollate(): void { $parser = new Parser(