From fa298dfd056e6c53fda1896a9097a91ad82ae5b5 Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 15 Jan 2026 11:07:00 +0100 Subject: [PATCH 1/8] Add test that reproduces issue --- tests/WP_SQLite_Translator_Tests.php | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/WP_SQLite_Translator_Tests.php b/tests/WP_SQLite_Translator_Tests.php index 035d3db4..4322678d 100644 --- a/tests/WP_SQLite_Translator_Tests.php +++ b/tests/WP_SQLite_Translator_Tests.php @@ -3520,4 +3520,41 @@ public static function mysqlVariablesToTest() { array( '@@sEssIOn.sqL_moDe' ), ); } + + /** + * Test CREATE TABLE with DEFAULT (now()) - GitHub issue #300 + * Tests that DEFAULT with function calls in parentheses works correctly. + */ + public function testCreateTableWithDefaultNowFunction() { + // Test the exact SQL from the issue + $this->assertQuery( + "CREATE TABLE `test_now_default` ( + `id` int NOT NULL, + `updated` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;" + ); + + // Verify the table was created successfully + $results = $this->assertQuery( 'DESCRIBE test_now_default;' ); + $this->assertCount( 2, $results ); + + // Verify the updated column has the correct properties + $updated_field = $results[1]; + $this->assertEquals( 'updated', $updated_field->Field ); + $this->assertEquals( 'timestamp', $updated_field->Type ); + $this->assertEquals( 'NO', $updated_field->Null ); + + // Insert a row to verify the default value works + $this->assertQuery( 'INSERT INTO test_now_default (id) VALUES (1)' ); + $result = $this->assertQuery( 'SELECT * FROM test_now_default WHERE id = 1' ); + $this->assertCount( 1, $result ); + + // Verify the updated timestamp was set (should match YYYY-MM-DD HH:MM:SS format) + $this->assertRegExp( '/\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/', $result[0]->updated ); + + // Test ON UPDATE trigger works + $this->assertQuery( 'UPDATE test_now_default SET id = 2 WHERE id = 1' ); + $result = $this->assertQuery( 'SELECT * FROM test_now_default WHERE id = 2' ); + $this->assertRegExp( '/\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/', $result[0]->updated ); + } } From 36b0fe2a2adfbe4bd62728b95e1ab522ff275b9c Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 15 Jan 2026 11:07:17 +0100 Subject: [PATCH 2/8] Fix the parenthesis issue --- .../sqlite/class-wp-sqlite-translator.php | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/wp-includes/sqlite/class-wp-sqlite-translator.php b/wp-includes/sqlite/class-wp-sqlite-translator.php index 62ed8b40..525132a4 100644 --- a/wp-includes/sqlite/class-wp-sqlite-translator.php +++ b/wp-includes/sqlite/class-wp-sqlite-translator.php @@ -1150,7 +1150,28 @@ private function parse_mysql_create_table_field() { WP_SQLite_Token::FLAG_KEYWORD_FUNCTION, array( 'DEFAULT' ) ) ) { - $result->default = $this->rewriter->consume()->token; + // Consume the next token (could be a value, opening paren, etc.) + $default_token = $this->rewriter->consume(); + $result->default = $default_token->token; + + // Check if the default value is wrapped in parentheses (for function calls like (now())) + if ( $default_token->matches( WP_SQLite_Token::TYPE_OPERATOR, null, array( '(' ) ) ) { + // Track parenthesis depth to consume the complete expression + $paren_depth = 1; + $default_value = '('; + + while ( $paren_depth > 0 && ( $next_token = $this->rewriter->consume() ) ) { + $default_value .= $next_token->token; + + if ( $next_token->matches( WP_SQLite_Token::TYPE_OPERATOR, null, array( '(' ) ) ) { + ++$paren_depth; + } elseif ( $next_token->matches( WP_SQLite_Token::TYPE_OPERATOR, null, array( ')' ) ) ) { + --$paren_depth; + } + } + + $result->default = $default_value; + } continue; } From 913ca4b154a05b41f3d03a6e78c8b50157237ad2 Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 15 Jan 2026 11:14:19 +0100 Subject: [PATCH 3/8] Fix formatting --- tests/WP_SQLite_Translator_Tests.php | 4 ++-- wp-includes/sqlite/class-wp-sqlite-translator.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/WP_SQLite_Translator_Tests.php b/tests/WP_SQLite_Translator_Tests.php index 4322678d..e53e139e 100644 --- a/tests/WP_SQLite_Translator_Tests.php +++ b/tests/WP_SQLite_Translator_Tests.php @@ -3528,10 +3528,10 @@ public static function mysqlVariablesToTest() { public function testCreateTableWithDefaultNowFunction() { // Test the exact SQL from the issue $this->assertQuery( - "CREATE TABLE `test_now_default` ( + 'CREATE TABLE `test_now_default` ( `id` int NOT NULL, `updated` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;" + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;' ); // Verify the table was created successfully diff --git a/wp-includes/sqlite/class-wp-sqlite-translator.php b/wp-includes/sqlite/class-wp-sqlite-translator.php index 525132a4..0c0dce6a 100644 --- a/wp-includes/sqlite/class-wp-sqlite-translator.php +++ b/wp-includes/sqlite/class-wp-sqlite-translator.php @@ -1151,13 +1151,13 @@ private function parse_mysql_create_table_field() { array( 'DEFAULT' ) ) ) { // Consume the next token (could be a value, opening paren, etc.) - $default_token = $this->rewriter->consume(); + $default_token = $this->rewriter->consume(); $result->default = $default_token->token; // Check if the default value is wrapped in parentheses (for function calls like (now())) if ( $default_token->matches( WP_SQLite_Token::TYPE_OPERATOR, null, array( '(' ) ) ) { // Track parenthesis depth to consume the complete expression - $paren_depth = 1; + $paren_depth = 1; $default_value = '('; while ( $paren_depth > 0 && ( $next_token = $this->rewriter->consume() ) ) { From 6e949cf22f25c0793da55537d53bdd75d273d01e Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 15 Jan 2026 11:32:53 +0100 Subject: [PATCH 4/8] Add (failing) test for AST driver --- tests/WP_SQLite_Driver_Tests.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 71cd0686..3dbfee19 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -11368,4 +11368,28 @@ public function testSubstringFunction(): void { $result = $this->assertQuery( "SELECT SUBSTRING('abcdef' FROM 4) AS s" ); $this->assertSame( 'def', $result[0]->s ); } + + /** + * Test CREATE TABLE with DEFAULT (now()) - GitHub issue #300 + * Tests that DEFAULT with function calls in parentheses works correctly in AST driver. + * + * @see https://github.com/WordPress/sqlite-database-integration/issues/300 + */ + public function testCreateTableWithDefaultNowFunction(): void { + // Test the exact SQL from the issue + $this->assertQuery( + 'CREATE TABLE `test_now_default` ( + `id` int NOT NULL, + `updated` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;' + ); + + // Insert a row to verify the default value works + $this->assertQuery( 'INSERT INTO test_now_default (id) VALUES (1)' ); + $result = $this->assertQuery( 'SELECT * FROM test_now_default WHERE id = 1' ); + $this->assertCount( 1, $result ); + + // Verify the updated timestamp was set (should match YYYY-MM-DD HH:MM:SS format) + $this->assertRegExp( '/\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/', $result[0]->updated ); + } } From 65aaca3e796c7770fffee402dc3f5d04ba0d7761 Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 15 Jan 2026 11:44:48 +0100 Subject: [PATCH 5/8] Add support for simple now expressions --- ...s-wp-sqlite-information-schema-builder.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php index ce625661..9adda535 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php @@ -2093,6 +2093,25 @@ private function get_column_default( WP_Parser_Node $node ): ?string { return $this->get_value( $signed_literal ); } + // DEFAULT (expression) - MySQL 8.0.13+ supports exprWithParentheses + $expr_with_parens = $default_attr->get_first_child_node( 'exprWithParentheses' ); + if ( $expr_with_parens ) { + // For now, only support simple function calls like (now()), (CURRENT_TIMESTAMP) + // Check if it's (now()) or (NOW()) + $now_tokens = $expr_with_parens->get_descendant_tokens( WP_MySQL_Lexer::NOW_SYMBOL ); + if ( ! empty( $now_tokens ) ) { + return 'CURRENT_TIMESTAMP'; + } + + // Check if it's (CURRENT_TIMESTAMP) or (CURRENT_TIMESTAMP()) + $current_ts_tokens = $expr_with_parens->get_descendant_tokens( WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL ); + if ( ! empty( $current_ts_tokens ) ) { + return 'CURRENT_TIMESTAMP'; + } + + // For any other complex expressions, throw an exception + throw new Exception( 'DEFAULT values with complex expressions are not yet supported. Only (now()) and (CURRENT_TIMESTAMP) are currently supported.' ); + } throw new Exception( 'DEFAULT values with expressions are not yet supported.' ); } From 3a204b112c22964c637ab2643ec6bc6ef957830d Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 16 Jan 2026 16:46:26 +0100 Subject: [PATCH 6/8] Add support for `DEFAULT (expression)` in table column definitions --- tests/WP_SQLite_Driver_Tests.php | 53 +++++++++++++++++++ .../class-wp-pdo-mysql-on-sqlite.php | 15 ++++-- ...s-wp-sqlite-information-schema-builder.php | 29 +++++----- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 3dbfee19..f2894dda 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -11391,5 +11391,58 @@ public function testCreateTableWithDefaultNowFunction(): void { // Verify the updated timestamp was set (should match YYYY-MM-DD HH:MM:SS format) $this->assertRegExp( '/\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/', $result[0]->updated ); + + // SHOW CREATE TABLE + $this->assertQuery( 'SHOW CREATE TABLE test_now_default' ); + $results = $this->engine->get_query_results(); + $this->assertEquals( + implode( + "\n", + array( + 'CREATE TABLE `test_now_default` (', + ' `id` int NOT NULL,', + ' `updated` timestamp NOT NULL DEFAULT ( now( ) ) ON UPDATE CURRENT_TIMESTAMP', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci', + ) + ), + $results[0]->{'Create Table'} + ); + } + + public function testCreateTableWithDefaultExpressions(): void { + $this->assertQuery( + 'CREATE TABLE t ( + id int NOT NULL, + col1 int NOT NULL DEFAULT (1 + 2), + col2 datetime NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 1 YEAR)), + col3 varchar(255) NOT NULL DEFAULT (CONCAT(\'a\', \'b\')) + )' + ); + + // Insert a row and verify the default values + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertQuery( 'SELECT * FROM t WHERE id = 1' ); + $results = $this->engine->get_query_results(); + $this->assertEquals( 3, $results[0]->col1 ); + $this->assertStringStartsWith( ( gmdate( 'Y' ) + 1 ) . '-', $results[0]->col2 ); + $this->assertEquals( 'ab', $results[0]->col3 ); + + // SHOW CREATE TABLE + $this->assertQuery( 'SHOW CREATE TABLE t' ); + $results = $this->engine->get_query_results(); + $this->assertEquals( + implode( + "\n", + array( + 'CREATE TABLE `t` (', + ' `id` int NOT NULL,', + ' `col1` int NOT NULL DEFAULT ( 1 + 2 ),', + ' `col2` datetime NOT NULL DEFAULT ( DATE_ADD( NOW( ) , INTERVAL 1 YEAR ) ),', + " `col3` varchar(255) NOT NULL DEFAULT ( CONCAT( 'a' , 'b' ) )", + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $results[0]->{'Create Table'} + ); } } diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 34e50ab6..90323bc8 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -5908,8 +5908,6 @@ private function get_sqlite_create_table_statement( $query .= ' PRIMARY KEY AUTOINCREMENT'; } if ( null !== $column['COLUMN_DEFAULT'] ) { - // @TODO: Handle defaults with expression values (DEFAULT_GENERATED). - // Handle DEFAULT CURRENT_TIMESTAMP. This works only with timestamp // and datetime columns. For other column types, it's just a string. if ( @@ -5917,6 +5915,13 @@ private function get_sqlite_create_table_statement( && ( 'timestamp' === $column['DATA_TYPE'] || 'datetime' === $column['DATA_TYPE'] ) ) { $query .= ' DEFAULT CURRENT_TIMESTAMP'; + } elseif ( str_contains( $column['EXTRA'], 'DEFAULT_GENERATED' ) ) { + // Handle DEFAULT values with expressions (DEFAULT_GENERATED). + // Translate the default clause from MySQL to SQLite. + $ast = $this->create_parser( 'SELECT ' . $column['COLUMN_DEFAULT'] )->parse(); + $expr = $ast->get_first_descendant_node( 'selectItem' )->get_first_child_node(); + $default_clause = $this->translate( $expr ); + $query .= ' DEFAULT ' . $default_clause; } else { $query .= ' DEFAULT ' . $this->quote_sqlite_value( $column['COLUMN_DEFAULT'] ); } @@ -6220,7 +6225,11 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str ) { $sql .= ' DEFAULT CURRENT_TIMESTAMP'; } elseif ( null !== $column['COLUMN_DEFAULT'] ) { - $sql .= ' DEFAULT ' . $this->quote_mysql_utf8_string_literal( $column['COLUMN_DEFAULT'] ); + if ( str_contains( $column['EXTRA'], 'DEFAULT_GENERATED' ) ) { + $sql .= ' DEFAULT ' . $column['COLUMN_DEFAULT']; + } else { + $sql .= ' DEFAULT ' . $this->quote_mysql_utf8_string_literal( $column['COLUMN_DEFAULT'] ); + } } elseif ( 'YES' === $column['IS_NULLABLE'] ) { $sql .= ' DEFAULT NULL'; } diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php index 9adda535..02d8024e 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php @@ -2096,23 +2096,22 @@ private function get_column_default( WP_Parser_Node $node ): ?string { // DEFAULT (expression) - MySQL 8.0.13+ supports exprWithParentheses $expr_with_parens = $default_attr->get_first_child_node( 'exprWithParentheses' ); if ( $expr_with_parens ) { - // For now, only support simple function calls like (now()), (CURRENT_TIMESTAMP) - // Check if it's (now()) or (NOW()) - $now_tokens = $expr_with_parens->get_descendant_tokens( WP_MySQL_Lexer::NOW_SYMBOL ); - if ( ! empty( $now_tokens ) ) { - return 'CURRENT_TIMESTAMP'; - } - - // Check if it's (CURRENT_TIMESTAMP) or (CURRENT_TIMESTAMP()) - $current_ts_tokens = $expr_with_parens->get_descendant_tokens( WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL ); - if ( ! empty( $current_ts_tokens ) ) { - return 'CURRENT_TIMESTAMP'; + $default_clause = ''; + foreach ( $expr_with_parens->get_descendant_tokens() as $i => $token ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + // TODO: This is just a quick fix to avoid inserting whitespace + // before '(', which would break function call expressions. + // The proper fix is to implement a "$node->get_bytes()" API. + // This same applies to the CHECK (expression) case as well. + $default_clause .= $token->get_bytes(); + } else { + $default_clause .= ( $i > 0 ? ' ' : '' ) . $token->get_bytes(); + } } - - // For any other complex expressions, throw an exception - throw new Exception( 'DEFAULT values with complex expressions are not yet supported. Only (now()) and (CURRENT_TIMESTAMP) are currently supported.' ); + return $default_clause; } - throw new Exception( 'DEFAULT values with expressions are not yet supported.' ); + + throw new Exception( 'DEFAULT value of this type is not supported.' ); } /** From 2535825a04ef60f1ebd68022e2e2bf79d4f9475f Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Tue, 20 Jan 2026 13:35:18 +0100 Subject: [PATCH 7/8] Improve DEFAULT (expression) tests --- tests/WP_SQLite_Driver_Tests.php | 78 ++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index f2894dda..e6019f19 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -11407,6 +11407,36 @@ public function testCreateTableWithDefaultNowFunction(): void { ), $results[0]->{'Create Table'} ); + + // DESCRIBE + $this->assertQuery( 'DESCRIBE test_now_default' ); + $results = $this->engine->get_query_results(); + $this->assertEquals( + array( + (object) array( + 'Field' => 'id', + 'Type' => 'int', + 'Null' => 'NO', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + (object) array( + 'Field' => 'updated', + 'Type' => 'timestamp', + 'Null' => 'NO', + 'Key' => '', + 'Default' => '( now( ) )', + 'Extra' => 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP', + ), + ), + $results + ); + + // Verify the translated SQLite definition. + $result = $this->sqlite->query( 'PRAGMA table_info(test_now_default)' )->fetchAll(); + $this->assertSame( null, $result[0]['dflt_value'] ); + $this->assertSame( 'CURRENT_TIMESTAMP', $result[1]['dflt_value'] ); } public function testCreateTableWithDefaultExpressions(): void { @@ -11444,5 +11474,53 @@ public function testCreateTableWithDefaultExpressions(): void { ), $results[0]->{'Create Table'} ); + + // DESCRIBE + $this->assertQuery( 'DESCRIBE t' ); + $results = $this->engine->get_query_results(); + $this->assertEquals( + array( + (object) array( + 'Field' => 'id', + 'Type' => 'int', + 'Null' => 'NO', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + (object) array( + 'Field' => 'col1', + 'Type' => 'int', + 'Null' => 'NO', + 'Key' => '', + 'Default' => '( 1 + 2 )', + 'Extra' => 'DEFAULT_GENERATED', + ), + (object) array( + 'Field' => 'col2', + 'Type' => 'datetime', + 'Null' => 'NO', + 'Key' => '', + 'Default' => '( DATE_ADD( NOW( ) , INTERVAL 1 YEAR ) )', + 'Extra' => 'DEFAULT_GENERATED', + ), + (object) array( + 'Field' => 'col3', + 'Type' => 'varchar(255)', + 'Null' => 'NO', + 'Key' => '', + 'Default' => "( CONCAT( 'a' , 'b' ) )", + 'Extra' => 'DEFAULT_GENERATED', + ), + ), + $results + ); + + // Verify the translated SQLite definition. + $result = $this->sqlite->query( 'PRAGMA table_info(t)' )->fetchAll(); + $this->assertSame( null, $result[0]['dflt_value'] ); + $this->assertSame( '1 + 2', $result[1]['dflt_value'] ); + $this->assertSame( "DATETIME(CURRENT_TIMESTAMP, '+' || 1 || ' YEAR')", $result[2]['dflt_value'] ); + $this->assertSame( "('a' || 'b')", $result[3]['dflt_value'] ); } } From d62e0284644154bdf59d924e051fd2d48077e1f2 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 21 Jan 2026 10:48:13 +0100 Subject: [PATCH 8/8] Improve and document DEFAULT and CHECK expression formatting --- tests/WP_SQLite_Driver_Metadata_Tests.php | 18 ++--- tests/WP_SQLite_Driver_Tests.php | 36 +++++----- .../class-wp-pdo-mysql-on-sqlite.php | 8 +-- ...s-wp-sqlite-information-schema-builder.php | 69 ++++++++++++++----- 4 files changed, 83 insertions(+), 48 deletions(-) diff --git a/tests/WP_SQLite_Driver_Metadata_Tests.php b/tests/WP_SQLite_Driver_Metadata_Tests.php index 104a8009..6be0a542 100644 --- a/tests/WP_SQLite_Driver_Metadata_Tests.php +++ b/tests/WP_SQLite_Driver_Metadata_Tests.php @@ -2176,43 +2176,43 @@ public function testInformationSchemaCheckConstraints(): void { 'CONSTRAINT_CATALOG' => 'def', 'CONSTRAINT_SCHEMA' => 'wp', 'CONSTRAINT_NAME' => 'c1', - 'CHECK_CLAUSE' => '( id < 10 )', + 'CHECK_CLAUSE' => 'id < 10', ), (object) array( 'CONSTRAINT_CATALOG' => 'def', 'CONSTRAINT_SCHEMA' => 'wp', 'CONSTRAINT_NAME' => 'c2', - 'CHECK_CLAUSE' => '( start_timestamp < end_timestamp )', + 'CHECK_CLAUSE' => 'start_timestamp < end_timestamp', ), (object) array( 'CONSTRAINT_CATALOG' => 'def', 'CONSTRAINT_SCHEMA' => 'wp', 'CONSTRAINT_NAME' => 'c3', - 'CHECK_CLAUSE' => '( length ( data ) < 20 )', + 'CHECK_CLAUSE' => 'length(data)< 20', ), (object) array( 'CONSTRAINT_CATALOG' => 'def', 'CONSTRAINT_SCHEMA' => 'wp', 'CONSTRAINT_NAME' => 't_chk_1', - 'CHECK_CLAUSE' => '( id > 0 )', + 'CHECK_CLAUSE' => 'id > 0', ), (object) array( 'CONSTRAINT_CATALOG' => 'def', 'CONSTRAINT_SCHEMA' => 'wp', 'CONSTRAINT_NAME' => 't_chk_2', - 'CHECK_CLAUSE' => "( name != '' )", + 'CHECK_CLAUSE' => "name != ''", ), (object) array( 'CONSTRAINT_CATALOG' => 'def', 'CONSTRAINT_SCHEMA' => 'wp', 'CONSTRAINT_NAME' => 't_chk_3', - 'CHECK_CLAUSE' => '( score > 0 AND score < 100 )', + 'CHECK_CLAUSE' => 'score > 0 AND score < 100', ), (object) array( 'CONSTRAINT_CATALOG' => 'def', 'CONSTRAINT_SCHEMA' => 'wp', 'CONSTRAINT_NAME' => 't_chk_4', - 'CHECK_CLAUSE' => '( json_valid ( data ) )', + 'CHECK_CLAUSE' => 'json_valid(data)', ), ), $result @@ -2262,13 +2262,13 @@ public function testInformationSchemaAlterTableAddCheckConstraint(): void { 'CONSTRAINT_CATALOG' => 'def', 'CONSTRAINT_SCHEMA' => 'wp', 'CONSTRAINT_NAME' => 'c', - 'CHECK_CLAUSE' => '( id > 0 )', + 'CHECK_CLAUSE' => 'id > 0', ), (object) array( 'CONSTRAINT_CATALOG' => 'def', 'CONSTRAINT_SCHEMA' => 'wp', 'CONSTRAINT_NAME' => 't_chk_1', - 'CHECK_CLAUSE' => '( id < 10 )', + 'CHECK_CLAUSE' => 'id < 10', ), ), $result diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index e6019f19..95a478db 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -9294,13 +9294,13 @@ public function testCheckConstraints(): void { // The of the check expressions below is not 100% matching MySQL, // because in MySQL the expressions are parsed and normalized. - ' CONSTRAINT `c1` CHECK ( id < 10 ),', - ' CONSTRAINT `c2` CHECK ( start_timestamp < end_timestamp ),', - ' CONSTRAINT `c3` CHECK ( length ( data ) < 20 ),', - ' CONSTRAINT `t_chk_1` CHECK ( id > 0 ),', - " CONSTRAINT `t_chk_2` CHECK ( name != '' ),", - ' CONSTRAINT `t_chk_3` CHECK ( score > 0 AND score < 100 ),', - ' CONSTRAINT `t_chk_4` CHECK ( json_valid ( data ) )', + ' CONSTRAINT `c1` CHECK (id < 10),', + ' CONSTRAINT `c2` CHECK (start_timestamp < end_timestamp),', + ' CONSTRAINT `c3` CHECK (length(data)< 20),', + ' CONSTRAINT `t_chk_1` CHECK (id > 0),', + " CONSTRAINT `t_chk_2` CHECK (name != ''),", + ' CONSTRAINT `t_chk_3` CHECK (score > 0 AND score < 100),', + ' CONSTRAINT `t_chk_4` CHECK (json_valid(data))', ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', ) ), @@ -9326,8 +9326,8 @@ public function testAlterTableAddCheckConstraint(): void { array( 'CREATE TABLE `t` (', ' `id` int DEFAULT NULL,', - ' CONSTRAINT `c` CHECK ( id > 0 ),', - ' CONSTRAINT `t_chk_1` CHECK ( id < 10 )', + ' CONSTRAINT `c` CHECK (id > 0),', + ' CONSTRAINT `t_chk_1` CHECK (id < 10)', ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', ) ), @@ -9396,7 +9396,7 @@ public function testCheckConstraintNotEnforced(): void { array( 'CREATE TABLE `t` (', ' `id` int DEFAULT NULL,', - ' CONSTRAINT `c` CHECK ( id > 0 ) /*!80016 NOT ENFORCED */', + ' CONSTRAINT `c` CHECK (id > 0) /*!80016 NOT ENFORCED */', ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', ) ), @@ -11401,7 +11401,7 @@ public function testCreateTableWithDefaultNowFunction(): void { array( 'CREATE TABLE `test_now_default` (', ' `id` int NOT NULL,', - ' `updated` timestamp NOT NULL DEFAULT ( now( ) ) ON UPDATE CURRENT_TIMESTAMP', + ' `updated` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP', ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci', ) ), @@ -11426,7 +11426,7 @@ public function testCreateTableWithDefaultNowFunction(): void { 'Type' => 'timestamp', 'Null' => 'NO', 'Key' => '', - 'Default' => '( now( ) )', + 'Default' => 'now()', 'Extra' => 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP', ), ), @@ -11466,9 +11466,9 @@ public function testCreateTableWithDefaultExpressions(): void { array( 'CREATE TABLE `t` (', ' `id` int NOT NULL,', - ' `col1` int NOT NULL DEFAULT ( 1 + 2 ),', - ' `col2` datetime NOT NULL DEFAULT ( DATE_ADD( NOW( ) , INTERVAL 1 YEAR ) ),', - " `col3` varchar(255) NOT NULL DEFAULT ( CONCAT( 'a' , 'b' ) )", + ' `col1` int NOT NULL DEFAULT (1 + 2),', + ' `col2` datetime NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 1 YEAR)),', + " `col3` varchar(255) NOT NULL DEFAULT (CONCAT('a' , 'b'))", ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', ) ), @@ -11493,7 +11493,7 @@ public function testCreateTableWithDefaultExpressions(): void { 'Type' => 'int', 'Null' => 'NO', 'Key' => '', - 'Default' => '( 1 + 2 )', + 'Default' => '1 + 2', 'Extra' => 'DEFAULT_GENERATED', ), (object) array( @@ -11501,7 +11501,7 @@ public function testCreateTableWithDefaultExpressions(): void { 'Type' => 'datetime', 'Null' => 'NO', 'Key' => '', - 'Default' => '( DATE_ADD( NOW( ) , INTERVAL 1 YEAR ) )', + 'Default' => 'DATE_ADD(NOW(), INTERVAL 1 YEAR)', 'Extra' => 'DEFAULT_GENERATED', ), (object) array( @@ -11509,7 +11509,7 @@ public function testCreateTableWithDefaultExpressions(): void { 'Type' => 'varchar(255)', 'Null' => 'NO', 'Key' => '', - 'Default' => "( CONCAT( 'a' , 'b' ) )", + 'Default' => "CONCAT('a' , 'b')", 'Extra' => 'DEFAULT_GENERATED', ), ), diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 90323bc8..4aaf10e4 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -5921,7 +5921,7 @@ private function get_sqlite_create_table_statement( $ast = $this->create_parser( 'SELECT ' . $column['COLUMN_DEFAULT'] )->parse(); $expr = $ast->get_first_descendant_node( 'selectItem' )->get_first_child_node(); $default_clause = $this->translate( $expr ); - $query .= ' DEFAULT ' . $default_clause; + $query .= sprintf( ' DEFAULT (%s)', $default_clause ); } else { $query .= ' DEFAULT ' . $this->quote_sqlite_value( $column['COLUMN_DEFAULT'] ); } @@ -6049,7 +6049,7 @@ function ( $column ) { $check_clause = $this->translate( $expr ); $sql = sprintf( - ' CONSTRAINT %s CHECK %s', + ' CONSTRAINT %s CHECK (%s)', $this->quote_sqlite_identifier( $check_constraint['CONSTRAINT_NAME'] ), $check_clause ); @@ -6226,7 +6226,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str $sql .= ' DEFAULT CURRENT_TIMESTAMP'; } elseif ( null !== $column['COLUMN_DEFAULT'] ) { if ( str_contains( $column['EXTRA'], 'DEFAULT_GENERATED' ) ) { - $sql .= ' DEFAULT ' . $column['COLUMN_DEFAULT']; + $sql .= sprintf( ' DEFAULT (%s)', $column['COLUMN_DEFAULT'] ); } else { $sql .= ' DEFAULT ' . $this->quote_mysql_utf8_string_literal( $column['COLUMN_DEFAULT'] ); } @@ -6333,7 +6333,7 @@ function ( $column ) { // 9. Add CHECK constraints. foreach ( $check_constraints_info as $check_constraint ) { $sql = sprintf( - ' CONSTRAINT %s CHECK %s%s', + ' CONSTRAINT %s CHECK (%s)%s', $this->quote_mysql_identifier( $check_constraint['CONSTRAINT_NAME'] ), $check_constraint['CHECK_CLAUSE'], 'NO' === $check_constraint['ENFORCED'] ? ' /*!80016 NOT ENFORCED */' : '' diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php index 02d8024e..8e84a9f2 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php @@ -1912,10 +1912,7 @@ private function extract_check_constraint_data( WP_Parser_Node $node, string $ta } $expr = $check_constraint->get_first_child_node( 'exprWithParentheses' ); - $check_clause = ''; - foreach ( $expr->get_descendant_tokens() as $i => $token ) { - $check_clause .= ( $i > 0 ? ' ' : '' ) . $token->get_bytes(); - } + $check_clause = $this->serialize_mysql_expression( $expr ); return array( 'constraint_schema' => self::SAVED_DATABASE_NAME, @@ -2096,19 +2093,7 @@ private function get_column_default( WP_Parser_Node $node ): ?string { // DEFAULT (expression) - MySQL 8.0.13+ supports exprWithParentheses $expr_with_parens = $default_attr->get_first_child_node( 'exprWithParentheses' ); if ( $expr_with_parens ) { - $default_clause = ''; - foreach ( $expr_with_parens->get_descendant_tokens() as $i => $token ) { - if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { - // TODO: This is just a quick fix to avoid inserting whitespace - // before '(', which would break function call expressions. - // The proper fix is to implement a "$node->get_bytes()" API. - // This same applies to the CHECK (expression) case as well. - $default_clause .= $token->get_bytes(); - } else { - $default_clause .= ( $i > 0 ? ' ' : '' ) . $token->get_bytes(); - } - } - return $default_clause; + return $this->serialize_mysql_expression( $expr_with_parens ); } throw new Exception( 'DEFAULT value of this type is not supported.' ); @@ -3034,6 +3019,56 @@ private function get_value( WP_Parser_Node $node ): string { return $full_value; } + /** + * Serialize a MySQL expression for storing in the information schema. + * + * This is used for storing DEFAULT and CHECK expressions in the database. + * + * The current implementation is using a naive approach based on directly + * joining the original expression token bytes. This is safe, beacuase the + * original tokens must comprise a valid expression. While functionally + * equivalent, it is not strictly identical to what MySQL stores, because + * MySQL normalizes and prints the expression in a specific format. + * + * TODO: Consider implementing a MySQL expression node -> string formatter + * that would produce results that are identical to MySQL formatting. + * This gets tricky from MySQL 8, where a double-escaping regression + * was introduced, storing strings like "_utf8mb4\'abc\'" instead of + * "_utf8mb4'abc'", but displaying them correctly in SHOW statements. + * @see https://bugs.mysql.com/bug.php?id=100607 + * + * @param WP_Parser_Node $node The AST node that needs to be serialized. + * @return string The serialized value of the node. + */ + private function serialize_mysql_expression( WP_Parser_Node $node ): string { + // The wrapping parentheses are generally not stored, although in MySQL, + // this varies by expression type as per the expression formatter logic. + if ( 'exprWithParentheses' === $node->rule_name ) { + return $this->serialize_mysql_expression( $node->get_first_child_node( 'expr' ) ); + } + + $value = ''; + $last_token_id = null; + foreach ( $node->get_descendant_tokens() as $i => $token ) { + // Do not insert whitespace around parentheses. This is primarily to + // avoid inserting whitespace before '(', which may break function + // calls, depending on the value of the "IGNORE_SPACE" SQL mode. + if ( + 0 === $i + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $last_token_id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $last_token_id + ) { + $value .= $token->get_bytes(); + } else { + $value .= ' ' . $token->get_bytes(); + } + $last_token_id = $token->id; + } + return $value; + } + /** * Insert values into an SQLite table. *