diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 51411385..d979c2fa 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -11238,4 +11238,81 @@ 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 ); + + // 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/tests/WP_SQLite_Translator_Tests.php b/tests/WP_SQLite_Translator_Tests.php index a892fe72..e73368d0 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 ); + } } 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 252573fd..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 @@ -2088,7 +2088,25 @@ private function get_column_default( WP_Parser_Node $node ): ?string { return $this->get_value( $signed_literal ); } - throw new Exception( 'DEFAULT values with expressions are not yet supported.' ); + // 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; + } + + throw new Exception( 'DEFAULT value of this type is not supported.' ); } /** diff --git a/wp-includes/sqlite/class-wp-sqlite-translator.php b/wp-includes/sqlite/class-wp-sqlite-translator.php index c0ac1b85..c7582210 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; }