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 5c31f6bf4..06218632c 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 @@ -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. * @@ -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. * @@ -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 @@ -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 ); @@ -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' ); } /* 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 65a25f22c..ceef0137c 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 @@ -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 ); @@ -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() ); @@ -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' );