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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ class WP_PDO_MySQL_On_SQLite extends PDO {
*/
const GLOBAL_VARIABLES_TABLE_NAME = self::RESERVED_PREFIX . 'global_variables';

/**
* Name of the connection-private TEMP table used to build empty result sets
* without acquiring a write lock on the database. See create_result_statement_from_data().
*/
const EMPTY_RESULT_TABLE_NAME = self::RESERVED_PREFIX . 'empty_result';

/**
* The name of the SQLite driver version variable.
*
Expand Down Expand Up @@ -527,6 +533,14 @@ class WP_PDO_MySQL_On_SQLite extends PDO {
*/
private $is_readonly;

/**
* Whether the TEMP table backing empty result sets has been created on this
* connection. See create_result_statement_from_data().
*
* @var bool
*/
private $empty_result_table_ready = false;

/**
* Type of wrapper transaction that is active for the MySQL query emulation.
*
Expand Down Expand Up @@ -920,6 +934,12 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar
if (
'selectStatement' === $statement_node->rule_name
|| 'showStatement' === $statement_node->rule_name
// Supported SET statements mutate only connection-local driver state
// (sql_mode, user variables, etc.) or fail before touching SQLite.
// They do not write to the SQLite database, so they must not take a
// write lock. Use a deferred BEGIN instead of BEGIN IMMEDIATE.
// This mirrors the SHOW/DESCRIBE handling.
|| 'setStatement' === $statement_node->rule_name
) {
$this->is_readonly = true;
} elseif ( 'utilityStatement' === $statement_node->rule_name ) {
Expand Down Expand Up @@ -7003,10 +7023,29 @@ private function create_result_statement_from_data( array $columns, array $rows
* This can be done using a noop INSERT statement that modifies no data.
*/
if ( 0 === count( $columns ) ) {
/*
* Build a 0-column statement to represent an empty result set.
*
* A no-op "INSERT ... WHERE FALSE" into a regular table still acquires
* SQLite's write lock, even though it writes nothing. That makes
* no-write statements such as "SET sql_mode = ..." fatal with
* "database is locked" whenever another connection holds the write
* lock. Writing to a connection-private TEMP table uses the temp
* database, which has no shared lock, so it never contends.
*/
if ( ! $this->empty_result_table_ready ) {
$pdo->exec(
sprintf(
'CREATE TEMP TABLE IF NOT EXISTS %s ( placeholder_for_empty_insert )',
$this->quote_sqlite_identifier( self::EMPTY_RESULT_TABLE_NAME )
)
);
$this->empty_result_table_ready = true;
}
return $pdo->query(
sprintf(
'INSERT INTO %s (rowid) SELECT NULL WHERE FALSE',
$this->quote_sqlite_identifier( self::GLOBAL_VARIABLES_TABLE_NAME )
'INSERT INTO %s ( placeholder_for_empty_insert ) SELECT NULL WHERE FALSE',
$this->quote_sqlite_identifier( self::EMPTY_RESULT_TABLE_NAME )
)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,45 @@ public function testDescribeQuerySucceedsWhileAnotherConnectionHoldsWriteLock():
$this->assertReadOnlyQuerySucceedsUnderWriteLock( 'DESCRIBE t' );
}

public function testSetQueryOpensReadOnlyTransaction(): void {
$driver = $this->create_in_memory_driver();
$driver->query( 'CREATE TABLE t (id INT, name VARCHAR(255))' );

$driver->query( "SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION'" );

$this->assertSame( 'BEGIN', $driver->get_last_sqlite_queries()[0]['sql'] );
}

public function testSetQuerySucceedsWhileAnotherConnectionHoldsWriteLock(): void {
// Connection A: set up the database and hold a write transaction.
$conn_a = new WP_SQLite_Connection( array( 'path' => $this->db_path ) );
$driver_a = new WP_SQLite_Driver( $conn_a, 'wp' );
$driver_a->query( 'CREATE TABLE t (id INT, name VARCHAR(255))' );
$driver_a->query( "INSERT INTO t VALUES (1, 'Alice')" );

// Simulate another PHP process holding a write transaction.
$conn_a->get_pdo()->exec( 'BEGIN IMMEDIATE' );

try {
// Connection B with zero timeout — any lock conflict fails immediately.
$conn_b = new WP_SQLite_Connection(
array(
'path' => $this->db_path,
'timeout' => 0,
)
);
$driver_b = new WP_SQLite_Driver( $conn_b, 'wp' );
$conn_b->get_pdo()->setAttribute( PDO::ATTR_TIMEOUT, 0 );

// SET writes nothing, so it must not contend for the write lock.
$result = $driver_b->query( "SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION'" );

$this->assertSame( 0, $result );
} finally {
$conn_a->get_pdo()->exec( 'ROLLBACK' );
}
}

private function assertReadOnlyQuerySucceedsUnderWriteLock( string $query ): void {
// Connection A: set up the database.
$conn_a = new WP_SQLite_Connection( array( 'path' => $this->db_path ) );
Expand Down
Loading