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 c8f20e27a..fe8d33b66 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 @@ -49,6 +49,12 @@ class WP_MySQL_On_SQLite extends PDO { */ const EMPTY_RESULT_TABLE_NAME = self::RESERVED_PREFIX . 'empty_result'; + /** + * Names of the internal SQLite helpers used to count changed rows. + */ + const UPDATE_COUNTER_FUNCTION_NAME = self::RESERVED_PREFIX . 'update_counter'; + const UPDATE_COUNTER_TRIGGER_NAME = self::RESERVED_PREFIX . 'update_counter_trigger'; + /** * The name of the SQLite driver version variable. * @@ -766,7 +772,16 @@ public function __construct( $this->connection->query( 'PRAGMA foreign_keys = ON' ); // Register SQLite functions. - $this->user_defined_functions = WP_SQLite_PDO_User_Defined_Functions::register_for( $this->connection->get_pdo() ); + $pdo = $this->connection->get_pdo(); + $this->user_defined_functions = WP_SQLite_PDO_User_Defined_Functions::register_for( $pdo ); + $increment_affected_rows = function (): void { + ++$this->last_affected_rows; + }; + if ( $pdo instanceof PDO\SQLite ) { + $pdo->createFunction( self::UPDATE_COUNTER_FUNCTION_NAME, $increment_affected_rows, 0 ); + } else { + $pdo->sqliteCreateFunction( self::UPDATE_COUNTER_FUNCTION_NAME, $increment_affected_rows, 0 ); + } // Load MySQL grammar. if ( null === self::$mysql_grammar ) { @@ -2258,7 +2273,86 @@ private function execute_update_statement( WP_Parser_Node $node ): void { ); $query = implode( ' ', array_filter( $parts ) ); - $this->last_result_statement = $this->execute_sqlite_query( $query ); + $update_columns = $this->get_update_column_names( $node ); + $this->execute_counted_update( $query, $update_target_table, $update_columns ); + } + + /** + * Get the names of columns assigned by an UPDATE statement. + * + * @param WP_Parser_Node $node The "updateStatement" AST node. + * @return array The assigned column names. + */ + private function get_update_column_names( WP_Parser_Node $node ): array { + $columns = array(); + foreach ( $node->get_first_child_node( 'updateList' )->get_child_nodes( 'updateElement' ) as $update_element ) { + $column_ref = $update_element->get_first_child_node( 'columnRef' ); + $column_ref_parts = $column_ref->get_descendant_nodes( 'identifier' ); + $columns[] = $this->unquote_sqlite_identifier( $this->translate( end( $column_ref_parts ) ) ); + } + return array_values( array_unique( $columns ) ); + } + + /** + * Execute an UPDATE and count rows whose assigned values changed. + * + * A nested trigger that changes one of the same columns is counted because + * PDO does not expose SQLite's trigger nesting depth. + * + * @param string $query The translated UPDATE query. + * @param string $table_name The target table name. + * @param array $column_names The assigned column names. + */ + private function execute_counted_update( string $query, string $table_name, array $column_names ): void { + $this->last_affected_rows = 0; + $this->create_update_counter_trigger( $table_name, $column_names ); + try { + $this->last_result_statement = $this->execute_sqlite_query( $query ); + } finally { + $this->execute_sqlite_query( + sprintf( 'DROP TRIGGER IF EXISTS %s', $this->quote_sqlite_identifier( self::UPDATE_COUNTER_TRIGGER_NAME ) ) + ); + } + } + + /** + * Create a TEMP trigger that counts rows changed by the current UPDATE. + * + * @param string $table_name The target table name. + * @param array $column_names The assigned column names. + */ + private function create_update_counter_trigger( string $table_name, array $column_names ): void { + $quoted_columns = array_map( array( $this, 'quote_sqlite_identifier' ), $column_names ); + $comparisons = array_map( + function ( string $column ): string { + return sprintf( + 'OLD.%1$s COLLATE BINARY IS NOT NEW.%1$s COLLATE BINARY', + $column + ); + }, + $quoted_columns + ); + $table_schema = $this->information_schema_builder->temporary_table_exists( $table_name ) + ? 'temp' + : 'main'; + + $this->execute_sqlite_query( + sprintf( + 'CREATE TEMP TRIGGER %1$s + AFTER UPDATE OF %2$s ON %3$s.%4$s + FOR EACH ROW + WHEN %5$s + BEGIN + SELECT %6$s(); + END', + $this->quote_sqlite_identifier( self::UPDATE_COUNTER_TRIGGER_NAME ), + implode( ', ', $quoted_columns ), + $this->quote_sqlite_identifier( $table_schema ), + $this->quote_sqlite_identifier( $table_name ), + implode( ' OR ', $comparisons ), + $this->quote_sqlite_identifier( self::UPDATE_COUNTER_FUNCTION_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 e98ba9c44..c04d56cc2 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 @@ -3740,14 +3740,204 @@ public function testUpdateReturnValue() { $return = $this->assertQuery( "UPDATE _dates SET option_value = '2001-05-27 10:08:48'" ); - if ( 1 === $return ) { - $this->markTestIncomplete( - 'SQLite UPDATE query returned 1 when no rows were changed. ' . - 'This is a database compatibility issue – MySQL would return 0 ' . - 'in the same scenario.' + $this->assertSame( 0, $return, 'UPDATE query did not return 0 when no rows were changed' ); + + $this->assertQuery( + 'CREATE TABLE _nullable_updates (id INT PRIMARY KEY, value1 TEXT, value2 TEXT)' + ); + $this->assertQuery( + 'INSERT INTO _nullable_updates (id, value1, value2) VALUES (1, NULL, NULL)' + ); + + $return = $this->assertQuery( + "UPDATE _nullable_updates SET value1 = NULL, value2 = 'set' WHERE id = 1" + ); + $this->assertSame( 1, $return, 'UPDATE query did not return 1 when a nullable value was changed' ); + + $return = $this->assertQuery( + "UPDATE _nullable_updates SET value1 = NULL, value2 = 'set' WHERE id = 1" + ); + $this->assertSame( 0, $return, 'UPDATE query did not return 0 when nullable values were unchanged' ); + } + + public function testUpdateReturnValueWithDisjunction() { + $this->assertQuery( + 'CREATE TABLE _disjunctive_updates (id INT PRIMARY KEY, value INT)' + ); + $this->assertQuery( + 'INSERT INTO _disjunctive_updates (id, value) VALUES (1, 1), (2, 1)' + ); + + $return = $this->assertQuery( + 'UPDATE _disjunctive_updates SET value = 1 WHERE id = 1 OR id = 2' + ); + $this->assertSame( 0, $return, 'UPDATE query did not return 0 when disjunctive matches were unchanged' ); + + $return = $this->assertQuery( + 'UPDATE _disjunctive_updates SET value = 2 WHERE id = 1 OR id = 2' + ); + $this->assertSame( 2, $return, 'UPDATE query did not return 2 when both disjunctive matches changed' ); + } + + public function testUpdateReturnValueWithCommonTableExpression() { + $this->assertQuery( + 'CREATE TABLE _cte_updates (id INT PRIMARY KEY, value INT)' + ); + $this->assertQuery( + 'INSERT INTO _cte_updates (id, value) VALUES (1, 1)' + ); + + $query = ' + WITH source AS (SELECT 1 AS id, 2 AS value) + UPDATE _cte_updates + SET value = (SELECT value FROM source) + WHERE id = (SELECT id FROM source) + '; + $return = $this->assertQuery( $query ); + $this->assertSame( 1, $return, 'CTE UPDATE query did not return 1 when one row was changed' ); + + $return = $this->assertQuery( $query ); + $this->assertSame( 0, $return, 'CTE UPDATE query did not return 0 when no rows were changed' ); + } + + public function testUpdateReturnValueWithJoinedTable() { + $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->assertSame( 0, $return, 'UPDATE query did not return 0 when no rows were changed' ); + + $this->assertQuery( + 'CREATE TABLE _joined_update_targets (id INT PRIMARY KEY, value INT)' + ); + $this->assertQuery( + 'CREATE TABLE _joined_update_values (id INT, value INT)' + ); + $this->assertQuery( + 'INSERT INTO _joined_update_targets (id, value) VALUES (1, 10), (2, 20), (3, 30)' + ); + $this->assertQuery( + 'INSERT INTO _joined_update_values (id, value) VALUES (1, 10), (2, 25), (2, 25)' + ); + + $query = ' + UPDATE _joined_update_targets AS targets + JOIN _joined_update_values AS source ON targets.id = source.id + SET targets.value = source.value + '; + $return = $this->assertQuery( $query ); + $this->assertSame( 1, $return, 'Joined UPDATE query did not return 1 when one row was changed' ); + + $return = $this->assertQuery( $query ); + $this->assertSame( 0, $return, 'Joined UPDATE query did not return 0 when no rows were changed' ); + + $this->assertQuery( 'DELETE FROM _joined_update_values' ); + $this->assertQuery( + 'INSERT INTO _joined_update_values (id, value) VALUES (1, 20), (1, 10)' + ); + $return = $this->assertQuery( $query ); + $result = $this->assertQuery( 'SELECT value FROM _joined_update_targets WHERE id = 1' ); + $this->assertSame( + '10' === $result[0]->value ? 0 : 1, + $return, + 'Joined UPDATE query did not report whether the selected source value changed the target' + ); + + $this->assertQuery( + 'CREATE TABLE _shadowed_rowid_targets (rowid INT, id INT, value INT)' + ); + $this->assertQuery( + 'CREATE TABLE _shadowed_rowid_values (id INT, value INT)' + ); + $this->assertQuery( + 'INSERT INTO _shadowed_rowid_targets (rowid, id, value) VALUES (7, 1, 10), (7, 2, 20)' + ); + $this->assertQuery( + 'INSERT INTO _shadowed_rowid_values (id, value) VALUES (1, 11), (2, 21)' + ); + $return = $this->assertQuery( + ' + UPDATE _shadowed_rowid_targets AS targets + JOIN _shadowed_rowid_values AS source ON targets.id = source.id + SET targets.value = source.value + ' + ); + $this->assertSame( 2, $return, 'Joined UPDATE query did not count both target rows' ); + } + + public function testUpdateReturnValueWithIgnore() { + $this->assertQuery( + 'CREATE TABLE _ignored_updates (id INT PRIMARY KEY, value INT UNIQUE)' + ); + $this->assertQuery( + 'INSERT INTO _ignored_updates (id, value) VALUES (1, 1), (2, 2)' + ); + + $return = $this->assertQuery( + 'UPDATE IGNORE _ignored_updates SET value = 2 WHERE id = 1' + ); + $this->assertSame( 0, $return, 'UPDATE IGNORE query counted an ignored row' ); + + $return = $this->assertQuery( + 'UPDATE IGNORE _ignored_updates SET value = 3 WHERE id = 1' + ); + $this->assertSame( 1, $return, 'UPDATE IGNORE query did not count a changed row' ); + } + + public function testUpdateReturnValueForTemporaryTable() { + $this->assertQuery( + 'CREATE TEMPORARY TABLE _temporary_updates (id INT PRIMARY KEY, value INT)' + ); + $this->assertQuery( + 'INSERT INTO _temporary_updates (id, value) VALUES (1, 1)' + ); + + $return = $this->assertQuery( 'UPDATE _temporary_updates SET value = 2 WHERE id = 1' ); + $this->assertSame( 1, $return, 'Temporary table UPDATE did not count a changed row' ); + + $return = $this->assertQuery( 'UPDATE _temporary_updates SET value = 2 WHERE id = 1' ); + $this->assertSame( 0, $return, 'Temporary table UPDATE counted an unchanged row' ); + } + + public function testUpdateCounterTriggerIsRemovedAfterFailure() { + $this->assertQuery( + 'CREATE TABLE _failed_updates (id INT PRIMARY KEY, value INT UNIQUE)' + ); + $this->assertQuery( + 'INSERT INTO _failed_updates (id, value) VALUES (1, 1), (2, 2)' + ); + + $this->assertQueryError( + 'UPDATE _failed_updates SET value = 2 WHERE id = 1', + 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: _failed_updates.value' + ); + + $return = $this->assertQuery( 'UPDATE _failed_updates SET value = 3 WHERE id = 1' ); + $this->assertSame( 1, $return, 'UPDATE after a failure did not count a changed row' ); + } + + public function testUpdateReturnValueUsesBinaryComparison() { + $this->assertQuery( + 'CREATE TABLE _text_updates (id INT PRIMARY KEY, value VARCHAR(20))' + ); + $this->assertQuery( + "INSERT INTO _text_updates (id, value) VALUES (1, 'lowercase')" + ); + + $return = $this->assertQuery( + "UPDATE _text_updates SET value = 'LOWERCASE' WHERE id = 1" + ); + $this->assertSame( 1, $return, 'UPDATE query did not return 1 when only letter case changed' ); + + $result = $this->assertQuery( 'SELECT value FROM _text_updates WHERE id = 1' ); + $this->assertSame( 'LOWERCASE', $result[0]->value ); + + $return = $this->assertQuery( + "UPDATE _text_updates SET value = 'LOWERCASE' WHERE id = 1" + ); + $this->assertSame( 0, $return, 'UPDATE query did not return 0 when text was unchanged' ); } public function testOrderByField() { 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 894715ad5..de5c23a80 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 @@ -2108,6 +2108,16 @@ function ( $query ) { ) ); + // Remove internal UPDATE affected-row counting queries. + $executed_queries = array_values( + array_filter( + $executed_queries, + function ( $query ) { + return ! str_contains( $query, '`_wp_sqlite_update_counter' ); + } + ) + ); + // Remove "select changes()" executed after some queries. if ( count( $executed_queries ) > 1