Skip to content
Merged
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
72 changes: 28 additions & 44 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 @@ -82,12 +82,6 @@ class WP_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().
*/
private const EMPTY_RESULT_TABLE_NAME = self::RESERVED_PREFIX . 'empty_result';

/**
* The name of the SQLite driver version variable.
*
Expand Down Expand Up @@ -678,14 +672,6 @@ class WP_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 @@ -1145,8 +1131,8 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar
* [GRAMMAR]
* simpleStatement: selectStatement | showStatement | utilityStatement | ...
*/
if ( null !== $child_node && $child_node->has_child_node() ) {
$statement_node = $child_node->get_first_child_node();
$statement_node = null !== $child_node ? $child_node->get_first_child_node() : null;
if ( null !== $statement_node ) {
if (
'selectStatement' === $statement_node->rule_name
|| 'showStatement' === $statement_node->rule_name
Expand Down Expand Up @@ -1180,11 +1166,25 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar
$this->last_result_statement = $this->create_result_statement_from_data( array(), array() );
}

/*
* PDO SQLite reports affected rows only for INSERT, UPDATE, and DELETE.
* For other statements, it can report the row count of the last write,
* including the driver's internal writes. Use 0 for them instead.
*
* [GRAMMAR]
* simpleStatement: insertStatement | replaceStatement | updateStatement | deleteStatement | ...
*/
$reports_affected_rows = null !== $statement_node && in_array(
$statement_node->rule_name,
array( 'insertStatement', 'replaceStatement', 'updateStatement', 'deleteStatement' ),
true
);

$stmt = new WP_MySQL_On_SQLite_Statement(
$this->last_result_statement,
$query,
$this->create_column_meta_resolver( $this->last_column_meta ),
$this->last_affected_rows
$reports_affected_rows ? $this->last_affected_rows : 0
);
if ( null !== $fetch_mode ) {
$stmt->setFetchMode( $fetch_mode, ...$fetch_mode_args );
Expand Down Expand Up @@ -7738,38 +7738,22 @@ private function flush(): void {
* @return PDOStatement The corresponding PDO SQLite statement.
*/
private function create_result_statement_from_data( array $columns, array $rows ): PDOStatement {
/*
* The statement writes nothing, but PDO SQLite can report the row count
* of the last write for it. Default the affected rows to 0.
*/
$this->last_affected_rows = $this->last_affected_rows ?? 0;

$pdo = $this->connection->get_pdo();

/*
* With 0 columns, we need to create a PDO statement that has no columns.
* This can be done using a noop INSERT statement that modifies no data.
* With 0 columns, we need a PDO statement with no columns and no rows.
*
* An unknown pragma has no columns, needs no table, takes no locks,
* and SQLite ignores it.
*/
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 ( placeholder_for_empty_insert ) SELECT NULL WHERE FALSE',
$this->quote_sqlite_identifier( self::EMPTY_RESULT_TABLE_NAME )
)
);
return $pdo->query( 'PRAGMA ' . self::RESERVED_PREFIX . 'empty_result' );
}

/*
Expand Down
115 changes: 115 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 @@ -529,6 +529,59 @@ function () {
$this->assertSame( array( '00000', null, null ), $stmt->errorInfo() );
}

public function test_empty_result_statement(): void {
// The row count of a previous write must not leak into the empty result.
$this->driver->exec( 'CREATE TABLE t (id INT)' );
$this->driver->exec( 'INSERT INTO t VALUES (1), (2), (3)' );
$stmt = $this->driver->query( 'SET @a = 1' );

// Check the error state and iteration first, as fetching resets the error state.
$this->assertSame( '00000', $stmt->errorCode() );
$this->assertSame( array( '00000', null, null ), $stmt->errorInfo() );
$this->assertSame( array(), iterator_to_array( $stmt ) );

$this->assertSame( 0, $stmt->columnCount() );
$this->assertSame( 0, $stmt->rowCount() );
$this->assertFalse( $stmt->getColumnMeta( 0 ) );

$fetch_modes = array(
PDO::FETCH_BOTH,
PDO::FETCH_NUM,
PDO::FETCH_ASSOC,
PDO::FETCH_NAMED,
PDO::FETCH_OBJ,
PDO::FETCH_COLUMN,
PDO::FETCH_KEY_PAIR,
);
foreach ( $fetch_modes as $mode ) {
$this->assertFalse( $stmt->fetch( $mode ) );
$this->assertSame( array(), $stmt->fetchAll( $mode ) );
}
$this->assertFalse( $stmt->fetchColumn() );
$this->assertFalse( $stmt->fetchObject() );
$this->assertSame( array(), $stmt->fetchAll( PDO::FETCH_GROUP | PDO::FETCH_ASSOC ) );
$this->assertSame( array(), $stmt->fetchAll( PDO::FETCH_UNIQUE | PDO::FETCH_ASSOC ) );
$this->assertSame( array(), $stmt->fetchAll( PDO::FETCH_CLASS, stdClass::class ) );

$this->assertTrue( $stmt->setFetchMode( PDO::FETCH_CLASS, stdClass::class ) );
$this->assertFalse( $stmt->fetch() );

$value = 'unchanged';
$this->assertTrue( $stmt->bindColumn( 1, $value ) );
$this->assertFalse( $stmt->fetch( PDO::FETCH_BOUND ) );
$this->assertSame( 'unchanged', $value );

$this->assertTrue( $stmt->closeCursor() );
$this->assertFalse( $stmt->fetch() );

// Holding an unread statement must not block a commit or a schema change.
$stmt = $this->driver->query( 'SET @a = 1' );
$this->driver->exec( 'START TRANSACTION' );
$this->driver->exec( 'INSERT INTO t VALUES (4)' );
$this->assertSame( 0, $this->driver->exec( 'COMMIT' ) );
$this->assertSame( 0, $this->driver->exec( 'DROP TABLE t' ) );
}

public function test_statement_iteration(): void {
$stmt = $this->driver->query( 'SELECT 1 AS value UNION ALL SELECT 2', PDO::FETCH_ASSOC );

Expand Down Expand Up @@ -715,6 +768,45 @@ public function test_exec(): void {
$this->assertEquals( 0, $result );
}

public function test_row_count_of_result_set_without_rows(): void {
// The row count of a previous write must not leak into the result set.
$this->driver->exec( 'CREATE TABLE t (id INT)' );
$this->assertSame( 0, $this->driver->query( 'SELECT * FROM t' )->rowCount() );

$this->driver->exec( 'INSERT INTO t VALUES (1), (2), (3)' );
$this->assertSame( 0, $this->driver->query( 'SELECT * FROM t WHERE id > 3' )->rowCount() );
$this->assertSame( 0, $this->driver->query( 'SELECT 1 WHERE FALSE' )->rowCount() );
$this->assertSame( 0, $this->driver->query( "SHOW TABLES LIKE 'missing'" )->rowCount() );

$this->driver->exec( 'INSERT INTO t VALUES (4), (5), (6)' );
$this->driver->exec( 'SET @a = 1' );
$this->assertSame( 0, $this->driver->query( 'SELECT * FROM t WHERE id > 6' )->rowCount() );
}

public function test_row_count_of_non_dml_statements(): void {
// The row count of the internal writes of CREATE TABLE must not leak.
$this->driver->exec( 'CREATE TABLE t (id INT PRIMARY KEY AUTO_INCREMENT, a INT, b INT)' );
$this->assertSame( 0, $this->driver->exec( 'DROP VIEW IF EXISTS v' ) );
$this->assertSame( 0, $this->driver->exec( 'DROP TRIGGER IF EXISTS tr' ) );

// The row count of a previous write must not leak.
$this->driver->exec( 'INSERT INTO t (a, b) VALUES (1, 1), (2, 2), (3, 3)' );
$this->assertSame( 0, $this->driver->exec( 'DROP VIEW IF EXISTS v' ) );
$this->assertSame( 0, $this->driver->exec( 'DROP TRIGGER IF EXISTS tr' ) );

// TRUNCATE reports 0 affected rows in MySQL, regardless of the deleted rows.
$this->assertSame( 0, $this->driver->exec( 'TRUNCATE TABLE t' ) );

// DML statements still report their affected rows.
$this->assertSame( 3, $this->driver->exec( 'INSERT INTO t (a, b) VALUES (1, 1), (2, 2), (3, 3)' ) );
$this->assertSame( 1, $this->driver->exec( 'REPLACE INTO t (id, a, b) VALUES (4, 4, 4)' ) );
$this->assertSame( 2, $this->driver->exec( 'UPDATE t SET b = 0 WHERE a > 2' ) );
$this->driver->exec( 'CREATE TABLE u (id INT)' );
$this->driver->exec( 'INSERT INTO u VALUES (1), (2)' );
$this->assertSame( 2, $this->driver->exec( 'DELETE t FROM t JOIN u ON t.id = u.id' ) );
$this->assertSame( 2, $this->driver->exec( 'DELETE FROM t' ) );
}

public function test_last_insert_id(): void {
$this->assertSame( '0', $this->driver->lastInsertId() );

Expand Down Expand Up @@ -888,6 +980,29 @@ public function test_rollback(): void {
$this->assertTrue( $result );
}

public function test_rollback_statement_as_first_transaction_on_connection(): void {
// Roll back the first transaction on the connection.
$this->driver->exec( 'START TRANSACTION' );
$this->driver->exec( 'ROLLBACK' );
$this->assertFalse( $this->driver->inTransaction() );

// Statements with empty result sets keep working after the rollback.
$this->driver->exec( 'START TRANSACTION' );
$this->driver->exec( 'CREATE TABLE t (id INT)' );
$this->driver->exec( 'ROLLBACK' );
$this->assertSame( 0, $this->driver->exec( 'CREATE TABLE t (id INT)' ) );
}

public function test_rollback_method_as_first_transaction_on_connection(): void {
// Roll back the first transaction on the connection.
$this->driver->beginTransaction();
$this->driver->exec( 'CREATE TABLE t (id INT)' );
$this->assertTrue( $this->driver->rollBack() );

// Statements with empty result sets keep working after the rollback.
$this->assertSame( 0, $this->driver->exec( 'CREATE TABLE t (id INT)' ) );
}

public function test_rollback_no_active_transaction(): void {
$this->expectException( PDOException::class );
$this->expectExceptionMessage( 'There is no active transaction' );
Expand Down
Loading