From d2262de8c000cd857da37ac0623e98e4bf4d2c0f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 19 Oct 2026 09:53:11 -0400 Subject: [PATCH 1/7] Normalize zero defaults of temporal columns MySQL stores a numeric zero default of a temporal column, such as DEFAULT 0 on a TIMESTAMP column, as its zero value, e.g. '0000-00-00 00:00:00'. The driver stored it verbatim, so SHOW CREATE TABLE emitted DEFAULT '0', which MySQL rejects in strict SQL mode. Normalize these defaults when recording column definitions, and repair defaults stored by older driver versions when the database is configured. --- .../sqlite/class-wp-sqlite-configurator.php | 1 + ...s-wp-sqlite-information-schema-builder.php | 118 +++++++++++++-- .../tests/WP_MySQL_On_SQLite_Tests.php | 140 ++++++++++++++++++ 3 files changed, 245 insertions(+), 14 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php index 45da0863a..dc32b0877 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php @@ -82,6 +82,7 @@ public function configure_database(): void { $this->ensure_global_variables_table(); $this->schema_builder->ensure_information_schema_tables(); $this->schema_reconstructor->ensure_correct_information_schema(); + $this->schema_builder->normalize_temporal_zero_defaults(); $this->save_current_driver_version(); $this->ensure_database_data(); } catch ( Throwable $e ) { diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index 0b90df108..9bc136658 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -413,6 +413,46 @@ public function ensure_information_schema_tables(): void { } } + /** + * Normalize numeric zero defaults of temporal columns in the information schema. + * + * Older driver versions stored defaults such as "DEFAULT 0" of a TIMESTAMP + * column verbatim. MySQL stores them as a canonical zero value, such as + * "0000-00-00 00:00:00", and "SHOW CREATE TABLE" must emit that value for + * the output to be accepted by MySQL in strict SQL mode. + */ + public function normalize_temporal_zero_defaults(): void { + $columns_table = $this->connection->quote_identifier( $this->get_table_name( false, 'columns' ) ); + $columns = $this->connection->query( + sprintf( + "SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, COLUMN_DEFAULT + FROM %s + WHERE DATA_TYPE IN ('date', 'time', 'datetime', 'timestamp', 'year') + AND COLUMN_DEFAULT IS NOT NULL", + $columns_table + ) + )->fetchAll( PDO::FETCH_ASSOC ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + + foreach ( $columns as $column ) { + $default = $this->get_temporal_zero_default( + (string) $column['COLUMN_DEFAULT'], + $column['DATA_TYPE'], + $column['COLUMN_TYPE'] + ); + if ( null === $default || $default === $column['COLUMN_DEFAULT'] ) { + continue; + } + + $this->connection->query( + sprintf( + 'UPDATE %s SET COLUMN_DEFAULT = ? WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?', + $columns_table + ), + array( $default, $column['TABLE_SCHEMA'], $column['TABLE_NAME'], $column['COLUMN_NAME'] ) + ); + } + } + /** * Get the definition and data of a computed information schema table. * @@ -1028,7 +1068,7 @@ private function record_alter_column_default( ): void { $columns_table = $this->get_table_name( $table_is_temporary, 'columns' ); $column = $this->connection->query( - 'SELECT DATA_TYPE, IS_NULLABLE, EXTRA FROM ' . $this->connection->quote_identifier( $columns_table ) . ' + 'SELECT DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, EXTRA FROM ' . $this->connection->quote_identifier( $columns_table ) . ' WHERE table_schema = ? AND table_name = ? AND column_name = ?', array( self::SAVED_DATABASE_NAME, $table_name, $column_name ) )->fetch( PDO::FETCH_ASSOC ); @@ -1053,8 +1093,8 @@ private function record_alter_column_default( $expression = $node->get_first_child_node( 'exprWithParentheses' ); $literal = $expression ? $this->get_parenthesized_default_literal( $expression ) : null; $default = $literal - ? $this->get_literal_default( $literal, $column['DATA_TYPE'], $column_name ) - : $this->get_default_value( $node, $column['DATA_TYPE'], $column_name ); + ? $this->get_literal_default( $literal, $column['DATA_TYPE'], $column_name, $column['COLUMN_TYPE'] ) + : $this->get_default_value( $node, $column['DATA_TYPE'], $column_name, $column['COLUMN_TYPE'] ); if ( null === $default && 'NO' === $column['IS_NULLABLE'] ) { throw WP_SQLite_Information_Schema_Exception::invalid_default_value( $column_name ); } @@ -1628,7 +1668,7 @@ private function record_drop_check_constraint( private function extract_column_data( string $table_name, string $column_name, WP_Parser_Node $node, int $position ): array { list ( $data_type, $column_type ) = $this->get_column_data_types( $node ); - $default = $this->get_column_default( $node, $data_type, $column_name ); + $default = $this->get_column_default( $node, $data_type, $column_name, $column_type ); $nullable = $this->get_column_nullable( $node ); $key = $this->get_column_key( $node ); $extra = $this->get_column_extra( $node ); @@ -2173,9 +2213,10 @@ private function get_table_comment( WP_Parser_Node $node ): string { * @param WP_Parser_Node $node The "columnDefinition" or "fieldDefinition" AST node. * @param string $data_type The column data type as stored in information schema. * @param string $column_name The column name. + * @param string $column_type The column type as stored in information schema. * @return string|null The column default as stored in information schema. */ - private function get_column_default( WP_Parser_Node $node, string $data_type, string $column_name ): ?string { + private function get_column_default( WP_Parser_Node $node, string $data_type, string $column_name, string $column_type ): ?string { $default_attr = null; foreach ( $node->get_descendant_nodes( 'columnAttribute' ) as $attr ) { if ( $attr->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { @@ -2186,7 +2227,7 @@ private function get_column_default( WP_Parser_Node $node, string $data_type, st if ( null === $default_attr ) { return null; } - return $this->get_default_value( $default_attr, $data_type, $column_name ); + return $this->get_default_value( $default_attr, $data_type, $column_name, $column_type ); } /** @@ -2195,9 +2236,10 @@ private function get_column_default( WP_Parser_Node $node, string $data_type, st * @param WP_Parser_Node $node The "columnAttribute" or "alterListItem" AST node. * @param string $data_type The column data type as stored in information schema. * @param string $column_name The column name. + * @param string $column_type The column type as stored in information schema. * @return string|null The default value as stored in information schema. */ - private function get_default_value( WP_Parser_Node $node, string $data_type, string $column_name ): ?string { + private function get_default_value( WP_Parser_Node $node, string $data_type, string $column_name, string $column_type ): ?string { /* * [GRAMMAR] * columnAttribute: @@ -2227,9 +2269,9 @@ private function get_default_value( WP_Parser_Node $node, string $data_type, str // A signed number, such as "-5", has no "literal" child node. // Like MySQL, drop the unary plus, e.g., "+5" becomes "5". $number = ltrim( $this->get_value( $signed_literal ), '+' ); - return $this->normalize_literal_default( $number, $data_type, $column_name ); + return $this->normalize_literal_default( $number, $data_type, $column_name, $column_type ); } - return $this->get_literal_default( $literal, $data_type, $column_name ); + return $this->get_literal_default( $literal, $data_type, $column_name, $column_type ); } // DEFAULT (expression) - MySQL 8.0.13+ supports exprWithParentheses @@ -2247,9 +2289,10 @@ private function get_default_value( WP_Parser_Node $node, string $data_type, str * @param WP_Parser_Node $literal The "literal" AST node. * @param string $data_type The column data type as stored in information schema. * @param string $column_name The column name. + * @param string $column_type The column type as stored in information schema. * @return string|null The default value as stored in information schema. */ - private function get_literal_default( WP_Parser_Node $literal, string $data_type, string $column_name ): ?string { + private function get_literal_default( WP_Parser_Node $literal, string $data_type, string $column_name, string $column_type ): ?string { // DEFAULT NULL if ( $literal->has_child_node( 'nullLiteral' ) ) { return null; @@ -2262,7 +2305,7 @@ private function get_literal_default( WP_Parser_Node $literal, string $data_type return 'bit' === $data_type ? "b'{$bool_value}'" : $bool_value; } - return $this->normalize_literal_default( $this->get_value( $literal ), $data_type, $column_name ); + return $this->normalize_literal_default( $this->get_value( $literal ), $data_type, $column_name, $column_type ); } /** @@ -2271,9 +2314,16 @@ private function get_literal_default( WP_Parser_Node $literal, string $data_type * @param string $default_value The literal default value. * @param string $data_type The column data type as stored in information schema. * @param string $column_name The column name. + * @param string $column_type The column type as stored in information schema. * @return string The default value as stored in information schema. */ - private function normalize_literal_default( string $default_value, string $data_type, string $column_name ): string { + private function normalize_literal_default( string $default_value, string $data_type, string $column_name, string $column_type ): string { + // Numeric zero temporal defaults, such as "DEFAULT 0" of a DATETIME column. + $zero_default = $this->get_temporal_zero_default( $default_value, $data_type, $column_type ); + if ( null !== $zero_default ) { + return $zero_default; + } + if ( 'bit' === $data_type ) { /* * @TODO: Validate and normalize defaults from their AST and the full @@ -2287,8 +2337,9 @@ private function normalize_literal_default( string $default_value, string $data_ } /* - * @TODO: Non-BIT literal defaults are currently stored verbatim. To - * match MySQL, they should be normalized. For example: + * @TODO: Other non-BIT literal defaults (apart from numeric zero temporal + * defaults handled above) are currently stored verbatim. To match MySQL, + * they should be normalized. For example: * - BINARY/VARBINARY defaults stored as 0x hex literals. * - Hex and bit literals coerced to raw bytes for text columns and to * their decimal value for numeric columns. @@ -2298,6 +2349,45 @@ private function normalize_literal_default( string $default_value, string $data_ return $default_value; } + /** + * Normalize a numeric zero default of a temporal column to its MySQL zero value. + * + * MySQL stores "DEFAULT 0" of a DATETIME column as "0000-00-00 00:00:00", + * and it rejects the quoted form "DEFAULT '0'" in strict SQL mode. Other + * temporal defaults are not handled here. + * + * @param string $default_value The default value. + * @param string $data_type The column data type as stored in information schema. + * @param string $column_type The column type as stored in information schema. + * @return string|null The zero value, or null when the default is not a numeric zero + * or the column is not a temporal column. + */ + private function get_temporal_zero_default( string $default_value, string $data_type, string $column_type ): ?string { + if ( ! preg_match( '/\A[+-]?(?:0+(?:\.0*)?|\.0+)\z/', $default_value ) ) { + return null; + } + + // Fractional seconds precision, e.g., "datetime(3)". + $fraction = ''; + if ( preg_match( '/\((\d+)\)/', $column_type, $matches ) && (int) $matches[1] > 0 ) { + $fraction = '.' . str_repeat( '0', (int) $matches[1] ); + } + + switch ( $data_type ) { + case 'date': + return '0000-00-00'; + case 'year': + return '0000'; + case 'time': + return '00:00:00' . $fraction; + case 'datetime': + case 'timestamp': + return '0000-00-00 00:00:00' . $fraction; + default: + return null; + } + } + /** * Normalize a BIT default value to a MySQL bit literal. * diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 4ec8f3aa5..5be34fc5b 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -6333,6 +6333,146 @@ public function testUnionAll(): void { ); } + public function testShowCreateTableWithTemporalZeroDefaults(): void { + $this->assertQuery( + 'CREATE TABLE _tmp_table ( + col_timestamp TIMESTAMP NOT NULL DEFAULT 0, + col_datetime DATETIME NOT NULL DEFAULT 0, + col_date DATE NOT NULL DEFAULT 0, + col_time TIME NOT NULL DEFAULT 0, + col_year YEAR NOT NULL DEFAULT 0, + col_datetime_fsp DATETIME(3) NOT NULL DEFAULT 0, + col_timestamp_fsp TIMESTAMP(6) NOT NULL DEFAULT 0, + col_time_fsp TIME(2) NOT NULL DEFAULT 0, + col_decimal_zero DATETIME NOT NULL DEFAULT 0.0, + col_negative_zero DATETIME NOT NULL DEFAULT -0, + col_positive_zero DATETIME NOT NULL DEFAULT +0, + col_padded_zero DATETIME NOT NULL DEFAULT 00, + col_int INT NOT NULL DEFAULT 0 + )' + ); + + $this->assertQuery( 'SHOW CREATE TABLE _tmp_table' ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `_tmp_table` (', + " `col_timestamp` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `col_datetime` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `col_date` date NOT NULL DEFAULT '0000-00-00',", + " `col_time` time NOT NULL DEFAULT '00:00:00',", + " `col_year` year NOT NULL DEFAULT '0000',", + " `col_datetime_fsp` datetime(3) NOT NULL DEFAULT '0000-00-00 00:00:00.000',", + " `col_timestamp_fsp` timestamp(6) NOT NULL DEFAULT '0000-00-00 00:00:00.000000',", + " `col_time_fsp` time(2) NOT NULL DEFAULT '00:00:00.00',", + " `col_decimal_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `col_negative_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `col_positive_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `col_padded_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `col_int` int NOT NULL DEFAULT '0'", + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $this->last_result[0]->{'Create Table'} + ); + + $this->assertQuery( + "SELECT column_name, column_default FROM information_schema.columns WHERE table_name = '_tmp_table' AND column_name IN ('col_timestamp', 'col_date', 'col_time_fsp') ORDER BY ordinal_position" + ); + $this->assertEquals( + array( + (object) array( + 'COLUMN_NAME' => 'col_timestamp', + 'COLUMN_DEFAULT' => '0000-00-00 00:00:00', + ), + (object) array( + 'COLUMN_NAME' => 'col_date', + 'COLUMN_DEFAULT' => '0000-00-00', + ), + (object) array( + 'COLUMN_NAME' => 'col_time_fsp', + 'COLUMN_DEFAULT' => '00:00:00.00', + ), + ), + $this->last_result + ); + } + + public function testInsertUsesTemporalZeroDefaults(): void { + $this->assertQuery( + 'CREATE TABLE _tmp_table ( + id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT 0, + updated_on DATE NOT NULL DEFAULT 0 + )' + ); + $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (1)' ); + $this->assertQuery( 'SELECT created_at, updated_on FROM _tmp_table' ); + $this->assertEquals( + array( + (object) array( + 'created_at' => '0000-00-00 00:00:00', + 'updated_on' => '0000-00-00', + ), + ), + $this->last_result + ); + } + + public function testAlterTableWithTemporalZeroDefaults(): void { + $this->assertQuery( 'CREATE TABLE _tmp_table (id INT NOT NULL, modified_at DATETIME NOT NULL)' ); + $this->assertQuery( 'ALTER TABLE _tmp_table ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT 0' ); + $this->assertQuery( 'ALTER TABLE _tmp_table MODIFY COLUMN modified_at DATETIME NOT NULL DEFAULT 0' ); + + $this->assertQuery( 'SHOW CREATE TABLE _tmp_table' ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `_tmp_table` (', + ' `id` int NOT NULL,', + " `modified_at` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'", + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $this->last_result[0]->{'Create Table'} + ); + } + + public function testConfigurationNormalizesStoredTemporalZeroDefaults(): void { + $this->assertQuery( 'CREATE TABLE _tmp_table (id INT NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT 0, label VARCHAR(20) NOT NULL DEFAULT 0)' ); + + // Simulate a database configured by an older driver version that + // stored numeric zero temporal defaults verbatim. + $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET column_default = '0' WHERE table_name = '_tmp_table'" ); + $this->sqlite->exec( "UPDATE _wp_sqlite_global_variables SET value = '0.0.0' WHERE name = '_wp_sqlite_driver_version'" ); + + $this->engine = new WP_MySQL_On_SQLite( + 'mysql-on-sqlite:dbname=wp', + null, + null, + array( 'sqlite_pdo' => $this->sqlite ) + ); + $this->engine->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + + $this->assertQuery( 'SHOW CREATE TABLE _tmp_table' ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `_tmp_table` (', + " `id` int NOT NULL DEFAULT '0',", + " `created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `label` varchar(20) NOT NULL DEFAULT '0'", + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $this->last_result[0]->{'Create Table'} + ); + } + public function testShowCreateTableWithEmptyDatetimeDefault() { $this->assertQuery( "CREATE TABLE _tmp_table ( From cf6be954259df1fb0fe0b5ff312314e37343a619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Thu, 22 Oct 2026 22:56:40 +0200 Subject: [PATCH 2/7] Detect temporal zero defaults from the AST Check the zero value after the literal default is resolved, so that all numeric forms are covered, including FALSE and 0e0, not only those that a regex matched. Take the fractional seconds precision from the column data type in the AST. This also fixes reading the precision from the AST, which used the whole "(3)" node and cast it to 0, and which could match a CAST(... AS DATETIME(n)) in a default, generated column, or CHECK expression. Besides the zero values, this corrects the DATETIME_PRECISION of new TIME(n), DATETIME(n), and TIMESTAMP(n) columns. String zeros, such as '0', are normalized as well, as older driver versions stored them the same as numeric zeros. The exception is a YEAR column, where MySQL reads the strings '0' and '00' as the year 2000. Expression defaults, such as DEFAULT (0), stay unchanged. ALTER COLUMN ... SET DEFAULT is normalized the same way, including a parenthesized literal, such as SET DEFAULT (0), which MySQL stores as a literal. As the statement has no data type, the precision comes from the information schema. --- ...s-wp-sqlite-information-schema-builder.php | 152 +++++++++-------- .../tests/WP_MySQL_On_SQLite_Tests.php | 156 +++++++++++++++++- 2 files changed, 228 insertions(+), 80 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index 9bc136658..86d6ca7b1 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -312,6 +312,17 @@ class WP_SQLite_Information_Schema_Builder { 'utf8mb4' => 4, ); + /** + * Zero values of temporal data types, without fractional seconds. + */ + const TEMPORAL_ZERO_VALUE_MAP = array( + 'date' => '0000-00-00', + 'time' => '00:00:00', + 'datetime' => '0000-00-00 00:00:00', + 'timestamp' => '0000-00-00 00:00:00', + 'year' => '0000', + ); + /** * A prefix for information schema table names. * @@ -425,7 +436,7 @@ public function normalize_temporal_zero_defaults(): void { $columns_table = $this->connection->quote_identifier( $this->get_table_name( false, 'columns' ) ); $columns = $this->connection->query( sprintf( - "SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, COLUMN_DEFAULT + "SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE, DATETIME_PRECISION, COLUMN_DEFAULT FROM %s WHERE DATA_TYPE IN ('date', 'time', 'datetime', 'timestamp', 'year') AND COLUMN_DEFAULT IS NOT NULL", @@ -434,10 +445,10 @@ public function normalize_temporal_zero_defaults(): void { )->fetchAll( PDO::FETCH_ASSOC ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO foreach ( $columns as $column ) { - $default = $this->get_temporal_zero_default( - (string) $column['COLUMN_DEFAULT'], + $default = $this->get_temporal_zero_value( $column['DATA_TYPE'], - $column['COLUMN_TYPE'] + $column['COLUMN_DEFAULT'], + null === $column['DATETIME_PRECISION'] ? null : (int) $column['DATETIME_PRECISION'] ); if ( null === $default || $default === $column['COLUMN_DEFAULT'] ) { continue; @@ -1068,7 +1079,7 @@ private function record_alter_column_default( ): void { $columns_table = $this->get_table_name( $table_is_temporary, 'columns' ); $column = $this->connection->query( - 'SELECT DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, EXTRA FROM ' . $this->connection->quote_identifier( $columns_table ) . ' + 'SELECT DATA_TYPE, DATETIME_PRECISION, IS_NULLABLE, EXTRA FROM ' . $this->connection->quote_identifier( $columns_table ) . ' WHERE table_schema = ? AND table_name = ? AND column_name = ?', array( self::SAVED_DATABASE_NAME, $table_name, $column_name ) )->fetch( PDO::FETCH_ASSOC ); @@ -1090,11 +1101,12 @@ private function record_alter_column_default( $default = null; $extra = trim( str_replace( 'DEFAULT_GENERATED', '', $column['EXTRA'] ) ); if ( $node->has_child_token( WP_MySQL_Lexer::SET_SYMBOL ) ) { - $expression = $node->get_first_child_node( 'exprWithParentheses' ); - $literal = $expression ? $this->get_parenthesized_default_literal( $expression ) : null; - $default = $literal - ? $this->get_literal_default( $literal, $column['DATA_TYPE'], $column_name, $column['COLUMN_TYPE'] ) - : $this->get_default_value( $node, $column['DATA_TYPE'], $column_name, $column['COLUMN_TYPE'] ); + $expression = $node->get_first_child_node( 'exprWithParentheses' ); + $literal = $expression ? $this->get_parenthesized_default_literal( $expression ) : null; + $datetime_precision = null === $column['DATETIME_PRECISION'] ? null : (int) $column['DATETIME_PRECISION']; + $default = $literal + ? $this->get_literal_default( $literal, $column['DATA_TYPE'], $column_name, $datetime_precision ) + : $this->get_default_value( $node, $column['DATA_TYPE'], $column_name, $datetime_precision ); if ( null === $default && 'NO' === $column['IS_NULLABLE'] ) { throw WP_SQLite_Information_Schema_Exception::invalid_default_value( $column_name ); } @@ -1668,7 +1680,7 @@ private function record_drop_check_constraint( private function extract_column_data( string $table_name, string $column_name, WP_Parser_Node $node, int $position ): array { list ( $data_type, $column_type ) = $this->get_column_data_types( $node ); - $default = $this->get_column_default( $node, $data_type, $column_name, $column_type ); + $default = $this->get_column_default( $node, $data_type, $column_name ); $nullable = $this->get_column_nullable( $node ); $key = $this->get_column_key( $node ); $extra = $this->get_column_extra( $node ); @@ -2213,10 +2225,9 @@ private function get_table_comment( WP_Parser_Node $node ): string { * @param WP_Parser_Node $node The "columnDefinition" or "fieldDefinition" AST node. * @param string $data_type The column data type as stored in information schema. * @param string $column_name The column name. - * @param string $column_type The column type as stored in information schema. * @return string|null The column default as stored in information schema. */ - private function get_column_default( WP_Parser_Node $node, string $data_type, string $column_name, string $column_type ): ?string { + private function get_column_default( WP_Parser_Node $node, string $data_type, string $column_name ): ?string { $default_attr = null; foreach ( $node->get_descendant_nodes( 'columnAttribute' ) as $attr ) { if ( $attr->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { @@ -2227,19 +2238,20 @@ private function get_column_default( WP_Parser_Node $node, string $data_type, st if ( null === $default_attr ) { return null; } - return $this->get_default_value( $default_attr, $data_type, $column_name, $column_type ); + $datetime_precision = $this->get_column_datetime_precision( $node, $data_type ); + return $this->get_default_value( $default_attr, $data_type, $column_name, $datetime_precision ); } /** * Extract a default value from a column attribute or ALTER COLUMN action. * - * @param WP_Parser_Node $node The "columnAttribute" or "alterListItem" AST node. - * @param string $data_type The column data type as stored in information schema. - * @param string $column_name The column name. - * @param string $column_type The column type as stored in information schema. - * @return string|null The default value as stored in information schema. + * @param WP_Parser_Node $node The "columnAttribute" or "alterListItem" AST node. + * @param string $data_type The column data type as stored in information schema. + * @param string $column_name The column name. + * @param int|null $datetime_precision The column fractional seconds precision. + * @return string|null The default value as stored in information schema. */ - private function get_default_value( WP_Parser_Node $node, string $data_type, string $column_name, string $column_type ): ?string { + private function get_default_value( WP_Parser_Node $node, string $data_type, string $column_name, ?int $datetime_precision ): ?string { /* * [GRAMMAR] * columnAttribute: @@ -2268,10 +2280,11 @@ private function get_default_value( WP_Parser_Node $node, string $data_type, str if ( null === $literal ) { // A signed number, such as "-5", has no "literal" child node. // Like MySQL, drop the unary plus, e.g., "+5" becomes "5". - $number = ltrim( $this->get_value( $signed_literal ), '+' ); - return $this->normalize_literal_default( $number, $data_type, $column_name, $column_type ); + $number = ltrim( $this->get_value( $signed_literal ), '+' ); + $default = $this->normalize_literal_default( $number, $data_type, $column_name ); + return $this->get_temporal_zero_value( $data_type, $default, $datetime_precision ) ?? $default; } - return $this->get_literal_default( $literal, $data_type, $column_name, $column_type ); + return $this->get_literal_default( $literal, $data_type, $column_name, $datetime_precision ); } // DEFAULT (expression) - MySQL 8.0.13+ supports exprWithParentheses @@ -2286,13 +2299,13 @@ private function get_default_value( WP_Parser_Node $node, string $data_type, str /** * Extract and normalize a literal default value. * - * @param WP_Parser_Node $literal The "literal" AST node. - * @param string $data_type The column data type as stored in information schema. - * @param string $column_name The column name. - * @param string $column_type The column type as stored in information schema. - * @return string|null The default value as stored in information schema. + * @param WP_Parser_Node $literal The "literal" AST node. + * @param string $data_type The column data type as stored in information schema. + * @param string $column_name The column name. + * @param int|null $datetime_precision The column fractional seconds precision. + * @return string|null The default value as stored in information schema. */ - private function get_literal_default( WP_Parser_Node $literal, string $data_type, string $column_name, string $column_type ): ?string { + private function get_literal_default( WP_Parser_Node $literal, string $data_type, string $column_name, ?int $datetime_precision ): ?string { // DEFAULT NULL if ( $literal->has_child_node( 'nullLiteral' ) ) { return null; @@ -2302,10 +2315,20 @@ private function get_literal_default( WP_Parser_Node $literal, string $data_type if ( $literal->has_child_node( 'boolLiteral' ) ) { $bool_literal = $literal->get_first_child_node( 'boolLiteral' ); $bool_value = $bool_literal->has_child_token( WP_MySQL_Lexer::TRUE_SYMBOL ) ? '1' : '0'; - return 'bit' === $data_type ? "b'{$bool_value}'" : $bool_value; + $default = 'bit' === $data_type ? "b'{$bool_value}'" : $bool_value; + } else { + $default = $this->normalize_literal_default( $this->get_value( $literal ), $data_type, $column_name ); } - return $this->normalize_literal_default( $this->get_value( $literal ), $data_type, $column_name, $column_type ); + /* + * Normalize numeric zero defaults to MySQL's temporal zero values. + * Also normalize string zeros, even where strict MySQL rejects them. + * Preserve YEAR strings '0' and '00', which MySQL interprets as 2000. + */ + if ( 'year' === $data_type && $literal->has_child_node( 'textLiteral' ) ) { + return $default; + } + return $this->get_temporal_zero_value( $data_type, $default, $datetime_precision ) ?? $default; } /** @@ -2314,16 +2337,9 @@ private function get_literal_default( WP_Parser_Node $literal, string $data_type * @param string $default_value The literal default value. * @param string $data_type The column data type as stored in information schema. * @param string $column_name The column name. - * @param string $column_type The column type as stored in information schema. * @return string The default value as stored in information schema. */ - private function normalize_literal_default( string $default_value, string $data_type, string $column_name, string $column_type ): string { - // Numeric zero temporal defaults, such as "DEFAULT 0" of a DATETIME column. - $zero_default = $this->get_temporal_zero_default( $default_value, $data_type, $column_type ); - if ( null !== $zero_default ) { - return $zero_default; - } - + private function normalize_literal_default( string $default_value, string $data_type, string $column_name ): string { if ( 'bit' === $data_type ) { /* * @TODO: Validate and normalize defaults from their AST and the full @@ -2337,9 +2353,9 @@ private function normalize_literal_default( string $default_value, string $data_ } /* - * @TODO: Other non-BIT literal defaults (apart from numeric zero temporal - * defaults handled above) are currently stored verbatim. To match MySQL, - * they should be normalized. For example: + * @TODO: Non-BIT literal defaults, except for temporal zero values, are + * currently stored verbatim. To match MySQL, they should be normalized. + * For example: * - BINARY/VARBINARY defaults stored as 0x hex literals. * - Hex and bit literals coerced to raw bytes for text columns and to * their decimal value for numeric columns. @@ -2350,42 +2366,29 @@ private function normalize_literal_default( string $default_value, string $data_ } /** - * Normalize a numeric zero default of a temporal column to its MySQL zero value. + * Get the zero value of a temporal column when its default is zero. * - * MySQL stores "DEFAULT 0" of a DATETIME column as "0000-00-00 00:00:00", - * and it rejects the quoted form "DEFAULT '0'" in strict SQL mode. Other - * temporal defaults are not handled here. + * For example, a 0, '0', -0.0, or 0e0 default of a DATETIME(3) column has + * the zero value of "0000-00-00 00:00:00.000". * - * @param string $default_value The default value. - * @param string $data_type The column data type as stored in information schema. - * @param string $column_type The column type as stored in information schema. - * @return string|null The zero value, or null when the default is not a numeric zero - * or the column is not a temporal column. + * @param string $data_type The column data type as stored in information schema. + * @param string $default_value The column default value of a number or string literal. + * @param int|null $datetime_precision The column fractional seconds precision. + * @return string|null The zero value, or null when the column is not temporal + * or the default is not zero. */ - private function get_temporal_zero_default( string $default_value, string $data_type, string $column_type ): ?string { - if ( ! preg_match( '/\A[+-]?(?:0+(?:\.0*)?|\.0+)\z/', $default_value ) ) { - return null; - } + private function get_temporal_zero_value( string $data_type, string $default_value, ?int $datetime_precision ): ?string { + $zero_value = self::TEMPORAL_ZERO_VALUE_MAP[ $data_type ] ?? null; - // Fractional seconds precision, e.g., "datetime(3)". - $fraction = ''; - if ( preg_match( '/\((\d+)\)/', $column_type, $matches ) && (int) $matches[1] > 0 ) { - $fraction = '.' . str_repeat( '0', (int) $matches[1] ); + // A signed number can be a hex literal, such as "-0x0", which is not numeric. + if ( null === $zero_value || ! is_numeric( $default_value ) || 0.0 !== (float) $default_value ) { + return null; } - switch ( $data_type ) { - case 'date': - return '0000-00-00'; - case 'year': - return '0000'; - case 'time': - return '00:00:00' . $fraction; - case 'datetime': - case 'timestamp': - return '0000-00-00 00:00:00' . $fraction; - default: - return null; + if ( $datetime_precision > 0 ) { + $zero_value .= '.' . str_repeat( '0', $datetime_precision ); } + return $zero_value; } /** @@ -2922,11 +2925,14 @@ private function get_column_numeric_attributes( WP_Parser_Node $node, string $da */ private function get_column_datetime_precision( WP_Parser_Node $node, string $data_type ): ?int { if ( 'time' === $data_type || 'datetime' === $data_type || 'timestamp' === $data_type ) { - $precision = $node->get_first_descendant_node( 'typeDatetimePrecision' ); + // Expressions in the column definition may have their own precision, + // e.g., "CAST(... AS DATETIME(6))", so only the data type is checked. + $type_node = $node->get_first_descendant_node( 'dataType' ); + $precision = $type_node->get_first_descendant_node( 'typeDatetimePrecision' ); if ( null === $precision ) { return 0; } else { - return (int) $this->get_value( $precision ); + return (int) $precision->get_first_child_token( WP_MySQL_Lexer::INT_NUMBER )->get_value(); } } return null; diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 5be34fc5b..5265b9525 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -6333,6 +6333,77 @@ public function testUnionAll(): void { ); } + public function testInformationSchemaColumnsDatetimePrecision(): void { + $this->assertQuery( + 'CREATE TABLE _tmp_table ( + col_date DATE, + col_time TIME, + col_time_fsp TIME(2), + col_datetime DATETIME, + col_datetime_fsp DATETIME(3), + col_timestamp TIMESTAMP NULL, + col_timestamp_fsp TIMESTAMP(6) NULL, + col_generated DATETIME AS (CAST(\'2020-01-01\' AS DATETIME(6))), + col_expression_default DATETIME DEFAULT (CAST(\'2020-01-01\' AS DATETIME(6))), + col_year YEAR, + col_int INT + )' + ); + + $this->assertQuery( + "SELECT column_name, datetime_precision FROM information_schema.columns WHERE table_name = '_tmp_table' ORDER BY ordinal_position" + ); + $this->assertEquals( + array( + (object) array( + 'COLUMN_NAME' => 'col_date', + 'DATETIME_PRECISION' => null, + ), + (object) array( + 'COLUMN_NAME' => 'col_time', + 'DATETIME_PRECISION' => '0', + ), + (object) array( + 'COLUMN_NAME' => 'col_time_fsp', + 'DATETIME_PRECISION' => '2', + ), + (object) array( + 'COLUMN_NAME' => 'col_datetime', + 'DATETIME_PRECISION' => '0', + ), + (object) array( + 'COLUMN_NAME' => 'col_datetime_fsp', + 'DATETIME_PRECISION' => '3', + ), + (object) array( + 'COLUMN_NAME' => 'col_timestamp', + 'DATETIME_PRECISION' => '0', + ), + (object) array( + 'COLUMN_NAME' => 'col_timestamp_fsp', + 'DATETIME_PRECISION' => '6', + ), + (object) array( + 'COLUMN_NAME' => 'col_generated', + 'DATETIME_PRECISION' => '0', + ), + (object) array( + 'COLUMN_NAME' => 'col_expression_default', + 'DATETIME_PRECISION' => '0', + ), + (object) array( + 'COLUMN_NAME' => 'col_year', + 'DATETIME_PRECISION' => null, + ), + (object) array( + 'COLUMN_NAME' => 'col_int', + 'DATETIME_PRECISION' => null, + ), + ), + $this->last_result + ); + } + public function testShowCreateTableWithTemporalZeroDefaults(): void { $this->assertQuery( 'CREATE TABLE _tmp_table ( @@ -6345,10 +6416,21 @@ public function testShowCreateTableWithTemporalZeroDefaults(): void { col_timestamp_fsp TIMESTAMP(6) NOT NULL DEFAULT 0, col_time_fsp TIME(2) NOT NULL DEFAULT 0, col_decimal_zero DATETIME NOT NULL DEFAULT 0.0, + col_fraction_zero DATETIME NOT NULL DEFAULT .0, + col_exponent_zero DATETIME NOT NULL DEFAULT 0e0, col_negative_zero DATETIME NOT NULL DEFAULT -0, col_positive_zero DATETIME NOT NULL DEFAULT +0, col_padded_zero DATETIME NOT NULL DEFAULT 00, - col_int INT NOT NULL DEFAULT 0 + col_false DATETIME NOT NULL DEFAULT FALSE, + col_check DATETIME NOT NULL DEFAULT 0 CHECK (col_check < CAST(\'9999-12-31\' AS DATETIME(3))), + col_string_zero DATETIME NOT NULL DEFAULT \'0\', + col_time_string_zero TIME NOT NULL DEFAULT \'00\', + col_year_false YEAR NOT NULL DEFAULT FALSE, + col_year_string_zero YEAR NOT NULL DEFAULT \'0\', + col_year_string_double_zero YEAR NOT NULL DEFAULT \'00\', + col_expression DATETIME NOT NULL DEFAULT (0), + col_int INT NOT NULL DEFAULT 0, + col_varchar VARCHAR(10) NOT NULL DEFAULT \'0\' )' ); @@ -6367,10 +6449,22 @@ public function testShowCreateTableWithTemporalZeroDefaults(): void { " `col_timestamp_fsp` timestamp(6) NOT NULL DEFAULT '0000-00-00 00:00:00.000000',", " `col_time_fsp` time(2) NOT NULL DEFAULT '00:00:00.00',", " `col_decimal_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `col_fraction_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `col_exponent_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", " `col_negative_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", " `col_positive_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", " `col_padded_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", - " `col_int` int NOT NULL DEFAULT '0'", + " `col_false` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `col_check` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `col_string_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `col_time_string_zero` time NOT NULL DEFAULT '00:00:00',", + " `col_year_false` year NOT NULL DEFAULT '0000',", + " `col_year_string_zero` year NOT NULL DEFAULT '0',", + " `col_year_string_double_zero` year NOT NULL DEFAULT '00',", + ' `col_expression` datetime NOT NULL DEFAULT (0),', + " `col_int` int NOT NULL DEFAULT '0',", + " `col_varchar` varchar(10) NOT NULL DEFAULT '0',", + " CONSTRAINT `_tmp_table_chk_1` CHECK (col_check < CAST('9999-12-31' AS DATETIME(3)))", ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', ) ), @@ -6404,16 +6498,32 @@ public function testInsertUsesTemporalZeroDefaults(): void { 'CREATE TABLE _tmp_table ( id INT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT 0, - updated_on DATE NOT NULL DEFAULT 0 + created_at_fsp DATETIME(3) NOT NULL DEFAULT 0, + updated_on DATE NOT NULL DEFAULT FALSE )' ); + + // Strict SQL mode. $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (1)' ); - $this->assertQuery( 'SELECT created_at, updated_on FROM _tmp_table' ); + + // Non-strict SQL mode. + $this->assertQuery( "SET sql_mode = ''" ); + $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (2)' ); + + $this->assertQuery( 'SELECT * FROM _tmp_table ORDER BY id' ); $this->assertEquals( array( (object) array( - 'created_at' => '0000-00-00 00:00:00', - 'updated_on' => '0000-00-00', + 'id' => '1', + 'created_at' => '0000-00-00 00:00:00', + 'created_at_fsp' => '0000-00-00 00:00:00.000', + 'updated_on' => '0000-00-00', + ), + (object) array( + 'id' => '2', + 'created_at' => '0000-00-00 00:00:00', + 'created_at_fsp' => '0000-00-00 00:00:00.000', + 'updated_on' => '0000-00-00', ), ), $this->last_result @@ -6421,9 +6531,33 @@ public function testInsertUsesTemporalZeroDefaults(): void { } public function testAlterTableWithTemporalZeroDefaults(): void { - $this->assertQuery( 'CREATE TABLE _tmp_table (id INT NOT NULL, modified_at DATETIME NOT NULL)' ); + $this->assertQuery( + 'CREATE TABLE _tmp_table ( + id INT NOT NULL, + modified_at DATETIME NOT NULL, + published_at DATETIME(3) NOT NULL, + updated_on DATE NOT NULL, + duration TIME(2) NOT NULL, + year_number YEAR NOT NULL, + year_string YEAR NOT NULL, + parenthesized_zero DATETIME NOT NULL, + false_value DATETIME NOT NULL, + string_zero TIME NOT NULL + )' + ); $this->assertQuery( 'ALTER TABLE _tmp_table ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT 0' ); $this->assertQuery( 'ALTER TABLE _tmp_table MODIFY COLUMN modified_at DATETIME NOT NULL DEFAULT 0' ); + $this->assertQuery( + "ALTER TABLE _tmp_table + ALTER COLUMN published_at SET DEFAULT 0, + ALTER COLUMN updated_on SET DEFAULT 0, + ALTER COLUMN duration SET DEFAULT 0, + ALTER COLUMN year_number SET DEFAULT 0, + ALTER COLUMN year_string SET DEFAULT '0', + ALTER COLUMN parenthesized_zero SET DEFAULT (0), + ALTER COLUMN false_value SET DEFAULT FALSE, + ALTER COLUMN string_zero SET DEFAULT '0'" + ); $this->assertQuery( 'SHOW CREATE TABLE _tmp_table' ); $this->assertSame( @@ -6433,6 +6567,14 @@ public function testAlterTableWithTemporalZeroDefaults(): void { 'CREATE TABLE `_tmp_table` (', ' `id` int NOT NULL,', " `modified_at` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `published_at` datetime(3) NOT NULL DEFAULT '0000-00-00 00:00:00.000',", + " `updated_on` date NOT NULL DEFAULT '0000-00-00',", + " `duration` time(2) NOT NULL DEFAULT '00:00:00.00',", + " `year_number` year NOT NULL DEFAULT '0000',", + " `year_string` year NOT NULL DEFAULT '0',", + " `parenthesized_zero` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `false_value` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `string_zero` time NOT NULL DEFAULT '00:00:00',", " `created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'", ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', ) From 0116e184aabd32df20e709cc4274bd5b581127de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 20 Oct 2026 13:59:58 +0200 Subject: [PATCH 3/7] Repair temporal zero defaults with ALTER TABLE The repair of temporal zero defaults stored by older driver versions only updated the information schema, while the SQLite tables kept their "0" defaults. INSERT statements that omitted the column then stored "0", also in non-strict SQL mode, where the stored "0" default was previously replaced by an implicit zero value. Apply the zero values with ALTER TABLE ... ALTER COLUMN ... SET DEFAULT, which stores them in the information schema and recreates the table, so that its SQLite defaults match. ALTER TABLE runs as a savepoint within the configuration transaction. On PHP < 8.4, the driver can't detect a transaction that it didn't start, so the configurator sets its private transaction state directly. A failed ALTER TABLE rolls back the whole transaction in the driver, so the configuration doesn't roll it back again. A transaction that the configuration didn't start, such as one on a shared SQLite connection, is never rolled back. Foreign keys are disabled during the configuration, as recreating a table drops it, which would otherwise fail or trigger foreign key actions, such as ON DELETE CASCADE, in referencing tables. The driver itself has the same problem when recreating tables: https://github.com/WordPress/sqlite-database-integration/issues/528 The repair moves from the information schema builder to the configurator, next to the other configuration steps, as nothing else uses it. It now also: - Runs after the database name migration, which table recreation needs. - Finds the columns by their SQLite defaults, so that it also repairs tables reconstructed from the legacy SQLite driver, whose information schema already has a zero value. - Reads the fractional seconds precision from COLUMN_TYPE, as older driver versions stored a DATETIME_PRECISION of 0. - Repairs YEAR columns only for an unquoted SQLite default, such as 0, as a string zero, such as '0', can be the year 2000. - Skips expression defaults, such as DEFAULT (0). --- .../sqlite/class-wp-sqlite-configurator.php | 152 +++++++- ...s-wp-sqlite-information-schema-builder.php | 40 -- .../tests/WP_MySQL_On_SQLite_Tests.php | 344 +++++++++++++++++- 3 files changed, 477 insertions(+), 59 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php index dc32b0877..f73880972 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php @@ -75,21 +75,45 @@ public function ensure_database_configured(): void { * SQLite driver version and attempt to repair any configuration corruption. */ public function configure_database(): void { - // Use an EXCLUSIVE transaction to prevent multiple connections - // from attempting to configure the database at the same time. - $this->driver->execute_sqlite_query( 'BEGIN EXCLUSIVE TRANSACTION' ); + /* + * In SQLite, disabling foreign keys inside a transaction is a no-op. + * We need to disable them here, because some configuration operations + * may expect them disabled, e.g., to avoid triggering cascades. + * + * See: + * https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes + */ + $foreign_keys = $this->driver->execute_sqlite_query( 'PRAGMA foreign_keys' )->fetchColumn(); + $this->driver->execute_sqlite_query( 'PRAGMA foreign_keys = OFF' ); + $transaction_started = false; try { + // Use an EXCLUSIVE transaction to prevent multiple connections + // from attempting to configure the database at the same time. + $this->driver->execute_sqlite_query( 'BEGIN EXCLUSIVE TRANSACTION' ); + $transaction_started = true; + $this->set_driver_in_transaction( true ); $this->ensure_global_variables_table(); $this->schema_builder->ensure_information_schema_tables(); $this->schema_reconstructor->ensure_correct_information_schema(); - $this->schema_builder->normalize_temporal_zero_defaults(); $this->save_current_driver_version(); $this->ensure_database_data(); + $this->ensure_temporal_zero_defaults(); + $this->driver->execute_sqlite_query( 'COMMIT' ); } catch ( Throwable $e ) { - $this->driver->execute_sqlite_query( 'ROLLBACK' ); + // Roll back only the transaction started here, unless a failed + // MySQL query already rolled it back in the driver. + if ( $transaction_started && $this->driver->inTransaction() ) { + $this->driver->execute_sqlite_query( 'ROLLBACK' ); + } throw $e; + } finally { + if ( $transaction_started ) { + $this->set_driver_in_transaction( false ); + } + if ( 1 === (int) $foreign_keys ) { + $this->driver->execute_sqlite_query( 'PRAGMA foreign_keys = ON' ); + } } - $this->driver->execute_sqlite_query( 'COMMIT' ); } /** @@ -253,6 +277,29 @@ public function ensure_database_data(): void { } } + /** + * Ensure that the driver knows that we started or ended an SQLite transaction. + * + * This is a workaround for PHP < 8.4, where PDO::inTransaction() doesn't + * reflect transaction state started via a query, such as "BEGIN EXCLUSIVE". + * + * @see WP_MySQL_On_SQLite::inTransaction() + * + * @param bool $in_transaction Whether the driver is in a transaction. + */ + private function set_driver_in_transaction( bool $in_transaction ): void { + if ( PHP_VERSION_ID >= 80400 ) { + return; + } + + // Bind the closure to the driver class, so that it can access the private + // property even when the driver is an instance of a subclass. + $set_in_transaction = function () use ( $in_transaction ): void { + $this->in_transaction = $in_transaction; + }; + Closure::bind( $set_in_transaction, $this->driver, WP_MySQL_On_SQLite::class )(); + } + /** * Save the current SQLite driver version. * @@ -273,4 +320,97 @@ private function save_current_driver_version(): void { ) ); } + + /** + * Ensure that temporal columns store numeric zero defaults as zero values. + * + * Older driver versions stored defaults such as "DEFAULT 0" of a TIMESTAMP + * column verbatim, while MySQL stores them as a zero date/time value. + */ + private function ensure_temporal_zero_defaults(): void { + $columns_table = $this->driver->get_connection()->quote_identifier( + $this->schema_builder->get_table_name( false, 'columns' ) + ); + + /* + * Older driver versions stored a DATETIME_PRECISION of 0, so the precision + * is read from the column type, such as "datetime(3)". CAST() reads the + * leading integer, e.g., "3)" as 3, or returns 0 when there is none. + */ + + // Read SQLite defaults; reconstructed metadata may already be normalized. + $columns = $this->driver->execute_sqlite_query( + sprintf( + "SELECT + c.TABLE_NAME, + c.COLUMN_NAME, + c.DATA_TYPE, + CAST(SUBSTR(c.COLUMN_TYPE, INSTR(c.COLUMN_TYPE, '(') + 1) AS INTEGER) AS DATETIME_PRECISION, + p.dflt_value + FROM %s AS c + JOIN pragma_table_info(c.TABLE_NAME, 'main') AS p ON c.COLUMN_NAME = p.name + WHERE c.TABLE_SCHEMA = ? + AND c.DATA_TYPE IN ('date', 'time', 'datetime', 'timestamp', 'year') + AND c.EXTRA NOT LIKE '%%DEFAULT_GENERATED%%' + AND p.dflt_value IS NOT NULL", + $columns_table + ), + array( WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME ) + )->fetchAll( PDO::FETCH_ASSOC ); + + // Normalize numeric zeros, such as 0, '0', '-0.0', or '0e0'. + $table_columns = array(); + foreach ( $columns as $column ) { + // SQLite returns the default as an SQL literal, e.g., 0, '0', or "0". + $default = $column['dflt_value']; + $is_string = in_array( $default[0], array( "'", '"', '`' ), true ); + if ( $is_string ) { + $default = substr( $default, 1, -1 ); + } + if ( ! is_numeric( $default ) || 0.0 !== (float) $default ) { + continue; + } + + // A string zero of a YEAR column, such as '0', can be the year 2000. + if ( $is_string && 'year' === $column['DATA_TYPE'] ) { + continue; + } + + $zero_value = WP_SQLite_Information_Schema_Builder::TEMPORAL_ZERO_VALUE_MAP[ $column['DATA_TYPE'] ]; + $precision = (int) $column['DATETIME_PRECISION']; + if ( $precision > 0 ) { + $zero_value .= '.' . str_repeat( '0', $precision ); + } + + $table_columns[ $column['TABLE_NAME'] ][] = array( + 'name' => $column['COLUMN_NAME'], + 'zero_value' => $zero_value, + ); + } + + foreach ( $table_columns as $table_name => $repaired_columns ) { + $alter_list = array(); + foreach ( $repaired_columns as $column ) { + $alter_list[] = sprintf( + 'ALTER COLUMN `%s` SET DEFAULT %s', + str_replace( '`', '``', $column['name'] ), + $this->driver->quote( $column['zero_value'] ) + ); + } + + // ALTER TABLE stores the new defaults, and it recreates the table to apply them. + try { + $this->driver->query( + sprintf( 'ALTER TABLE `%s` %s', str_replace( '`', '``', $table_name ), implode( ', ', $alter_list ) ) + ); + } catch ( WP_MySQL_On_SQLite_Exception $e ) { + throw new WP_MySQL_On_SQLite_Exception( + $this->driver, + sprintf( 'Failed to recreate table "%s": %s', $table_name, $e->getMessage() ), + $e->getCode(), + $e + ); + } + } + } } diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index 86d6ca7b1..b37347c22 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -424,46 +424,6 @@ public function ensure_information_schema_tables(): void { } } - /** - * Normalize numeric zero defaults of temporal columns in the information schema. - * - * Older driver versions stored defaults such as "DEFAULT 0" of a TIMESTAMP - * column verbatim. MySQL stores them as a canonical zero value, such as - * "0000-00-00 00:00:00", and "SHOW CREATE TABLE" must emit that value for - * the output to be accepted by MySQL in strict SQL mode. - */ - public function normalize_temporal_zero_defaults(): void { - $columns_table = $this->connection->quote_identifier( $this->get_table_name( false, 'columns' ) ); - $columns = $this->connection->query( - sprintf( - "SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE, DATETIME_PRECISION, COLUMN_DEFAULT - FROM %s - WHERE DATA_TYPE IN ('date', 'time', 'datetime', 'timestamp', 'year') - AND COLUMN_DEFAULT IS NOT NULL", - $columns_table - ) - )->fetchAll( PDO::FETCH_ASSOC ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO - - foreach ( $columns as $column ) { - $default = $this->get_temporal_zero_value( - $column['DATA_TYPE'], - $column['COLUMN_DEFAULT'], - null === $column['DATETIME_PRECISION'] ? null : (int) $column['DATETIME_PRECISION'] - ); - if ( null === $default || $default === $column['COLUMN_DEFAULT'] ) { - continue; - } - - $this->connection->query( - sprintf( - 'UPDATE %s SET COLUMN_DEFAULT = ? WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?', - $columns_table - ), - array( $default, $column['TABLE_SCHEMA'], $column['TABLE_NAME'], $column['COLUMN_NAME'] ) - ); - } - } - /** * Get the definition and data of a computed information schema table. * diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 5265b9525..52d5f80da 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -97,6 +97,28 @@ private function getLastColumnMeta(): array { return $column_meta; } + /** + * Reconnect as if the database was configured by an older driver version, + * so that the new connection configures the database again. + */ + private function reconnectAfterDriverUpgrade(): void { + $this->sqlite->exec( + sprintf( + "UPDATE %s SET value = '0.0.0' WHERE name = '%s'", + WP_MySQL_On_SQLite::GLOBAL_VARIABLES_TABLE_NAME, + WP_MySQL_On_SQLite::DRIVER_VERSION_VARIABLE_NAME + ) + ); + + $this->engine = new WP_MySQL_On_SQLite( + 'mysql-on-sqlite:dbname=wp', + null, + null, + array( 'sqlite_pdo' => $this->sqlite ) + ); + $this->engine->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + } + public function testRegexp() { $this->assertQuery( "INSERT INTO _options (option_name, option_value) VALUES ('rss_0123456789abcdef0123456789abcdef', '1');" @@ -6584,35 +6606,331 @@ public function testAlterTableWithTemporalZeroDefaults(): void { } public function testConfigurationNormalizesStoredTemporalZeroDefaults(): void { - $this->assertQuery( 'CREATE TABLE _tmp_table (id INT NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT 0, label VARCHAR(20) NOT NULL DEFAULT 0)' ); + $this->assertQuery( + "CREATE TABLE _tmp_table ( + id INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT 0, + created_at_fsp DATETIME(3) NOT NULL DEFAULT 0, + updated_on DATE NOT NULL DEFAULT 0, + duration TIME(2) NOT NULL DEFAULT 0, + year_number YEAR NOT NULL DEFAULT 0, + year_string YEAR NOT NULL DEFAULT '0', + expression DATETIME NOT NULL DEFAULT (0), + label VARCHAR(20) NOT NULL DEFAULT 0 + )" + ); - // Simulate a database configured by an older driver version that - // stored numeric zero temporal defaults verbatim. - $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET column_default = '0' WHERE table_name = '_tmp_table'" ); - $this->sqlite->exec( "UPDATE _wp_sqlite_global_variables SET value = '0.0.0' WHERE name = '_wp_sqlite_driver_version'" ); + // Store the defaults verbatim and a zero precision, as older driver versions + // did, and apply them to the SQLite table. Then, insert a row with them. + $this->sqlite->exec( + "UPDATE _wp_sqlite_mysql_information_schema_columns + SET + column_default = '0', + datetime_precision = CASE WHEN datetime_precision IS NULL THEN NULL ELSE 0 END + WHERE table_name = '_tmp_table'" + ); + $this->assertQuery( 'OPTIMIZE TABLE _tmp_table' ); + $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (1)' ); - $this->engine = new WP_MySQL_On_SQLite( - 'mysql-on-sqlite:dbname=wp', - null, - null, - array( 'sqlite_pdo' => $this->sqlite ) + $this->reconnectAfterDriverUpgrade(); + + // YEAR and expression defaults are not normalized. + $expected_create_table = implode( + "\n", + array( + 'CREATE TABLE `_tmp_table` (', + " `id` int NOT NULL DEFAULT '0',", + " `created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',", + " `created_at_fsp` datetime(3) NOT NULL DEFAULT '0000-00-00 00:00:00.000',", + " `updated_on` date NOT NULL DEFAULT '0000-00-00',", + " `duration` time(2) NOT NULL DEFAULT '00:00:00.00',", + " `year_number` year NOT NULL DEFAULT '0',", + " `year_string` year NOT NULL DEFAULT '0',", + ' `expression` datetime NOT NULL DEFAULT (0),', + " `label` varchar(20) NOT NULL DEFAULT '0'", + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ); + $this->assertQuery( 'SHOW CREATE TABLE _tmp_table' ); + $this->assertSame( $expected_create_table, $this->last_result[0]->{'Create Table'} ); + + // New rows get the normalized defaults in both strict and non-strict SQL + // mode. The existing row keeps its values. + $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (2)' ); + $this->assertQuery( "SET sql_mode = ''" ); + $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (3)' ); + $this->assertQuery( 'SELECT id, created_at, created_at_fsp, updated_on, duration FROM _tmp_table ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'created_at' => '0', + 'created_at_fsp' => '0', + 'updated_on' => '0', + 'duration' => '0', + ), + (object) array( + 'id' => '2', + 'created_at' => '0000-00-00 00:00:00', + 'created_at_fsp' => '0000-00-00 00:00:00.000', + 'updated_on' => '0000-00-00', + 'duration' => '00:00:00.00', + ), + (object) array( + 'id' => '3', + 'created_at' => '0000-00-00 00:00:00', + 'created_at_fsp' => '0000-00-00 00:00:00.000', + 'updated_on' => '0000-00-00', + 'duration' => '00:00:00.00', + ), + ), + $this->last_result ); - $this->engine->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + // Configuring the database again changes nothing. + $this->reconnectAfterDriverUpgrade(); + $this->assertQuery( 'SHOW CREATE TABLE _tmp_table' ); + $this->assertSame( $expected_create_table, $this->last_result[0]->{'Create Table'} ); + $this->assertQuery( 'SELECT COUNT(*) AS count FROM _tmp_table' ); + $this->assertSame( '3', $this->last_result[0]->count ); + } + + public function testConfigurationNormalizesStoredTemporalZeroDefaultsOfReferencedTable(): void { + $this->assertQuery( 'CREATE TABLE _tmp_parent (id INT PRIMARY KEY, created_at TIMESTAMP NOT NULL DEFAULT 0)' ); + $this->assertQuery( 'CREATE TABLE _tmp_cascade (id INT PRIMARY KEY, parent_id INT, FOREIGN KEY (parent_id) REFERENCES _tmp_parent (id) ON DELETE CASCADE)' ); + $this->assertQuery( 'CREATE TABLE _tmp_no_action (id INT PRIMARY KEY, parent_id INT, FOREIGN KEY (parent_id) REFERENCES _tmp_parent (id))' ); + + // Store the default verbatim, as older driver versions did, and apply it to the SQLite table. + $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET column_default = '0' WHERE table_name = '_tmp_parent' AND column_name = 'created_at'" ); + $this->assertQuery( 'OPTIMIZE TABLE _tmp_parent' ); + + $this->assertQuery( 'INSERT INTO _tmp_parent (id) VALUES (1), (2)' ); + $this->assertQuery( 'INSERT INTO _tmp_cascade (id, parent_id) VALUES (1, 1), (2, 2)' ); + $this->assertQuery( 'INSERT INTO _tmp_no_action (id, parent_id) VALUES (1, 1)' ); + + // Recreating the parent table must not trigger the foreign key actions. + $this->reconnectAfterDriverUpgrade(); + + $this->assertQuery( 'SHOW CREATE TABLE _tmp_parent' ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `_tmp_parent` (', + ' `id` int NOT NULL,', + " `created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',", + ' PRIMARY KEY (`id`)', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $this->last_result[0]->{'Create Table'} + ); + $this->assertQuery( 'SELECT id, parent_id FROM _tmp_cascade ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'parent_id' => '1', + ), + (object) array( + 'id' => '2', + 'parent_id' => '2', + ), + ), + $this->last_result + ); + $this->assertQuery( 'SELECT COUNT(*) AS count FROM _tmp_no_action' ); + $this->assertSame( '1', $this->last_result[0]->count ); + + // Foreign keys are enforced again. + $this->assertQueryError( + 'DELETE FROM _tmp_parent WHERE id = 1', + 'SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed' + ); + $this->assertQuery( 'DELETE FROM _tmp_parent WHERE id = 2' ); + $this->assertQuery( 'SELECT id FROM _tmp_cascade' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $this->last_result ); + } + + public function testConfigurationNormalizesTemporalZeroDefaultsOfReconstructedTable(): void { + // A table created by the legacy SQLite driver, with no information schema data. + $this->sqlite->exec( + 'CREATE TABLE _mysql_data_types_cache ( + `table` TEXT NOT NULL, + `column_or_index` TEXT NOT NULL, + `mysql_type` TEXT NOT NULL, + PRIMARY KEY(`table`, `column_or_index`) + )' + ); + $this->sqlite->exec( + "INSERT INTO _mysql_data_types_cache VALUES + ('_tmp_table', 'id', 'int'), + ('_tmp_table', 'created_at', 'timestamp'), + ('_tmp_table', 'created_at_fsp', 'datetime(3)'), + ('_tmp_table', 'updated_on', 'date'), + ('_tmp_table', 'year_number', 'year'), + ('_tmp_table', 'year_string', 'year'), + ('_tmp_double_quoted', 'id', 'int'), + ('_tmp_double_quoted', 'created_at', 'timestamp')" + ); + $this->sqlite->exec( + "CREATE TABLE _tmp_table ( + id INTEGER, + created_at TEXT NOT NULL DEFAULT 0, + created_at_fsp TEXT NOT NULL DEFAULT 0, + updated_on TEXT NOT NULL DEFAULT '0', + year_number TEXT NOT NULL DEFAULT 0, + year_string TEXT NOT NULL DEFAULT '0' + )" + ); + $this->sqlite->exec( 'INSERT INTO _tmp_table (id) VALUES (1)' ); + + // A double-quoted SQLite default is a string as well. + $this->sqlite->exec( 'CREATE TABLE _tmp_double_quoted (id INTEGER, created_at TEXT NOT NULL DEFAULT "0")' ); + + // The information schema is reconstructed, and then the defaults are normalized. + // A string zero of a YEAR column, such as '0', is not normalized. + $this->reconnectAfterDriverUpgrade(); $this->assertQuery( 'SHOW CREATE TABLE _tmp_table' ); $this->assertSame( implode( "\n", array( 'CREATE TABLE `_tmp_table` (', - " `id` int NOT NULL DEFAULT '0',", + ' `id` int DEFAULT NULL,', " `created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',", - " `label` varchar(20) NOT NULL DEFAULT '0'", + " `created_at_fsp` datetime(3) NOT NULL DEFAULT '0000-00-00 00:00:00.000',", + " `updated_on` date NOT NULL DEFAULT '0000-00-00',", + " `year_number` year NOT NULL DEFAULT '0000',", + " `year_string` year NOT NULL DEFAULT '0'", ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', ) ), $this->last_result[0]->{'Create Table'} ); + + // New rows get the normalized defaults. The existing row keeps its values. + $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (2)' ); + $this->assertQuery( 'SELECT * FROM _tmp_table ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'created_at' => '0', + 'created_at_fsp' => '0', + 'updated_on' => '0', + 'year_number' => '0', + 'year_string' => '0', + ), + (object) array( + 'id' => '2', + 'created_at' => '0000-00-00 00:00:00', + 'created_at_fsp' => '0000-00-00 00:00:00.000', + 'updated_on' => '0000-00-00', + 'year_number' => '0000', + 'year_string' => '0', + ), + ), + $this->last_result + ); + + $this->assertQuery( 'INSERT INTO _tmp_double_quoted (id) VALUES (1)' ); + $this->assertQuery( 'SELECT created_at FROM _tmp_double_quoted' ); + $this->assertSame( '0000-00-00 00:00:00', $this->last_result[0]->created_at ); + } + + public function testConfigurationRollsBackWhenRecreatingTableFails(): void { + $this->assertQuery( 'CREATE TABLE _tmp_recreated (id INT, created_at TIMESTAMP NOT NULL DEFAULT 0)' ); + $this->assertQuery( 'CREATE TABLE _tmp_failing (id INT, created_at TIMESTAMP NOT NULL DEFAULT 0)' ); + $this->assertQuery( 'INSERT INTO _tmp_failing (id) VALUES (NULL)' ); + + // Store the defaults verbatim, as older driver versions did, and apply them to the SQLite tables. + $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET column_default = '0' WHERE table_name IN ('_tmp_recreated', '_tmp_failing') AND column_name = 'created_at'" ); + $this->assertQuery( 'OPTIMIZE TABLE _tmp_recreated, _tmp_failing' ); + + // Make recreating the second table fail, as its data doesn't match the information schema. + $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET is_nullable = 'NO' WHERE table_name = '_tmp_failing' AND column_name = 'id'" ); + + $sqlite_schema_query = "SELECT sql FROM sqlite_master WHERE name IN ('_tmp_recreated', '_tmp_failing') ORDER BY name"; + $sqlite_schema = $this->sqlite->query( $sqlite_schema_query )->fetchAll( PDO::FETCH_COLUMN ); + + $exception = null; + try { + $this->reconnectAfterDriverUpgrade(); + } catch ( Throwable $e ) { + $exception = $e; + } + $this->assertInstanceOf( WP_MySQL_On_SQLite_Exception::class, $exception ); + $this->assertStringContainsString( 'Failed to recreate table "_tmp_failing"', $exception->getMessage() ); + $this->assertStringContainsString( 'NOT NULL constraint failed', $exception->getMessage() ); + + // All configuration changes are rolled back, including the recreated table. + $this->assertSame( '0.0.0', $this->engine->get_saved_driver_version() ); + $this->assertSame( $sqlite_schema, $this->sqlite->query( $sqlite_schema_query )->fetchAll( PDO::FETCH_COLUMN ) ); + $this->assertQuery( "SELECT table_name, column_default FROM information_schema.columns WHERE column_name = 'created_at' ORDER BY table_name" ); + $this->assertEquals( + array( + (object) array( + 'TABLE_NAME' => '_tmp_failing', + 'COLUMN_DEFAULT' => '0', + ), + (object) array( + 'TABLE_NAME' => '_tmp_recreated', + 'COLUMN_DEFAULT' => '0', + ), + ), + $this->last_result + ); + + // Foreign keys are enabled again. + $this->assertSame( 1, (int) $this->sqlite->query( 'PRAGMA foreign_keys' )->fetchColumn() ); + } + + public function testConfigurationKeepsTransactionOfSharedConnection(): void { + // The configuration can't start its transaction within an active one. + $this->sqlite->beginTransaction(); + $this->sqlite->exec( "INSERT INTO _options (option_name, option_value) VALUES ('name', 'value')" ); + $error = ''; + try { + $this->reconnectAfterDriverUpgrade(); + } catch ( Throwable $e ) { + $error = $e->getMessage(); + } + $this->assertStringContainsString( 'cannot start a transaction within a transaction', $error ); + + // The failed configuration must not roll back the active transaction. + $this->assertTrue( $this->sqlite->inTransaction() ); + $this->assertSame( 1, (int) $this->sqlite->query( "SELECT COUNT(*) FROM _options WHERE option_name = 'name'" )->fetchColumn() ); + $this->sqlite->rollBack(); + } + + public function testConfigurationRecreatesTablesWithDriverSubclass(): void { + $this->assertQuery( 'CREATE TABLE _tmp_table (id INT, created_at TIMESTAMP NOT NULL DEFAULT 0)' ); + + // Store the default verbatim, as older driver versions did, and apply it to the SQLite table. + $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET column_default = '0' WHERE table_name = '_tmp_table' AND column_name = 'created_at'" ); + $this->assertQuery( 'OPTIMIZE TABLE _tmp_table' ); + + // Configure the database again, using a driver subclass. + $this->sqlite->exec( + sprintf( + "UPDATE %s SET value = '0.0.0' WHERE name = '%s'", + WP_MySQL_On_SQLite::GLOBAL_VARIABLES_TABLE_NAME, + WP_MySQL_On_SQLite::DRIVER_VERSION_VARIABLE_NAME + ) + ); + $this->engine = new class( + 'mysql-on-sqlite:dbname=wp', + null, + null, + array( 'sqlite_pdo' => $this->sqlite ) + ) extends WP_MySQL_On_SQLite {}; + $this->engine->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + + // The table was recreated, so new rows get the normalized default. + $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (1)' ); + $this->assertQuery( 'SELECT created_at FROM _tmp_table' ); + $this->assertSame( '0000-00-00 00:00:00', $this->last_result[0]->created_at ); } public function testShowCreateTableWithEmptyDatetimeDefault() { From 813062fa4bb53aaab78a30c9f8bb358142694b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 20 Oct 2026 17:24:36 +0200 Subject: [PATCH 4/7] Repair DATETIME_PRECISION stored by older driver versions Older driver versions read the precision from the whole "(3)" node, which casts to 0, so INFORMATION_SCHEMA.COLUMNS reported a DATETIME_PRECISION of 0 for every TIME(n), DATETIME(n), and TIMESTAMP(n) column. Repair the stored values when configuring the database, using the precision in COLUMN_TYPE. The repair of temporal zero defaults now runs after it, and it reads the repaired DATETIME_PRECISION instead of parsing COLUMN_TYPE itself. --- .../sqlite/class-wp-sqlite-configurator.php | 34 ++++++++++++------- .../tests/WP_MySQL_On_SQLite_Tests.php | 33 ++++++++++++++++++ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php index f73880972..a81c4752c 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php @@ -97,6 +97,7 @@ public function configure_database(): void { $this->schema_reconstructor->ensure_correct_information_schema(); $this->save_current_driver_version(); $this->ensure_database_data(); + $this->ensure_correct_datetime_precision(); $this->ensure_temporal_zero_defaults(); $this->driver->execute_sqlite_query( 'COMMIT' ); } catch ( Throwable $e ) { @@ -321,6 +322,26 @@ private function save_current_driver_version(): void { ); } + /** + * Ensure that the information schema stores correct DATETIME_PRECISION values. + * + * Older versions stored 0 regardless of fractional seconds precision. + * The correct value is read from the column type, such as "datetime(3)". + */ + private function ensure_correct_datetime_precision(): void { + // CAST() reads the leading integer, e.g., "3)" as 3, or returns 0 when there is none. + $this->driver->execute_sqlite_query( + sprintf( + "UPDATE %s + SET DATETIME_PRECISION = CAST(SUBSTR(COLUMN_TYPE, INSTR(COLUMN_TYPE, '(') + 1) AS INTEGER) + WHERE DATA_TYPE IN ('time', 'datetime', 'timestamp')", + $this->driver->get_connection()->quote_identifier( + $this->schema_builder->get_table_name( false, 'columns' ) + ) + ) + ); + } + /** * Ensure that temporal columns store numeric zero defaults as zero values. * @@ -332,21 +353,10 @@ private function ensure_temporal_zero_defaults(): void { $this->schema_builder->get_table_name( false, 'columns' ) ); - /* - * Older driver versions stored a DATETIME_PRECISION of 0, so the precision - * is read from the column type, such as "datetime(3)". CAST() reads the - * leading integer, e.g., "3)" as 3, or returns 0 when there is none. - */ - // Read SQLite defaults; reconstructed metadata may already be normalized. $columns = $this->driver->execute_sqlite_query( sprintf( - "SELECT - c.TABLE_NAME, - c.COLUMN_NAME, - c.DATA_TYPE, - CAST(SUBSTR(c.COLUMN_TYPE, INSTR(c.COLUMN_TYPE, '(') + 1) AS INTEGER) AS DATETIME_PRECISION, - p.dflt_value + "SELECT c.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE, c.DATETIME_PRECISION, p.dflt_value FROM %s AS c JOIN pragma_table_info(c.TABLE_NAME, 'main') AS p ON c.COLUMN_NAME = p.name WHERE c.TABLE_SCHEMA = ? diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 52d5f80da..1e7b84256 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -6426,6 +6426,39 @@ public function testInformationSchemaColumnsDatetimePrecision(): void { ); } + public function testConfigurationRepairsStoredDatetimePrecision(): void { + $this->assertQuery( 'CREATE TABLE _tmp_table (col_date DATE, col_time TIME(2), col_datetime DATETIME, col_timestamp TIMESTAMP(6) NULL)' ); + + // Older driver versions stored a zero precision for all temporal columns. + $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET datetime_precision = 0 WHERE data_type IN ('time', 'datetime', 'timestamp')" ); + $this->reconnectAfterDriverUpgrade(); + + $this->assertQuery( + "SELECT column_name, datetime_precision FROM information_schema.columns WHERE table_name = '_tmp_table' ORDER BY ordinal_position" + ); + $this->assertEquals( + array( + (object) array( + 'COLUMN_NAME' => 'col_date', + 'DATETIME_PRECISION' => null, + ), + (object) array( + 'COLUMN_NAME' => 'col_time', + 'DATETIME_PRECISION' => '2', + ), + (object) array( + 'COLUMN_NAME' => 'col_datetime', + 'DATETIME_PRECISION' => '0', + ), + (object) array( + 'COLUMN_NAME' => 'col_timestamp', + 'DATETIME_PRECISION' => '6', + ), + ), + $this->last_result + ); + } + public function testShowCreateTableWithTemporalZeroDefaults(): void { $this->assertQuery( 'CREATE TABLE _tmp_table ( From 353d246bbc66d691a284aaa85dc4920e615b1a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 20 Oct 2026 17:28:31 +0200 Subject: [PATCH 5/7] Run migrations only for databases from older driver versions The repairs of DATETIME_PRECISION and temporal zero defaults ran on every driver upgrade, although they fix values that only older driver versions stored. Run both repairs only when the database was last configured by driver 3.1.1 or older, the last version that stored these values incorrectly. The saved version is read within the configuration transaction, before it is updated. --- .../sqlite/class-wp-sqlite-configurator.php | 35 ++++++++++--- .../tests/WP_MySQL_On_SQLite_Tests.php | 52 +++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php index a81c4752c..7fd8c7eda 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php @@ -92,13 +92,16 @@ public function configure_database(): void { $this->driver->execute_sqlite_query( 'BEGIN EXCLUSIVE TRANSACTION' ); $transaction_started = true; $this->set_driver_in_transaction( true ); + + // Read the saved version within the transaction, before it's updated. + $saved_version = $this->driver->get_saved_driver_version(); + $this->ensure_global_variables_table(); $this->schema_builder->ensure_information_schema_tables(); $this->schema_reconstructor->ensure_correct_information_schema(); - $this->save_current_driver_version(); $this->ensure_database_data(); - $this->ensure_correct_datetime_precision(); - $this->ensure_temporal_zero_defaults(); + $this->run_migrations( $saved_version ); + $this->save_current_driver_version(); $this->driver->execute_sqlite_query( 'COMMIT' ); } catch ( Throwable $e ) { // Roll back only the transaction started here, unless a failed @@ -323,12 +326,28 @@ private function save_current_driver_version(): void { } /** - * Ensure that the information schema stores correct DATETIME_PRECISION values. + * Migrate data stored by older driver versions. + * + * This is used when a new driver version needs to change some existing data. + * Migrations can be version-gated so that they don't rerun on every update. + * + * @param string $saved_version The driver version that last configured the database. + */ + private function run_migrations( string $saved_version ): void { + // Fix temporal precision and zero defaults stored by versions <= 3.1.1. + if ( version_compare( $saved_version, '3.1.1', '<=' ) ) { + $this->migrate_datetime_precision(); + $this->migrate_temporal_zero_defaults(); + } + } + + /** + * Migrate DATETIME_PRECISION values stored by older driver versions. * * Older versions stored 0 regardless of fractional seconds precision. * The correct value is read from the column type, such as "datetime(3)". */ - private function ensure_correct_datetime_precision(): void { + private function migrate_datetime_precision(): void { // CAST() reads the leading integer, e.g., "3)" as 3, or returns 0 when there is none. $this->driver->execute_sqlite_query( sprintf( @@ -343,12 +362,12 @@ private function ensure_correct_datetime_precision(): void { } /** - * Ensure that temporal columns store numeric zero defaults as zero values. + * Migrate zero defaults of temporal columns stored by older driver versions. * * Older driver versions stored defaults such as "DEFAULT 0" of a TIMESTAMP * column verbatim, while MySQL stores them as a zero date/time value. */ - private function ensure_temporal_zero_defaults(): void { + private function migrate_temporal_zero_defaults(): void { $columns_table = $this->driver->get_connection()->quote_identifier( $this->schema_builder->get_table_name( false, 'columns' ) ); @@ -368,7 +387,7 @@ private function ensure_temporal_zero_defaults(): void { array( WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME ) )->fetchAll( PDO::FETCH_ASSOC ); - // Normalize numeric zeros, such as 0, '0', '-0.0', or '0e0'. + // Normalize zeros, such as 0, '0', -0.0, or 0e0. $table_columns = array(); foreach ( $columns as $column ) { // SQLite returns the default as an SQL literal, e.g., 0, '0', or "0". diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 1e7b84256..918d60f8c 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -6728,6 +6728,58 @@ public function testConfigurationNormalizesStoredTemporalZeroDefaults(): void { $this->assertSame( '3', $this->last_result[0]->count ); } + /** + * @dataProvider migrationDriverVersions + */ + public function testConfigurationRunsMigrationsOnlyForOlderDriverVersions( + string $saved_version, + string $expected_default, + string $expected_precision + ): void { + // Store the default verbatim and a zero precision, as older driver versions + // did, and apply them to the SQLite table. + $this->assertQuery( 'CREATE TABLE _tmp_table (created_at DATETIME(3) NOT NULL DEFAULT 0)' ); + $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET column_default = '0', datetime_precision = 0 WHERE table_name = '_tmp_table'" ); + $this->assertQuery( 'OPTIMIZE TABLE _tmp_table' ); + + // Configure the database as if it was last configured by the given driver version. + $this->sqlite->exec( + sprintf( + "UPDATE %s SET value = '%s' WHERE name = '%s'", + WP_MySQL_On_SQLite::GLOBAL_VARIABLES_TABLE_NAME, + $saved_version, + WP_MySQL_On_SQLite::DRIVER_VERSION_VARIABLE_NAME + ) + ); + $configurator = new WP_SQLite_Configurator( + $this->engine, + new WP_SQLite_Information_Schema_Builder( WP_MySQL_On_SQLite::RESERVED_PREFIX, $this->engine->get_connection() ) + ); + $configurator->configure_database(); + + $this->assertQuery( 'SHOW CREATE TABLE _tmp_table' ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `_tmp_table` (', + " `created_at` datetime(3) NOT NULL DEFAULT '$expected_default'", + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $this->last_result[0]->{'Create Table'} + ); + $this->assertQuery( "SELECT datetime_precision FROM information_schema.columns WHERE table_name = '_tmp_table'" ); + $this->assertSame( $expected_precision, $this->last_result[0]->DATETIME_PRECISION ); + } + + public static function migrationDriverVersions(): array { + return array( + 'migrated from 3.1.1' => array( '3.1.1', '0000-00-00 00:00:00.000', '3' ), + 'not migrated from 3.1.2' => array( '3.1.2', '0', '0' ), + ); + } + public function testConfigurationNormalizesStoredTemporalZeroDefaultsOfReferencedTable(): void { $this->assertQuery( 'CREATE TABLE _tmp_parent (id INT PRIMARY KEY, created_at TIMESTAMP NOT NULL DEFAULT 0)' ); $this->assertQuery( 'CREATE TABLE _tmp_cascade (id INT PRIMARY KEY, parent_id INT, FOREIGN KEY (parent_id) REFERENCES _tmp_parent (id) ON DELETE CASCADE)' ); From 53a6ed69c7d8bd3f146fbdf257512f172bd35d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Wed, 21 Oct 2026 17:54:22 +0200 Subject: [PATCH 6/7] Report the actual error of failed table maintenance statements ANALYZE, CHECK, OPTIMIZE, and REPAIR TABLE reported every general SQLite error as "Table '...' doesn't exist", although only the "no such table" error means that. Other errors, such as a failed table rebuild, are now reported as SQLite returns them. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 6 +- .../WP_MySQL_On_SQLite_Metadata_Tests.php | 65 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index 6c3af4b25..eee3f050b 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -4155,7 +4155,11 @@ private function execute_administration_statement( WP_Parser_Node $node ): void } catch ( PDOException $e ) { if ( '42S02' === $e->getCode() && isset( $e->errorInfo[2] ) ) { $errors = array( $e->errorInfo[2] ); - } elseif ( 'HY000' === $e->getCode() ) { + } elseif ( + // SQLite reports a missing table as a general error. + 'HY000' === $e->getCode() + && str_starts_with( $e->errorInfo[2] ?? '', 'no such table' ) + ) { $errors = array( "Table '$table_name' doesn't exist" ); } else { $errors = array( $e->getMessage() ); diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php index 727e4c76a..1366cb840 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php @@ -234,6 +234,44 @@ private function assertQuery( $sql ) { return $retval; } + public function testAnalyzeTable() { + $this->assertQuery( 'CREATE TABLE t1 (id INT, name TEXT)' ); + + // A good table. + $result = $this->assertQuery( 'ANALYZE TABLE t1' ); + $this->assertEquals( + array( + (object) array( + 'Table' => 'wp.t1', + 'Op' => 'analyze', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $result + ); + + // A missing table. + $result = $this->assertQuery( 'ANALYZE TABLE missing' ); + $this->assertEquals( + array( + (object) array( + 'Table' => 'wp.missing', + 'Op' => 'analyze', + 'Msg_type' => 'Error', + 'Msg_text' => "Table 'missing' doesn't exist", + ), + (object) array( + 'Table' => 'wp.missing', + 'Op' => 'analyze', + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $result + ); + } + public function testCheckTable() { $this->assertQuery( 'CREATE TABLE t1 (id INT, name TEXT)' ); $this->assertQuery( 'CREATE TABLE t2 (id INT, name TEXT)' ); @@ -436,6 +474,33 @@ public function testOptimizeTable() { ); } + public function testOptimizeTableWithFailedRecreation() { + $this->assertQuery( 'CREATE TABLE t (id INT, name TEXT)' ); + + // Make recreating the table fail, as its information schema names a column that doesn't exist. + $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET column_name = 'title' WHERE table_name = 't' AND column_name = 'name'" ); + + // The error from SQLite is reported, not a missing table. + $result = $this->assertQuery( 'OPTIMIZE TABLE t' ); + $this->assertEquals( + array( + (object) array( + 'Table' => 'wp.t', + 'Op' => 'optimize', + 'Msg_type' => 'Error', + 'Msg_text' => 'SQLSTATE[HY000]: General error: 1 no such column: title', + ), + (object) array( + 'Table' => 'wp.t', + 'Op' => 'optimize', + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $result + ); + } + public function testRepairTable() { $this->assertQuery( 'CREATE TABLE t1 (id INT, name TEXT)' ); $this->assertQuery( 'CREATE TABLE t2 (id INT, name TEXT)' ); From ef85628ffc421d486372455bb58e523929cfddd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Wed, 21 Oct 2026 18:36:31 +0200 Subject: [PATCH 7/7] Repair temporal zero values stored in existing rows Rows that got a zero default from older driver versions, such as "0" in a TIMESTAMP column, kept that value after the defaults were repaired. MySQL stores the zero value in these rows, and a dump of them doesn't import into MySQL in strict SQL mode. When repairing the defaults, replace the old default in existing rows with the zero value of the column. The table triggers are dropped first, so that the update doesn't run them, e.g., to emulate ON UPDATE CURRENT_TIMESTAMP. Recreating the table drops them anyway, and it creates the driver's triggers again. --- .../sqlite/class-wp-sqlite-configurator.php | 39 ++++- .../tests/WP_MySQL_On_SQLite_Tests.php | 162 ++++++++++++++---- 2 files changed, 161 insertions(+), 40 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php index 7fd8c7eda..045f409ec 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php @@ -365,10 +365,12 @@ private function migrate_datetime_precision(): void { * Migrate zero defaults of temporal columns stored by older driver versions. * * Older driver versions stored defaults such as "DEFAULT 0" of a TIMESTAMP - * column verbatim, while MySQL stores them as a zero date/time value. + * column verbatim, while MySQL stores them as a zero date/time value. Rows + * that got such a default store it as well, so they get the zero value too. */ private function migrate_temporal_zero_defaults(): void { - $columns_table = $this->driver->get_connection()->quote_identifier( + $connection = $this->driver->get_connection(); + $columns_table = $connection->quote_identifier( $this->schema_builder->get_table_name( false, 'columns' ) ); @@ -412,14 +414,43 @@ private function migrate_temporal_zero_defaults(): void { } $table_columns[ $column['TABLE_NAME'] ][] = array( - 'name' => $column['COLUMN_NAME'], - 'zero_value' => $zero_value, + 'name' => $column['COLUMN_NAME'], + 'zero_value' => $zero_value, + 'old_default' => $default, + 'is_string' => $is_string, ); } foreach ( $table_columns as $table_name => $repaired_columns ) { + // Disable driver triggers during row repair. The table rebuild restores them. + $triggers = $this->driver->execute_sqlite_query( + "SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ? COLLATE NOCASE", + array( $table_name ) + )->fetchAll( PDO::FETCH_COLUMN ); + foreach ( $triggers as $trigger ) { + $this->driver->execute_sqlite_query( + sprintf( 'DROP TRIGGER %s', $connection->quote_identifier( $trigger ) ) + ); + } + $alter_list = array(); foreach ( $repaired_columns as $column ) { + // Preserve numeric and string types so comparisons apply the same + // SQLite conversion as when the defaults were stored. + $quoted_column = $connection->quote_identifier( $column['name'] ); + $this->driver->execute_sqlite_query( + sprintf( + 'UPDATE %s SET %s = ? WHERE %s = %s', + $connection->quote_identifier( $table_name ), + $quoted_column, + $quoted_column, + $column['is_string'] ? '?' : $column['old_default'] + ), + $column['is_string'] + ? array( $column['zero_value'], $column['old_default'] ) + : array( $column['zero_value'] ) + ); + $alter_list[] = sprintf( 'ALTER COLUMN `%s` SET DEFAULT %s', str_replace( '`', '``', $column['name'] ), diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 918d60f8c..64c9ad8c9 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -6654,7 +6654,8 @@ public function testConfigurationNormalizesStoredTemporalZeroDefaults(): void { ); // Store the defaults verbatim and a zero precision, as older driver versions - // did, and apply them to the SQLite table. Then, insert a row with them. + // did, and apply them to the SQLite table. Then, insert a row with them, and + // a row with other values. $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET @@ -6664,6 +6665,10 @@ public function testConfigurationNormalizesStoredTemporalZeroDefaults(): void { ); $this->assertQuery( 'OPTIMIZE TABLE _tmp_table' ); $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (1)' ); + $this->assertQuery( + "INSERT INTO _tmp_table (id, created_at, created_at_fsp, updated_on, duration) + VALUES (2, '2020-01-02 03:04:05', '2020-01-02 03:04:05', '2020-01-02', '01:02:03')" + ); $this->reconnectAfterDriverUpgrade(); @@ -6687,34 +6692,54 @@ public function testConfigurationNormalizesStoredTemporalZeroDefaults(): void { $this->assertQuery( 'SHOW CREATE TABLE _tmp_table' ); $this->assertSame( $expected_create_table, $this->last_result[0]->{'Create Table'} ); - // New rows get the normalized defaults in both strict and non-strict SQL - // mode. The existing row keeps its values. - $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (2)' ); - $this->assertQuery( "SET sql_mode = ''" ); + // The existing row with the old defaults gets the zero values, while the + // other existing row is unchanged. New rows get the normalized defaults + // in both strict and non-strict SQL mode. $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (3)' ); - $this->assertQuery( 'SELECT id, created_at, created_at_fsp, updated_on, duration FROM _tmp_table ORDER BY id' ); + $this->assertQuery( "SET sql_mode = ''" ); + $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (4)' ); + $expected_rows_query = 'SELECT id, created_at, created_at_fsp, updated_on, duration FROM _tmp_table ORDER BY id'; + $expected_rows = array( + (object) array( + 'id' => '1', + 'created_at' => '0000-00-00 00:00:00', + 'created_at_fsp' => '0000-00-00 00:00:00.000', + 'updated_on' => '0000-00-00', + 'duration' => '00:00:00.00', + ), + (object) array( + 'id' => '2', + 'created_at' => '2020-01-02 03:04:05', + 'created_at_fsp' => '2020-01-02 03:04:05', + 'updated_on' => '2020-01-02', + 'duration' => '01:02:03', + ), + (object) array( + 'id' => '3', + 'created_at' => '0000-00-00 00:00:00', + 'created_at_fsp' => '0000-00-00 00:00:00.000', + 'updated_on' => '0000-00-00', + 'duration' => '00:00:00.00', + ), + (object) array( + 'id' => '4', + 'created_at' => '0000-00-00 00:00:00', + 'created_at_fsp' => '0000-00-00 00:00:00.000', + 'updated_on' => '0000-00-00', + 'duration' => '00:00:00.00', + ), + ); + $this->assertQuery( $expected_rows_query ); + $this->assertEquals( $expected_rows, $this->last_result ); + + // The existing row keeps the YEAR and expression values, as their defaults are not normalized. + $this->assertQuery( 'SELECT year_number, year_string, expression FROM _tmp_table WHERE id = 1' ); $this->assertEquals( array( (object) array( - 'id' => '1', - 'created_at' => '0', - 'created_at_fsp' => '0', - 'updated_on' => '0', - 'duration' => '0', - ), - (object) array( - 'id' => '2', - 'created_at' => '0000-00-00 00:00:00', - 'created_at_fsp' => '0000-00-00 00:00:00.000', - 'updated_on' => '0000-00-00', - 'duration' => '00:00:00.00', - ), - (object) array( - 'id' => '3', - 'created_at' => '0000-00-00 00:00:00', - 'created_at_fsp' => '0000-00-00 00:00:00.000', - 'updated_on' => '0000-00-00', - 'duration' => '00:00:00.00', + 'year_number' => '0', + 'year_string' => '0', + 'expression' => '0', ), ), $this->last_result @@ -6724,8 +6749,41 @@ public function testConfigurationNormalizesStoredTemporalZeroDefaults(): void { $this->reconnectAfterDriverUpgrade(); $this->assertQuery( 'SHOW CREATE TABLE _tmp_table' ); $this->assertSame( $expected_create_table, $this->last_result[0]->{'Create Table'} ); - $this->assertQuery( 'SELECT COUNT(*) AS count FROM _tmp_table' ); - $this->assertSame( '3', $this->last_result[0]->count ); + $this->assertQuery( $expected_rows_query ); + $this->assertEquals( $expected_rows, $this->last_result ); + } + + public function testConfigurationNormalizesStoredTemporalZeroValuesWithoutRunningTriggers(): void { + $this->assertQuery( + 'CREATE TABLE _tmp_table ( + id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT 0, + updated_at TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + + // Store the default verbatim, as older driver versions did, and apply it to the SQLite table. + $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET column_default = '0' WHERE table_name = '_tmp_table' AND column_name = 'created_at'" ); + $this->assertQuery( 'OPTIMIZE TABLE _tmp_table' ); + $this->assertQuery( "INSERT INTO _tmp_table (id, updated_at) VALUES (1, '2020-01-02 03:04:05')" ); + + // Updating the stored zero doesn't run the ON UPDATE CURRENT_TIMESTAMP trigger. + $this->reconnectAfterDriverUpgrade(); + $this->assertQuery( 'SELECT created_at, updated_at FROM _tmp_table' ); + $this->assertEquals( + array( + (object) array( + 'created_at' => '0000-00-00 00:00:00', + 'updated_at' => '2020-01-02 03:04:05', + ), + ), + $this->last_result + ); + + // The recreated table has the trigger again. + $this->assertQuery( 'UPDATE _tmp_table SET id = 2' ); + $this->assertQuery( 'SELECT updated_at FROM _tmp_table' ); + $this->assertNotSame( '2020-01-02 03:04:05', $this->last_result[0]->updated_at ); } /** @@ -6856,7 +6914,10 @@ public function testConfigurationNormalizesTemporalZeroDefaultsOfReconstructedTa ('_tmp_table', 'year_number', 'year'), ('_tmp_table', 'year_string', 'year'), ('_tmp_double_quoted', 'id', 'int'), - ('_tmp_double_quoted', 'created_at', 'timestamp')" + ('_tmp_double_quoted', 'created_at', 'timestamp'), + ('_tmp_number_forms', 'id', 'int'), + ('_tmp_number_forms', 'negative_zero', 'datetime'), + ('_tmp_number_forms', 'decimal_zero', 'datetime')" ); $this->sqlite->exec( "CREATE TABLE _tmp_table ( @@ -6872,6 +6933,11 @@ public function testConfigurationNormalizesTemporalZeroDefaultsOfReconstructedTa // A double-quoted SQLite default is a string as well. $this->sqlite->exec( 'CREATE TABLE _tmp_double_quoted (id INTEGER, created_at TEXT NOT NULL DEFAULT "0")' ); + $this->sqlite->exec( 'INSERT INTO _tmp_double_quoted (id) VALUES (1)' ); + + // Rows store numeric defaults as converted by the column affinity, e.g., -0 as '0'. + $this->sqlite->exec( 'CREATE TABLE _tmp_number_forms (id INTEGER, negative_zero TEXT NOT NULL DEFAULT -0, decimal_zero TEXT NOT NULL DEFAULT 0.0)' ); + $this->sqlite->exec( 'INSERT INTO _tmp_number_forms (id) VALUES (1)' ); // The information schema is reconstructed, and then the defaults are normalized. // A string zero of a YEAR column, such as '0', is not normalized. @@ -6894,17 +6960,18 @@ public function testConfigurationNormalizesTemporalZeroDefaultsOfReconstructedTa $this->last_result[0]->{'Create Table'} ); - // New rows get the normalized defaults. The existing row keeps its values. + // The existing row gets the zero values, except for the YEAR string zero. + // New rows get the normalized defaults. $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (2)' ); $this->assertQuery( 'SELECT * FROM _tmp_table ORDER BY id' ); $this->assertEquals( array( (object) array( 'id' => '1', - 'created_at' => '0', - 'created_at_fsp' => '0', - 'updated_on' => '0', - 'year_number' => '0', + 'created_at' => '0000-00-00 00:00:00', + 'created_at_fsp' => '0000-00-00 00:00:00.000', + 'updated_on' => '0000-00-00', + 'year_number' => '0000', 'year_string' => '0', ), (object) array( @@ -6919,9 +6986,32 @@ public function testConfigurationNormalizesTemporalZeroDefaultsOfReconstructedTa $this->last_result ); - $this->assertQuery( 'INSERT INTO _tmp_double_quoted (id) VALUES (1)' ); - $this->assertQuery( 'SELECT created_at FROM _tmp_double_quoted' ); - $this->assertSame( '0000-00-00 00:00:00', $this->last_result[0]->created_at ); + $this->assertQuery( 'INSERT INTO _tmp_double_quoted (id) VALUES (2)' ); + $this->assertQuery( 'SELECT id, created_at FROM _tmp_double_quoted ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'created_at' => '0000-00-00 00:00:00', + ), + (object) array( + 'id' => '2', + 'created_at' => '0000-00-00 00:00:00', + ), + ), + $this->last_result + ); + + $this->assertQuery( 'SELECT negative_zero, decimal_zero FROM _tmp_number_forms' ); + $this->assertEquals( + array( + (object) array( + 'negative_zero' => '0000-00-00 00:00:00', + 'decimal_zero' => '0000-00-00 00:00:00', + ), + ), + $this->last_result + ); } public function testConfigurationRollsBackWhenRecreatingTableFails(): void {