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..95944f734 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,17 @@ class WP_MySQL_On_SQLite extends PDO { */ private $in_transaction = false; + /** + * Names of active user savepoints, outermost first. + * + * MySQL replaces a savepoint with the same name, while SQLite only shadows it. + * Tracking normalized names prevents shadowed SQLite savepoints from becoming + * visible again and supports case-insensitive lookup. + * + * @var string[] + */ + private $savepoint_names = array(); + /** * Whether a MySQL table lock is active. * @@ -2092,7 +2103,8 @@ 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->savepoint_names = array(); } /** @@ -2104,7 +2116,8 @@ private function commit_user_transaction(): void { return; } $this->connection->query( 'COMMIT' ); - $this->in_transaction = false; + $this->in_transaction = false; + $this->savepoint_names = array(); } /** @@ -2116,7 +2129,8 @@ private function rollback_user_transaction(): void { return; } $this->connection->query( 'ROLLBACK' ); - $this->in_transaction = false; + $this->in_transaction = false; + $this->savepoint_names = array(); } /** @@ -2146,26 +2160,58 @@ 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 ) { if ( null === $savepoint_name ) { $this->rollback_user_transaction(); } else { + // ROLLBACK TO keeps the named savepoint and deletes those created after it. + $index = array_search( $savepoint_key, $this->savepoint_names, true ); + if ( false === $index ) { + throw $this->new_savepoint_does_not_exist_exception( $savepoint_name ); + } $this->execute_sqlite_query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) ); + array_splice( $this->savepoint_names, $index + 1 ); } return; } // SAVEPOINT. if ( WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $token->id ) { + // In MySQL with autocommit enabled, a standalone savepoint is discarded + // immediately without starting a transaction. + if ( ! $this->inTransaction() ) { + return; + } $this->execute_sqlite_query( sprintf( 'SAVEPOINT %s', $savepoint_name ) ); + + /* + * MySQL deletes an existing savepoint when its name is reused, while + * SQLite keeps it on the stack, shadowed by the new one. Drop the old + * name so that it can no longer be referenced. The shadowed SQLite + * savepoint is harmless; it is discarded when the transaction ends. + */ + $index = array_search( $savepoint_key, $this->savepoint_names, true ); + if ( false !== $index ) { + array_splice( $this->savepoint_names, $index, 1 ); + } + $this->savepoint_names[] = $savepoint_key; return; } // RELEASE SAVEPOINT. if ( WP_MySQL_Lexer::RELEASE_SYMBOL === $token->id ) { + // RELEASE deletes the named savepoint and those created after it. + $index = array_search( $savepoint_key, $this->savepoint_names, true ); + if ( false === $index ) { + throw $this->new_savepoint_does_not_exist_exception( $savepoint_name ); + } $this->execute_sqlite_query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); + array_splice( $this->savepoint_names, $index ); return; } @@ -7816,6 +7862,25 @@ private function new_table_not_found_exception( ); } + /** + * Create a MySQL-compatible savepoint-not-found exception. + * + * @param string $savepoint_name The missing savepoint name, as an SQLite identifier. + * @return WP_MySQL_On_SQLite_Exception + */ + private function new_savepoint_does_not_exist_exception( string $savepoint_name ): WP_MySQL_On_SQLite_Exception { + $driver_message = sprintf( + 'SAVEPOINT %s does not exist', + $this->unquote_sqlite_identifier( $savepoint_name ) + ); + return $this->new_driver_exception( + 'SQLSTATE[42000]: Syntax error or access violation: 1305 ' . $driver_message, + '42000', + null, + array( '42000', 1305, $driver_message ) + ); + } + /** * Create a MySQL-compatible exception for an invalid SQL mode value. * diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php index 7447482ed..b693fa1b5 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php @@ -72,6 +72,19 @@ public function testWriteQueryOpensWriteTransaction( string $query ): void { $this->assertSame( 'BEGIN IMMEDIATE', $driver->get_last_sqlite_queries()[0]['sql'] ); } + public function testWriteQueryAfterSavepointOpensWriteTransaction(): void { + $driver = $this->create_in_memory_driver(); + $driver->query( 'CREATE TABLE t (id INT, name VARCHAR(255))' ); + $driver->query( "INSERT INTO t VALUES (1, 'Alice')" ); + + // A savepoint outside of a transaction must not leave a deferred SQLite + // transaction open, which would make the following write skip the lock. + $driver->query( 'SAVEPOINT sp1' ); + $driver->query( "UPDATE t SET name = 'Carol' WHERE id = 1" ); + + $this->assertSame( 'BEGIN IMMEDIATE', $driver->get_last_sqlite_queries()[0]['sql'] ); + } + public function provideWriteStatements(): array { return array( 'INSERT' => array( "INSERT INTO t VALUES (2, 'Bob')" ), 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..65a25f22c 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,118 @@ 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_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)' ); + + $this->driver->query( 'START TRANSACTION' ); + $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->assertTrue( $this->driver->inTransaction() ); + $this->driver->query( 'COMMIT' ); + + $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( 'START TRANSACTION' ); + $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->assertTrue( $this->driver->inTransaction() ); + $this->driver->query( 'COMMIT' ); + + $this->assertFalse( $this->driver->inTransaction() ); + $this->assertSame( '2', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); + } + + public function test_duplicate_savepoint_names_roll_back_to_the_latest(): 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 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' ); + + // A reused name refers to the savepoint that was set last. + $this->driver->query( 'ROLLBACK TO SAVEPOINT repeated' ); + $this->assertSame( '2', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); + + $this->driver->query( 'COMMIT' ); + $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( 'START TRANSACTION' ); + $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->assertTrue( $this->driver->inTransaction() ); + $this->driver->query( 'COMMIT' ); + + $this->assertFalse( $this->driver->inTransaction() ); + $this->assertSame( '1', $this->driver->query( 'SELECT value FROM t' )->fetchColumn() ); + } + + public function test_failed_write_after_standalone_savepoint_keeps_autocommit(): 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/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 1681fe15f..8d1d4cf3a 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 @@ -16,6 +16,8 @@ class WP_MySQL_On_SQLite_Tests extends TestCase { private const SQL_MODE_TIME_TRUNCATE_FRACTIONAL = 1 << 32; private const UNKNOWN_SQL_MODE_BIT = 1 << 33; + private const SAVEPOINT_DOES_NOT_EXIST_ERROR = 'SQLSTATE[42000]: Syntax error or access violation: 1305 SAVEPOINT %s does not exist'; + /** @var WP_MySQL_On_SQLite */ private $engine; @@ -7731,6 +7733,95 @@ public function testTransactionSavepoints(): void { $this->assertSame( array(), (array) 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)' ); + + // With autocommit, each statement forms its own transaction, so a savepoint + // is discarded as soon as the SAVEPOINT statement completes. + $this->assertQuery( 'SAVEPOINT sp1' ); + $this->assertFalse( $this->engine->inTransaction() ); + + // The write must succeed and be committed immediately. + $this->assertQuery( 'UPDATE t SET v = 2 WHERE id = 1' ); + $this->assertFalse( $this->engine->inTransaction() ); + $result = $this->assertQuery( 'SELECT v FROM t WHERE id = 1' ); + $this->assertSame( '2', $result[0]->v ); + + // The savepoint is no longer available. + $this->assertQueryError( + 'ROLLBACK TO SAVEPOINT sp1', + sprintf( self::SAVEPOINT_DOES_NOT_EXIST_ERROR, 'sp1' ) + ); + } + + public function testReleaseSavepointWithoutTransaction(): void { + $this->assertQuery( 'SAVEPOINT sp1' ); + $this->assertQueryError( + 'RELEASE SAVEPOINT sp1', + sprintf( self::SAVEPOINT_DOES_NOT_EXIST_ERROR, 'sp1' ) + ); + } + + public function testDuplicateSavepointNameReplacesOldSavepoint(): void { + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'SAVEPOINT sp1' ); + $this->assertQuery( 'SAVEPOINT sp2' ); + $this->assertQuery( 'SAVEPOINT sp1' ); + + // Releasing the replacement must leave sp2 available. + $this->assertQuery( 'RELEASE SAVEPOINT sp1' ); + $this->assertQuery( 'ROLLBACK TO SAVEPOINT sp2' ); + + // The original sp1 must remain unavailable. + $this->assertQueryError( + 'ROLLBACK TO SAVEPOINT sp1', + sprintf( self::SAVEPOINT_DOES_NOT_EXIST_ERROR, 'sp1' ) + ); + } + + public function testReleaseSavepointDeletesNestedSavepoints(): void { + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'SAVEPOINT sp1' ); + $this->assertQuery( 'SAVEPOINT sp2' ); + $this->assertQuery( 'RELEASE SAVEPOINT sp1' ); + $this->assertQueryError( + 'ROLLBACK TO SAVEPOINT sp2', + sprintf( self::SAVEPOINT_DOES_NOT_EXIST_ERROR, 'sp2' ) + ); + } + + public function testRollbackToSavepointDeletesNestedSavepoints(): void { + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'SAVEPOINT sp1' ); + $this->assertQuery( 'SAVEPOINT sp2' ); + $this->assertQuery( 'ROLLBACK TO SAVEPOINT sp1' ); + $this->assertQueryError( + 'ROLLBACK TO SAVEPOINT sp2', + sprintf( self::SAVEPOINT_DOES_NOT_EXIST_ERROR, 'sp2' ) + ); + } + + public function testCommitDeletesSavepoints(): void { + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'SAVEPOINT sp1' ); + $this->assertQuery( 'COMMIT' ); + $this->assertQueryError( + 'ROLLBACK TO SAVEPOINT sp1', + sprintf( self::SAVEPOINT_DOES_NOT_EXIST_ERROR, 'sp1' ) + ); + } + + public function testRollbackDeletesSavepoints(): void { + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'SAVEPOINT sp1' ); + $this->assertQuery( 'ROLLBACK' ); + $this->assertQueryError( + 'ROLLBACK TO SAVEPOINT sp1', + sprintf( self::SAVEPOINT_DOES_NOT_EXIST_ERROR, 'sp1' ) + ); + } + public function testRowLeveLockingClauses() { $this->assertQuery( 'CREATE TABLE t (name VARCHAR(255), value VARCHAR(255))' ); $this->query( "INSERT INTO t (name, value) VALUES ('test_lock', '123')" ); @@ -8011,7 +8102,7 @@ public function testSelectHavingAmbiguousColumnErrorWithoutSelectList(): void { public function testRollbackNonExistentTransactionSavepoint(): void { $this->expectException( 'WP_MySQL_On_SQLite_Exception' ); - $this->expectExceptionMessage( 'no such savepoint: sp1' ); + $this->expectExceptionMessage( 'SAVEPOINT sp1 does not exist' ); $this->assertQuery( 'ROLLBACK TO SAVEPOINT sp1' ); } @@ -11547,7 +11638,8 @@ public function testEmptyColumnMeta(): void { $this->assertSame( 0, $this->last_statement->columnCount() ); $this->assertSame( array(), $this->getLastColumnMeta() ); - // SAVEPOINT + // SAVEPOINT (savepoints exist only within a transaction). + $this->assertQuery( 'START TRANSACTION' ); $this->assertQuery( 'SAVEPOINT s1' ); $this->assertSame( 0, $this->last_statement->columnCount() ); $this->assertSame( array(), $this->getLastColumnMeta() ); @@ -11561,6 +11653,7 @@ public function testEmptyColumnMeta(): void { $this->assertQuery( 'RELEASE SAVEPOINT s1' ); $this->assertSame( 0, $this->last_statement->columnCount() ); $this->assertSame( array(), $this->getLastColumnMeta() ); + $this->assertQuery( 'COMMIT' ); // LOCK TABLE $this->assertQuery( 'LOCK TABLES t READ' ); 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..053432322 --- /dev/null +++ b/tests/phpunit/WP_SQLite_Database_Integration_Savepoint_Test.php @@ -0,0 +1,52 @@ +insert( + $wpdb->options, + array( + 'option_name' => $option_name, + 'option_value' => '1', + 'autoload' => 'no', + ) + ); + + try { + $this->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 ) ); + $queries = array_column( $wpdb->get_driver()->get_last_sqlite_queries(), 'sql' ); + + // The savepoint does not open a transaction, so the update takes the + // write lock and is committed on its own, as it would be on MySQL. + $this->assertSame( 1, $result ); + $this->assertSame( 1, $wpdb->rows_affected ); + $this->assertSame( '', $wpdb->last_error ); + $this->assertContains( 'BEGIN IMMEDIATE', $queries ); + $this->assertFalse( $wpdb->get_driver()->inTransaction() ); + + // The savepoint was discarded, so releasing it reports MySQL error 1305. + $suppress = $wpdb->suppress_errors( true ); + $release_result = $wpdb->query( 'RELEASE SAVEPOINT wpdb_update' ); + $wpdb->suppress_errors( $suppress ); + $this->assertFalse( $release_result ); + $this->assertStringContainsString( 'SAVEPOINT wpdb_update does not exist', $wpdb->last_error ); + $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->delete( $wpdb->options, array( 'option_name' => $option_name ) ); + } + } +}