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 eee3f050b..8793c8651 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 @@ -2724,7 +2724,7 @@ private function execute_update_statement( WP_Parser_Node $node ): void { } // Translate UPDATE list, applying relevant type casting and IMPLICIT DEFAULT values. - $update_list = $this->translate_update_list( $update_target_table, $node ); + $update_list = $this->translate_update_list( $update_target_table, $node, $update_target ); // Translate WHERE, ORDER BY, and LIMIT clauses. if ( $where_subquery ) { @@ -6275,11 +6275,19 @@ function ( $column ) use ( $is_strict_mode, $insert_map ) { * For more information about IMPLICIT DEFAULT values in MySQL, see: * https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html#data-type-defaults-implicit * + * Additionally, this method emulates "ON UPDATE CURRENT_TIMESTAMP" columns. + * In MySQL, such a column is set to the current timestamp when any other + * column in the row changes, unless the statement assigns it explicitly. + * + * For more information about ON UPDATE CURRENT_TIMESTAMP in MySQL, see: + * https://dev.mysql.com/doc/refman/8.4/en/timestamp-initialization.html + * * @param string $table_name The name of the target table. * @param WP_Parser_Node $parent_node The "updateList" AST node parent node. + * @param string|null $table_alias Optional. The target table alias. Defaults to the table name. * @return string The translated UPDATE list. */ - private function translate_update_list( string $table_name, WP_Parser_Node $parent_node ): string { + private function translate_update_list( string $table_name, WP_Parser_Node $parent_node, ?string $table_alias = null ): string { $node = $parent_node->get_first_child_node( 'updateList' ); // This method is always used with the main database. @@ -6296,7 +6304,7 @@ private function translate_update_list( string $table_name, WP_Parser_Node $pare $columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' ); $columns = $this->execute_sqlite_query( ' - SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, data_type, column_default + SELECT LOWER(column_name) AS COLUMN_NAME, is_nullable, data_type, column_default, extra FROM ' . $this->quote_sqlite_identifier( $columns_table ) . ' WHERE table_schema = ? AND table_name = ? @@ -6312,7 +6320,8 @@ private function translate_update_list( string $table_name, WP_Parser_Node $pare $column_map = array_combine( array_column( $columns, 'COLUMN_NAME' ), $columns ); // Translate the UPDATE list, emulating IMPLICIT DEFAULTs for NULL values. - $fragment = ''; + $fragment = ''; + $assigned_values = array(); foreach ( $node->get_child_nodes() as $i => $update_element ) { $column_ref = $update_element->get_first_child_node( 'columnRef' ); $column_ref_parts = $column_ref->get_descendant_nodes( 'identifier' ); @@ -6364,8 +6373,80 @@ private function translate_update_list( string $table_name, WP_Parser_Node $pare $fragment .= $this->translate( end( $column_ref_parts ) ); $fragment .= ' = '; $fragment .= $value; + + $assigned_values[ strtolower( $column_name ) ] = $value; } - return $fragment; + + // Collect unassigned ON UPDATE CURRENT_TIMESTAMP columns for automatic updates. + $on_update_columns = array(); + foreach ( $column_map as $column_name => $column_info ) { + if ( + isset( $assigned_values[ $column_name ] ) + || ! str_contains( $column_info['EXTRA'], 'on update CURRENT_TIMESTAMP' ) + ) { + continue; + } + + $on_update_columns[] = $this->quote_sqlite_identifier( $column_name ); + } + if ( 0 === count( $on_update_columns ) ) { + return $fragment; + } + + /* + * Emulate ON UPDATE CURRENT_TIMESTAMP for columns that are not assigned. + * + * Evaluate assigned expressions once per row and reuse their values to + * detect changes and determine whether to use the current timestamp. + * + * With "updated_at ... ON UPDATE CURRENT_TIMESTAMP", this MySQL query: + * + * UPDATE items SET score = FLOOR(RAND(1) * 10); + * + * Becomes the following SQLite query (simplified): + * + * UPDATE items SET (score, updated_at) = ( + * SELECT + * score, + * CASE WHEN items.score IS NOT score THEN CURRENT_TIMESTAMP ELSE items.updated_at END + * FROM (SELECT FLOOR(RAND(1) * 10) AS score) + * ); + * + * Here, items.score is the stored value and score is the new value. + * The inner SELECT has no FROM clause, so SQLite cannot flatten it + * and repeat the assigned expressions when checking for changes. + * See: https://www.sqlite.org/optoverview.html#flattening + */ + $quoted_table = $this->quote_sqlite_identifier( $table_alias ?? $table_name ); + $assigned_columns = array(); + $value_expressions = array(); + $changes = array(); + foreach ( $assigned_values as $column_name => $value ) { + $quoted_column = $this->quote_sqlite_identifier( $column_name ); + $assigned_columns[] = $quoted_column; + $value_expressions[] = sprintf( '(%s) COLLATE BINARY AS %s', $value, $quoted_column ); + + // Compare row data using binary collation, qualifying the old values. + $changes[] = sprintf( '%s.%s IS NOT %s COLLATE BINARY', $quoted_table, $quoted_column, $quoted_column ); + } + $is_row_changed = implode( ' OR ', $changes ); + $row_values = $assigned_columns; + foreach ( $on_update_columns as $quoted_column ) { + $assigned_columns[] = $quoted_column; + $row_values[] = sprintf( + 'CASE WHEN %s THEN CURRENT_TIMESTAMP ELSE %s.%s END', + $is_row_changed, + $quoted_table, + $quoted_column + ); + } + + return sprintf( + '(%s) = (SELECT %s FROM (SELECT %s))', + implode( ', ', $assigned_columns ), + implode( ', ', $row_values ), + implode( ', ', $value_expressions ) + ); } /** @@ -7159,7 +7240,6 @@ private function get_sqlite_create_table_statement( // 6. Generate CREATE TABLE statement columns. $rows = array(); - $on_update_queries = array(); $has_autoincrement = false; foreach ( $column_info as $column ) { $query = ' '; @@ -7230,13 +7310,6 @@ private function get_sqlite_create_table_statement( } } $rows[] = $query; - - if ( 'on update CURRENT_TIMESTAMP' === $column['EXTRA'] ) { - $on_update_queries[] = $this->get_column_on_update_trigger_query( - $table_name, - $column['COLUMN_NAME'] - ); - } } // 6. Generate CREATE TABLE statement constraints, collect indexes. @@ -7372,7 +7445,7 @@ function ( $column ) { $create_table_query .= ' STRICT'; } - return array_merge( array( $create_table_query ), $create_index_queries, $on_update_queries ); + return array_merge( array( $create_table_query ), $create_index_queries ); } /** @@ -7705,36 +7778,6 @@ private function get_internal_savepoint_name( string $name ): string { return sprintf( '%ssavepoint_%s', self::RESERVED_PREFIX, $name ); } - /** - * Get an SQLite query to emulate MySQL "ON UPDATE CURRENT_TIMESTAMP". - * - * In SQLite, "ON UPDATE CURRENT_TIMESTAMP" is not supported. We need to - * create a trigger to emulate this behavior. - * - * @param string $table The table name. - * @param string $column The column name. - */ - private function get_column_on_update_trigger_query( string $table, string $column ): string { - // The trigger wouldn't work for virtual and "WITHOUT ROWID" tables, - // but currently that can't happen as we're not creating such tables. - // See: https://www.sqlite.org/rowidtable.html - $trigger_name = self::RESERVED_PREFIX . "{$table}_{$column}_on_update"; - return sprintf( - ' - CREATE TRIGGER %s - AFTER UPDATE ON %s - FOR EACH ROW - BEGIN - UPDATE %s SET %s = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid; - END - ', - $this->quote_sqlite_identifier( $trigger_name ), - $this->quote_sqlite_identifier( $table ), - $this->quote_sqlite_identifier( $table ), - $this->quote_sqlite_identifier( $column ) - ); - } - /** * Format a column default value as a literal for an SQLite CREATE TABLE statement. * 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 045f409ec..2a37b6816 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 @@ -334,6 +334,12 @@ private function save_current_driver_version(): void { * @param string $saved_version The driver version that last configured the database. */ private function run_migrations( string $saved_version ): void { + // Drop ON UPDATE CURRENT_TIMESTAMP triggers created by versions <= 3.1.1. + // Run this before other migrations would drop them by table rebuilds. + if ( version_compare( $saved_version, '3.1.1', '<=' ) ) { + $this->migrate_on_update_triggers(); + } + // Fix temporal precision and zero defaults stored by versions <= 3.1.1. if ( version_compare( $saved_version, '3.1.1', '<=' ) ) { $this->migrate_datetime_precision(); @@ -341,6 +347,51 @@ private function run_migrations( string $saved_version ): void { } } + /** + * Migrate ON UPDATE CURRENT_TIMESTAMP triggers to column metadata. + * + * Handles old ON UPDATE triggers with the following name formats: + * + * 1. Versions 3.0.0 to 3.1.1: "_wp_sqlite_{table}_{column}_on_update" + * 2. Versions before 3.0.0: "__{table}_{column}_on_update__" + */ + private function migrate_on_update_triggers(): void { + $connection = $this->driver->get_connection(); + $columns_table = $connection->quote_identifier( $this->schema_builder->get_table_name( false, 'columns' ) ); + $columns = $this->driver->execute_sqlite_query( + sprintf( + "SELECT c.TABLE_NAME, c.COLUMN_NAME, c.EXTRA, t.name AS TRIGGER_NAME + FROM %s AS c + JOIN main.sqlite_master AS t ON t.type = 'trigger' + AND t.tbl_name = c.TABLE_NAME COLLATE NOCASE + AND ( + t.name = '__' || c.TABLE_NAME || '_' || c.COLUMN_NAME || '_on_update__' COLLATE NOCASE + OR t.name = ? || c.TABLE_NAME || '_' || c.COLUMN_NAME || '_on_update' COLLATE NOCASE + ) + WHERE c.TABLE_SCHEMA = ?", + $columns_table + ), + array( WP_MySQL_On_SQLite::RESERVED_PREFIX, WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME ) + )->fetchAll( PDO::FETCH_ASSOC ); + + foreach ( $columns as $column ) { + if ( ! str_contains( $column['EXTRA'], 'on update CURRENT_TIMESTAMP' ) ) { + $this->driver->execute_sqlite_query( + sprintf( 'UPDATE %s SET EXTRA = ? WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?', $columns_table ), + array( + trim( $column['EXTRA'] . ' on update CURRENT_TIMESTAMP' ), + WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME, + $column['TABLE_NAME'], + $column['COLUMN_NAME'], + ) + ); + } + $this->driver->execute_sqlite_query( + sprintf( 'DROP TRIGGER main.%s', $connection->quote_identifier( $column['TRIGGER_NAME'] ) ) + ); + } + } + /** * Migrate DATETIME_PRECISION values stored by older driver versions. * @@ -422,7 +473,7 @@ private function migrate_temporal_zero_defaults(): void { } foreach ( $table_columns as $table_name => $repaired_columns ) { - // Disable driver triggers during row repair. The table rebuild restores them. + // Drop triggers, so they don't run during row repair. The table rebuild drops them anyway. $triggers = $this->driver->execute_sqlite_query( "SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ? COLLATE NOCASE", array( $table_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 64c9ad8c9..fd8c62811 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 @@ -119,6 +119,52 @@ private function reconnectAfterDriverUpgrade(): void { $this->engine->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); } + /** + * Fetch a timestamp column, replacing values set by the tested updates with $now. + * + * Values between $now and the time of this call are replaced, so a timestamp + * that is too old or in the future still fails the assertion. + * + * @param string $query A query that selects a single timestamp column. + * @param string $now The time before the tested updates, in UTC. + * @return array The column values. + */ + private function getOnUpdateTimestamps( string $query, string $now ): array { + $after = gmdate( 'Y-m-d H:i:s' ); + return array_map( + function ( $row ) use ( $now, $after ) { + $value = current( (array) $row ); + return null !== $value && $value >= $now && $value <= $after ? $now : $value; + }, + $this->assertQuery( $query ) + ); + } + + /** + * Create an ON UPDATE CURRENT_TIMESTAMP trigger, as driver versions <= 3.1.1 did. + * + * @param string $table The table name. + * @param string $column The column name. + * @param bool $legacy Whether to use the legacy driver's trigger name. + */ + private function createOnUpdateTrigger( string $table, string $column, bool $legacy = false ): void { + $connection = $this->engine->get_connection(); + $trigger_name = $legacy ? "__{$table}_{$column}_on_update__" : "_wp_sqlite_{$table}_{$column}_on_update"; + $this->sqlite->exec( + sprintf( + 'CREATE TRIGGER %1$s + AFTER UPDATE ON %2$s + FOR EACH ROW + BEGIN + UPDATE %2$s SET %3$s = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid; + END', + $connection->quote_identifier( $trigger_name ), + $connection->quote_identifier( $table ), + $connection->quote_identifier( $column ) + ) + ); + } + public function testRegexp() { $this->assertQuery( "INSERT INTO _options (option_name, option_value) VALUES ('rss_0123456789abcdef0123456789abcdef', '1');" @@ -1909,47 +1955,9 @@ public function testColumnWithOnUpdate() { $results ); - // assert ON UPDATE triggers + // ON UPDATE is emulated in UPDATE statements, without triggers $results = $this->assertQuery( "SELECT * FROM sqlite_master WHERE type = 'trigger'" ); - $this->assertEquals( - array( - (object) array( - 'type' => 'trigger', - 'name' => '_wp_sqlite__tmp_table_created_at_on_update', - 'tbl_name' => '_tmp_table', - 'rootpage' => '0', - 'sql' => implode( - "\n\t\t\t\t", - array( - 'CREATE TRIGGER `_wp_sqlite__tmp_table_created_at_on_update`', - 'AFTER UPDATE ON `_tmp_table`', - 'FOR EACH ROW', - 'BEGIN', - ' UPDATE `_tmp_table` SET `created_at` = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid;', - 'END', - ) - ), - ), - (object) array( - 'type' => 'trigger', - 'name' => '_wp_sqlite__tmp_table_updated_at_on_update', - 'tbl_name' => '_tmp_table', - 'rootpage' => '0', - 'sql' => implode( - "\n\t\t\t\t", - array( - 'CREATE TRIGGER `_wp_sqlite__tmp_table_updated_at_on_update`', - 'AFTER UPDATE ON `_tmp_table`', - 'FOR EACH ROW', - 'BEGIN', - ' UPDATE `_tmp_table` SET `updated_at` = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid;', - 'END', - ) - ), - ), - ), - $results - ); + $this->assertEquals( array(), $results ); // on INSERT, no timestamps are expected $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (1)' ); @@ -2000,10 +2008,6 @@ public function testColumnWithOnUpdate() { $results ); - // assert ON UPDATE triggers are removed - $results = $this->assertQuery( "SELECT * FROM sqlite_master WHERE type = 'trigger'" ); - $this->assertEquals( array(), $results ); - // now, no timestamps are expected $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (10)' ); $this->assertQuery( 'UPDATE _tmp_table SET id = 11 WHERE id = 10' ); @@ -2065,8 +2069,8 @@ public function testChangeColumnWithOnUpdate() { // no ON UPDATE is set $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (1)' ); - $this->assertQuery( 'UPDATE _tmp_table SET id = 1 WHERE id = 1' ); - $result = $this->assertQuery( 'SELECT * FROM _tmp_table WHERE id = 1' ); + $this->assertQuery( 'UPDATE _tmp_table SET id = 2 WHERE id = 1' ); + $result = $this->assertQuery( 'SELECT * FROM _tmp_table WHERE id = 2' ); $this->assertNull( $result[0]->created_at ); // CHANGE COLUMN to add ON UPDATE @@ -2097,8 +2101,8 @@ public function testChangeColumnWithOnUpdate() { ); // now, ON UPDATE SHOULD BE SET - $this->assertQuery( 'UPDATE _tmp_table SET id = 1 WHERE id = 1' ); - $result = $this->assertQuery( 'SELECT * FROM _tmp_table WHERE id = 1' ); + $this->assertQuery( 'UPDATE _tmp_table SET id = 3 WHERE id = 2' ); + $result = $this->assertQuery( 'SELECT * FROM _tmp_table WHERE id = 3' ); $this->assertRegExp( '/\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/', $result[0]->created_at ); // change column to remove ON UPDATE @@ -2129,12 +2133,461 @@ public function testChangeColumnWithOnUpdate() { ); // now, no timestamp is expected - $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (2)' ); - $this->assertQuery( 'UPDATE _tmp_table SET id = 2 WHERE id = 2' ); - $result = $this->assertQuery( 'SELECT * FROM _tmp_table WHERE id = 2' ); + $this->assertQuery( 'INSERT INTO _tmp_table (id) VALUES (4)' ); + $this->assertQuery( 'UPDATE _tmp_table SET id = 5 WHERE id = 4' ); + $result = $this->assertQuery( 'SELECT * FROM _tmp_table WHERE id = 5' ); $this->assertNull( $result[0]->created_at ); } + public function testOnUpdateCurrentTimestamp(): void { + $this->assertQuery( + 'CREATE TABLE t ( + id INT PRIMARY KEY, + n INT, + s VARCHAR(20), + ts TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( + "INSERT INTO t VALUES + (1, 1, 'a', '2000-01-01 00:00:00'), + (2, 1, 'a', '2000-01-01 00:00:00'), + (3, 1, 'a', '2000-01-01 00:00:00'), + (4, 1, 'a', '2000-01-01 00:00:00'), + (5, 1, 'a', '2000-01-01 00:00:00'), + (6, 1, 'a', '2000-01-01 00:00:00'), + (7, 1, 'a', '2000-01-01 00:00:00'), + (8, 1, 'a', '2000-01-01 00:00:00'), + (9, 1, 'a', '2000-01-01 00:00:00'), + (10, 1, 'a', '2000-01-01 00:00:00')" + ); + + $now = gmdate( 'Y-m-d H:i:s' ); + + // A changed value sets the current timestamp. + $this->assertQuery( 'UPDATE t SET n = 2 WHERE id = 1' ); + + // An explicit value is saved. + $this->assertQuery( "UPDATE t SET ts = '2001-01-01 00:00:00' WHERE id = 2" ); + + // An update that changes nothing keeps the timestamp. + $this->assertQuery( 'UPDATE t SET n = n WHERE id = 3' ); + + // A value that is equal after type casting is not a change. + $this->assertQuery( "UPDATE t SET n = '1' WHERE id = 4" ); + + // Assigning the current timestamp prevents the automatic update. + $this->assertQuery( 'UPDATE t SET n = 2, ts = ts WHERE id = 5' ); + + // Assigning DEFAULT prevents the automatic update. + $this->assertQuery( 'UPDATE t SET n = 2, ts = DEFAULT WHERE id = 6' ); + + // A case-only change is a change. + $this->assertQuery( "UPDATE t SET s = 'A' WHERE id = 7" ); + + // A trailing space is a change. + $this->assertQuery( "UPDATE t SET s = 'a ' WHERE id = 8" ); + + // An unchanged string is not a change. + $this->assertQuery( "UPDATE t SET s = 'a' WHERE id = 9" ); + + // An explicit MySQL collation doesn't hide a case-only change. + $this->assertQuery( "UPDATE t SET s = 'A' COLLATE utf8mb4_general_ci WHERE id = 10" ); + + $this->assertSame( + array( + $now, + '2001-01-01 00:00:00', + '2000-01-01 00:00:00', + '2000-01-01 00:00:00', + '2000-01-01 00:00:00', + null, + $now, + $now, + '2000-01-01 00:00:00', + $now, + ), + $this->getOnUpdateTimestamps( 'SELECT ts FROM t ORDER BY id', $now ) + ); + } + + /** + * @dataProvider onUpdateRandomAssignments + */ + public function testOnUpdateCurrentTimestampWithRandomAssignments( string $query ): void { + $this->assertQuery( + 'CREATE TABLE t ( + id INT PRIMARY KEY, + n INT, + ts1 TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP, + ts2 DATETIME NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( + "INSERT INTO t VALUES + (1, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00'), + (2, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00'), + (3, 0, '2000-01-01 00:00:00', '2000-01-01 00:00:00'), + (4, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00')" + ); + + $now = gmdate( 'Y-m-d H:i:s' ); + $this->assertQuery( $query ); + + // MySQL's first four RAND(1) >= 0.5 values are 0, 1, 0, and 0. + $this->assertSame( array( '0', '1', '0', '0' ), array_column( $this->assertQuery( 'SELECT n FROM t ORDER BY id' ), 'n' ) ); + $expected = array( $now, '2000-01-01 00:00:00', '2000-01-01 00:00:00', $now ); + $this->assertSame( $expected, $this->getOnUpdateTimestamps( 'SELECT ts1 FROM t ORDER BY id', $now ) ); + $this->assertSame( $expected, $this->getOnUpdateTimestamps( 'SELECT ts2 FROM t ORDER BY id', $now ) ); + } + + public static function onUpdateRandomAssignments(): array { + return array( + 'update' => array( 'UPDATE t SET n = RAND(1) >= 0.5' ), + 'on duplicate key update' => array( + 'INSERT INTO t (id, n) VALUES (1, 0), (2, 0), (3, 0), (4, 0) + ON DUPLICATE KEY UPDATE n = RAND(1) >= 0.5', + ), + ); + } + + /** + * @dataProvider onUpdateCountedAssignments + */ + public function testOnUpdateCurrentTimestampEvaluatesAssignmentsOnce( string $query ): void { + if ( str_contains( $query, 'JOIN' ) && version_compare( $this->engine->get_sqlite_version(), '3.33.0', '<' ) ) { + $this->markTestSkipped( 'SQLite versions before 3.33.0 do not support UPDATE with FROM.' ); + } + + $this->assertQuery( + 'CREATE TABLE t ( + id INT PRIMARY KEY, + n INT, + ts1 TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP, + ts2 DATETIME NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( + "INSERT INTO t VALUES + (1, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00'), + (2, 2, '2000-01-01 00:00:00', '2000-01-01 00:00:00'), + (3, 3, '2000-01-01 00:00:00', '2000-01-01 00:00:00'), + (4, 4, '2000-01-01 00:00:00', '2000-01-01 00:00:00')" + ); + $this->assertQuery( 'CREATE TABLE t2 (id INT, n INT)' ); + $this->assertQuery( 'INSERT INTO t2 VALUES (1, 1), (2, 2), (3, 3)' ); + + $calls = array(); + $callback = static function ( $value ) use ( &$calls ) { + $calls[] = $value; + return 1 === $value ? 2 : $value; + }; + if ( $this->sqlite instanceof Pdo\Sqlite ) { + $this->sqlite->createFunction( 'on_update_test_probe', $callback, 1 ); + } else { + $this->sqlite->sqliteCreateFunction( 'on_update_test_probe', $callback, 1 ); + } + + $now = gmdate( 'Y-m-d H:i:s' ); + $this->assertQuery( $query ); + $this->assertSame( array( 1, 2, 3 ), $calls ); + $this->assertSame( array( '2', '2', '3', '4' ), array_column( $this->assertQuery( 'SELECT n FROM t WHERE id <= 4 ORDER BY id' ), 'n' ) ); + $expected = array( $now, '2000-01-01 00:00:00', '2000-01-01 00:00:00', '2000-01-01 00:00:00' ); + $this->assertSame( $expected, $this->getOnUpdateTimestamps( 'SELECT ts1 FROM t WHERE id <= 4 ORDER BY id', $now ) ); + $this->assertSame( $expected, $this->getOnUpdateTimestamps( 'SELECT ts2 FROM t WHERE id <= 4 ORDER BY id', $now ) ); + } + + public static function onUpdateCountedAssignments(): array { + return array( + 'update' => array( 'UPDATE t SET n = on_update_test_probe(n) WHERE id < 4' ), + 'alias' => array( 'UPDATE t AS a SET a.n = on_update_test_probe(a.n) WHERE a.id < 4' ), + 'limit' => array( 'UPDATE t SET n = on_update_test_probe(n) ORDER BY id LIMIT 3' ), + 'join' => array( 'UPDATE t AS a JOIN t2 AS b ON a.id = b.id SET a.n = on_update_test_probe(b.n)' ), + 'on duplicate key update' => array( + 'INSERT INTO t (id, n) VALUES (1, 1), (2, 2), (3, 3), (5, 5) + ON DUPLICATE KEY UPDATE n = on_update_test_probe(VALUES(n))', + ), + 'scalar subquery' => array( 'UPDATE t SET n = (SELECT on_update_test_probe(n)) WHERE id < 4' ), + ); + } + + public function testOnUpdateCurrentTimestampWithMultipleColumns(): void { + $this->assertQuery( + 'CREATE TABLE t ( + id INT PRIMARY KEY, + n INT, + ts1 TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP, + ts2 DATETIME NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( + "INSERT INTO t VALUES + (1, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00'), + (2, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00'), + (3, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00'), + (4, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00'), + (5, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00')" + ); + + $now = gmdate( 'Y-m-d H:i:s' ); + + // A changed value updates both columns. + $this->assertQuery( 'UPDATE t SET n = 2 WHERE id = 1' ); + + // A changed ON UPDATE column updates the other one. + $this->assertQuery( "UPDATE t SET ts1 = '2001-01-01 00:00:00' WHERE id = 2" ); + + // An unchanged ON UPDATE column doesn't update the other one. + $this->assertQuery( "UPDATE t SET ts1 = '2000-01-01 00:00:00' WHERE id = 3" ); + $this->assertQuery( 'UPDATE t SET ts1 = ts1 WHERE id = 4' ); + + // A self-assignment prevents only the assigned column update. + $this->assertQuery( 'UPDATE t SET n = 2, ts1 = ts1 WHERE id = 5' ); + + $this->assertSame( + array( + $now, + '2001-01-01 00:00:00', + '2000-01-01 00:00:00', + '2000-01-01 00:00:00', + '2000-01-01 00:00:00', + ), + $this->getOnUpdateTimestamps( 'SELECT ts1 FROM t ORDER BY id', $now ) + ); + $this->assertSame( + array( $now, $now, '2000-01-01 00:00:00', '2000-01-01 00:00:00', $now ), + $this->getOnUpdateTimestamps( 'SELECT ts2 FROM t ORDER BY id', $now ) + ); + } + + public function testOnUpdateCurrentTimestampWithOnDuplicateKeyUpdate(): void { + $this->assertQuery( + 'CREATE TABLE t ( + id INT PRIMARY KEY, + n INT, + ts TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( + "INSERT INTO t VALUES + (1, 1, '2000-01-01 00:00:00'), + (2, 1, '2000-01-01 00:00:00'), + (3, 1, '2000-01-01 00:00:00'), + (4, 1, '2000-01-01 00:00:00')" + ); + + $now = gmdate( 'Y-m-d H:i:s' ); + + // A changed value sets the current timestamp. + $this->assertQuery( 'INSERT INTO t (id, n) VALUES (1, 2) ON DUPLICATE KEY UPDATE n = VALUES(n)' ); + + // An update that changes nothing keeps the timestamp. + $this->assertQuery( 'INSERT INTO t (id, n) VALUES (2, 1) ON DUPLICATE KEY UPDATE n = VALUES(n)' ); + + // An explicit value is saved. + $this->assertQuery( "INSERT INTO t VALUES (3, 2, '2001-01-01 00:00:00') ON DUPLICATE KEY UPDATE n = VALUES(n), ts = VALUES(ts)" ); + + // A self-assignment prevents the automatic update. + $this->assertQuery( 'INSERT INTO t (id, n) VALUES (4, 2) ON DUPLICATE KEY UPDATE n = VALUES(n), ts = ts' ); + + // A new row doesn't get the current timestamp. + $this->assertQuery( 'INSERT INTO t (id, n) VALUES (5, 1) ON DUPLICATE KEY UPDATE n = VALUES(n)' ); + + $this->assertSame( + array( + $now, + '2000-01-01 00:00:00', + '2001-01-01 00:00:00', + '2000-01-01 00:00:00', + null, + ), + $this->getOnUpdateTimestamps( 'SELECT ts FROM t ORDER BY id', $now ) + ); + } + + public function testOnUpdateCurrentTimestampWithDefaultValues(): void { + $this->assertQuery( + 'CREATE TABLE t ( + id INT PRIMARY KEY, + n INT, + ts1 TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + ts2 TIMESTAMP NULL DEFAULT (NOW()) ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( + "INSERT INTO t VALUES + (1, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00'), + (2, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00')" + ); + + $now = gmdate( 'Y-m-d H:i:s' ); + + // A changed value sets the current timestamp. + $this->assertQuery( 'UPDATE t SET n = 2 WHERE id = 1' ); + + // An update that changes nothing keeps the timestamps. + $this->assertQuery( 'UPDATE t SET n = 1 WHERE id = 2' ); + + $this->assertSame( + array( $now, '2000-01-01 00:00:00' ), + $this->getOnUpdateTimestamps( 'SELECT ts1 FROM t ORDER BY id', $now ) + ); + $this->assertSame( + array( $now, '2000-01-01 00:00:00' ), + $this->getOnUpdateTimestamps( 'SELECT ts2 FROM t ORDER BY id', $now ) + ); + } + + public function testOnUpdateCurrentTimestampWithAlterColumnDefault(): void { + $this->assertQuery( + 'CREATE TABLE t ( + id INT PRIMARY KEY, + n INT, + ts DATETIME NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( + "INSERT INTO t VALUES + (1, 1, '2000-01-01 00:00:00'), + (2, 1, '2000-01-01 00:00:00'), + (3, 1, '2000-01-01 00:00:00'), + (4, 1, '2000-01-01 00:00:00')" + ); + + // An expression default adds DEFAULT_GENERATED to EXTRA. + $this->assertQuery( 'ALTER TABLE t ALTER COLUMN ts SET DEFAULT (NOW())' ); + $this->assertQuery( "SELECT extra FROM information_schema.columns WHERE table_name = 't' AND column_name = 'ts'" ); + $this->assertSame( 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP', $this->last_result[0]->EXTRA ); + + $now = gmdate( 'Y-m-d H:i:s' ); + $this->assertQuery( 'UPDATE t SET n = 2 WHERE id = 1' ); + $this->assertQuery( 'UPDATE t SET n = 1 WHERE id = 2' ); + + $this->assertQuery( 'ALTER TABLE t ALTER COLUMN ts DROP DEFAULT' ); + $this->assertQuery( "SELECT extra FROM information_schema.columns WHERE table_name = 't' AND column_name = 'ts'" ); + $this->assertSame( 'on update CURRENT_TIMESTAMP', $this->last_result[0]->EXTRA ); + + $this->assertQuery( 'UPDATE t SET n = 2 WHERE id = 3' ); + $this->assertQuery( 'UPDATE t SET n = 1 WHERE id = 4' ); + + $this->assertSame( + array( $now, '2000-01-01 00:00:00', $now, '2000-01-01 00:00:00' ), + $this->getOnUpdateTimestamps( 'SELECT ts FROM t ORDER BY id', $now ) + ); + } + + public function testOnUpdateCurrentTimestampInTemporaryTable(): void { + $this->assertQuery( + 'CREATE TEMPORARY TABLE t ( + id INT PRIMARY KEY, + n INT, + ts TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( + "INSERT INTO t VALUES + (1, 1, '2000-01-01 00:00:00'), + (2, 1, '2000-01-01 00:00:00')" + ); + + $now = gmdate( 'Y-m-d H:i:s' ); + $this->assertQuery( 'UPDATE t SET n = 2 WHERE id = 1' ); + $this->assertQuery( 'UPDATE t SET n = 1 WHERE id = 2' ); + + $this->assertSame( + array( $now, '2000-01-01 00:00:00' ), + $this->getOnUpdateTimestamps( 'SELECT ts FROM t ORDER BY id', $now ) + ); + } + + public function testOnUpdateCurrentTimestampWithTableAlias(): void { + $this->assertQuery( + 'CREATE TABLE t ( + id INT PRIMARY KEY, + n INT, + ts TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( + "INSERT INTO t VALUES + (1, 1, '2000-01-01 00:00:00'), + (2, 1, '2000-01-01 00:00:00')" + ); + + $now = gmdate( 'Y-m-d H:i:s' ); + $this->assertQuery( 'UPDATE t AS a SET a.n = 2 WHERE a.id = 1' ); + $this->assertQuery( 'UPDATE t AS a SET n = a.n WHERE a.id = 2' ); + + $this->assertSame( + array( $now, '2000-01-01 00:00:00' ), + $this->getOnUpdateTimestamps( 'SELECT ts FROM t ORDER BY id', $now ) + ); + } + + public function testOnUpdateCurrentTimestampWithJoinedTables(): void { + $sqlite_version = $this->engine->get_sqlite_version(); + if ( version_compare( $sqlite_version, '3.33.0', '<' ) ) { + $this->markTestSkipped( + sprintf( "SQLite version %s doesn't support UPDATE with FROM clause.", $sqlite_version ) + ); + return; + } + + $this->assertQuery( + 'CREATE TABLE t ( + id INT PRIMARY KEY, + n INT, + ts TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( 'CREATE TABLE t2 (id INT PRIMARY KEY, n INT, ts TIMESTAMP NULL)' ); + $this->assertQuery( + "INSERT INTO t VALUES + (1, 1, '2000-01-01 00:00:00'), + (2, 1, '2000-01-01 00:00:00'), + (3, 1, '2000-01-01 00:00:00'), + (4, 1, '2000-01-01 00:00:00')" + ); + $this->assertQuery( 'INSERT INTO t2 VALUES (1, 5, NULL), (2, 1, NULL), (3, 5, NULL), (4, 1, NULL)' ); + + $now = gmdate( 'Y-m-d H:i:s' ); + + // Columns with the same names in both tables must not be ambiguous. + $this->assertQuery( 'UPDATE t JOIN t2 ON t.id = t2.id SET t.n = t2.n WHERE t.id <= 2' ); + $this->assertQuery( 'UPDATE t AS a JOIN t2 AS b ON a.id = b.id SET a.n = b.n WHERE a.id > 2' ); + + $this->assertSame( + array( $now, '2000-01-01 00:00:00', $now, '2000-01-01 00:00:00' ), + $this->getOnUpdateTimestamps( 'SELECT ts FROM t ORDER BY id', $now ) + ); + } + + public function testOnUpdateCurrentTimestampWithImplicitDefault(): void { + $this->assertQuery( "SET SESSION sql_mode = ''" ); + $this->assertQuery( + 'CREATE TABLE t ( + id INT PRIMARY KEY, + n INT NOT NULL, + ts TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( + "INSERT INTO t VALUES + (1, 0, '2000-01-01 00:00:00'), + (2, 1, '2000-01-01 00:00:00')" + ); + + $now = gmdate( 'Y-m-d H:i:s' ); + + // In non-strict mode, NULL is saved as an IMPLICIT DEFAULT value of 0. + $this->assertQuery( 'UPDATE t SET n = NULL' ); + + $this->assertSame( + array( '2000-01-01 00:00:00', $now ), + $this->getOnUpdateTimestamps( 'SELECT ts FROM t ORDER BY id', $now ) + ); + } + public function testAlterTableWithColumnFirstAndAfter() { $this->assertQuery( "CREATE TABLE _tmp_table ( @@ -6767,6 +7220,9 @@ public function testConfigurationNormalizesStoredTemporalZeroValuesWithoutRunnin $this->assertQuery( 'OPTIMIZE TABLE _tmp_table' ); $this->assertQuery( "INSERT INTO _tmp_table (id, updated_at) VALUES (1, '2020-01-02 03:04:05')" ); + // Create the ON UPDATE CURRENT_TIMESTAMP trigger, as older driver versions did. + $this->createOnUpdateTrigger( '_tmp_table', 'updated_at' ); + // 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' ); @@ -6780,7 +7236,11 @@ public function testConfigurationNormalizesStoredTemporalZeroValuesWithoutRunnin $this->last_result ); - // The recreated table has the trigger again. + // The recreated table has no trigger, and UPDATE emulates ON UPDATE CURRENT_TIMESTAMP. + $this->assertSame( + array(), + $this->sqlite->query( "SELECT name FROM sqlite_master WHERE type = 'trigger'" )->fetchAll( PDO::FETCH_COLUMN ) + ); $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 ); @@ -6838,6 +7298,222 @@ public static function migrationDriverVersions(): array { ); } + public function testConfigurationDropsOnUpdateCurrentTimestampTriggers(): void { + $this->assertQuery( + 'CREATE TABLE _tmp_table ( + id INT PRIMARY KEY, + n INT, + updated_at TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP + )' + ); + $this->assertQuery( + "INSERT INTO _tmp_table VALUES + (1, 1, '2000-01-01 00:00:00'), + (2, 1, '2000-01-01 00:00:00')" + ); + + // Create the trigger, as older driver versions did, and a user trigger. + $this->createOnUpdateTrigger( '_tmp_table', 'updated_at' ); + $this->sqlite->exec( 'CREATE TRIGGER `_tmp_table_updated_at_on_update` AFTER UPDATE ON `_tmp_table` BEGIN SELECT 1; END' ); + + // Only the driver trigger is dropped. + $this->reconnectAfterDriverUpgrade(); + $this->assertSame( + array( '_tmp_table_updated_at_on_update' ), + $this->sqlite->query( "SELECT name FROM sqlite_master WHERE type = 'trigger'" )->fetchAll( PDO::FETCH_COLUMN ) + ); + + // UPDATE emulates ON UPDATE CURRENT_TIMESTAMP as MySQL does. + $now = gmdate( 'Y-m-d H:i:s' ); + $this->assertQuery( 'UPDATE _tmp_table SET n = 2 WHERE id = 1' ); + $this->assertQuery( 'UPDATE _tmp_table SET n = 1 WHERE id = 2' ); + $this->assertSame( + array( $now, '2000-01-01 00:00:00' ), + $this->getOnUpdateTimestamps( 'SELECT updated_at FROM _tmp_table ORDER BY id', $now ) + ); + } + + /** + * @dataProvider onUpdateTriggerMigrationDriverVersions + */ + public function testConfigurationDropsOnUpdateCurrentTimestampTriggersOnlyForOlderDriverVersions( + string $saved_version, + array $expected_triggers, + bool $legacy = false + ): void { + $this->assertQuery( 'CREATE TABLE _tmp_table (id INT, updated_at TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP)' ); + $this->createOnUpdateTrigger( '_tmp_table', 'updated_at', $legacy ); + + // 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->assertSame( + $expected_triggers, + $this->sqlite->query( "SELECT name FROM sqlite_master WHERE type = 'trigger'" )->fetchAll( PDO::FETCH_COLUMN ) + ); + } + + public static function onUpdateTriggerMigrationDriverVersions(): array { + return array( + 'migrated from 3.1.1' => array( '3.1.1', array() ), + 'not migrated from 3.1.2' => array( '3.1.2', array( '_wp_sqlite__tmp_table_updated_at_on_update' ) ), + 'legacy from 3.1.1' => array( '3.1.1', array(), true ), + 'legacy from 3.1.2' => array( '3.1.2', array( '___tmp_table_updated_at_on_update__' ), true ), + ); + } + + /** + * @dataProvider legacyOnUpdateTriggerMigrations + */ + public function testConfigurationMigratesLegacyOnUpdateTriggers( bool $already_reconstructed, string $rebuild_query ): void { + // Reproduce the table, type cache, and triggers created by the legacy driver. + $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 + ('legacy_timestamps', 'id', 'int'), + ('legacy_timestamps', 'n', 'int'), + ('legacy_timestamps', 'zero_default', 'timestamp'), + ('legacy_timestamps', 'generated_default', 'datetime'), + ('legacy_timestamps', 'nullable', 'timestamp')" + ); + $this->sqlite->exec( + 'CREATE TABLE legacy_timestamps ( + id INTEGER PRIMARY KEY, + n INTEGER, + zero_default TEXT NOT NULL DEFAULT 0, + generated_default TEXT DEFAULT CURRENT_TIMESTAMP, + nullable TEXT + )' + ); + foreach ( range( 1, 5 ) as $id ) { + $this->sqlite->exec( + "INSERT INTO legacy_timestamps VALUES ($id, 1, '2000-01-01 00:00:00', '2000-01-01 00:00:00', '2000-01-01 00:00:00')" + ); + } + $this->sqlite->exec( 'UPDATE legacy_timestamps SET zero_default = 0 WHERE id = 5' ); + foreach ( array( 'zero_default', 'generated_default', 'nullable' ) as $column ) { + $this->createOnUpdateTrigger( 'legacy_timestamps', $column, true ); + } + + if ( $already_reconstructed ) { + // Earlier AST versions reconstructed the table but missed ON UPDATE metadata. + $reconstructor = new WP_SQLite_Information_Schema_Reconstructor( + $this->engine, + new WP_SQLite_Information_Schema_Builder( WP_MySQL_On_SQLite::RESERVED_PREFIX, $this->engine->get_connection() ) + ); + $reconstructor->ensure_correct_information_schema(); + } + + // Recover ON UPDATE before the zero-default migration rebuilds the table. + $this->reconnectAfterDriverUpgrade(); + $this->assertQuery( 'SELECT zero_default, generated_default, nullable FROM legacy_timestamps WHERE id = 5' ); + $this->assertEquals( + array( + (object) array( + 'zero_default' => '0000-00-00 00:00:00', + 'generated_default' => '2000-01-01 00:00:00', + 'nullable' => '2000-01-01 00:00:00', + ), + ), + $this->last_result + ); + $this->assertSame( + array(), + $this->sqlite->query( "SELECT name FROM sqlite_master WHERE type = 'trigger'" )->fetchAll( PDO::FETCH_COLUMN ) + ); + + // Repeated configuration must preserve other EXTRA flags without duplicating ON UPDATE. + $this->reconnectAfterDriverUpgrade(); + $this->assertQuery( "SELECT extra FROM information_schema.columns WHERE table_name = 'legacy_timestamps' AND column_name != 'id' AND column_name != 'n' ORDER BY ordinal_position" ); + $this->assertSame( + array( 'on update CURRENT_TIMESTAMP', 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP', 'on update CURRENT_TIMESTAMP' ), + array_column( $this->last_result, 'EXTRA' ) + ); + + if ( '' !== $rebuild_query ) { + $this->assertQuery( $rebuild_query ); + } + $this->assertQuery( 'SHOW CREATE TABLE legacy_timestamps' ); + $this->assertSame( 3, substr_count( $this->last_result[0]->{'Create Table'}, 'ON UPDATE CURRENT_TIMESTAMP' ) ); + + $now = gmdate( 'Y-m-d H:i:s' ); + $this->assertQuery( 'UPDATE legacy_timestamps SET n = 2 WHERE id IN (1, 5)' ); + $this->assertQuery( 'UPDATE legacy_timestamps SET n = n WHERE id = 2' ); + $this->assertQuery( 'UPDATE legacy_timestamps SET n = 2, zero_default = zero_default, generated_default = generated_default, nullable = nullable WHERE id = 3' ); + $this->assertQuery( "UPDATE legacy_timestamps SET zero_default = '2001-01-01 00:00:00', generated_default = '2001-01-01 00:00:00', nullable = '2001-01-01 00:00:00' WHERE id = 4" ); + foreach ( array( 'zero_default', 'generated_default', 'nullable' ) as $column ) { + $this->assertSame( + array( $now, '2000-01-01 00:00:00', '2000-01-01 00:00:00', '2001-01-01 00:00:00', $now ), + $this->getOnUpdateTimestamps( "SELECT $column FROM legacy_timestamps ORDER BY id", $now ) + ); + } + } + + public static function legacyOnUpdateTriggerMigrations(): array { + $cases = array(); + foreach ( array( false, true ) as $already_reconstructed ) { + foreach ( array( '', 'ALTER TABLE legacy_timestamps ADD COLUMN note TEXT', 'OPTIMIZE TABLE legacy_timestamps', 'REPAIR TABLE legacy_timestamps' ) as $query ) { + $name = ( $already_reconstructed ? 'existing metadata: ' : 'reconstructed metadata: ' ) . $query; + $cases[ $name ] = array( $already_reconstructed, $query ); + } + } + return $cases; + } + + public function testConfigurationMigratesLegacyOnUpdateTriggersWithQuotedNames(): void { + $table = 'Legacy_`"table'; + $column = 'Updated_`"at'; + $connection = $this->engine->get_connection(); + $quoted_table = $connection->quote_identifier( $table ); + $quoted_column = $connection->quote_identifier( $column ); + $this->assertQuery( "CREATE TABLE $quoted_table (id INT, $quoted_column TIMESTAMP NULL)" ); + $this->assertQuery( "INSERT INTO $quoted_table VALUES (1, '2000-01-01 00:00:00')" ); + $this->createOnUpdateTrigger( strtoupper( $table ), strtoupper( $column ), true ); + $this->createOnUpdateTrigger( $table, $column ); + + // Similar names and a matching name on a different table are user triggers. + $this->sqlite->exec( "CREATE TRIGGER `__legacy_user_on_update__suffix` AFTER UPDATE ON $quoted_table BEGIN SELECT 1; END" ); + $this->assertQuery( 'CREATE TABLE other_table (updated_at TIMESTAMP NULL)' ); + $this->sqlite->exec( "CREATE TRIGGER `__other_table_updated_at_on_update__` AFTER UPDATE ON $quoted_table BEGIN SELECT 1; END" ); + + // A temporary trigger with the same name must not hide the main trigger. + $temp_trigger = $connection->quote_identifier( '__' . strtoupper( $table ) . '_' . strtoupper( $column ) . '_on_update__' ); + $this->sqlite->exec( "CREATE TEMP TRIGGER $temp_trigger AFTER UPDATE ON $quoted_table BEGIN SELECT 1; END" ); + $this->reconnectAfterDriverUpgrade(); + $this->assertSame( + array( '__legacy_user_on_update__suffix', '__other_table_updated_at_on_update__' ), + $this->sqlite->query( "SELECT name FROM sqlite_master WHERE type = 'trigger' ORDER BY name" )->fetchAll( PDO::FETCH_COLUMN ) + ); + $this->assertSame( '1', (string) $this->sqlite->query( "SELECT COUNT(*) FROM sqlite_temp_master WHERE type = 'trigger'" )->fetchColumn() ); + $this->assertQuery( "SELECT extra FROM information_schema.columns WHERE table_name = 'other_table'" ); + $this->assertSame( '', $this->last_result[0]->EXTRA ); + + $now = gmdate( 'Y-m-d H:i:s' ); + $this->assertQuery( "UPDATE $quoted_table SET id = 2" ); + $this->assertSame( array( $now ), $this->getOnUpdateTimestamps( "SELECT $quoted_column FROM $quoted_table", $now ) ); + $this->assertQuery( "UPDATE $quoted_table SET $quoted_column = '2001-01-01 00:00:00'" ); + $this->assertSame( array( '2001-01-01 00:00:00' ), $this->getOnUpdateTimestamps( "SELECT $quoted_column FROM $quoted_table", $now ) ); + } + 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)' ); @@ -7026,7 +7702,9 @@ public function testConfigurationRollsBackWhenRecreatingTableFails(): void { // 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"; + // Trigger removal and recovered ON UPDATE metadata must also roll back. + $this->createOnUpdateTrigger( '_tmp_recreated', 'created_at', true ); + $sqlite_schema_query = "SELECT sql FROM sqlite_master WHERE tbl_name IN ('_tmp_recreated', '_tmp_failing') ORDER BY name"; $sqlite_schema = $this->sqlite->query( $sqlite_schema_query )->fetchAll( PDO::FETCH_COLUMN ); $exception = null; @@ -7042,16 +7720,18 @@ public function testConfigurationRollsBackWhenRecreatingTableFails(): void { // 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->assertQuery( "SELECT table_name, column_default, extra 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', + 'EXTRA' => '', ), (object) array( 'TABLE_NAME' => '_tmp_recreated', 'COLUMN_DEFAULT' => '0', + 'EXTRA' => '', ), ), $this->last_result diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php index a09a0dcdb..550c8975c 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php @@ -415,6 +415,21 @@ public function testUpdate(): void { ); } + public function testUpdateWithOnUpdateCurrentTimestamp(): void { + $this->driver->query( 'CREATE TABLE t (c1 INT, c2 INT, ts TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP)' ); + + $this->assertQuery( + 'UPDATE `t` SET (`c1`, `c2`, `ts`) = (SELECT `c1`, `c2`, CASE WHEN `t`.`c1` IS NOT `c1` COLLATE BINARY OR `t`.`c2` IS NOT `c2` COLLATE BINARY THEN CURRENT_TIMESTAMP ELSE `t`.`ts` END FROM (SELECT (1) COLLATE BINARY AS `c1`, (2) COLLATE BINARY AS `c2`))', + 'UPDATE t SET c1 = 1, c2 = 2' + ); + + // UPDATE with a table alias. + $this->assertQuery( + 'UPDATE `t` AS `a` SET (`c1`, `ts`) = (SELECT `c1`, CASE WHEN `a`.`c1` IS NOT `c1` COLLATE BINARY THEN CURRENT_TIMESTAMP ELSE `a`.`ts` END FROM (SELECT (1) COLLATE BINARY AS `c1`))', + 'UPDATE t AS a SET a.c1 = 1' + ); + } + public function testUpdateJoinWithDerivedTablePreservesTargetAlias(): void { $sqlite_version = $this->driver->get_sqlite_version(); if ( version_compare( $sqlite_version, '3.33.0', '<' ) ) {