Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 96 additions & 2 deletions packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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<string> 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<string> $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<string> $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 )
)
);
}

/**
Expand Down
202 changes: 196 additions & 6 deletions packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading