From 4ae6d34a5729a74795b6bed9922771e5ade50fe2 Mon Sep 17 00:00:00 2001 From: Samuel de Rougemont Date: Mon, 12 Oct 2026 20:13:57 +0200 Subject: [PATCH 1/5] Keep the user transaction active after a failed statement When a statement failed, the driver rolled back the whole user transaction, including the work done before the failed statement, and ended it. MySQL undoes only the failed statement and keeps the transaction active, so the caller can decide whether to commit or roll back the rest. A later COMMIT kept only the work done after the error, and a savepoint created before the error could no longer be used. Roll back only the wrapper transaction of the failed statement. Inside a user transaction, the wrapper is a savepoint, so the transaction stays active. This also keeps an active LOCK TABLES transaction, and replaces the special case for a missing savepoint, since savepoint statements are not wrapped. Part of https://github.com/WordPress/sqlite-database-integration/issues/473. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 59 +++++++++++++-- .../WP_MySQL_On_SQLite_PDO_API_Tests.php | 16 ++++ .../tests/WP_MySQL_On_SQLite_Tests.php | 73 +++++++++++++++++++ 3 files changed, 140 insertions(+), 8 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 57d72f70c..4adb23126 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 @@ -1210,16 +1210,18 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar $this->error_info = array( '00000', null, null ); return $stmt; } catch ( Throwable $e ) { - // MySQL error 1305 reports a missing savepoint without ending the transaction. - $preserves_transaction = $e instanceof WP_MySQL_On_SQLite_Exception - && 1305 === ( $e->errorInfo[1] ?? null ); - if ( ! $preserves_transaction ) { - try { - $this->rollback_user_transaction(); + /* + * Undo the failed statement. Like MySQL, keep an active user + * transaction open, so the caller can decide whether to commit + * or roll back the rest of it. + */ + try { + $this->rollback_wrapper_transaction(); + if ( ! $this->inTransaction() ) { $this->table_lock_active = false; - } catch ( Throwable $rollback_exception ) { - // Ignore rollback errors. } + } catch ( Throwable $rollback_exception ) { + // Ignore rollback errors. } if ( $e instanceof WP_SQLite_Information_Schema_Exception ) { $e = $this->convert_information_schema_exception( $e ); @@ -2105,6 +2107,47 @@ private function commit_wrapper_transaction(): void { $this->in_transaction = $in_transaction; } + /** + * Roll back a wrapper transaction. + * + * This undoes all changes of a failed statement. When the wrapper is + * a savepoint, the user transaction around it stays active. + */ + private function rollback_wrapper_transaction(): void { + if ( null === $this->wrapper_transaction_type ) { + return; + } + + try { + if ( 'savepoint' === $this->wrapper_transaction_type ) { + $savepoint_name = $this->get_internal_savepoint_name( 'wrapper' ); + $this->connection->query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) ); + $this->connection->query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); + } else { + $this->connection->query( 'ROLLBACK' ); + $this->in_transaction = false; + $this->savepoint_names = array(); + } + } catch ( PDOException $e ) { + /* + * Some SQLite errors roll back the whole transaction automatically, + * which also removes the savepoint. In that case, or if the rollback + * fails for any other reason, end the transaction. + * + * @see https://www.sqlite.org/lang_transaction.html#response_to_errors_within_a_transaction + */ + try { + $this->connection->query( 'ROLLBACK' ); + } catch ( PDOException $rollback_exception ) { + // The transaction was already rolled back. + } + $this->in_transaction = false; + $this->savepoint_names = array(); + } finally { + $this->wrapper_transaction_type = null; + } + } + /** * Execute the "BEGIN" or "START TRANSACTION" MySQL statement in SQLite. */ 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 ceef0137c..02ac69c49 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 @@ -1003,6 +1003,22 @@ public function test_rollback_method_as_first_transaction_on_connection(): void $this->assertSame( 0, $this->driver->exec( 'CREATE TABLE t (id INT)' ) ); } + public function test_failed_query_keeps_transaction_active(): void { + $this->driver->exec( 'CREATE TABLE t (id INT PRIMARY KEY)' ); + $this->driver->beginTransaction(); + $this->driver->exec( 'INSERT INTO t VALUES (1)' ); + try { + $this->driver->exec( 'INSERT INTO t VALUES (1)' ); + $this->fail( 'An exception was expected, but none was thrown.' ); + } catch ( PDOException $e ) { + $this->assertSame( '23000', $e->getCode() ); + } + + $this->assertTrue( $this->driver->inTransaction() ); + $this->assertTrue( $this->driver->commit() ); + $this->assertSame( array( '1' ), $this->driver->query( 'SELECT id FROM t' )->fetchAll( PDO::FETCH_COLUMN ) ); + } + public function test_rollback_no_active_transaction(): void { $this->expectException( PDOException::class ); $this->expectExceptionMessage( 'There is no active transaction' ); 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 d404a0b44..0108d86bd 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 @@ -8335,6 +8335,79 @@ public function testTransactionSavepoints(): void { $this->assertSame( array(), (array) array_column( $result, 'id' ) ); } + public function testFailedStatementKeepsTransactionActive(): void { + $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY)' ); + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'INSERT INTO t VALUES (1)' ); + + // A failed write and a failed read keep the transaction active. + $this->assertQueryError( + 'INSERT INTO t VALUES (1)', + 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: t.id' + ); + $this->assertTrue( $this->engine->inTransaction() ); + $this->assertQueryError( + 'SELECT * FROM missing_table', + "SQLSTATE[42S02]: Base table or view not found: 1146 Table 'missing_table' doesn't exist" + ); + $this->assertTrue( $this->engine->inTransaction() ); + + $this->assertQuery( 'INSERT INTO t VALUES (2)' ); + $this->assertQuery( 'COMMIT' ); + $result = $this->assertQuery( 'SELECT id FROM t ORDER BY id' ); + $this->assertSame( array( '1', '2' ), array_column( $result, 'id' ) ); + } + + public function testFailedStatementIsRolledBackInTransaction(): void { + $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY)' ); + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'INSERT INTO t VALUES (1)' ); + + // The first row of the failed statement is not kept. + $this->assertQueryError( + 'INSERT INTO t VALUES (2), (1)', + 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: t.id' + ); + + $this->assertQuery( 'COMMIT' ); + $result = $this->assertQuery( 'SELECT id FROM t ORDER BY id' ); + $this->assertSame( array( '1' ), array_column( $result, 'id' ) ); + } + + public function testRollbackToSavepointAfterFailedStatement(): void { + $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY)' ); + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'INSERT INTO t VALUES (1)' ); + $this->assertQuery( 'SAVEPOINT sp1' ); + $this->assertQuery( 'INSERT INTO t VALUES (2)' ); + $this->assertQueryError( + 'INSERT INTO t VALUES (1)', + 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: t.id' + ); + + // The savepoint still exists after the failed statement. + $this->assertQuery( 'ROLLBACK TO SAVEPOINT sp1' ); + $this->assertQuery( 'COMMIT' ); + $result = $this->assertQuery( 'SELECT id FROM t ORDER BY id' ); + $this->assertSame( array( '1' ), array_column( $result, 'id' ) ); + } + + public function testFailedStatementKeepsTableLock(): void { + $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY)' ); + $this->assertQuery( 'LOCK TABLES t WRITE' ); + $this->assertQuery( 'INSERT INTO t VALUES (1)' ); + $this->assertQueryError( + 'INSERT INTO t VALUES (1)', + 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: t.id' + ); + + // UNLOCK TABLES keeps the rows written before the failed statement. + $this->assertQuery( 'UNLOCK TABLES' ); + $this->assertFalse( $this->engine->inTransaction() ); + $result = $this->assertQuery( 'SELECT id FROM t' ); + $this->assertSame( array( '1' ), array_column( $result, 'id' ) ); + } + public function testSavepointWithoutTransactionDoesNotStartTransaction(): void { $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY, v INT)' ); $this->assertQuery( 'INSERT INTO t (id, v) VALUES (1, 1)' ); From c3b72dd3afaf06195bab091fcdb9761350bf8d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 27 Oct 2026 16:09:24 +0100 Subject: [PATCH 2/5] Test the statement rollback with a multi-table DELETE SQLite undoes a failed INSERT by itself, so the multi-row INSERT test passed even without the savepoint rollback. A multi-table DELETE runs a DELETE query for each table. When deleting from the second table fails, only the savepoint rollback restores the rows deleted from the first one. ALTER TABLE also runs multiple queries, but MySQL commits the active transaction before DDL statements, so the transaction would not stay active after a failed ALTER TABLE. --- .../tests/WP_MySQL_On_SQLite_Tests.php | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) 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 0108d86bd..7aa6d4712 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 @@ -8359,18 +8359,30 @@ public function testFailedStatementKeepsTransactionActive(): void { } public function testFailedStatementIsRolledBackInTransaction(): void { - $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY)' ); + $this->assertQuery( 'CREATE TABLE t1 (id INT PRIMARY KEY)' ); + $this->assertQuery( 'CREATE TABLE t2 (id INT PRIMARY KEY)' ); + $this->assertQuery( 'CREATE TABLE t3 (id INT PRIMARY KEY, t2_id INT, FOREIGN KEY (t2_id) REFERENCES t2 (id))' ); + $this->assertQuery( 'INSERT INTO t1 VALUES (1)' ); + $this->assertQuery( 'INSERT INTO t2 VALUES (1)' ); + $this->assertQuery( 'INSERT INTO t3 VALUES (1, 1)' ); $this->assertQuery( 'BEGIN' ); - $this->assertQuery( 'INSERT INTO t VALUES (1)' ); + $this->assertQuery( 'INSERT INTO t1 VALUES (2)' ); - // The first row of the failed statement is not kept. + /* + * A multi-table DELETE runs a DELETE query for each table. Deleting + * from t1 succeeds, and then deleting from t2 fails on the foreign key. + */ $this->assertQueryError( - 'INSERT INTO t VALUES (2), (1)', - 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: t.id' + 'DELETE t1, t2 FROM t1 JOIN t2 ON t2.id = t1.id', + 'SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed' ); - + $this->assertTrue( $this->engine->inTransaction() ); $this->assertQuery( 'COMMIT' ); - $result = $this->assertQuery( 'SELECT id FROM t ORDER BY id' ); + + // The changes of the failed statement are undone, and the rest is kept. + $result = $this->assertQuery( 'SELECT id FROM t1 ORDER BY id' ); + $this->assertSame( array( '1', '2' ), array_column( $result, 'id' ) ); + $result = $this->assertQuery( 'SELECT id FROM t2' ); $this->assertSame( array( '1' ), array_column( $result, 'id' ) ); } From c6f9ebc547c93f8455cf46048be92ff87e9c61b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 27 Oct 2026 16:10:04 +0100 Subject: [PATCH 3/5] Test SQLite rolling back the whole transaction Some SQLite errors, such as SQLITE_FULL, roll back the whole transaction, including the wrapper savepoint. A RAISE(ROLLBACK) trigger makes this path testable without such errors. Check that the transaction, its savepoints, and its table lock end, and that the connection stays usable. --- .../tests/WP_MySQL_On_SQLite_Tests.php | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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 7aa6d4712..cdc507fad 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 @@ -8420,6 +8420,61 @@ public function testFailedStatementKeepsTableLock(): void { $this->assertSame( array( '1' ), array_column( $result, 'id' ) ); } + public function testFailedStatementRolledBackBySqliteEndsTransaction(): void { + $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY)' ); + + // Some SQLite errors roll back the whole transaction. Emulate them with a trigger. + $this->sqlite->exec( + "CREATE TRIGGER t_rollback BEFORE INSERT ON t WHEN NEW.id = 2 BEGIN SELECT RAISE(ROLLBACK, 'Rolled back'); END" + ); + + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'INSERT INTO t VALUES (1)' ); + $this->assertQuery( 'SAVEPOINT sp1' ); + $this->assertQueryError( + 'INSERT INTO t VALUES (2)', + 'SQLSTATE[23000]: Integrity constraint violation: 19 Rolled back' + ); + + // The transaction and its savepoints are gone. + $this->assertFalse( $this->engine->inTransaction() ); + $this->assertQueryError( + 'ROLLBACK TO SAVEPOINT sp1', + 'SQLSTATE[42000]: Syntax error or access violation: 1305 SAVEPOINT sp1 does not exist' + ); + + // Without a transaction, the same error leaves the connection usable. + $this->assertQueryError( + 'INSERT INTO t VALUES (2)', + 'SQLSTATE[23000]: Integrity constraint violation: 19 Rolled back' + ); + $this->assertFalse( $this->engine->inTransaction() ); + $this->assertQuery( 'INSERT INTO t VALUES (3)' ); + $result = $this->assertQuery( 'SELECT id FROM t' ); + $this->assertSame( array( '3' ), array_column( $result, 'id' ) ); + } + + public function testFailedStatementRolledBackBySqliteReleasesTableLock(): void { + $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY)' ); + + // Some SQLite errors roll back the whole transaction. Emulate them with a trigger. + $this->sqlite->exec( + "CREATE TRIGGER t_rollback BEFORE INSERT ON t WHEN NEW.id = 2 BEGIN SELECT RAISE(ROLLBACK, 'Rolled back'); END" + ); + + $this->assertQuery( 'LOCK TABLES t WRITE' ); + $this->assertQueryError( + 'INSERT INTO t VALUES (2)', + 'SQLSTATE[23000]: Integrity constraint violation: 19 Rolled back' + ); + $this->assertFalse( $this->engine->inTransaction() ); + + // The table lock is released, so UNLOCK TABLES doesn't commit a new transaction. + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'UNLOCK TABLES' ); + $this->assertTrue( $this->engine->inTransaction() ); + } + public function testSavepointWithoutTransactionDoesNotStartTransaction(): void { $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY, v INT)' ); $this->assertQuery( 'INSERT INTO t (id, v) VALUES (1, 1)' ); From a802253614d8543fba3380045c6aed3c5341af7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 27 Oct 2026 16:11:32 +0100 Subject: [PATCH 4/5] Simplify the rollback of a failed statement Reuse rollback_user_transaction() to end the transaction, both for a transaction wrapper and when SQLite has already rolled back the whole transaction. It now always resets the transaction state, as ROLLBACK always ends the transaction, even when it fails. End the user transaction only when ROLLBACK TO fails. When only RELEASE fails, the statement is already undone, and the leftover savepoint is harmless. Release the table lock outside of the try block, as the rollback can now throw on PHP < 8.4. Don't reset the wrapper transaction type, as flush() already does that before every query. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 65 ++++++++----------- 1 file changed, 27 insertions(+), 38 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 4adb23126..00e3e4e62 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 @@ -1217,12 +1217,12 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar */ try { $this->rollback_wrapper_transaction(); - if ( ! $this->inTransaction() ) { - $this->table_lock_active = false; - } } catch ( Throwable $rollback_exception ) { // Ignore rollback errors. } + if ( ! $this->inTransaction() ) { + $this->table_lock_active = false; + } if ( $e instanceof WP_SQLite_Information_Schema_Exception ) { $e = $this->convert_information_schema_exception( $e ); } @@ -2114,37 +2114,23 @@ private function commit_wrapper_transaction(): void { * a savepoint, the user transaction around it stays active. */ private function rollback_wrapper_transaction(): void { - if ( null === $this->wrapper_transaction_type ) { - return; - } - - try { - if ( 'savepoint' === $this->wrapper_transaction_type ) { - $savepoint_name = $this->get_internal_savepoint_name( 'wrapper' ); - $this->connection->query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) ); - $this->connection->query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); - } else { - $this->connection->query( 'ROLLBACK' ); - $this->in_transaction = false; - $this->savepoint_names = array(); - } - } catch ( PDOException $e ) { - /* - * Some SQLite errors roll back the whole transaction automatically, - * which also removes the savepoint. In that case, or if the rollback - * fails for any other reason, end the transaction. - * - * @see https://www.sqlite.org/lang_transaction.html#response_to_errors_within_a_transaction - */ + if ( 'transaction' === $this->wrapper_transaction_type ) { + $this->rollback_user_transaction(); + } elseif ( 'savepoint' === $this->wrapper_transaction_type ) { + $savepoint_name = $this->get_internal_savepoint_name( 'wrapper' ); try { - $this->connection->query( 'ROLLBACK' ); - } catch ( PDOException $rollback_exception ) { - // The transaction was already rolled back. + $this->connection->query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) ); + } catch ( PDOException $e ) { + /* + * Some SQLite errors roll back the whole transaction automatically, + * which also removes the savepoint. In that case, end the transaction. + * + * @see https://www.sqlite.org/lang_transaction.html#response_to_errors_within_a_transaction + */ + $this->rollback_user_transaction(); + return; } - $this->in_transaction = false; - $this->savepoint_names = array(); - } finally { - $this->wrapper_transaction_type = null; + $this->connection->query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); } } @@ -2189,13 +2175,16 @@ private function commit_user_transaction(): void { * Execute the "ROLLBACK" MySQL statement in SQLite. */ private function rollback_user_transaction(): void { - // MySQL doesn't throw an error if there is no active transaction. - if ( ! $this->inTransaction() ) { - return; + try { + // MySQL doesn't throw an error if there is no active transaction. + if ( $this->inTransaction() ) { + $this->connection->query( 'ROLLBACK' ); + } + } finally { + // A ROLLBACK always ends the transaction, even when it fails. + $this->in_transaction = false; + $this->savepoint_names = array(); } - $this->connection->query( 'ROLLBACK' ); - $this->in_transaction = false; - $this->savepoint_names = array(); } /** From 771c5628dd900ab78b3a64ce069ca706afc3347e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 27 Oct 2026 16:11:55 +0100 Subject: [PATCH 5/5] Roll back a transaction when a COMMIT statement fails Failed statements no longer roll back the user transaction, and COMMIT runs without a wrapper transaction. So when COMMIT failed, nothing was rolled back and the transaction stayed open. It kept the write lock, and later statements silently joined the uncommitted transaction. Like MySQL, roll back a transaction that fails to commit. This covers COMMIT, UNLOCK TABLES, and the implicit commits of START TRANSACTION, BEGIN, and LOCK TABLES. PDO::commit() still keeps the transaction active on failure. PDO MySQL still reports an active transaction after a failed commit, so callers can call PDO::rollBack(). The tests make COMMIT fail with a deferred foreign key check. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 19 +++++++++- .../WP_MySQL_On_SQLite_PDO_API_Tests.php | 21 +++++++++++ .../tests/WP_MySQL_On_SQLite_Tests.php | 37 +++++++++++++++++++ 3 files changed, 75 insertions(+), 2 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 00e3e4e62..5ae0d26b5 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 @@ -1398,7 +1398,12 @@ public function commit(): bool { throw $this->new_driver_exception( 'There is no active transaction' ); } $this->flush(); - $this->commit_user_transaction(); + + // Unlike a COMMIT statement, PDO::commit() doesn't roll back on failure, + // so the caller can still call PDO::rollBack(), as with PDO MySQL. + $this->connection->query( 'COMMIT' ); + $this->in_transaction = false; + $this->savepoint_names = array(); return true; } @@ -2166,7 +2171,17 @@ private function commit_user_transaction(): void { if ( ! $this->inTransaction() ) { return; } - $this->connection->query( 'COMMIT' ); + try { + $this->connection->query( 'COMMIT' ); + } catch ( PDOException $e ) { + // Like MySQL, roll back a transaction that fails to commit. + try { + $this->rollback_user_transaction(); + } catch ( PDOException $rollback_exception ) { + // SQLite has already rolled back the transaction. + } + throw $e; + } $this->in_transaction = false; $this->savepoint_names = array(); } 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 02ac69c49..4454b4754 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 @@ -1019,6 +1019,27 @@ public function test_failed_query_keeps_transaction_active(): void { $this->assertSame( array( '1' ), $this->driver->query( 'SELECT id FROM t' )->fetchAll( PDO::FETCH_COLUMN ) ); } + public function test_failed_commit_keeps_transaction_active(): void { + $this->driver->exec( 'CREATE TABLE p (id INT PRIMARY KEY)' ); + $this->driver->exec( 'CREATE TABLE c (id INT PRIMARY KEY, p_id INT, FOREIGN KEY (p_id) REFERENCES p (id))' ); + $this->driver->beginTransaction(); + + // Make the commit fail by deferring a foreign key check to it. + $this->driver->get_sqlite_pdo()->exec( 'PRAGMA defer_foreign_keys = ON' ); + $this->driver->exec( 'INSERT INTO c VALUES (1, 1)' ); + try { + $this->driver->commit(); + $this->fail( 'An exception was expected, but none was thrown.' ); + } catch ( PDOException $e ) { + $this->assertStringContainsString( 'FOREIGN KEY constraint failed', $e->getMessage() ); + } + + // Like PDO MySQL, the transaction is still active, so it can be rolled back. + $this->assertTrue( $this->driver->inTransaction() ); + $this->assertTrue( $this->driver->rollBack() ); + $this->assertSame( array(), $this->driver->query( 'SELECT id FROM c' )->fetchAll( PDO::FETCH_COLUMN ) ); + } + public function test_rollback_no_active_transaction(): void { $this->expectException( PDOException::class ); $this->expectExceptionMessage( 'There is no active transaction' ); 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 cdc507fad..f29956d43 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 @@ -8420,6 +8420,43 @@ public function testFailedStatementKeepsTableLock(): void { $this->assertSame( array( '1' ), array_column( $result, 'id' ) ); } + /** + * @dataProvider dataFailedCommits + */ + public function testFailedCommitRollsBackTransaction( string $begin_query, string $commit_query ): void { + $this->assertQuery( 'CREATE TABLE p (id INT PRIMARY KEY)' ); + $this->assertQuery( 'CREATE TABLE c (id INT PRIMARY KEY, p_id INT, FOREIGN KEY (p_id) REFERENCES p (id))' ); + $this->assertQuery( $begin_query ); + + // Make the commit fail by deferring a foreign key check to it. + $this->sqlite->exec( 'PRAGMA defer_foreign_keys = ON' ); + $this->assertQuery( 'INSERT INTO c VALUES (1, 1)' ); + $this->assertQueryError( + $commit_query, + 'SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed' + ); + + // Like MySQL, the failed commit rolls back the transaction. + $this->assertFalse( $this->engine->inTransaction() ); + $result = $this->assertQuery( 'SELECT id FROM c' ); + $this->assertSame( array(), array_column( $result, 'id' ) ); + + // The table lock is released, so UNLOCK TABLES doesn't commit a new transaction. + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'UNLOCK TABLES' ); + $this->assertTrue( $this->engine->inTransaction() ); + } + + public static function dataFailedCommits(): array { + return array( + 'COMMIT' => array( 'BEGIN', 'COMMIT' ), + 'UNLOCK TABLES' => array( 'LOCK TABLES c WRITE', 'UNLOCK TABLES' ), + 'implicit START TRANSACTION' => array( 'BEGIN', 'START TRANSACTION' ), + 'implicit BEGIN' => array( 'BEGIN', 'BEGIN' ), + 'implicit LOCK TABLES' => array( 'BEGIN', 'LOCK TABLES p WRITE' ), + ); + } + public function testFailedStatementRolledBackBySqliteEndsTransaction(): void { $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY)' );