Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 78 additions & 8 deletions packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -1182,11 +1193,16 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar
$this->error_info = array( '00000', null, null );
return $stmt;
} catch ( Throwable $e ) {
try {
$this->rollback_user_transaction();
$this->table_lock_active = false;
} catch ( Throwable $rollback_exception ) {
// Ignore rollback errors.
// 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();
$this->table_lock_active = false;
} catch ( Throwable $rollback_exception ) {
// Ignore rollback errors.
}
}
if ( $e instanceof WP_SQLite_Information_Schema_Exception ) {
$e = $this->convert_information_schema_exception( $e );
Expand Down Expand Up @@ -2092,7 +2108,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();
}

/**
Expand All @@ -2104,7 +2121,8 @@ private function commit_user_transaction(): void {
return;
}
$this->connection->query( 'COMMIT' );
$this->in_transaction = false;
$this->in_transaction = false;
$this->savepoint_names = array();
}

/**
Expand All @@ -2116,7 +2134,8 @@ private function rollback_user_transaction(): void {
return;
}
$this->connection->query( 'ROLLBACK' );
$this->in_transaction = false;
$this->in_transaction = false;
$this->savepoint_names = array();
}

/**
Expand Down Expand Up @@ -2146,26 +2165,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 <identifier>.
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;
}

Expand Down Expand Up @@ -7816,6 +7867,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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')" ),
Expand Down
112 changes: 112 additions & 0 deletions packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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" );
Expand Down
Loading
Loading