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 71cd0686..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', ) ), @@ -11368,4 +11368,159 @@ 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 ); + + // 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'} + ); + + // 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 { + $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'} + ); + + // 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'] ); + } } diff --git a/tests/WP_SQLite_Translator_Tests.php b/tests/WP_SQLite_Translator_Tests.php index 035d3db4..e53e139e 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 ); + } } 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..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 @@ -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 .= sprintf( ' DEFAULT (%s)', $default_clause ); } else { $query .= ' DEFAULT ' . $this->quote_sqlite_value( $column['COLUMN_DEFAULT'] ); } @@ -6044,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 ); @@ -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 .= sprintf( ' DEFAULT (%s)', $column['COLUMN_DEFAULT'] ); + } else { + $sql .= ' DEFAULT ' . $this->quote_mysql_utf8_string_literal( $column['COLUMN_DEFAULT'] ); + } } elseif ( 'YES' === $column['IS_NULLABLE'] ) { $sql .= ' DEFAULT NULL'; } @@ -6324,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 ce625661..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, @@ -2093,7 +2090,13 @@ 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 ) { + return $this->serialize_mysql_expression( $expr_with_parens ); + } + + throw new Exception( 'DEFAULT value of this type is not supported.' ); } /** @@ -3016,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. * diff --git a/wp-includes/sqlite/class-wp-sqlite-translator.php b/wp-includes/sqlite/class-wp-sqlite-translator.php index 62ed8b40..0c0dce6a 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; }