From 16bf97eee29dbc5d3f80b6be0bff3b8ca5f1c03c Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 17 Dec 2025 17:38:34 +0100 Subject: [PATCH 1/3] Improve transaction behavior compatibility, add PDO-like APIs --- tests/WP_SQLite_Driver_PDO_API_Tests.php | 53 +++ tests/WP_SQLite_Driver_Tests.php | 83 ----- .../sqlite-ast/class-wp-sqlite-connection.php | 14 + .../sqlite-ast/class-wp-sqlite-driver.php | 343 ++++++++++++------ 4 files changed, 297 insertions(+), 196 deletions(-) create mode 100644 tests/WP_SQLite_Driver_PDO_API_Tests.php diff --git a/tests/WP_SQLite_Driver_PDO_API_Tests.php b/tests/WP_SQLite_Driver_PDO_API_Tests.php new file mode 100644 index 00000000..75a55067 --- /dev/null +++ b/tests/WP_SQLite_Driver_PDO_API_Tests.php @@ -0,0 +1,53 @@ + ':memory:' ) ); + $this->driver = new WP_SQLite_Driver( $connection, 'wp' ); + } + + public function test_begin_transaction(): void { + $result = $this->driver->beginTransaction(); + $this->assertTrue( $result ); + } + + public function test_begin_transaction_already_active(): void { + $this->driver->beginTransaction(); + + $this->expectException( PDOException::class ); + $this->expectExceptionMessage( 'There is already an active transaction' ); + $this->expectExceptionCode( 0 ); + $this->driver->beginTransaction(); + } + + public function test_commit(): void { + $this->driver->beginTransaction(); + $result = $this->driver->commit(); + $this->assertTrue( $result ); + } + + public function test_commit_no_active_transaction(): void { + $this->expectException( PDOException::class ); + $this->expectExceptionMessage( 'There is no active transaction' ); + $this->expectExceptionCode( 0 ); + $this->driver->commit(); + } + + public function test_rollback(): void { + $this->driver->beginTransaction(); + $result = $this->driver->rollBack(); + $this->assertTrue( $result ); + } + + public function test_rollback_no_active_transaction(): void { + $this->expectException( PDOException::class ); + $this->expectExceptionMessage( 'There is no active transaction' ); + $this->expectExceptionCode( 0 ); + $this->driver->rollBack(); + } +} diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 542c5ea2..764b99a8 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -2490,89 +2490,6 @@ public function testStartTransactionCommand() { $this->assertCount( 0, $this->engine->get_query_results() ); } - public function testNestedTransactionWork() { - $this->assertQuery( 'BEGIN' ); - $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('first');" ); - $this->assertQuery( 'START TRANSACTION' ); - $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('second');" ); - $this->assertQuery( 'START TRANSACTION' ); - $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('third');" ); - $this->assertQuery( 'SELECT * FROM _options;' ); - $this->assertCount( 3, $this->engine->get_query_results() ); - - $this->assertQuery( 'ROLLBACK' ); - $this->assertQuery( 'SELECT * FROM _options;' ); - $this->assertCount( 2, $this->engine->get_query_results() ); - - $this->assertQuery( 'ROLLBACK' ); - $this->assertQuery( 'SELECT * FROM _options;' ); - $this->assertCount( 1, $this->engine->get_query_results() ); - - $this->assertQuery( 'COMMIT' ); - $this->assertQuery( 'SELECT * FROM _options;' ); - $this->assertCount( 1, $this->engine->get_query_results() ); - } - - public function testNestedTransactionWorkComplexModify() { - $this->assertQuery( 'BEGIN' ); - // Create a complex ALTER Table query where the first - // column is added successfully, but the second fails. - // Behind the scenes, this single MySQL query is split - // into multiple SQLite queries – some of them will - // succeed, some will fail. - $error = ''; - try { - $this->engine->query( - ' - ALTER TABLE _options - ADD COLUMN test varchar(20), - ADD COLUMN test varchar(20) - ' - ); - } catch ( Throwable $e ) { - $error = $e->getMessage(); - } - $this->assertStringContainsString( "Duplicate column name 'test'", $error ); - - // Commit the transaction. - $this->assertQuery( 'COMMIT' ); - - // Confirm the entire query failed atomically and no column was - // added to the table. - $this->assertQuery( 'DESCRIBE _options;' ); - $fields = $this->engine->get_query_results(); - - $this->assertEquals( - array( - (object) array( - 'Field' => 'ID', - 'Type' => 'int', - 'Null' => 'NO', - 'Key' => 'PRI', - 'Default' => null, - 'Extra' => 'auto_increment', - ), - (object) array( - 'Field' => 'option_name', - 'Type' => 'text', - 'Null' => 'NO', - 'Key' => '', - 'Default' => '', - 'Extra' => '', - ), - (object) array( - 'Field' => 'option_value', - 'Type' => 'text', - 'Null' => 'NO', - 'Key' => '', - 'Default' => '', - 'Extra' => '', - ), - ), - $fields - ); - } - public function testCount() { $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('first');" ); $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('second');" ); diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-connection.php b/wp-includes/sqlite-ast/class-wp-sqlite-connection.php index b015d7c9..ba607e09 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-connection.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-connection.php @@ -118,6 +118,20 @@ public function query( string $sql, array $params = array() ): PDOStatement { return $stmt; } + /** + * Prepare a SQLite query for execution. + * + * @param string $sql The query to prepare. + * @return PDOStatement The prepared statement. + * @throws PDOException When the query preparation fails. + */ + public function prepare( string $sql ): PDOStatement { + if ( $this->query_logger ) { + ( $this->query_logger )( $sql, array() ); + } + return $this->pdo->prepare( $sql ); + } + /** * Returns the ID of the last inserted row. * diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index 3a9c13e6..447450a9 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -493,11 +493,16 @@ class WP_SQLite_Driver { private $is_readonly; /** - * Transaction nesting level of the executed SQLite queries. + * Type of wrapper transaction that is active for the MySQL query emulation. * - * @var int + * Possible values: + * - null: No wrapper transaction is active. + * - 'transaction': A top-level transaction is active. + * - 'savepoint': A nested savepoint is active. + * + * @var null|'transaction'|'savepoint' */ - private $transaction_level = 0; + private $wrapper_transaction_type = null; /** * Whether a MySQL table lock is active. @@ -666,6 +671,66 @@ function ( string $sql, array $params ) { ); } + /** + * PDO API: Begin a new transaction or nested transaction. + * + * @return bool True on success, false on failure. + */ + // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + public function beginTransaction(): bool { + if ( $this->inTransaction() ) { + throw $this->new_driver_exception( 'There is already an active transaction' ); + } + $this->begin_user_transaction(); + return true; + } + + /** + * A temporary alias for back compatibility. + * + * @see self::beginTransaction() + */ + public function begin_transaction(): void { + $this->beginTransaction(); + } + + /** + * PDO API: Commit the current transaction or nested transaction. + * + * @return bool True on success, false on failure. + */ + public function commit(): bool { + if ( ! $this->inTransaction() ) { + throw $this->new_driver_exception( 'There is no active transaction' ); + } + $this->commit_user_transaction(); + return true; + } + + /** + * PDO API: Rollback the current transaction or nested transaction. + * + * @return bool True on success, false on failure. + */ + // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + public function rollBack(): bool { + if ( ! $this->inTransaction() ) { + throw $this->new_driver_exception( 'There is no active transaction' ); + } + $this->rollback_user_transaction(); + return true; + } + + /** + * PDO API: Check if a transaction is active. + * + * @return bool True if a transaction is active, false otherwise. + */ + // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + public function inTransaction(): bool { + return $this->connection->get_pdo()->inTransaction(); + } + /** * Get the SQLite connection instance. * @@ -809,18 +874,19 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo } if ( $wrap_in_transaction ) { - $this->begin_transaction(); + $this->begin_wrapper_transaction(); } $this->execute_mysql_query( $ast ); if ( $wrap_in_transaction ) { - $this->commit(); + $this->commit_wrapper_transaction(); } return $this->last_return_value; } catch ( Throwable $e ) { try { - $this->rollback(); + $this->rollback_user_transaction(); + $this->table_lock_active = false; } catch ( Throwable $rollback_exception ) { // Ignore rollback errors. } @@ -1049,92 +1115,6 @@ public function execute_sqlite_query( string $sql, array $params = array() ): PD return $this->connection->query( $sql, $params ); } - /** - * Begin a new transaction or nested transaction. - */ - public function begin_transaction(): void { - if ( 0 === $this->transaction_level ) { - /* - * When we're executing a statement that will write to the database, - * we need to use "BEGIN IMMEDIATE" to open a write transaction. - * - * This is needed to avoid the "database is locked" error (SQLITE_BUSY) - * when SQLite can't upgrade a read transaction to a write transaction, - * because another connection is modifying the database. - * - * From the SQLite documentation: - * - * ## Read transactions versus write transactions - * - * If a write statement occurs while a read transaction is active, - * then the read transaction is upgraded to a write transaction if - * possible. If some other database connection has already modified - * the database or is already in the process of modifying the database, - * then upgrading to a write transaction is not possible and the write - * statement will fail with SQLITE_BUSY. - * - * ## DEFERRED, IMMEDIATE, and EXCLUSIVE transactions - * - * Transactions can be DEFERRED, IMMEDIATE, or EXCLUSIVE. The default - * transaction behavior is DEFERRED. - * - * DEFERRED means that the transaction does not actually start until - * the database is first accessed. - * - * IMMEDIATE causes the database connection to start a new write - * immediately, without waiting for a write statement. The BEGIN - * IMMEDIATE might fail with SQLITE_BUSY if another write transaction - * is already active on another database connection. - * - * See: - * - https://www.sqlite.org/lang_transaction.html - * - https://www.sqlite.org/rescode.html#busy - * - * For better performance, we could also consider opening the write - * transaction later in the session - just before the first write. - */ - $this->execute_sqlite_query( $this->is_readonly ? 'BEGIN' : 'BEGIN IMMEDIATE' ); - } else { - $savepoint_name = $this->get_internal_savepoint_name( $this->transaction_level ); - $this->execute_sqlite_query( sprintf( 'SAVEPOINT %s', $savepoint_name ) ); - } - ++$this->transaction_level; - } - - /** - * Commit the current transaction or nested transaction. - */ - public function commit(): void { - if ( 0 === $this->transaction_level ) { - return; - } - - --$this->transaction_level; - if ( 0 === $this->transaction_level ) { - $this->execute_sqlite_query( 'COMMIT' ); - } else { - $savepoint_name = $this->get_internal_savepoint_name( $this->transaction_level ); - $this->execute_sqlite_query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); - } - } - - /** - * Rollback the current transaction or nested transaction. - */ - public function rollback(): void { - if ( 0 === $this->transaction_level ) { - return; - } - - --$this->transaction_level; - if ( 0 === $this->transaction_level ) { - $this->execute_sqlite_query( 'ROLLBACK' ); - } else { - $savepoint_name = $this->get_internal_savepoint_name( $this->transaction_level ); - $this->execute_sqlite_query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) ); - } - } - /** * Translate and execute a MySQL query in SQLite. * @@ -1162,7 +1142,7 @@ private function execute_mysql_query( WP_Parser_Node $node ): void { } if ( 'beginWork' === $children[0]->rule_name ) { - $this->begin_transaction(); + $this->begin_user_transaction(); return; } @@ -1291,6 +1271,145 @@ private function execute_mysql_query( WP_Parser_Node $node ): void { } } + /** + * Begin a wrapper transaction. + * + * A wrapper transaction is used to ensure consistency by encapsulating SQLite + * statements that are executed during a single MySQL query emulation process. + * + * TOP-LEVEL TRANSACTION vs. SAVEPOINT: + * + * When no transaction is active, we can use a top-level TRANSACTION to wrap + * the emulated MySQL statement. However, if a transaction is already active, + * we must use a SAVEPOINT, as SQLite doesn't support transaction nesting. + * + * BEGIN vs. BEGIN IMMEDIATE: + * + * When we're executing a statement that will need to write to the database, + * we must use "BEGIN IMMEDIATE" to immediately open a write transaction. + * + * This is needed to avoid the "database is locked" error (SQLITE_BUSY) when + * SQLite can't upgrade a read transaction to a write transaction, because + * another connection is already modifying the database. + * + * From the SQLite documentation: + * + * ## Read transactions versus write transactions + * + * If a write statement occurs while a read transaction is active, + * then the read transaction is upgraded to a write transaction if + * possible. If some other database connection has already modified + * the database or is already in the process of modifying the database, + * then upgrading to a write transaction is not possible and the write + * statement will fail with SQLITE_BUSY. + * + * ## DEFERRED, IMMEDIATE, and EXCLUSIVE transactions + * + * Transactions can be DEFERRED, IMMEDIATE, or EXCLUSIVE. The default + * transaction behavior is DEFERRED. + * + * DEFERRED means that the transaction does not actually start until + * the database is first accessed. + * + * IMMEDIATE causes the database connection to start a new write + * immediately, without waiting for a write statement. The BEGIN + * IMMEDIATE might fail with SQLITE_BUSY if another write transaction + * is already active on another database connection. + * + * See: + * - https://www.sqlite.org/lang_transaction.html + * - https://www.sqlite.org/rescode.html#busy + * + * For better performance, we could also consider opening the write + * transaction later in the session - just before the first write. + */ + private function begin_wrapper_transaction(): void { + if ( null !== $this->wrapper_transaction_type ) { + return; + } + + $wrapper_transaction_type = $this->wrapper_transaction_type; + if ( $this->inTransaction() ) { + $savepoint_name = $this->get_internal_savepoint_name( 'wrapper' ); + $stmt = $this->connection->prepare( sprintf( 'SAVEPOINT %s', $savepoint_name ) ); + $wrapper_transaction_type = 'savepoint'; + } else { + // For write transactions, we must use "BEGIN IMMEDIATE". + $stmt = $this->connection->prepare( $this->is_readonly ? 'BEGIN' : 'BEGIN IMMEDIATE' ); + $wrapper_transaction_type = 'transaction'; + } + + if ( ! $stmt->execute() ) { + throw $this->new_driver_exception( 'Failed to begin wrapper transaction.' ); + } + $this->wrapper_transaction_type = $wrapper_transaction_type; + } + + /** + * Commit a wrapper transaction. + */ + private function commit_wrapper_transaction(): void { + if ( null === $this->wrapper_transaction_type ) { + return; + } + + if ( 'savepoint' === $this->wrapper_transaction_type ) { + $savepoint_name = $this->get_internal_savepoint_name( 'wrapper' ); + $stmt = $this->connection->prepare( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); + } else { + $stmt = $this->connection->prepare( 'COMMIT' ); + } + + if ( ! $stmt->execute() ) { + throw $this->new_driver_exception( 'Failed to commit wrapper transaction.' ); + } + $this->wrapper_transaction_type = null; + } + + /** + * Execute the "BEGIN" or "START TRANSACTION" MySQL statement in SQLite. + */ + private function begin_user_transaction(): void { + // MySQL implicitly commits previous transaction when starting a new one. + if ( $this->inTransaction() ) { + $this->commit_user_transaction(); + } + + /* + * Since we don't know whether the user will write to the database, we + * must use "BEGIN IMMEDIATE" to immediately open a write transaction. + * + * This is needed to avoid the "database is locked" error (SQLITE_BUSY) + * when SQLite can't upgrade a read transaction to a write transaction, + * because another connection is already modifying the database. + * + * @see self::begin_wrapper_transaction() + */ + $this->connection->query( 'BEGIN IMMEDIATE' ); + } + + /** + * Execute the "COMMIT" MySQL statement in SQLite. + */ + private function commit_user_transaction(): void { + // MySQL doesn't throw an error if there is no active transaction. + if ( ! $this->inTransaction() ) { + return; + } + $this->connection->query( 'COMMIT' ); + } + + /** + * 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; + } + $this->connection->query( 'ROLLBACK' ); + } + /** * Execute a MySQL transaction or locking statement in SQLite. * @@ -1305,13 +1424,13 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node case 'transactionStatement': // START TRANSACTION. if ( WP_MySQL_Lexer::START_SYMBOL === $token->id ) { - $this->begin_transaction(); + $this->begin_user_transaction(); return; } // COMMIT. if ( WP_MySQL_Lexer::COMMIT_SYMBOL === $token->id ) { - $this->commit(); + $this->commit_user_transaction(); return; } @@ -1322,7 +1441,7 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node // ROLLBACK/ROLLBACK TO SAVEPOINT . if ( WP_MySQL_Lexer::ROLLBACK_SYMBOL === $token->id ) { if ( null === $savepoint_name ) { - $this->rollback(); + $this->rollback_user_transaction(); } else { $this->execute_sqlite_query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) ); } @@ -1375,11 +1494,8 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node } } - // Start a transaction when no top-level transaction is active. - if ( 0 === $this->transaction_level ) { - $this->begin_transaction(); - $this->table_lock_active = true; - } + $this->begin_user_transaction(); + $this->table_lock_active = true; return; } @@ -1392,8 +1508,8 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node ) ) { // Commit the transaction when created by the LOCK statement. - if ( 1 === $this->transaction_level && $this->table_lock_active ) { - $this->commit(); + if ( $this->table_lock_active && $this->inTransaction() ) { + $this->commit_user_transaction(); $this->table_lock_active = false; } return; @@ -5867,11 +5983,11 @@ private function get_sqlite_index_name( string $mysql_table_name, string $mysql_ * Internal savepoints are used to emulate MySQL transactions that are run * inside a wrapping SQLite transaction, as transactions can't be nested. * - * @param int $level The transaction nesting level. - * @return string The internal savepoint name. + * @param string $name The name of the savepoint. + * @return string The internal savepoint name. */ - private function get_internal_savepoint_name( int $level ): string { - return sprintf( '%ssavepoint_%d', self::RESERVED_PREFIX, $level ); + private function get_internal_savepoint_name( string $name ): string { + return sprintf( '%ssavepoint_%s', self::RESERVED_PREFIX, $name ); } /** @@ -5994,12 +6110,13 @@ private function quote_mysql_utf8_string_literal( string $utf8_literal ): string * Clear the state of the driver. */ private function flush(): void { - $this->last_mysql_query = ''; - $this->last_sqlite_queries = array(); - $this->last_result = null; - $this->last_return_value = null; - $this->last_column_meta = array(); - $this->is_readonly = false; + $this->last_mysql_query = ''; + $this->last_sqlite_queries = array(); + $this->last_result = null; + $this->last_return_value = null; + $this->last_column_meta = array(); + $this->is_readonly = false; + $this->wrapper_transaction_type = null; } /** From 816e0b57b0a4ca2b77f203311bea6fd8adb51cb1 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 17 Dec 2025 18:46:23 +0100 Subject: [PATCH 2/3] Fix PDO::inTransaction() for SQLite on PHP < 8.4 --- .../sqlite-ast/class-wp-sqlite-driver.php | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index 447450a9..75d368d5 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -504,6 +504,19 @@ class WP_SQLite_Driver { */ private $wrapper_transaction_type = null; + /** + * Whether an SQLite transaction is active in the current session. + * + * This is a polyfill of the "PDO::inTransaction()" method for PHP < 8.4, + * where the "PDO::inTransaction()" method is not reliable with SQLite. + * + * @see https://bugs.php.net/bug.php?id=81227 + * @see https://github.com/php/php-src/pull/14268 + * + * @var bool + */ + private $in_transaction = false; + /** * Whether a MySQL table lock is active. * @@ -728,6 +741,15 @@ public function rollBack(): bool { */ // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid public function inTransaction(): bool { + if ( PHP_VERSION_ID < 80400 ) { + /* + * On PHP < 8.4, the "PDO::inTransaction()" method is not reliable. + * + * @see https://bugs.php.net/bug.php?id=81227 + * @see https://github.com/php/php-src/pull/14268 + */ + return $this->in_transaction; + } return $this->connection->get_pdo()->inTransaction(); } @@ -1335,6 +1357,7 @@ private function begin_wrapper_transaction(): void { $wrapper_transaction_type = 'savepoint'; } else { // For write transactions, we must use "BEGIN IMMEDIATE". + // @see self::begin_user_transaction() method comments. $stmt = $this->connection->prepare( $this->is_readonly ? 'BEGIN' : 'BEGIN IMMEDIATE' ); $wrapper_transaction_type = 'transaction'; } @@ -1343,6 +1366,7 @@ private function begin_wrapper_transaction(): void { throw $this->new_driver_exception( 'Failed to begin wrapper transaction.' ); } $this->wrapper_transaction_type = $wrapper_transaction_type; + $this->in_transaction = true; } /** @@ -1353,17 +1377,20 @@ private function commit_wrapper_transaction(): void { return; } + $in_transaction = $this->in_transaction; if ( 'savepoint' === $this->wrapper_transaction_type ) { $savepoint_name = $this->get_internal_savepoint_name( 'wrapper' ); $stmt = $this->connection->prepare( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); } else { - $stmt = $this->connection->prepare( 'COMMIT' ); + $stmt = $this->connection->prepare( 'COMMIT' ); + $in_transaction = false; } if ( ! $stmt->execute() ) { throw $this->new_driver_exception( 'Failed to commit wrapper transaction.' ); } $this->wrapper_transaction_type = null; + $this->in_transaction = $in_transaction; } /** @@ -1386,6 +1413,7 @@ private function begin_user_transaction(): void { * @see self::begin_wrapper_transaction() */ $this->connection->query( 'BEGIN IMMEDIATE' ); + $this->in_transaction = true; } /** @@ -1397,6 +1425,7 @@ private function commit_user_transaction(): void { return; } $this->connection->query( 'COMMIT' ); + $this->in_transaction = false; } /** @@ -1408,6 +1437,7 @@ private function rollback_user_transaction(): void { return; } $this->connection->query( 'ROLLBACK' ); + $this->in_transaction = false; } /** From 56c11650c16b85d29c05a333e9adbac0d50cc2e1 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 18 Dec 2025 10:25:35 +0100 Subject: [PATCH 3/3] Add a test for repeated transaction commands --- tests/WP_SQLite_Driver_Tests.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 764b99a8..cc33e352 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -2490,6 +2490,28 @@ public function testStartTransactionCommand() { $this->assertCount( 0, $this->engine->get_query_results() ); } + public function testRepeatedTransactionCommands(): void { + $this->assertQuery( 'CREATE TABLE t (id INT)' ); + + // 1st BEGIN starts a transaction. + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1);' ); + + // 2nd BEGIN commits the previous transaction and starts a new one. + $this->assertQuery( 'BEGIN' ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (2);' ); + + // ROLLBACK rolls back the 2nd transaction. + $this->assertQuery( 'ROLLBACK' ); + $results = $this->assertQuery( 'SELECT * FROM t;' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $results ); + + // Repeated ROLLBACK should do nothing. + $this->assertQuery( 'ROLLBACK' ); + $results = $this->assertQuery( 'SELECT * FROM t;' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $results ); + } + public function testCount() { $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('first');" ); $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('second');" );