From 369aeed94748f3fbb8fb2d3e2faa13459b6506ed Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 15 Jan 2026 11:07:00 +0100 Subject: [PATCH 1/6] 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 a892fe72..daf1ac11 100644 --- a/tests/WP_SQLite_Translator_Tests.php +++ b/tests/WP_SQLite_Translator_Tests.php @@ -3510,4 +3510,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 185db71090be121e9566ffb79cfdb8f2facaa7a8 Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 15 Jan 2026 11:07:17 +0100 Subject: [PATCH 2/6] 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 c0ac1b85..a29d3c85 100644 --- a/wp-includes/sqlite/class-wp-sqlite-translator.php +++ b/wp-includes/sqlite/class-wp-sqlite-translator.php @@ -1149,7 +1149,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 a1fa523294de91569d9db837bc2c42b16d5a3869 Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 15 Jan 2026 11:14:19 +0100 Subject: [PATCH 3/6] 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 daf1ac11..e73368d0 100644 --- a/tests/WP_SQLite_Translator_Tests.php +++ b/tests/WP_SQLite_Translator_Tests.php @@ -3518,10 +3518,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 a29d3c85..c7582210 100644 --- a/wp-includes/sqlite/class-wp-sqlite-translator.php +++ b/wp-includes/sqlite/class-wp-sqlite-translator.php @@ -1150,13 +1150,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 f6e392e97c27417ae4f795f092d8edb0af1742b5 Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 15 Jan 2026 11:32:53 +0100 Subject: [PATCH 4/6] 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 51411385..8307b2cc 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -11238,4 +11238,28 @@ public function testVersionFunction(): void { $result = $this->engine->query( 'SELECT VERSION()' ); $this->assertSame( '8.0.38', $result[0]->{'VERSION()'} ); } + + /** + * 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 14c63a7f6769f386320c277c4f274020f3227588 Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 15 Jan 2026 11:44:48 +0100 Subject: [PATCH 5/6] 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 252573fd..34396816 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 @@ -2088,6 +2088,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 f4592f1e0f5d00ff674f1d09422a9dd285412ad5 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 16 Jan 2026 16:46:26 +0100 Subject: [PATCH 6/6] Add support for `DEFAULT (expression)` in table column definitions --- tests/WP_SQLite_Driver_Tests.php | 53 +++++++++++++++++++ .../sqlite-ast/class-wp-sqlite-driver.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 8307b2cc..d979c2fa 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -11261,5 +11261,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-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index 3a9c13e6..a17a1f74 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -5413,8 +5413,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 ( @@ -5422,6 +5420,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->connection->quote( $column['COLUMN_DEFAULT'] ); } @@ -5713,7 +5718,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 34396816..752049cb 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 @@ -2091,23 +2091,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.' ); } /**