From 852f068e11f5bd4a3cb32a818476da56f277ea9a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 23 Aug 2026 21:50:18 +0000 Subject: [PATCH 1/4] fix: track SQL savepoint transactions --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 62 +++++++++++++- .../WP_MySQL_On_SQLite_PDO_API_Tests.php | 84 +++++++++++++++++++ ...te_Database_Integration_Savepoint_Test.php | 37 ++++++++ 3 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php 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 d92b7e9dc..08119f7e6 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 @@ -711,6 +711,20 @@ class WP_MySQL_On_SQLite extends PDO { */ private $in_transaction = false; + /** + * User savepoints active in the current transaction, from outermost to innermost. + * + * @var string[] + */ + private $transaction_savepoints = array(); + + /** + * Whether the current transaction was opened by the outermost user savepoint. + * + * @var bool + */ + private $transaction_started_by_savepoint = false; + /** * Whether a MySQL table lock is active. * @@ -2092,7 +2106,9 @@ private function begin_user_transaction(): void { * @see self::begin_wrapper_transaction() */ $this->connection->query( 'BEGIN IMMEDIATE' ); - $this->in_transaction = true; + $this->in_transaction = true; + $this->transaction_savepoints = array(); + $this->transaction_started_by_savepoint = false; } /** @@ -2104,7 +2120,9 @@ private function commit_user_transaction(): void { return; } $this->connection->query( 'COMMIT' ); - $this->in_transaction = false; + $this->in_transaction = false; + $this->transaction_savepoints = array(); + $this->transaction_started_by_savepoint = false; } /** @@ -2116,7 +2134,9 @@ private function rollback_user_transaction(): void { return; } $this->connection->query( 'ROLLBACK' ); - $this->in_transaction = false; + $this->in_transaction = false; + $this->transaction_savepoints = array(); + $this->transaction_started_by_savepoint = false; } /** @@ -2146,6 +2166,9 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node break; case 'savepointStatement': $savepoint_name = $this->translate( $subnode->get_first_child_node( 'identifier' ) ); + $savepoint_key = null === $savepoint_name + ? null + : strtolower( $this->unquote_sqlite_identifier( $savepoint_name ) ); // ROLLBACK/ROLLBACK TO SAVEPOINT . if ( WP_MySQL_Lexer::ROLLBACK_SYMBOL === $token->id ) { @@ -2153,19 +2176,37 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node $this->rollback_user_transaction(); } else { $this->execute_sqlite_query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) ); + $savepoint_index = $this->find_transaction_savepoint_index( $savepoint_key ); + if ( null !== $savepoint_index ) { + $this->transaction_savepoints = array_slice( $this->transaction_savepoints, 0, $savepoint_index + 1 ); + } } return; } // SAVEPOINT. if ( WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $token->id ) { + $starts_transaction = ! $this->inTransaction(); $this->execute_sqlite_query( sprintf( 'SAVEPOINT %s', $savepoint_name ) ); + if ( $starts_transaction ) { + $this->transaction_started_by_savepoint = true; + } + $this->transaction_savepoints[] = $savepoint_key; + $this->in_transaction = true; return; } // RELEASE SAVEPOINT. if ( WP_MySQL_Lexer::RELEASE_SYMBOL === $token->id ) { $this->execute_sqlite_query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); + $savepoint_index = $this->find_transaction_savepoint_index( $savepoint_key ); + if ( null !== $savepoint_index ) { + $this->transaction_savepoints = array_slice( $this->transaction_savepoints, 0, $savepoint_index ); + } + if ( $this->transaction_started_by_savepoint && empty( $this->transaction_savepoints ) ) { + $this->in_transaction = false; + $this->transaction_started_by_savepoint = false; + } return; } @@ -2236,6 +2277,21 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node ); } + /** + * Find the innermost active user savepoint with the given name. + * + * @param string $savepoint_name Normalized savepoint name. + * @return int|null Savepoint index, or null when not tracked. + */ + private function find_transaction_savepoint_index( string $savepoint_name ): ?int { + for ( $index = count( $this->transaction_savepoints ) - 1; $index >= 0; $index-- ) { + if ( $savepoint_name === $this->transaction_savepoints[ $index ] ) { + return $index; + } + } + return null; + } + /** * Translate and execute a MySQL SELECT 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 6249c067d..46ff8e90d 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 @@ -923,6 +923,90 @@ public function test_transaction_methods_flush_operation_state(): void { $this->assertSame( array( 'ROLLBACK' ), array_column( $this->driver->get_last_sqlite_queries(), 'sql' ) ); } + public function test_standalone_write_uses_wrapper_transaction(): void { + $this->driver->query( 'CREATE TABLE t (id INT PRIMARY KEY, value INT)' ); + $this->driver->query( 'INSERT INTO t VALUES (1, 1)' ); + + $statement = $this->driver->query( 'UPDATE t SET value = 2 WHERE id = 1' ); + $queries = array_column( $this->driver->get_last_sqlite_queries(), 'sql' ); + + $this->assertSame( 1, $statement->rowCount() ); + $this->assertSame( 'BEGIN IMMEDIATE', $queries[0] ); + $this->assertSame( 'COMMIT', end( $queries ) ); + } + + public function test_write_inside_savepoint_commits_on_release(): void { + $this->driver->query( 'CREATE TABLE t (id INT PRIMARY KEY, value INT)' ); + $this->driver->query( 'INSERT INTO t VALUES (1, 1)' ); + + $this->driver->query( 'SAVEPOINT outer_transaction' ); + $statement = $this->driver->query( 'UPDATE t SET value = 2 WHERE id = 1' ); + $queries = array_column( $this->driver->get_last_sqlite_queries(), 'sql' ); + + $this->assertSame( 1, $statement->rowCount() ); + $this->assertNotContains( 'BEGIN IMMEDIATE', $queries ); + $this->assertTrue( $this->driver->inTransaction() ); + + $this->driver->query( 'RELEASE SAVEPOINT outer_transaction' ); + + $this->assertFalse( $this->driver->inTransaction() ); + $this->assertSame( '2', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); + } + + public function test_write_inside_savepoint_can_be_rolled_back(): void { + $this->driver->query( 'CREATE TABLE t (id INT PRIMARY KEY, value INT)' ); + $this->driver->query( 'INSERT INTO t VALUES (1, 1)' ); + + $this->driver->query( 'SAVEPOINT outer_transaction' ); + $this->driver->query( 'UPDATE t SET value = 2 WHERE id = 1' ); + $this->driver->query( 'ROLLBACK TO SAVEPOINT outer_transaction' ); + $this->driver->query( 'RELEASE SAVEPOINT outer_transaction' ); + + $this->assertFalse( $this->driver->inTransaction() ); + $this->assertSame( '1', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); + } + + public function test_writes_inside_nested_savepoints_preserve_outer_changes(): void { + $this->driver->query( 'CREATE TABLE t (id INT PRIMARY KEY, value INT)' ); + $this->driver->query( 'INSERT INTO t VALUES (1, 1)' ); + + $this->driver->query( 'SAVEPOINT outer_transaction' ); + $this->driver->query( 'UPDATE t SET value = 2 WHERE id = 1' ); + $this->driver->query( 'SAVEPOINT inner_transaction' ); + $this->driver->query( 'UPDATE t SET value = 3 WHERE id = 1' ); + $this->driver->query( 'ROLLBACK TO SAVEPOINT inner_transaction' ); + $this->driver->query( 'RELEASE SAVEPOINT inner_transaction' ); + + $this->assertTrue( $this->driver->inTransaction() ); + $this->assertSame( '2', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); + + $this->driver->query( 'RELEASE SAVEPOINT outer_transaction' ); + + $this->assertFalse( $this->driver->inTransaction() ); + $this->assertSame( '2', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); + } + + public function test_failed_write_cleans_up_savepoint_transaction_state(): void { + $this->driver->query( 'CREATE TABLE t (id INT PRIMARY KEY)' ); + $this->driver->query( 'INSERT INTO t VALUES (1)' ); + $this->driver->query( 'SAVEPOINT outer_transaction' ); + + try { + $this->driver->query( 'INSERT INTO t VALUES (1)' ); + $this->fail( 'Expected the duplicate insert to fail.' ); + } catch ( PDOException $e ) { + $this->assertStringContainsString( 'UNIQUE constraint failed', $e->getMessage() ); + } + + $this->assertFalse( $this->driver->inTransaction() ); + $this->assertSame( '1', $this->driver->query( 'SELECT COUNT(*) FROM t' )->fetchColumn() ); + + $this->driver->query( 'INSERT INTO t VALUES (2)' ); + $queries = array_column( $this->driver->get_last_sqlite_queries(), 'sql' ); + $this->assertSame( 'BEGIN IMMEDIATE', $queries[0] ); + $this->assertSame( 'COMMIT', end( $queries ) ); + } + public function test_fetch_default(): void { // Default fetch mode is PDO::FETCH_BOTH. $result = $this->driver->query( "SELECT 1, 'abc', 2" ); diff --git a/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php b/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php new file mode 100644 index 000000000..9b2f85800 --- /dev/null +++ b/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php @@ -0,0 +1,37 @@ +prefix . 'sqlite_savepoint_write_test'; + $wpdb->query( $wpdb->prepare( 'CREATE TABLE %i (id INT PRIMARY KEY, value INT)', $table_name ) ); + $wpdb->insert( + $table_name, + array( + 'id' => 1, + 'value' => 1, + ) + ); + + try { + $this->assertNotFalse( $wpdb->query( 'SAVEPOINT wpdb_update' ) ); + + $result = $wpdb->update( $table_name, array( 'value' => 2 ), array( 'id' => 1 ) ); + $queries = array_column( $wpdb->get_driver()->get_last_sqlite_queries(), 'sql' ); + + $this->assertSame( 1, $result ); + $this->assertSame( 1, $wpdb->rows_affected ); + $this->assertSame( '', $wpdb->last_error ); + $this->assertNotContains( 'BEGIN IMMEDIATE', $queries ); + $this->assertNotFalse( $wpdb->query( 'RELEASE SAVEPOINT wpdb_update' ) ); + $this->assertSame( '2', $wpdb->get_var( $wpdb->prepare( 'SELECT value FROM %i WHERE id = 1', $table_name ) ) ); + } finally { + if ( $wpdb->get_driver()->inTransaction() ) { + $wpdb->query( 'ROLLBACK' ); + } + $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table_name ) ); + } + } +} From 791a7da7ec645a4f86bd9c594694801f216e1096 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 23 Aug 2026 22:03:39 +0000 Subject: [PATCH 2/4] test: avoid custom savepoint fixture table --- ...te_Database_Integration_Savepoint_Test.php | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php b/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php index 9b2f85800..6b73aa0da 100644 --- a/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php +++ b/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php @@ -5,20 +5,20 @@ class WP_SQLite_Database_Integration_Savepoint_Test extends WP_UnitTestCase { public function test_wpdb_update_inside_savepoint_does_not_open_nested_transaction() { global $wpdb; - $table_name = $wpdb->prefix . 'sqlite_savepoint_write_test'; - $wpdb->query( $wpdb->prepare( 'CREATE TABLE %i (id INT PRIMARY KEY, value INT)', $table_name ) ); + $option_name = 'sqlite_savepoint_write_test'; $wpdb->insert( - $table_name, + $wpdb->options, array( - 'id' => 1, - 'value' => 1, + 'option_name' => $option_name, + 'option_value' => '1', + 'autoload' => 'no', ) ); try { $this->assertNotFalse( $wpdb->query( 'SAVEPOINT wpdb_update' ) ); - $result = $wpdb->update( $table_name, array( 'value' => 2 ), array( 'id' => 1 ) ); + $result = $wpdb->update( $wpdb->options, array( 'option_value' => '2' ), array( 'option_name' => $option_name ) ); $queries = array_column( $wpdb->get_driver()->get_last_sqlite_queries(), 'sql' ); $this->assertSame( 1, $result ); @@ -26,12 +26,17 @@ public function test_wpdb_update_inside_savepoint_does_not_open_nested_transacti $this->assertSame( '', $wpdb->last_error ); $this->assertNotContains( 'BEGIN IMMEDIATE', $queries ); $this->assertNotFalse( $wpdb->query( 'RELEASE SAVEPOINT wpdb_update' ) ); - $this->assertSame( '2', $wpdb->get_var( $wpdb->prepare( 'SELECT value FROM %i WHERE id = 1', $table_name ) ) ); + $this->assertSame( + '2', + $wpdb->get_var( + $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, $option_name ) + ) + ); } finally { if ( $wpdb->get_driver()->inTransaction() ) { $wpdb->query( 'ROLLBACK' ); } - $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table_name ) ); + $wpdb->delete( $wpdb->options, array( 'option_name' => $option_name ) ); } } } From 9879478e39d5e8b3eb0e6a3651b39d4f0f7f77f0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 23 Aug 2026 22:07:04 +0000 Subject: [PATCH 3/4] test: cover standalone savepoint edge cases --- .../WP_MySQL_On_SQLite_PDO_API_Tests.php | 33 +++++++++++++++++++ ...te_Database_Integration_Savepoint_Test.php | 4 ++- 2 files changed, 36 insertions(+), 1 deletion(-) 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 46ff8e90d..8345ff4e2 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 @@ -986,6 +986,39 @@ public function test_writes_inside_nested_savepoints_preserve_outer_changes(): v $this->assertSame( '2', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); } + public function test_duplicate_savepoint_names_track_innermost_scope(): void { + $this->driver->query( 'CREATE TABLE t (id INT PRIMARY KEY, value INT)' ); + $this->driver->query( 'INSERT INTO t VALUES (1, 1)' ); + + $this->driver->query( 'SAVEPOINT repeated' ); + $this->driver->query( 'UPDATE t SET value = 2 WHERE id = 1' ); + $this->driver->query( 'SAVEPOINT repeated' ); + $this->driver->query( 'UPDATE t SET value = 3 WHERE id = 1' ); + $this->driver->query( 'ROLLBACK TO SAVEPOINT repeated' ); + $this->driver->query( 'RELEASE SAVEPOINT repeated' ); + + $this->assertTrue( $this->driver->inTransaction() ); + $this->assertSame( '2', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); + + $this->driver->query( 'RELEASE SAVEPOINT repeated' ); + + $this->assertFalse( $this->driver->inTransaction() ); + $this->assertSame( '2', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); + } + + public function test_quoted_savepoint_names_are_case_insensitive(): void { + $this->driver->query( 'CREATE TABLE t (id INT PRIMARY KEY, value INT)' ); + $this->driver->query( 'INSERT INTO t VALUES (1, 1)' ); + + $this->driver->query( 'SAVEPOINT `MixedCase`' ); + $this->driver->query( 'UPDATE t SET value = 2 WHERE id = 1' ); + $this->driver->query( 'ROLLBACK TO SAVEPOINT `mixedcase`' ); + $this->driver->query( 'RELEASE SAVEPOINT `MIXEDCASE`' ); + + $this->assertFalse( $this->driver->inTransaction() ); + $this->assertSame( '1', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); + } + public function test_failed_write_cleans_up_savepoint_transaction_state(): void { $this->driver->query( 'CREATE TABLE t (id INT PRIMARY KEY)' ); $this->driver->query( 'INSERT INTO t VALUES (1)' ); diff --git a/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php b/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php index 6b73aa0da..945e9e821 100644 --- a/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php +++ b/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php @@ -1,6 +1,6 @@ assertFalse( $wpdb->get_driver()->inTransaction() ); $this->assertNotFalse( $wpdb->query( 'SAVEPOINT wpdb_update' ) ); $result = $wpdb->update( $wpdb->options, array( 'option_value' => '2' ), array( 'option_name' => $option_name ) ); @@ -26,6 +27,7 @@ public function test_wpdb_update_inside_savepoint_does_not_open_nested_transacti $this->assertSame( '', $wpdb->last_error ); $this->assertNotContains( 'BEGIN IMMEDIATE', $queries ); $this->assertNotFalse( $wpdb->query( 'RELEASE SAVEPOINT wpdb_update' ) ); + $this->assertFalse( $wpdb->get_driver()->inTransaction() ); $this->assertSame( '2', $wpdb->get_var( From 48c457eb7d569ca50e49d53312199927ebf180c7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 24 Aug 2026 18:38:58 +0000 Subject: [PATCH 4/4] refactor: simplify SQL savepoint tracking --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 53 +++++++++---------- .../WP_MySQL_On_SQLite_PDO_API_Tests.php | 15 ++++++ 2 files changed, 39 insertions(+), 29 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 08119f7e6..9557d6f2b 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 @@ -712,18 +712,18 @@ class WP_MySQL_On_SQLite extends PDO { private $in_transaction = false; /** - * User savepoints active in the current transaction, from outermost to innermost. + * User savepoints in a transaction opened by a SAVEPOINT statement. * - * @var string[] - */ - private $transaction_savepoints = array(); - - /** - * Whether the current transaction was opened by the outermost user savepoint. + * On PHP < 8.4, PDO SQLite cannot detect transactions opened with raw SQL. + * Tracking the savepoint stack keeps the inTransaction() polyfill accurate + * when the outermost savepoint is released. * - * @var bool + * Savepoints inside a transaction opened by BEGIN are not tracked because + * releasing them cannot end the outer transaction. + * + * @var string[] */ - private $transaction_started_by_savepoint = false; + private $savepoint_transaction_stack = array(); /** * Whether a MySQL table lock is active. @@ -2106,9 +2106,8 @@ private function begin_user_transaction(): void { * @see self::begin_wrapper_transaction() */ $this->connection->query( 'BEGIN IMMEDIATE' ); - $this->in_transaction = true; - $this->transaction_savepoints = array(); - $this->transaction_started_by_savepoint = false; + $this->in_transaction = true; + $this->savepoint_transaction_stack = array(); } /** @@ -2120,9 +2119,8 @@ private function commit_user_transaction(): void { return; } $this->connection->query( 'COMMIT' ); - $this->in_transaction = false; - $this->transaction_savepoints = array(); - $this->transaction_started_by_savepoint = false; + $this->in_transaction = false; + $this->savepoint_transaction_stack = array(); } /** @@ -2134,9 +2132,8 @@ private function rollback_user_transaction(): void { return; } $this->connection->query( 'ROLLBACK' ); - $this->in_transaction = false; - $this->transaction_savepoints = array(); - $this->transaction_started_by_savepoint = false; + $this->in_transaction = false; + $this->savepoint_transaction_stack = array(); } /** @@ -2178,7 +2175,7 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node $this->execute_sqlite_query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) ); $savepoint_index = $this->find_transaction_savepoint_index( $savepoint_key ); if ( null !== $savepoint_index ) { - $this->transaction_savepoints = array_slice( $this->transaction_savepoints, 0, $savepoint_index + 1 ); + $this->savepoint_transaction_stack = array_slice( $this->savepoint_transaction_stack, 0, $savepoint_index + 1 ); } } return; @@ -2188,11 +2185,10 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node if ( WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $token->id ) { $starts_transaction = ! $this->inTransaction(); $this->execute_sqlite_query( sprintf( 'SAVEPOINT %s', $savepoint_name ) ); - if ( $starts_transaction ) { - $this->transaction_started_by_savepoint = true; + if ( $starts_transaction || ! empty( $this->savepoint_transaction_stack ) ) { + $this->savepoint_transaction_stack[] = $savepoint_key; } - $this->transaction_savepoints[] = $savepoint_key; - $this->in_transaction = true; + $this->in_transaction = true; return; } @@ -2201,11 +2197,10 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node $this->execute_sqlite_query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); $savepoint_index = $this->find_transaction_savepoint_index( $savepoint_key ); if ( null !== $savepoint_index ) { - $this->transaction_savepoints = array_slice( $this->transaction_savepoints, 0, $savepoint_index ); + $this->savepoint_transaction_stack = array_slice( $this->savepoint_transaction_stack, 0, $savepoint_index ); } - if ( $this->transaction_started_by_savepoint && empty( $this->transaction_savepoints ) ) { - $this->in_transaction = false; - $this->transaction_started_by_savepoint = false; + if ( null !== $savepoint_index && empty( $this->savepoint_transaction_stack ) ) { + $this->in_transaction = false; } return; } @@ -2284,8 +2279,8 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node * @return int|null Savepoint index, or null when not tracked. */ private function find_transaction_savepoint_index( string $savepoint_name ): ?int { - for ( $index = count( $this->transaction_savepoints ) - 1; $index >= 0; $index-- ) { - if ( $savepoint_name === $this->transaction_savepoints[ $index ] ) { + for ( $index = count( $this->savepoint_transaction_stack ) - 1; $index >= 0; $index-- ) { + if ( $savepoint_name === $this->savepoint_transaction_stack[ $index ] ) { return $index; } } 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 8345ff4e2..f93a74c7f 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 @@ -953,6 +953,21 @@ public function test_write_inside_savepoint_commits_on_release(): void { $this->assertSame( '2', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); } + public function test_releasing_savepoint_inside_explicit_transaction_keeps_transaction_active(): void { + $this->driver->query( 'CREATE TABLE t (id INT PRIMARY KEY, value INT)' ); + $this->driver->query( 'INSERT INTO t VALUES (1, 1)' ); + + $this->driver->query( 'START TRANSACTION' ); + $this->driver->query( 'SAVEPOINT nested_transaction' ); + $this->driver->query( 'UPDATE t SET value = 2 WHERE id = 1' ); + $this->driver->query( 'RELEASE SAVEPOINT nested_transaction' ); + + $this->assertTrue( $this->driver->inTransaction() ); + $this->driver->query( 'ROLLBACK' ); + $this->assertFalse( $this->driver->inTransaction() ); + $this->assertSame( '1', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); + } + public function test_write_inside_savepoint_can_be_rolled_back(): void { $this->driver->query( 'CREATE TABLE t (id INT PRIMARY KEY, value INT)' ); $this->driver->query( 'INSERT INTO t VALUES (1, 1)' );