From 359c4846f652dadee09ee9fc86cc5612a8959ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Thu, 22 Oct 2026 22:27:17 +0200 Subject: [PATCH 1/2] Emulate ON UPDATE CURRENT_TIMESTAMP in UPDATE translation The driver emulated ON UPDATE CURRENT_TIMESTAMP with an unconditional AFTER UPDATE trigger. Explicit values, self-assignments, and unchanged rows all got the current time. Columns with DEFAULT_GENERATED in EXTRA never got a trigger. Now, the SET list translation appends a CASE for each ON UPDATE column that the statement doesn't assign. The column gets the current time only when an assigned value differs from the stored one. This covers UPDATE and ON DUPLICATE KEY UPDATE, and tables no longer get triggers. See: https://github.com/WordPress/sqlite-database-integration/issues/554 --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 131 ++-- .../sqlite/class-wp-sqlite-configurator.php | 2 +- .../tests/WP_MySQL_On_SQLite_Tests.php | 560 ++++++++++++++++-- .../WP_MySQL_On_SQLite_Translation_Tests.php | 15 + 4 files changed, 611 insertions(+), 97 deletions(-) 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..3fc378133 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 @@ -422,7 +422,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..217d5f671 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,48 @@ 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. + */ + private function createOnUpdateTrigger( string $table, string $column ): void { + $this->sqlite->exec( + sprintf( + 'CREATE TRIGGER `_wp_sqlite_%1$s_%2$s_on_update` + AFTER UPDATE ON `%1$s` + FOR EACH ROW + BEGIN + UPDATE `%1$s` SET `%2$s` = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid; + END', + $table, + $column + ) + ); + } + public function testRegexp() { $this->assertQuery( "INSERT INTO _options (option_name, option_value) VALUES ('rss_0123456789abcdef0123456789abcdef', '1');" @@ -1909,47 +1951,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 +2004,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 +2065,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 +2097,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 +2129,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 +7216,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 +7232,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 ); 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', '<' ) ) { From 0a5f1db5527ff874b5031041c100414140fa6b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Thu, 22 Oct 2026 22:27:33 +0200 Subject: [PATCH 2/2] Drop old ON UPDATE CURRENT_TIMESTAMP triggers on upgrade Remove the legacy and AST driver triggers before they can overwrite values set by the UPDATE translation. Recover missing ON UPDATE column metadata before other migrations rebuild tables and discard the legacy triggers. Match exact trigger names and their tables to preserve unrelated triggers. Cover legacy upgrades, table rebuilds, version gating, and rollback. See: https://github.com/WordPress/sqlite-database-integration/issues/554 Fixes: https://github.com/WordPress/sqlite-database-integration/issues/547 --- .../sqlite/class-wp-sqlite-configurator.php | 51 ++++ .../tests/WP_MySQL_On_SQLite_Tests.php | 240 +++++++++++++++++- 2 files changed, 283 insertions(+), 8 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php index 3fc378133..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. * 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 217d5f671..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 @@ -145,18 +145,22 @@ function ( $row ) use ( $now, $after ) { * * @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 ): void { + 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 `_wp_sqlite_%1$s_%2$s_on_update` - AFTER UPDATE ON `%1$s` + 'CREATE TRIGGER %1$s + AFTER UPDATE ON %2$s FOR EACH ROW BEGIN - UPDATE `%1$s` SET `%2$s` = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid; + UPDATE %2$s SET %3$s = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid; END', - $table, - $column + $connection->quote_identifier( $trigger_name ), + $connection->quote_identifier( $table ), + $connection->quote_identifier( $column ) ) ); } @@ -7294,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)' ); @@ -7482,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; @@ -7498,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