From f035d51a86a129dee74df7fe219b2542d2185813 Mon Sep 17 00:00:00 2001 From: Samuel de Rougemont Date: Mon, 12 Oct 2026 19:12:56 +0200 Subject: [PATCH] Fix ROLLBACK as the first transaction of a connection Statements with an empty result set use a TEMP table that is created lazily, the first time such a result is needed. When that happened inside a transaction, for example for START TRANSACTION itself, a rollback dropped the table, while the driver still assumed it existed. ROLLBACK and every later statement with an empty result set then failed with "Table '_wp_sqlite_empty_result' doesn't exist". When the table is missing, create it again and retry, as the information schema builder already does for temporary information schema tables. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 47 ++++++++++++++----- .../WP_MySQL_On_SQLite_PDO_API_Tests.php | 23 +++++++++ 2 files changed, 57 insertions(+), 13 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 37d4a4c78..dc407a141 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 @@ -7747,21 +7747,28 @@ private function create_result_statement_from_data( array $columns, array $rows * lock. Writing to a connection-private TEMP table uses the temp * database, which has no shared lock, so it never contends. */ + $empty_insert = sprintf( + 'INSERT INTO %s ( placeholder_for_empty_insert ) SELECT NULL WHERE FALSE', + $this->quote_sqlite_identifier( self::EMPTY_RESULT_TABLE_NAME ) + ); if ( ! $this->empty_result_table_ready ) { - $pdo->exec( - sprintf( - 'CREATE TEMP TABLE IF NOT EXISTS %s ( placeholder_for_empty_insert )', - $this->quote_sqlite_identifier( self::EMPTY_RESULT_TABLE_NAME ) - ) - ); - $this->empty_result_table_ready = true; + $this->create_empty_result_table(); + } + try { + return $pdo->query( $empty_insert ); + } catch ( PDOException $e ) { + /* + * Even though we keep track of whether the table was already + * created, the tracked information may be incorrect when the + * table was created in a transaction that was later rolled back. + * In that case, let's create the table again, and try again. + */ + if ( ! str_contains( $e->getMessage(), 'no such table' ) ) { + throw $e; + } + $this->create_empty_result_table(); + return $pdo->query( $empty_insert ); } - return $pdo->query( - sprintf( - 'INSERT INTO %s ( placeholder_for_empty_insert ) SELECT NULL WHERE FALSE', - $this->quote_sqlite_identifier( self::EMPTY_RESULT_TABLE_NAME ) - ) - ); } /* @@ -7815,6 +7822,20 @@ private function create_result_statement_from_data( array $columns, array $rows return $pdo->query( $query ); } + /** + * Create the TEMP table backing empty result sets on this connection. + * See create_result_statement_from_data(). + */ + private function create_empty_result_table(): void { + $this->connection->get_pdo()->exec( + sprintf( + 'CREATE TEMP TABLE IF NOT EXISTS %s ( placeholder_for_empty_insert )', + $this->quote_sqlite_identifier( self::EMPTY_RESULT_TABLE_NAME ) + ) + ); + $this->empty_result_table_ready = true; + } + /** * Create a new MySQL-on-SQLite driver exception. * diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php index 65a25f22c..a7574e192 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -888,6 +888,29 @@ public function test_rollback(): void { $this->assertTrue( $result ); } + public function test_rollback_statement_as_first_transaction_on_connection(): void { + // The first empty result set on the connection is created in the transaction. + $this->driver->exec( 'START TRANSACTION' ); + $this->driver->exec( 'ROLLBACK' ); + $this->assertFalse( $this->driver->inTransaction() ); + + // Statements with empty result sets keep working after the rollback. + $this->driver->exec( 'START TRANSACTION' ); + $this->driver->exec( 'CREATE TABLE t (id INT)' ); + $this->driver->exec( 'ROLLBACK' ); + $this->assertSame( 0, $this->driver->exec( 'CREATE TABLE t (id INT)' ) ); + } + + public function test_rollback_method_as_first_transaction_on_connection(): void { + // The first empty result set on the connection is created in the transaction. + $this->driver->beginTransaction(); + $this->driver->exec( 'CREATE TABLE t (id INT)' ); + $this->assertTrue( $this->driver->rollBack() ); + + // Statements with empty result sets keep working after the rollback. + $this->assertSame( 0, $this->driver->exec( 'CREATE TABLE t (id INT)' ) ); + } + public function test_rollback_no_active_transaction(): void { $this->expectException( PDOException::class ); $this->expectExceptionMessage( 'There is no active transaction' );