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/src/sqlite/class-wp-sqlite-configurator.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php index 45da0863a..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 @@ -75,20 +75,49 @@ 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 ); + + // 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->run_migrations( $saved_version ); + $this->save_current_driver_version(); + $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' ); } /** @@ -252,6 +281,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. * @@ -272,4 +324,153 @@ private function save_current_driver_version(): void { ) ); } + + /** + * 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 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( + "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' ) + ) + ) + ); + } + + /** + * 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. Rows + * that got such a default store it as well, so they get the zero value too. + */ + private function migrate_temporal_zero_defaults(): void { + $connection = $this->driver->get_connection(); + $columns_table = $connection->quote_identifier( + $this->schema_builder->get_table_name( false, 'columns' ) + ); + + // 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, 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 = ? + 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 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, + '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'] ), + $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 0b90df108..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 @@ -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. * @@ -1028,7 +1039,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, 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 ); @@ -1050,11 +1061,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 ) - : $this->get_default_value( $node, $column['DATA_TYPE'], $column_name ); + $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 ); } @@ -2186,18 +2198,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 ); + $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. - * @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 { + private function get_default_value( WP_Parser_Node $node, string $data_type, string $column_name, ?int $datetime_precision ): ?string { /* * [GRAMMAR] * columnAttribute: @@ -2226,10 +2240,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 ); + $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 ); + return $this->get_literal_default( $literal, $data_type, $column_name, $datetime_precision ); } // DEFAULT (expression) - MySQL 8.0.13+ supports exprWithParentheses @@ -2244,12 +2259,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. - * @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 { + 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; @@ -2259,10 +2275,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 ); + /* + * 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; } /** @@ -2287,8 +2313,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: 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. @@ -2298,6 +2325,32 @@ private function normalize_literal_default( string $default_value, string $data_ return $default_value; } + /** + * Get the zero value of a temporal column when its default is zero. + * + * 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 $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_value( string $data_type, string $default_value, ?int $datetime_precision ): ?string { + $zero_value = self::TEMPORAL_ZERO_VALUE_MAP[ $data_type ] ?? null; + + // 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; + } + + if ( $datetime_precision > 0 ) { + $zero_value .= '.' . str_repeat( '0', $datetime_precision ); + } + return $zero_value; + } + /** * Normalize a BIT default value to a MySQL bit literal. * @@ -2832,11 +2885,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_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)' ); 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..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 @@ -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');" @@ -6333,6 +6355,759 @@ 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 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 ( + 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_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_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\' + )' + ); + + $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_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_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', + ) + ), + $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, + 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)' ); + + // 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( + '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 + ); + } + + public function testAlterTableWithTemporalZeroDefaults(): void { + $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( + implode( + "\n", + array( + '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', + ) + ), + $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, + 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 + )" + ); + + // 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, and + // a row with other values. + $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->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(); + + // 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'} ); + + // 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( "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( + 'year_number' => '0', + 'year_string' => '0', + 'expression' => '0', + ), + ), + $this->last_result + ); + + // 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( $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 ); + } + + /** + * @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)' ); + $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'), + ('_tmp_number_forms', 'id', 'int'), + ('_tmp_number_forms', 'negative_zero', 'datetime'), + ('_tmp_number_forms', 'decimal_zero', 'datetime')" + ); + $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")' ); + $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. + $this->reconnectAfterDriverUpgrade(); + $this->assertQuery( 'SHOW CREATE TABLE _tmp_table' ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `_tmp_table` (', + ' `id` int DEFAULT NULL,', + " `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',", + " `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'} + ); + + // 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' => '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( + '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 (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 { + $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() { $this->assertQuery( "CREATE TABLE _tmp_table (