From 011a6286b1d7cd5086d38b7e1b896afbbc7367dc Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 10 Dec 2025 15:09:44 +0100 Subject: [PATCH 01/23] Make WP_PDO_MySQL_On_SQLite extend PDO, add a dummy WP_PDO_Synthetic_Statement --- .../WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 13 +- .../class-wp-pdo-mysql-on-sqlite.php | 60 +++- .../class-wp-pdo-synthetic-statement.php | 308 ++++++++++++++++++ .../sqlite-ast/class-wp-sqlite-driver.php | 13 +- wp-pdo-mysql-on-sqlite.php | 1 + 5 files changed, 373 insertions(+), 22 deletions(-) create mode 100644 wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index c6ac2017..6dac0871 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -7,8 +7,17 @@ class WP_PDO_MySQL_On_SQLite_PDO_API_Tests extends TestCase { private $driver; public function setUp(): void { - $connection = new WP_SQLite_Connection( array( 'path' => ':memory:' ) ); - $this->driver = new WP_PDO_MySQL_On_SQLite( $connection, 'wp' ); + $this->driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;' ); + } + + public function test_connection(): void { + $driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=WordPress;' ); + $this->assertInstanceOf( PDO::class, $driver ); + } + + public function test_query(): void { + $result = $this->driver->query( "SELECT 1, 'abc'" ); + $this->assertInstanceOf( PDOStatement::class, $result ); } public function test_begin_transaction(): void { diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 659b3f1c..f4532883 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -14,7 +14,7 @@ * * The driver requires PDO with the SQLite driver, and the PCRE engine. */ -class WP_PDO_MySQL_On_SQLite { +class WP_PDO_MySQL_On_SQLite extends PDO { /** * The path to the MySQL SQL grammar file. */ @@ -579,24 +579,51 @@ class WP_PDO_MySQL_On_SQLite { private $user_variables = array(); /** - * Constructor. + * PDO API: Constructor. * * Set up an SQLite connection and the MySQL-on-SQLite driver. * * @param WP_SQLite_Connection $connection A SQLite database connection. - * @param string $database The database name. + * @param string $db_name The database name. * * @throws WP_SQLite_Driver_Exception When the driver initialization fails. */ public function __construct( - WP_SQLite_Connection $connection, - string $database, - int $mysql_version = 80038 + string $dsn, + ?string $username = null, + ?string $password = null, + array $options = array() ) { - $this->mysql_version = $mysql_version; - $this->connection = $connection; - $this->main_db_name = $database; - $this->db_name = $database; + // Parse the DSN. + $dsn_parts = explode( ':', $dsn, 2 ); + if ( count( $dsn_parts ) < 2 ) { + throw new PDOException( 'invalid data source name' ); + } + + $driver = $dsn_parts[0]; + if ( 'mysql-on-sqlite' !== $driver ) { + throw new PDOException( 'could not find driver' ); + } + + $args = array(); + foreach ( explode( ';', $dsn_parts[1] ) as $arg ) { + $arg_parts = explode( '=', $arg, 2 ); + $args[ $arg_parts[0] ] = $arg_parts[1] ?? null; + } + + $path = $args['path'] ?? ':memory:'; + $db_name = $args['dbname'] ?? 'sqlite_database'; + + // Create a new SQLite connection. + if ( isset( $options['pdo'] ) ) { + $this->connection = new WP_SQLite_Connection( array( 'pdo' => $options['pdo'] ) ); + } else { + $this->connection = new WP_SQLite_Connection( array( 'path' => $path ) ); + } + + $this->mysql_version = $options['mysql_version'] ?? 80038; + $this->main_db_name = $db_name; + $this->db_name = $db_name; // Check the database name. if ( '' === $this->db_name ) { @@ -685,7 +712,7 @@ function ( string $sql, array $params ) { } /** - * Translate and execute a MySQL query in SQLite. + * PDO API: Translate and execute a MySQL query in SQLite. * * A single MySQL query can be translated into zero or more SQLite queries. * @@ -696,13 +723,9 @@ function ( string $sql, array $params ) { * @return mixed Return value, depending on the query type. * * @throws WP_SQLite_Driver_Exception When the query execution fails. - * - * TODO: - * The API of this function is not final. - * We should also add support for parametrized queries. - * See: https://github.com/Automattic/sqlite-database-integration/issues/7 */ - public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + #[ReturnTypeWillChange] + public function query( string $query, ?int $fetch_mode = PDO::FETCH_COLUMN, ...$fetch_mode_args ) { $this->flush(); $this->pdo_fetch_mode = $fetch_mode; $this->last_mysql_query = $query; @@ -748,7 +771,8 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo if ( $wrap_in_transaction ) { $this->commit_wrapper_transaction(); } - return $this->last_return_value; + + return new WP_PDO_Synthetic_Statement(); } catch ( Throwable $e ) { try { $this->rollback_user_transaction(); diff --git a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php new file mode 100644 index 00000000..76dd6ff3 --- /dev/null +++ b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php @@ -0,0 +1,308 @@ +fetchAllRows( $mode, $class_name, $constructor_args ); + } + } +} else { + trait WP_PDO_Synthetic_Statement_PHP_Compat { + /** + * Set the default fetch mode for this statement. + * + * @param int $mode The fetch mode to set as the default. + * @param mixed $args Additional parameters for the default fetch mode. + * @return bool True on success, false on failure. + */ + #[ReturnTypeWillChange] + public function setFetchMode( $mode, ...$args ): bool { + $this->fetch_mode = $mode; + return true; + } + + /** + * Fetch all remaining rows from the result set. + * + * @param int $mode The fetch mode to use. + * @param mixed $args Additional parameters for the fetch mode. + * @return array The result set as an array of rows. + */ + public function fetchAll( $mode = PDO::FETCH_DEFAULT, ...$args ): array { + return $this->fetchAllRows( $mode, ...$args ); + } + } +} + +/** + * PDOStatement implementation that operates on in-memory data. + * + * This class implements a complete PDOStatement interface on top of PHP arrays. + * It is used for result sets that are composed or transformed in the PHP layer. + */ +class WP_PDO_Synthetic_Statement extends PDOStatement { + use WP_PDO_Synthetic_Statement_PHP_Compat; + + /** + * Execute a prepared statement. + * + * @param mixed $params The values to bind to the parameters of the prepared statement. + * @return bool True on success, false on failure. + */ + public function execute( $params = null ): bool { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Get the number of columns in the result set. + * + * @return int The number of columns in the result set. + */ + public function columnCount(): int { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Get the number of rows affected by the statement. + * + * @return int The number of rows affected by the statement. + */ + public function rowCount(): int { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Fetch the next row from the result set. + * + * @param int|null $mode The fetch mode. Controls how the row is returned. + * Default: PDO::FETCH_DEFAULT (null for PHP < 8.0) + * @param int|null $cursorOrientation The cursor orientation. Controls which row is returned. + * Default: PDO::FETCH_ORI_NEXT (null for PHP < 8.0) + * @param int|null $cursorOffset The cursor offset. Controls which row is returned. + * Default: 0 (null for PHP < 8.0) + * @return mixed The row data formatted according to the fetch mode; + * false if there are no more rows or a failure occurs. + */ + #[ReturnTypeWillChange] + public function fetch( + $mode = 0, // PDO::FETCH_DEFAULT (available from PHP 8.0) + $cursorOrientation = 0, + $cursorOffset = 0 + ) { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Fetch a single column from the next row of a result set. + * + * @param int $column The index of the column to fetch (0-indexed). + * @return mixed The value of the column; false if there are no more rows. + */ + #[ReturnTypeWillChange] + public function fetchColumn( $column = 0 ) { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Fetch the next row as an object. + * + * @param string $class The name of the class to instantiate. + * @param array $constructorArgs The parameters to pass to the class constructor. + * @return object The next row as an object. + */ + #[ReturnTypeWillChange] + public function fetchObject( $class = 'stdClass', $constructorArgs = array() ) { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Get metadata for a column in a result set. + * + * @param int $column The index of the column (0-indexed). + * @return array|false The column metadata as an associative array, + * or false if the column does not exist. + */ + public function getColumnMeta( $column ): array { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Fetch the SQLSTATE associated with the last statement operation. + * + * @return string|null The SQLSTATE error code (as defined by the ANSI SQL standard), + * or null if there is no error. + */ + public function errorCode(): ?string { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Fetch error information associated with the last statement operation. + * + * @return array The array consists of at least the following fields: + * 0: SQLSTATE error code (as defined by the ANSI SQL standard). + * 1: Driver-specific error code. + * 2: Driver-specific error message. + */ + public function errorInfo(): array { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Get a statement attribute. + * + * @param int $attribute The attribute to get. + * @return mixed The value of the attribute. + */ + #[ReturnTypeWillChange] + public function getAttribute( $attribute ) { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Set a statement attribute. + * + * @param int $attribute The attribute to set. + * @param mixed $value The value of the attribute. + * @return bool True on success, false on failure. + */ + public function setAttribute( $attribute, $value ): bool { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Get result set as iterator. + * + * @return Iterator The iterator for the result set. + */ + public function getIterator(): Iterator { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Advances to the next rowset in a multi-rowset statement handle. + * + * @return bool True on success, false on failure. + */ + public function nextRowset(): bool { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Closes the cursor, enabling the statement to be executed again. + * + * @return bool True on success, false on failure. + */ + public function closeCursor(): bool { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Bind a column to a PHP variable. + * + * @param int|string $column Number of the column (1-indexed) or name of the column in the result set. + * @param mixed $var PHP variable to which the column will be bound. + * @param int $type Data type of the parameter, specified by the PDO::PARAM_* constants. + * @param int $maxLength A hint for pre-allocation. + * @param mixed $driverOptions Optional parameters for the driver. + * @return bool True on success, false on failure. + */ + public function bindColumn( $column, &$var, $type = null, $maxLength = null, $driverOptions = null ): bool { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Bind a parameter to a PHP variable. + * + * @param int|string $param Parameter identifier. Either a 1-indexed position of the parameter or a named parameter. + * @param mixed $var PHP variable to which the parameter will be bound. + * @param int $type Data type of the parameter, specified by the PDO::PARAM_* constants. + * @param int $maxLength Length of the data type. + * @param mixed $driverOptions Optional parameters for the driver. + * @return bool True on success, false on failure. + */ + public function bindParam( $param, &$var, $type = PDO::PARAM_STR, $maxLength = 0, $driverOptions = null ): bool { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Bind a value to a parameter. + * + * @param int|string $param Parameter identifier. Either a 1-indexed position of the parameter or a named parameter. + * @param mixed $value The value to bind to the parameter. + * @param int $type Data type of the parameter, specified by the PDO::PARAM_* constants. + * @return bool True on success, false on failure. + */ + public function bindValue( $param, $value, $type = PDO::PARAM_STR ): bool { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Dump information about the statement. + * + * Dupms the SQL query and parameters information. + * + * @return bool|null Returns null, or false on failure. + */ + public function debugDumpParams(): ?bool { + throw new RuntimeException( 'Not implemented' ); + } + + /** + * Fetch all remaining rows from the result set. + * + * This is used internally by the "WP_PDO_Synthetic_Statement_PHP_Compat" + * trait, that is defined conditionally based on the current PHP version. + * + * @param int $mode The fetch mode to use. + * @param mixed $args Additional parameters for the fetch mode. + * @return array The result set as an array of rows. + */ + private function fetchAllRows( $mode = null, ...$args ): array { + throw new RuntimeException( 'Not implemented' ); + } +} diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index a562ed59..82068705 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -56,7 +56,15 @@ public function __construct( string $database, int $mysql_version = 80038 ) { - $this->mysql_on_sqlite_driver = new WP_PDO_MySQL_On_SQLite( $connection, $database, $mysql_version ); + $this->mysql_on_sqlite_driver = new WP_PDO_MySQL_On_SQLite( + sprintf( 'mysql-on-sqlite:dbname=%s', $database ), + null, + null, + array( + 'mysql_version' => $mysql_version, + 'pdo' => $connection->get_pdo(), + ) + ); $this->main_db_name = $database; $this->client_info = $this->mysql_on_sqlite_driver->client_info; } @@ -139,7 +147,8 @@ public function get_insert_id() { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { - return $this->mysql_on_sqlite_driver->query( $query, $fetch_mode, ...$fetch_mode_args ); + $this->mysql_on_sqlite_driver->query( $query, $fetch_mode, ...$fetch_mode_args ); + return $this->mysql_on_sqlite_driver->get_query_results(); } /** diff --git a/wp-pdo-mysql-on-sqlite.php b/wp-pdo-mysql-on-sqlite.php index 2061de07..39126b56 100644 --- a/wp-pdo-mysql-on-sqlite.php +++ b/wp-pdo-mysql-on-sqlite.php @@ -20,3 +20,4 @@ require_once __DIR__ . '/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-exception.php'; require_once __DIR__ . '/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-reconstructor.php'; require_once __DIR__ . '/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php'; +require_once __DIR__ . '/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php'; From d47bb69d242d65cedd9f59e8994a1fb87ad1928a Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 11 Dec 2025 14:48:34 +0100 Subject: [PATCH 02/23] Implement PDO::exec() API --- .../WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 32 +++++++++++++++++++ .../class-wp-pdo-mysql-on-sqlite.php | 14 +++++++- .../class-wp-pdo-synthetic-statement.php | 20 +++++++++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index 6dac0871..d8867180 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -20,6 +20,38 @@ public function test_query(): void { $this->assertInstanceOf( PDOStatement::class, $result ); } + public function test_exec(): void { + $result = $this->driver->exec( 'SELECT 1' ); + $this->assertEquals( 0, $result ); + + $result = $this->driver->exec( 'CREATE TABLE t (id INT)' ); + $this->assertEquals( 0, $result ); + + $result = $this->driver->exec( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertEquals( 1, $result ); + + $result = $this->driver->exec( 'INSERT INTO t (id) VALUES (2), (3)' ); + $this->assertEquals( 2, $result ); + + $result = $this->driver->exec( 'UPDATE t SET id = 10 + id WHERE id = 0' ); + $this->assertEquals( 0, $result ); + + $result = $this->driver->exec( 'UPDATE t SET id = 10 + id WHERE id = 1' ); + $this->assertEquals( 1, $result ); + + $result = $this->driver->exec( 'UPDATE t SET id = 10 + id WHERE id < 10' ); + $this->assertEquals( 2, $result ); + + $result = $this->driver->exec( 'DELETE FROM t WHERE id = 11' ); + $this->assertEquals( 1, $result ); + + $result = $this->driver->exec( 'DELETE FROM t' ); + $this->assertEquals( 2, $result ); + + $result = $this->driver->exec( 'DROP TABLE t' ); + $this->assertEquals( 0, $result ); + } + public function test_begin_transaction(): void { $result = $this->driver->beginTransaction(); $this->assertTrue( $result ); diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index f4532883..bbcb49d9 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -772,7 +772,8 @@ public function query( string $query, ?int $fetch_mode = PDO::FETCH_COLUMN, ...$ $this->commit_wrapper_transaction(); } - return new WP_PDO_Synthetic_Statement(); + $affected_rows = is_int( $this->last_return_value ) ? $this->last_return_value : 0; + return new WP_PDO_Synthetic_Statement( $affected_rows ); } catch ( Throwable $e ) { try { $this->rollback_user_transaction(); @@ -789,6 +790,17 @@ public function query( string $query, ?int $fetch_mode = PDO::FETCH_COLUMN, ...$ } } + /** + * PDO API: Execute a MySQL statement and return the number of affected rows. + * + * @return int|false The number of affected rows or false on failure. + */ + #[ReturnTypeWillChange] + public function exec( $query ) { + $stmt = $this->query( $query ); + return $stmt->rowCount(); + } + /** * PDO API: Begin a transaction. * diff --git a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php index 76dd6ff3..d2f4ab2a 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php @@ -87,6 +87,24 @@ public function fetchAll( $mode = PDO::FETCH_DEFAULT, ...$args ): array { class WP_PDO_Synthetic_Statement extends PDOStatement { use WP_PDO_Synthetic_Statement_PHP_Compat; + /** + * The number of affected rows. + * + * @var int + */ + private $affected_rows; + + /** + * Constructor. + * + * @param int $affected_rows The number of affected rows. + */ + public function __construct( + int $affected_rows = 0 + ) { + $this->affected_rows = $affected_rows; + } + /** * Execute a prepared statement. * @@ -112,7 +130,7 @@ public function columnCount(): int { * @return int The number of rows affected by the statement. */ public function rowCount(): int { - throw new RuntimeException( 'Not implemented' ); + return $this->affected_rows; } /** From e56b913861efa6301b2d3be75c396f451ddeba13 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 19 Dec 2025 15:16:41 +0100 Subject: [PATCH 03/23] Implement and use PDOStatement fetch() and fetchAll() with basic fetch modes --- .../WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 91 ++++++++++++ .../class-wp-pdo-mysql-on-sqlite.php | 36 +++-- .../class-wp-pdo-synthetic-statement.php | 137 +++++++++++++++++- .../sqlite-ast/class-wp-sqlite-driver.php | 21 ++- 4 files changed, 264 insertions(+), 21 deletions(-) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index d8867180..56ba5398 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -18,6 +18,14 @@ public function test_connection(): void { public function test_query(): void { $result = $this->driver->query( "SELECT 1, 'abc'" ); $this->assertInstanceOf( PDOStatement::class, $result ); + $this->assertSame( + array( + 1 => 1, + 0 => 1, + 'abc' => 'abc', + ), + $result->fetch() + ); } public function test_exec(): void { @@ -91,4 +99,87 @@ public function test_rollback_no_active_transaction(): void { $this->expectExceptionCode( 0 ); $this->driver->rollBack(); } + + public function test_fetch_default(): void { + // Default fetch mode is PDO::FETCH_BOTH. + $result = $this->driver->query( "SELECT 1, 'abc', 2" ); + $this->assertSame( + array( + 1 => 1, + 0 => 1, + 'abc' => 'abc', + '2' => 2, + ), + $result->fetch() + ); + } + + /** + * @dataProvider data_pdo_fetch_methods + */ + public function test_fetch( $query, $mode, $expected ): void { + $stmt = $this->driver->query( $query ); + $result = $stmt->fetch( $mode ); + if ( is_object( $expected ) ) { + $this->assertInstanceOf( get_class( $expected ), $result ); + $this->assertEquals( $expected, $result ); + } else { + $this->assertSame( $expected, $result ); + } + } + + public function data_pdo_fetch_methods(): Generator { + // PDO::FETCH_BOTH + yield 'PDO::FETCH_BOTH' => array( + "SELECT 1, 'abc', 2, 'two' as `2`", + PDO::FETCH_BOTH, + array( + 1 => 1, + 0 => 1, + 'abc' => 'abc', + '2' => 'two', + '3' => 'two', + ), + ); + + // PDO::FETCH_NUM + yield 'PDO::FETCH_NUM' => array( + "SELECT 1, 'abc', 2, 'two' as `2`", + PDO::FETCH_NUM, + array( 1, 'abc', 2, 'two' ), + ); + + // PDO::FETCH_ASSOC + yield 'PDO::FETCH_ASSOC' => array( + "SELECT 1, 'abc', 2, 'two' as `2`", + PDO::FETCH_ASSOC, + array( + '1' => 1, + 'abc' => 'abc', + '2' => 'two', + ), + ); + + // PDO::FETCH_NAMED + yield 'PDO::FETCH_NAMED' => array( + "SELECT 1, 'abc', 2, 'two' as `2`", + PDO::FETCH_NAMED, + array( + '1' => 1, + 'abc' => 'abc', + '2' => array( 2, 'two' ), + ), + ); + + // PDO::FETCH_OBJ + yield 'PDO::FETCH_OBJ' => array( + "SELECT 1, 'abc', 2, 'two' as `2`", + PDO::FETCH_OBJ, + (object) array( + '1' => 1, + 'abc' => 'abc', + '2' => 'two', + ), + ); + } } diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index bbcb49d9..d053de52 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -727,9 +727,17 @@ function ( string $sql, array $params ) { #[ReturnTypeWillChange] public function query( string $query, ?int $fetch_mode = PDO::FETCH_COLUMN, ...$fetch_mode_args ) { $this->flush(); - $this->pdo_fetch_mode = $fetch_mode; $this->last_mysql_query = $query; + /** + * Use "PDO::FETCH_NUM" fetch mode, as the "WP_PDO_Synthetic_Statement" + * expects the row data to be passed as an array of values. + * + * @TODO: We can remove this when we use the SQLite PDOStatements directly, + * likely via a proxy, and will stop fetching the results eagerly. + */ + $this->pdo_fetch_mode = PDO::FETCH_NUM; + try { // Parse the MySQL query. $parser = $this->create_parser( $query ); @@ -772,8 +780,10 @@ public function query( string $query, ?int $fetch_mode = PDO::FETCH_COLUMN, ...$ $this->commit_wrapper_transaction(); } + $columns = is_array( $this->last_column_meta ) ? $this->last_column_meta : array(); + $rows = is_array( $this->last_result ) ? $this->last_result : array(); $affected_rows = is_int( $this->last_return_value ) ? $this->last_return_value : 0; - return new WP_PDO_Synthetic_Statement( $affected_rows ); + return new WP_PDO_Synthetic_Statement( $columns, $rows, $affected_rows ); } catch ( Throwable $e ) { try { $this->rollback_user_transaction(); @@ -2518,7 +2528,7 @@ private function execute_show_statement( WP_Parser_Node $node ): void { } else { $this->set_results_from_fetched_data( array( - (object) array( + array( 'Table' => $table_name, 'Create Table' => $sql, ), @@ -2557,7 +2567,7 @@ private function execute_show_statement( WP_Parser_Node $node ): void { case WP_MySQL_Lexer::GRANTS_SYMBOL: $this->set_results_from_fetched_data( array( - (object) array( + array( 'Grants for root@%' => 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION', ), ) @@ -2646,7 +2656,7 @@ private function execute_show_collation_statement( WP_Parser_Node $node ): void ) ); $this->store_last_column_meta_from_statement( $stmt ); - $this->set_results_from_fetched_data( $stmt->fetchAll( PDO::FETCH_OBJ ) ); + $this->set_results_from_fetched_data( $stmt->fetchAll( $this->pdo_fetch_mode ) ); } /** @@ -2680,7 +2690,7 @@ private function execute_show_databases_statement( WP_Parser_Node $node ): void ); $this->store_last_column_meta_from_statement( $stmt ); - $databases = $stmt->fetchAll( PDO::FETCH_OBJ ); + $databases = $stmt->fetchAll( $this->pdo_fetch_mode ); $this->set_results_from_fetched_data( $databases ); } @@ -2766,7 +2776,7 @@ private function execute_show_index_statement( WP_Parser_Node $node ): void { ); $this->store_last_column_meta_from_statement( $stmt ); - $index_info = $stmt->fetchAll( PDO::FETCH_OBJ ); + $index_info = $stmt->fetchAll( $this->pdo_fetch_mode ); $this->set_results_from_fetched_data( $index_info ); } @@ -2829,7 +2839,7 @@ private function execute_show_table_status_statement( WP_Parser_Node $node ): vo ); $this->store_last_column_meta_from_statement( $stmt ); - $table_info = $stmt->fetchAll( PDO::FETCH_OBJ ); + $table_info = $stmt->fetchAll( $this->pdo_fetch_mode ); if ( false === $table_info ) { $this->set_results_from_fetched_data( array() ); } @@ -2881,7 +2891,7 @@ private function execute_show_tables_statement( WP_Parser_Node $node ): void { ); $this->store_last_column_meta_from_statement( $stmt ); - $table_info = $stmt->fetchAll( PDO::FETCH_OBJ ); + $table_info = $stmt->fetchAll( $this->pdo_fetch_mode ); if ( false === $table_info ) { $this->set_results_from_fetched_data( array() ); } @@ -2954,7 +2964,7 @@ private function execute_show_columns_statement( WP_Parser_Node $node ): void { ); $this->store_last_column_meta_from_statement( $stmt ); - $column_info = $stmt->fetchAll( PDO::FETCH_OBJ ); + $column_info = $stmt->fetchAll( $this->pdo_fetch_mode ); if ( false === $column_info ) { $this->set_results_from_fetched_data( array() ); } @@ -2993,7 +3003,7 @@ private function execute_describe_statement( WP_Parser_Node $node ): void { ); $this->store_last_column_meta_from_statement( $stmt ); - $column_info = $stmt->fetchAll( PDO::FETCH_OBJ ); + $column_info = $stmt->fetchAll( $this->pdo_fetch_mode ); $this->set_results_from_fetched_data( $column_info ); } @@ -3315,14 +3325,14 @@ private function execute_administration_statement( WP_Parser_Node $node ): void $operation = strtolower( $first_token->get_value() ); foreach ( $errors as $error ) { - $results[] = (object) array( + $results[] = array( 'Table' => $this->db_name . '.' . $table_name, 'Op' => $operation, 'Msg_type' => 'Error', 'Msg_text' => $error, ); } - $results[] = (object) array( + $results[] = array( 'Table' => $this->db_name . '.' . $table_name, 'Op' => $operation, 'Msg_type' => 'status', diff --git a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php index d2f4ab2a..defe5ae9 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php @@ -87,6 +87,20 @@ public function fetchAll( $mode = PDO::FETCH_DEFAULT, ...$args ): array { class WP_PDO_Synthetic_Statement extends PDOStatement { use WP_PDO_Synthetic_Statement_PHP_Compat; + /** + * Basic column metadata (containing at least name, table name, and native type). + * + * @var array + */ + private $columns; + + /** + * Rows of the result set. + * + * @var array> + */ + private $rows; + /** * The number of affected rows. * @@ -94,14 +108,43 @@ class WP_PDO_Synthetic_Statement extends PDOStatement { */ private $affected_rows; + /** + * The current cursor offset. + * + * @var int + */ + private $cursor_offset = 0; + + /** + * The current fetch mode. + * + * TODO: Inherit this from "PDO::ATTR_DEFAULT_FETCH_MODE". + * + * @var int + */ + private $fetch_mode = PDO::FETCH_BOTH; + + /** + * Additional arguments for the current fetch mode. + * + * @var array + */ + private $fetch_mode_args = array(); + /** * Constructor. * - * @param int $affected_rows The number of affected rows. + * @param array $columns Basic column metadata (containing at least name, table name, and native type). + * @param array $rows Rows of the result set. + * @param int $affected_rows The number of affected rows. */ public function __construct( - int $affected_rows = 0 + array $columns, + array $rows, + int $affected_rows ) { + $this->columns = $columns; + $this->rows = $rows; $this->affected_rows = $affected_rows; } @@ -121,7 +164,7 @@ public function execute( $params = null ): bool { * @return int The number of columns in the result set. */ public function columnCount(): int { - throw new RuntimeException( 'Not implemented' ); + return count( $this->columns ); } /** @@ -151,7 +194,75 @@ public function fetch( $cursorOrientation = 0, $cursorOffset = 0 ) { - throw new RuntimeException( 'Not implemented' ); + if ( 0 === $mode || null === $mode ) { + $mode = $this->fetch_mode; + } + if ( null === $cursorOrientation ) { + $cursorOrientation = PDO::FETCH_ORI_NEXT; + } + if ( null === $cursorOffset ) { + $cursorOffset = 0; + } + + if ( ! array_key_exists( $this->cursor_offset, $this->rows ) ) { + return false; + } + + // Get current row data and column names. + $row = $this->rows[ $this->cursor_offset ]; + $column_names = array_column( $this->columns, 'name' ); + + // Advance the cursor to the next row. + $this->cursor_offset += 1; + + /* + * TODO: Support scrollable cursor ($cursorOrientation and $cursorOffset). + * This only has works for with statements that were prepared with + * the PDO::ATTR_CURSOR attribute set to PDO::CURSOR_SCROLL value. + * Without it, these parameters have no effect. + */ + + switch ( $mode ) { + case PDO::FETCH_BOTH: + $values = array(); + foreach ( $row as $i => $value ) { + $name = $column_names[ $i ]; + $values[ $name ] = $value; + if ( ! array_key_exists( $i, $values ) ) { + $values[ $i ] = $value; + } + } + return $values; + case PDO::FETCH_NUM: + return $row; + case PDO::FETCH_ASSOC: + return array_combine( $column_names, $row ); + case PDO::FETCH_NAMED: + $values = array(); + foreach ( $row as $i => $value ) { + $name = $column_names[ $i ]; + if ( is_array( $values[ $name ] ?? null ) ) { + $values[ $name ][] = $value; + } elseif ( array_key_exists( $name, $values ) ) { + $values[ $name ] = array( $values[ $name ], $value ); + } else { + $values[ $name ] = $value; + } + } + return $values; + case PDO::FETCH_OBJ: + return (object) array_combine( $column_names, $row ); + case PDO::FETCH_CLASS: + throw new RuntimeException( "'PDO::FETCH_CLASS' mode is not supported" ); + case PDO::FETCH_INTO: + throw new RuntimeException( "'PDO::FETCH_INTO' mode is not supported" ); + case PDO::FETCH_LAZY: + throw new RuntimeException( "'PDO::FETCH_LAZY' mode is not supported" ); + case PDO::FETCH_BOUND: + throw new RuntimeException( "'PDO::FETCH_BOUND' mode is not supported" ); + default: + throw new ValueError( sprintf( 'PDOStatement::fetch(): Argument #1 ($mode) must be a bitmask of PDO::FETCH_* constants', $mode ) ); + } } /** @@ -321,6 +432,22 @@ public function debugDumpParams(): ?bool { * @return array The result set as an array of rows. */ private function fetchAllRows( $mode = null, ...$args ): array { - throw new RuntimeException( 'Not implemented' ); + if ( null === $mode || 0 === $mode ) { + $mode = $this->fetch_mode; + } + + $rows = array(); + while ( $row = $this->fetch( $mode, ...$args ) ) { + $rows[] = $row; + } + return $rows; + } +} + +/** + * Polyfill ValueError for PHP < 8.0. + */ +if ( PHP_VERSION_ID < 80000 && ! class_exists( ValueError::class ) ) { + class ValueError extends Error { } } diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index 82068705..aee5eeca 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -41,6 +41,13 @@ class WP_SQLite_Driver { */ private $mysql_on_sqlite_driver; + /** + * Results of the last emulated query. + * + * @var mixed + */ + private $last_result; + /** * Constructor. * @@ -147,8 +154,16 @@ public function get_insert_id() { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { - $this->mysql_on_sqlite_driver->query( $query, $fetch_mode, ...$fetch_mode_args ); - return $this->mysql_on_sqlite_driver->get_query_results(); + $stmt = $this->mysql_on_sqlite_driver->query( $query, $fetch_mode, ...$fetch_mode_args ); + + if ( $stmt->columnCount() > 0 ) { + $this->last_result = $stmt->fetchAll( $fetch_mode ); + } elseif ( $stmt->rowCount() > 0 ) { + $this->last_result = $stmt->rowCount(); + } else { + $this->last_result = null; + } + return $this->last_result; } /** @@ -167,7 +182,7 @@ public function create_parser( string $query ): WP_MySQL_Parser { * @return mixed */ public function get_query_results() { - return $this->mysql_on_sqlite_driver->get_query_results(); + return $this->last_result; } /** From 6eadd3d5cf157814b5da983d3ee6282adf4f97c4 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 19 Dec 2025 16:05:52 +0100 Subject: [PATCH 04/23] Implement PDO and PDOStatement getAttribute() and setAttribute() --- .../class-wp-pdo-mysql-on-sqlite.php | 35 ++++++++++++++++++- .../class-wp-pdo-synthetic-statement.php | 22 ++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index d053de52..3c43f46b 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -443,6 +443,16 @@ class WP_PDO_MySQL_On_SQLite extends PDO { */ private $information_schema_builder; + /** + * PDO API: The PDO attributes of the connection. + * + * TODO: Add PDO default attribute values. + * + * @var array + */ + private $pdo_attributes = array( + ); + /** * Last executed MySQL query. * @@ -783,7 +793,7 @@ public function query( string $query, ?int $fetch_mode = PDO::FETCH_COLUMN, ...$ $columns = is_array( $this->last_column_meta ) ? $this->last_column_meta : array(); $rows = is_array( $this->last_result ) ? $this->last_result : array(); $affected_rows = is_int( $this->last_return_value ) ? $this->last_return_value : 0; - return new WP_PDO_Synthetic_Statement( $columns, $rows, $affected_rows ); + return new WP_PDO_Synthetic_Statement( $this, $columns, $rows, $affected_rows ); } catch ( Throwable $e ) { try { $this->rollback_user_transaction(); @@ -880,6 +890,29 @@ public function inTransaction(): bool { return $this->connection->get_pdo()->inTransaction(); } + /** + * PDO API: Set a PDO attribute. + * + * @param int $attribute The attribute to set. + * @param mixed $value The value of the attribute. + * @return bool True on success, false on failure. + */ + public function setAttribute( $attribute, $value ): bool { + $this->pdo_attributes[ $attribute ] = $value; + return true; + } + + /** + * PDO API: Get a PDO attribute. + * + * @param int $attribute The attribute to get. + * @return mixed The value of the attribute. + */ + #[ReturnTypeWillChange] + public function getAttribute( $attribute ) { + return $this->pdo_attributes[ $attribute ] ?? null; + } + /** * Get the SQLite connection instance. * diff --git a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php index defe5ae9..dc0a8715 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php @@ -87,6 +87,13 @@ public function fetchAll( $mode = PDO::FETCH_DEFAULT, ...$args ): array { class WP_PDO_Synthetic_Statement extends PDOStatement { use WP_PDO_Synthetic_Statement_PHP_Compat; + /** + * The PDO connection. + * + * @var PDO + */ + private $pdo; + /** * Basic column metadata (containing at least name, table name, and native type). * @@ -131,18 +138,28 @@ class WP_PDO_Synthetic_Statement extends PDOStatement { */ private $fetch_mode_args = array(); + /** + * The PDO attributes set for this statement. + * + * @var array + */ + private $attributes = array(); + /** * Constructor. * + * @param PDO $pdo The PDO connection. * @param array $columns Basic column metadata (containing at least name, table name, and native type). * @param array $rows Rows of the result set. * @param int $affected_rows The number of affected rows. */ public function __construct( + PDO $pdo, array $columns, array $rows, int $affected_rows ) { + $this->pdo = $pdo; $this->columns = $columns; $this->rows = $rows; $this->affected_rows = $affected_rows; @@ -329,7 +346,7 @@ public function errorInfo(): array { */ #[ReturnTypeWillChange] public function getAttribute( $attribute ) { - throw new RuntimeException( 'Not implemented' ); + return $this->attributes[ $attribute ] ?? $this->pdo->getAttribute( $attribute ); } /** @@ -340,7 +357,8 @@ public function getAttribute( $attribute ) { * @return bool True on success, false on failure. */ public function setAttribute( $attribute, $value ): bool { - throw new RuntimeException( 'Not implemented' ); + $this->attributes[ $attribute ] = $value; + return true; } /** From 5c1b7b78cf3354b16b293caa3baaf13ce7022d6f Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 19 Dec 2025 16:14:37 +0100 Subject: [PATCH 05/23] Use the PHP default for PDO::ATTR_STRINGIFY_FETCHES --- tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 4 ++++ wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php | 1 + wp-includes/sqlite-ast/class-wp-sqlite-connection.php | 3 --- wp-includes/sqlite-ast/class-wp-sqlite-driver.php | 2 ++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index 56ba5398..539f6367 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -8,6 +8,10 @@ class WP_PDO_MySQL_On_SQLite_PDO_API_Tests extends TestCase { public function setUp(): void { $this->driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;' ); + + // Set "PDO::ATTR_STRINGIFY_FETCHES" to "false" explicitly, so the tests + // are consistent across PHP versions ("false" is the default from 8.1). + $this->driver->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, false ); } public function test_connection(): void { diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 3c43f46b..193bec49 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -451,6 +451,7 @@ class WP_PDO_MySQL_On_SQLite extends PDO { * @var array */ private $pdo_attributes = array( + PDO::ATTR_STRINGIFY_FETCHES => PHP_VERSION_ID < 80100 ? true : false, ); /** diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-connection.php b/wp-includes/sqlite-ast/class-wp-sqlite-connection.php index ba607e09..1509a1e6 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-connection.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-connection.php @@ -91,9 +91,6 @@ public function __construct( array $options ) { } $this->pdo->setAttribute( PDO::ATTR_TIMEOUT, $timeout ); - // Return all values (except null) as strings. - $this->pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); - // Configure SQLite journal mode. $journal_mode = $options['journal_mode'] ?? null; if ( $journal_mode && in_array( $journal_mode, self::SQLITE_JOURNAL_MODES, true ) ) { diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index aee5eeca..6471f768 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -74,6 +74,8 @@ public function __construct( ); $this->main_db_name = $database; $this->client_info = $this->mysql_on_sqlite_driver->client_info; + + $connection->get_pdo()->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); } /** From 220c1e28c4c30d814b218d7ab73d1af258d27b2b Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 19 Dec 2025 16:24:35 +0100 Subject: [PATCH 06/23] Add a fix for PDO::ATTR_STRINGIFY_FETCHES=false with PHP < 8.1 --- .../class-wp-pdo-synthetic-statement.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php index dc0a8715..4b8cfebb 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php @@ -239,6 +239,22 @@ public function fetch( * Without it, these parameters have no effect. */ + /** + * With PHP < 8.1, the "PDO::ATTR_STRINGIFY_FETCHES" value of "false" + * is not working correctly with the PDO SQLite driver. In such case, + * we need to manually convert the row values to the correct types. + */ + if ( PHP_VERSION_ID < 80100 && ! $this->getAttribute( PDO::ATTR_STRINGIFY_FETCHES ) ) { + foreach ( $row as $i => $value ) { + $type = $this->columns[ $i ]['native_type']; + if ( 'integer' === $type ) { + $row[ $i ] = (int) $value; + } elseif ( 'float' === $type ) { + $row[ $i ] = (float) $value; + } + } + } + switch ( $mode ) { case PDO::FETCH_BOTH: $values = array(); From 011d091a954475db8cb27f2ddf061c2c61ade055 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 19 Dec 2025 16:30:51 +0100 Subject: [PATCH 07/23] Implement PDOStatement::setFetchMode() --- .../class-wp-pdo-synthetic-statement.php | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php index 4b8cfebb..d3b949df 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php @@ -35,7 +35,7 @@ trait WP_PDO_Synthetic_Statement_PHP_Compat { * @return bool True on success, false on failure. */ public function setFetchMode( $mode, $params = null ): bool { - throw new RuntimeException( 'Not implemented' ); + return $this->setDefaultFetchMode( $mode, $params ); } /** @@ -61,8 +61,7 @@ trait WP_PDO_Synthetic_Statement_PHP_Compat { */ #[ReturnTypeWillChange] public function setFetchMode( $mode, ...$args ): bool { - $this->fetch_mode = $mode; - return true; + return $this->setDefaultFetchMode( $mode, $args ); } /** @@ -476,6 +475,22 @@ private function fetchAllRows( $mode = null, ...$args ): array { } return $rows; } + + /** + * Set the default fetch mode for this statement. + * + * This is used internally by the "WP_PDO_Synthetic_Statement_PHP_Compat" + * trait, that is defined conditionally based on the current PHP version. + * + * @param int $mode The fetch mode to set as the default. + * @param mixed $args Additional parameters for the default fetch mode. + * @return bool True on success, false on failure. + */ + private function setDefaultFetchMode( $mode, ...$args ): bool { + $this->fetch_mode = $mode; + $this->fetch_mode_args = $args; + return true; + } } /** From 1da7bac73f6ba67442e4b883507a8f07958938f3 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 19 Dec 2025 16:46:01 +0100 Subject: [PATCH 08/23] Respect PDO::query() fetch mode arguments --- .../WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 100 ++++++++++++++++++ .../class-wp-pdo-mysql-on-sqlite.php | 86 ++++++++++++++- 2 files changed, 184 insertions(+), 2 deletions(-) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index 539f6367..b50bb46a 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -32,6 +32,106 @@ public function test_query(): void { ); } + /** + * @dataProvider data_pdo_fetch_methods + */ + public function test_query_with_fetch_mode( $query, $mode, $expected ): void { + $stmt = $this->driver->query( $query, $mode ); + $result = $stmt->fetch(); + if ( is_object( $expected ) ) { + $this->assertInstanceOf( get_class( $expected ), $result ); + $this->assertEquals( $expected, $result ); + } else { + $this->assertSame( $expected, $result ); + } + + $this->assertFalse( $stmt->fetch() ); + } + + public function test_query_fetch_mode_not_set(): void { + $result = $this->driver->query( 'SELECT 1' ); + $this->assertSame( + array( + '1' => 1, + 0 => 1, + ), + $result->fetch() + ); + $this->assertFalse( $result->fetch() ); + } + + public function test_query_fetch_mode_invalid_arg_count(): void { + $this->expectException( ArgumentCountError::class ); + $this->expectExceptionMessage( 'PDO::query() expects exactly 2 arguments for the fetch mode provided, 3 given' ); + $this->driver->query( 'SELECT 1', PDO::FETCH_ASSOC, 0 ); + } + + public function test_query_fetch_default_mode_allow_any_args(): void { + $expected_result = array( + array( + 1 => 1, + 0 => 1, + ), + ); + + $result = $this->driver->query( 'SELECT 1' ); + $this->assertSame( $expected_result, $result->fetchAll() ); + + $result = $this->driver->query( 'SELECT 1', null ); + $this->assertSame( $expected_result, $result->fetchAll() ); + + $result = $this->driver->query( 'SELECT 1', null, 1 ); + $this->assertSame( $expected_result, $result->fetchAll() ); + + $result = $this->driver->query( 'SELECT 1', null, 'abc' ); + $this->assertSame( $expected_result, $result->fetchAll() ); + + $result = $this->driver->query( 'SELECT 1', null, 1, 2, 'abc', array(), true ); + $this->assertSame( $expected_result, $result->fetchAll() ); + } + + public function test_query_fetch_class_not_enough_args(): void { + $this->expectException( ArgumentCountError::class ); + $this->expectExceptionMessage( 'PDO::query() expects at least 3 arguments for the fetch mode provided, 2 given' ); + $this->driver->query( 'SELECT 1', PDO::FETCH_CLASS ); + } + + public function test_query_fetch_class_too_many_args(): void { + $this->expectException( ArgumentCountError::class ); + $this->expectExceptionMessage( 'PDO::query() expects at most 4 arguments for the fetch mode provided, 5 given' ); + $this->driver->query( 'SELECT 1', PDO::FETCH_CLASS, '\stdClass', array(), array() ); + } + + public function test_query_fetch_class_invalid_class_type(): void { + $this->expectException( TypeError::class ); + $this->expectExceptionMessage( 'PDO::query(): Argument #3 must be of type string, int given' ); + $this->driver->query( 'SELECT 1', PDO::FETCH_CLASS, 1 ); + } + + public function test_query_fetch_class_invalid_class_name(): void { + $this->expectException( TypeError::class ); + $this->expectExceptionMessage( 'PDO::query(): Argument #3 must be a valid class' ); + $this->driver->query( 'SELECT 1', PDO::FETCH_CLASS, 'non-existent-class' ); + } + + public function test_query_fetch_class_invalid_constructor_args_type(): void { + $this->expectException( TypeError::class ); + $this->expectExceptionMessage( 'PDO::query(): Argument #4 must be of type ?array, int given' ); + $this->driver->query( 'SELECT 1', PDO::FETCH_CLASS, 'stdClass', 1 ); + } + + public function test_query_fetch_into_invalid_arg_count(): void { + $this->expectException( ArgumentCountError::class ); + $this->expectExceptionMessage( 'PDO::query() expects exactly 3 arguments for the fetch mode provided, 2 given' ); + $this->driver->query( 'SELECT 1', PDO::FETCH_INTO ); + } + + public function test_query_fetch_into_invalid_object_type(): void { + $this->expectException( TypeError::class ); + $this->expectExceptionMessage( 'PDO::query(): Argument #3 must be of type object, int given' ); + $this->driver->query( 'SELECT 1', PDO::FETCH_INTO, 1 ); + } + public function test_exec(): void { $result = $this->driver->exec( 'SELECT 1' ); $this->assertEquals( 0, $result ); diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 193bec49..32b23b8e 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -736,7 +736,86 @@ function ( string $sql, array $params ) { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ #[ReturnTypeWillChange] - public function query( string $query, ?int $fetch_mode = PDO::FETCH_COLUMN, ...$fetch_mode_args ) { + public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_args ) { + // Validate and parse the fetch mode and arguments. + $arg_count = func_num_args(); + $arg_colno = 0; + $arg_class = null; + $arg_constructor_args = array(); + $arg_into = null; + + $get_type = function ( $value ) { + $type = gettype( $value ); + if ( 'boolean' === $type ) { + return 'bool'; + } elseif ( 'integer' === $type ) { + return 'int'; + } elseif ( 'double' === $type ) { + return 'float'; + } + return $type; + }; + + if ( null === $fetch_mode ) { + // When the default FETCH_BOTH is not set explicitly, additional + // arguments are ignored, and the argument count is not validated. + $fetch_mode = PDO::FETCH_BOTH; + } elseif ( PDO::FETCH_COLUMN === $fetch_mode ) { + if ( 3 !== $arg_count ) { + throw new ArgumentCountError( + sprintf( 'PDO::query() expects exactly 3 arguments for the fetch mode provided, %d given', $arg_count ) + ); + } + if ( ! is_int( $fetch_mode_args[0] ) ) { + throw new TypeError( + sprintf( 'PDO::query(): Argument #3 must be of type int, %s given', $get_type( $fetch_mode_args[0] ) ) + ); + } + $arg_colno = $fetch_mode_args[0]; + } elseif ( PDO::FETCH_CLASS === $fetch_mode ) { + if ( $arg_count < 3 ) { + throw new ArgumentCountError( + sprintf( 'PDO::query() expects at least 3 arguments for the fetch mode provided, %d given', $arg_count ) + ); + } + if ( $arg_count > 4 ) { + throw new ArgumentCountError( + sprintf( 'PDO::query() expects at most 4 arguments for the fetch mode provided, %d given', $arg_count ) + ); + } + if ( ! is_string( $fetch_mode_args[0] ) ) { + throw new TypeError( + sprintf( 'PDO::query(): Argument #3 must be of type string, %s given', $get_type( $fetch_mode_args[0] ) ) + ); + } + if ( ! class_exists( $fetch_mode_args[0] ) ) { + throw new TypeError( 'PDO::query(): Argument #3 must be a valid class' ); + } + if ( 4 === $arg_count && ! is_array( $fetch_mode_args[1] ) ) { + throw new TypeError( + sprintf( 'PDO::query(): Argument #4 must be of type ?array, %s given', $get_type( $fetch_mode_args[1] ) ) + ); + } + $arg_class = $fetch_mode_args[0]; + $arg_constructor_args = $fetch_mode_args[1] ?? array(); + } elseif ( PDO::FETCH_INTO === $fetch_mode ) { + if ( 3 !== $arg_count ) { + throw new ArgumentCountError( + sprintf( 'PDO::query() expects exactly 3 arguments for the fetch mode provided, %d given', $arg_count ) + ); + } + if ( ! is_object( $fetch_mode_args[0] ) ) { + throw new TypeError( + sprintf( 'PDO::query(): Argument #3 must be of type object, %s given', $get_type( $fetch_mode_args[0] ) ) + ); + } + $arg_into = $fetch_mode_args[0]; + } elseif ( $arg_count > 2 ) { + throw new ArgumentCountError( + sprintf( 'PDO::query() expects exactly 2 arguments for the fetch mode provided, %d given', $arg_count ) + ); + } + $this->flush(); $this->last_mysql_query = $query; @@ -794,7 +873,10 @@ public function query( string $query, ?int $fetch_mode = PDO::FETCH_COLUMN, ...$ $columns = is_array( $this->last_column_meta ) ? $this->last_column_meta : array(); $rows = is_array( $this->last_result ) ? $this->last_result : array(); $affected_rows = is_int( $this->last_return_value ) ? $this->last_return_value : 0; - return new WP_PDO_Synthetic_Statement( $this, $columns, $rows, $affected_rows ); + + $stmt = new WP_PDO_Synthetic_Statement( $this, $columns, $rows, $affected_rows ); + $stmt->setFetchMode( $fetch_mode, ...$fetch_mode_args ); + return $stmt; } catch ( Throwable $e ) { try { $this->rollback_user_transaction(); From 3e541a0236719b9e81a941c59b8b78ce6088df7c Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 12 Jan 2026 15:24:28 +0100 Subject: [PATCH 09/23] Add comment explaining PDO attribute default --- wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php | 1 + 1 file changed, 1 insertion(+) diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 32b23b8e..9827ef00 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -451,6 +451,7 @@ class WP_PDO_MySQL_On_SQLite extends PDO { * @var array */ private $pdo_attributes = array( + // On PHP < 8.1, PDO::ATTR_STRINGIFY_FETCHES is enabled by default. PDO::ATTR_STRINGIFY_FETCHES => PHP_VERSION_ID < 80100 ? true : false, ); From caf76e8cc2e6075f7c83b550c2173959ef78158f Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 12 Jan 2026 15:25:06 +0100 Subject: [PATCH 10/23] Support whitespace in PDO DSN as per PHP PDO --- tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 18 ++++++++++++++++++ .../class-wp-pdo-mysql-on-sqlite.php | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index b50bb46a..f455f9d4 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -19,6 +19,24 @@ public function test_connection(): void { $this->assertInstanceOf( PDO::class, $driver ); } + public function test_dsn_parsing(): void { + // Standard DSN. + $driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp' ); + $this->assertSame( 'wp', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); + + // DSN with trailing semicolon. + $driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;' ); + $this->assertSame( 'wp', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); + + // DSN with whitespace before argument names. + $driver = new WP_PDO_MySQL_On_SQLite( "mysql-on-sqlite: path=:memory:;\t dbname=wp" ); + $this->assertSame( 'wp', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); + + // DSN with whitespace in the database name. + $driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname= w p ' ); + $this->assertSame( ' w p ', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); + } + public function test_query(): void { $result = $this->driver->query( "SELECT 1, 'abc'" ); $this->assertInstanceOf( PDOStatement::class, $result ); diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 9827ef00..7b1d985d 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -619,6 +619,10 @@ public function __construct( $args = array(); foreach ( explode( ';', $dsn_parts[1] ) as $arg ) { + $arg = ltrim( $arg ); // PDO DSN allows whitespace before argument name. + if ( '' === $arg ) { + continue; + } $arg_parts = explode( '=', $arg, 2 ); $args[ $arg_parts[0] ] = $arg_parts[1] ?? null; } From 0859d004eacdb663fe8dbeba00d4842c286eeed1 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 12 Jan 2026 15:49:37 +0100 Subject: [PATCH 11/23] Support semicolon quoting in PDO DSN --- tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 4 ++++ .../sqlite-ast/class-wp-pdo-mysql-on-sqlite.php | 14 +++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index f455f9d4..b2615fa1 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -35,6 +35,10 @@ public function test_dsn_parsing(): void { // DSN with whitespace in the database name. $driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname= w p ' ); $this->assertSame( ' w p ', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); + + // DSN with semicolon in the database name. + $driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;dbname=w;;p;' ); + $this->assertSame( 'w;p', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); } public function test_query(): void { diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 7b1d985d..c03a347d 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -617,9 +617,17 @@ public function __construct( throw new PDOException( 'could not find driver' ); } - $args = array(); - foreach ( explode( ';', $dsn_parts[1] ) as $arg ) { - $arg = ltrim( $arg ); // PDO DSN allows whitespace before argument name. + // PDO DSN supports semicolon quoting using double semicolon sequences. + // Replace ";;" with "\0" to preserve quoted semicolons in "explode()". + $args_string = str_replace( ';;', "\0", $dsn_parts[1] ); + $args = array(); + foreach ( explode( ';', $args_string ) as $arg ) { + // Restore quoted semicolons that were replaced with "\0". + $arg = str_replace( "\0", ';', $arg ); + + // PDO DSN allows whitespace before argument name. + $arg = ltrim( $arg ); + if ( '' === $arg ) { continue; } From 06f568166592e94067d3de0e38ca1fb25004dfcb Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 12 Jan 2026 16:57:07 +0100 Subject: [PATCH 12/23] Document PDO fetch modes --- .../class-wp-pdo-synthetic-statement.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php index d3b949df..3ab42180 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php @@ -82,6 +82,21 @@ public function fetchAll( $mode = PDO::FETCH_DEFAULT, ...$args ): array { * * This class implements a complete PDOStatement interface on top of PHP arrays. * It is used for result sets that are composed or transformed in the PHP layer. + * + * PDO supports the following fetch modes: + * - PDO::FETCH_DEFAULT: current default fetch mode (available from PHP 8.0) + * - PDO::FETCH_BOTH: default + * - PDO::FETCH_NUM: numeric array + * - PDO::FETCH_ASSOC: associative array + * - PDO::FETCH_NAMED: associative array retaining duplicate columns + * - PDO::FETCH_COLUMN: single column value [1 extra arg] + * - PDO::FETCH_KEY_PAIR: key-value pair + * - PDO::FETCH_OBJ: object (stdClass) + * - PDO::FETCH_CLASS: object (custom class) [1-2 extra args] + * - PDO::FETCH_INTO: update an exisisting object, can't be used with fetchAll() [1 extra arg] + * - PDO::FETCH_LAZY: lazy fetch via PDORow, can't be used with fetchAll() + * - PDO::FETCH_BOUND: bind values to PHP variables, can't be used with fetchAll() + * - PDO::FETCH_FUNC: custom function, only works with fetchAll(), can't be default [1 extra arg] */ class WP_PDO_Synthetic_Statement extends PDOStatement { use WP_PDO_Synthetic_Statement_PHP_Compat; From ba5b0cece2321c91547b23922b58e97e2d7cd7eb Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Tue, 13 Jan 2026 16:48:27 +0100 Subject: [PATCH 13/23] Use PDO SQLite statement as a source for MySQL proxy statement --- .../class-wp-pdo-mysql-on-sqlite.php | 104 ++++++++- ...t.php => class-wp-pdo-proxy-statement.php} | 197 +++--------------- wp-pdo-mysql-on-sqlite.php | 2 +- 3 files changed, 127 insertions(+), 176 deletions(-) rename wp-includes/sqlite-ast/{class-wp-pdo-synthetic-statement.php => class-wp-pdo-proxy-statement.php} (70%) diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index c03a347d..4918a8a1 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -469,6 +469,13 @@ class WP_PDO_MySQL_On_SQLite extends PDO { */ private $last_sqlite_queries = array(); + /** + * A PDO SQLite statement that represents the result of the last emulated query. + * + * @var PDOStatement|null + */ + private $last_result_statement; + /** * Results of the last emulated query. * @@ -772,7 +779,8 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar if ( null === $fetch_mode ) { // When the default FETCH_BOTH is not set explicitly, additional // arguments are ignored, and the argument count is not validated. - $fetch_mode = PDO::FETCH_BOTH; + $fetch_mode = PDO::FETCH_BOTH; + $fetch_mode_args = array(); } elseif ( PDO::FETCH_COLUMN === $fetch_mode ) { if ( 3 !== $arg_count ) { throw new ArgumentCountError( @@ -833,7 +841,7 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar $this->last_mysql_query = $query; /** - * Use "PDO::FETCH_NUM" fetch mode, as the "WP_PDO_Synthetic_Statement" + * Use "PDO::FETCH_NUM" fetch mode, as "create_result_statement_from_data()" * expects the row data to be passed as an array of values. * * @TODO: We can remove this when we use the SQLite PDOStatements directly, @@ -883,11 +891,20 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar $this->commit_wrapper_transaction(); } - $columns = is_array( $this->last_column_meta ) ? $this->last_column_meta : array(); + /* + * For now, create all statements from data loaded in memory. This is + * a temporary solution until all queries set their result statement. + * + * TODO: Use "$this->last_result_statement" with an actual PDO SQLite + * statement whenever possible rather than loading all data. + */ + $columns = is_array( $this->last_column_meta ) ? array_column( $this->last_column_meta, 'name' ) : array(); $rows = is_array( $this->last_result ) ? $this->last_result : array(); $affected_rows = is_int( $this->last_return_value ) ? $this->last_return_value : 0; - $stmt = new WP_PDO_Synthetic_Statement( $this, $columns, $rows, $affected_rows ); + $this->last_result_statement = $this->create_result_statement_from_data( $columns, $rows ); + + $stmt = new WP_PDO_Proxy_Statement( $this->last_result_statement, $affected_rows ); $stmt->setFetchMode( $fetch_mode, ...$fetch_mode_args ); return $stmt; } catch ( Throwable $e ) { @@ -6471,6 +6488,85 @@ private function flush(): void { $this->wrapper_transaction_type = null; } + /** + * Create a PDO SQLite statement from the specified columns and rows. + * + * Some emulated MySQL queries don't have an SQLite counterpart and their + * result data may be generated without a corresponding SQLite statement. + * In such cases, we can generate a simple SQLite SELECT query that will + * provide us with the PDOStatement API for the given column and row data. + * + * @param array $columns The columns of the result set. + * @param array $rows The rows of the result set. + * @return PDOStatement The corresponding PDO SQLite statement. + */ + private function create_result_statement_from_data( array $columns, array $rows ): PDOStatement { + $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. + */ + if ( 0 === count( $columns ) ) { + return $pdo->query( + sprintf( + 'INSERT INTO %s (rowid) SELECT NULL WHERE FALSE', + $this->quote_sqlite_identifier( self::GLOBAL_VARIABLES_TABLE_NAME ) + ) + ); + } + + /* + * Create an SQLite statement that returns the specified columns and rows. + * This can be done using a SELECT statement in the following form: + * + * -- A dummy header row to assign correct column names. + * SELECT NULL AS `col1`, NULL AS `col2`, ... WHERE FALSE + * + * UNION ALL + * + * -- The actual data rows. + * VALUES + * (val11, val12, ...), + * (val21, val22, ...), + * ... + */ + + // Construct column header row ("SELECT WHERE FALSE"). + $query = 'SELECT '; + foreach ( $columns as $i => $column ) { + $query .= $i > 0 ? ', ' : ''; + $query .= 'NULL AS ' . $pdo->quote( $column ); + } + $query .= ' WHERE FALSE'; + + // UNION ALL + if ( count( $rows ) > 0 ) { + $query .= ' UNION ALL VALUES '; + } + + // Construct data rows ("VALUES "). + foreach ( $rows as $i => $row ) { + $query .= $i > 0 ? ', ' : ''; + $query .= '('; + foreach ( array_values( $row ) as $j => $value ) { + $query .= $j > 0 ? ', ' : ''; + if ( null === $value ) { + $query .= 'NULL'; + } elseif ( is_string( $value ) && strpos( $value, "\0" ) !== false ) { + // Handle null characters; see self::translate_string_literal(). + $query .= sprintf( "CAST(x'%s' AS TEXT)", bin2hex( $value ) ); + } elseif ( is_string( $value ) ) { + $query .= $pdo->quote( $value ); + } else { + $query .= $value; + } + } + $query .= ')'; + } + return $pdo->query( $query ); + } + /** * Set results of a query() call using fetched data. * diff --git a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php b/wp-includes/sqlite-ast/class-wp-pdo-proxy-statement.php similarity index 70% rename from wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php rename to wp-includes/sqlite-ast/class-wp-pdo-proxy-statement.php index 3ab42180..5d32628c 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-proxy-statement.php @@ -26,7 +26,7 @@ * we conditionally define traits with different APIs based on the PHP version. */ if ( PHP_VERSION_ID < 80000 ) { - trait WP_PDO_Synthetic_Statement_PHP_Compat { + trait WP_PDO_Proxy_Statement_PHP_Compat { /** * Set the default fetch mode for this statement. * @@ -51,7 +51,7 @@ public function fetchAll( $mode = null, $class_name = null, $constructor_args = } } } else { - trait WP_PDO_Synthetic_Statement_PHP_Compat { + trait WP_PDO_Proxy_Statement_PHP_Compat { /** * Set the default fetch mode for this statement. * @@ -61,7 +61,7 @@ trait WP_PDO_Synthetic_Statement_PHP_Compat { */ #[ReturnTypeWillChange] public function setFetchMode( $mode, ...$args ): bool { - return $this->setDefaultFetchMode( $mode, $args ); + return $this->setDefaultFetchMode( $mode, ...$args ); } /** @@ -98,84 +98,34 @@ public function fetchAll( $mode = PDO::FETCH_DEFAULT, ...$args ): array { * - PDO::FETCH_BOUND: bind values to PHP variables, can't be used with fetchAll() * - PDO::FETCH_FUNC: custom function, only works with fetchAll(), can't be default [1 extra arg] */ -class WP_PDO_Synthetic_Statement extends PDOStatement { - use WP_PDO_Synthetic_Statement_PHP_Compat; +class WP_PDO_Proxy_Statement extends PDOStatement { + use WP_PDO_Proxy_Statement_PHP_Compat; /** - * The PDO connection. + * The original PDO statement. * - * @var PDO + * @var PDOStatement */ - private $pdo; - - /** - * Basic column metadata (containing at least name, table name, and native type). - * - * @var array - */ - private $columns; - - /** - * Rows of the result set. - * - * @var array> - */ - private $rows; + private $statement; /** * The number of affected rows. * - * @var int + * @var int|null */ private $affected_rows; - /** - * The current cursor offset. - * - * @var int - */ - private $cursor_offset = 0; - - /** - * The current fetch mode. - * - * TODO: Inherit this from "PDO::ATTR_DEFAULT_FETCH_MODE". - * - * @var int - */ - private $fetch_mode = PDO::FETCH_BOTH; - - /** - * Additional arguments for the current fetch mode. - * - * @var array - */ - private $fetch_mode_args = array(); - - /** - * The PDO attributes set for this statement. - * - * @var array - */ - private $attributes = array(); - /** * Constructor. * - * @param PDO $pdo The PDO connection. - * @param array $columns Basic column metadata (containing at least name, table name, and native type). - * @param array $rows Rows of the result set. - * @param int $affected_rows The number of affected rows. + * @param PDOStatement $statement The original PDO statement. + * @param int $affected_rows The number of affected rows. */ public function __construct( - PDO $pdo, - array $columns, - array $rows, - int $affected_rows + PDOStatement $statement, + ?int $affected_rows = null ) { - $this->pdo = $pdo; - $this->columns = $columns; - $this->rows = $rows; + $this->statement = $statement; $this->affected_rows = $affected_rows; } @@ -186,7 +136,7 @@ public function __construct( * @return bool True on success, false on failure. */ public function execute( $params = null ): bool { - throw new RuntimeException( 'Not implemented' ); + return $this->statement->execute( $params ); } /** @@ -195,7 +145,7 @@ public function execute( $params = null ): bool { * @return int The number of columns in the result set. */ public function columnCount(): int { - return count( $this->columns ); + return $this->statement->columnCount(); } /** @@ -204,7 +154,7 @@ public function columnCount(): int { * @return int The number of rows affected by the statement. */ public function rowCount(): int { - return $this->affected_rows; + return $this->affected_rows ?? $this->statement->rowCount(); } /** @@ -225,91 +175,7 @@ public function fetch( $cursorOrientation = 0, $cursorOffset = 0 ) { - if ( 0 === $mode || null === $mode ) { - $mode = $this->fetch_mode; - } - if ( null === $cursorOrientation ) { - $cursorOrientation = PDO::FETCH_ORI_NEXT; - } - if ( null === $cursorOffset ) { - $cursorOffset = 0; - } - - if ( ! array_key_exists( $this->cursor_offset, $this->rows ) ) { - return false; - } - - // Get current row data and column names. - $row = $this->rows[ $this->cursor_offset ]; - $column_names = array_column( $this->columns, 'name' ); - - // Advance the cursor to the next row. - $this->cursor_offset += 1; - - /* - * TODO: Support scrollable cursor ($cursorOrientation and $cursorOffset). - * This only has works for with statements that were prepared with - * the PDO::ATTR_CURSOR attribute set to PDO::CURSOR_SCROLL value. - * Without it, these parameters have no effect. - */ - - /** - * With PHP < 8.1, the "PDO::ATTR_STRINGIFY_FETCHES" value of "false" - * is not working correctly with the PDO SQLite driver. In such case, - * we need to manually convert the row values to the correct types. - */ - if ( PHP_VERSION_ID < 80100 && ! $this->getAttribute( PDO::ATTR_STRINGIFY_FETCHES ) ) { - foreach ( $row as $i => $value ) { - $type = $this->columns[ $i ]['native_type']; - if ( 'integer' === $type ) { - $row[ $i ] = (int) $value; - } elseif ( 'float' === $type ) { - $row[ $i ] = (float) $value; - } - } - } - - switch ( $mode ) { - case PDO::FETCH_BOTH: - $values = array(); - foreach ( $row as $i => $value ) { - $name = $column_names[ $i ]; - $values[ $name ] = $value; - if ( ! array_key_exists( $i, $values ) ) { - $values[ $i ] = $value; - } - } - return $values; - case PDO::FETCH_NUM: - return $row; - case PDO::FETCH_ASSOC: - return array_combine( $column_names, $row ); - case PDO::FETCH_NAMED: - $values = array(); - foreach ( $row as $i => $value ) { - $name = $column_names[ $i ]; - if ( is_array( $values[ $name ] ?? null ) ) { - $values[ $name ][] = $value; - } elseif ( array_key_exists( $name, $values ) ) { - $values[ $name ] = array( $values[ $name ], $value ); - } else { - $values[ $name ] = $value; - } - } - return $values; - case PDO::FETCH_OBJ: - return (object) array_combine( $column_names, $row ); - case PDO::FETCH_CLASS: - throw new RuntimeException( "'PDO::FETCH_CLASS' mode is not supported" ); - case PDO::FETCH_INTO: - throw new RuntimeException( "'PDO::FETCH_INTO' mode is not supported" ); - case PDO::FETCH_LAZY: - throw new RuntimeException( "'PDO::FETCH_LAZY' mode is not supported" ); - case PDO::FETCH_BOUND: - throw new RuntimeException( "'PDO::FETCH_BOUND' mode is not supported" ); - default: - throw new ValueError( sprintf( 'PDOStatement::fetch(): Argument #1 ($mode) must be a bitmask of PDO::FETCH_* constants', $mode ) ); - } + return $this->statement->fetch( $mode, $cursorOrientation, $cursorOffset ); } /** @@ -376,7 +242,7 @@ public function errorInfo(): array { */ #[ReturnTypeWillChange] public function getAttribute( $attribute ) { - return $this->attributes[ $attribute ] ?? $this->pdo->getAttribute( $attribute ); + return $this->statement->getAttribute( $attribute ); } /** @@ -387,8 +253,7 @@ public function getAttribute( $attribute ) { * @return bool True on success, false on failure. */ public function setAttribute( $attribute, $value ): bool { - $this->attributes[ $attribute ] = $value; - return true; + return $this->statement->setAttribute( $attribute, $value ); } /** @@ -472,39 +337,29 @@ public function debugDumpParams(): ?bool { /** * Fetch all remaining rows from the result set. * - * This is used internally by the "WP_PDO_Synthetic_Statement_PHP_Compat" - * trait, that is defined conditionally based on the current PHP version. + * This is used internally by the "WP_PDO_Proxy_Statement_PHP_Compat" trait, + * that is defined conditionally based on the current PHP version. * * @param int $mode The fetch mode to use. * @param mixed $args Additional parameters for the fetch mode. * @return array The result set as an array of rows. */ private function fetchAllRows( $mode = null, ...$args ): array { - if ( null === $mode || 0 === $mode ) { - $mode = $this->fetch_mode; - } - - $rows = array(); - while ( $row = $this->fetch( $mode, ...$args ) ) { - $rows[] = $row; - } - return $rows; + return $this->statement->fetchAll( $mode, ...$args ); } /** * Set the default fetch mode for this statement. * - * This is used internally by the "WP_PDO_Synthetic_Statement_PHP_Compat" - * trait, that is defined conditionally based on the current PHP version. + * This is used internally by the "WP_PDO_Proxy_Statement_PHP_Compat" trait, + * that is defined conditionally based on the current PHP version. * * @param int $mode The fetch mode to set as the default. * @param mixed $args Additional parameters for the default fetch mode. * @return bool True on success, false on failure. */ private function setDefaultFetchMode( $mode, ...$args ): bool { - $this->fetch_mode = $mode; - $this->fetch_mode_args = $args; - return true; + return $this->statement->setFetchMode( $mode, ...$args ); } } diff --git a/wp-pdo-mysql-on-sqlite.php b/wp-pdo-mysql-on-sqlite.php index 39126b56..b3d7d24b 100644 --- a/wp-pdo-mysql-on-sqlite.php +++ b/wp-pdo-mysql-on-sqlite.php @@ -20,4 +20,4 @@ require_once __DIR__ . '/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-exception.php'; require_once __DIR__ . '/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-reconstructor.php'; require_once __DIR__ . '/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php'; -require_once __DIR__ . '/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php'; +require_once __DIR__ . '/wp-includes/sqlite-ast/class-wp-pdo-proxy-statement.php'; From 2d28a4e7b0b9ef329a3a10d22b99ef897ad7332a Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 14 Jan 2026 12:32:52 +0100 Subject: [PATCH 14/23] Fix test assertions for PDO::FETCH_NAMED --- tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index b2615fa1..0e2c7511 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -63,6 +63,11 @@ public function test_query_with_fetch_mode( $query, $mode, $expected ): void { if ( is_object( $expected ) ) { $this->assertInstanceOf( get_class( $expected ), $result ); $this->assertEquals( $expected, $result ); + } elseif ( PDO::FETCH_NAMED === $mode ) { + // PDO::FETCH_NAMED returns all array keys as strings, even numeric + // ones. This is not possible in plain PHP and might be a PDO bug. + $this->assertSame( array_map( 'strval', array_keys( $expected ) ), array_keys( $result ) ); + $this->assertSame( array_values( $expected ), array_values( $result ) ); } else { $this->assertSame( $expected, $result ); } @@ -249,6 +254,11 @@ public function test_fetch( $query, $mode, $expected ): void { if ( is_object( $expected ) ) { $this->assertInstanceOf( get_class( $expected ), $result ); $this->assertEquals( $expected, $result ); + } elseif ( PDO::FETCH_NAMED === $mode ) { + // PDO::FETCH_NAMED returns all array keys as strings, even numeric + // ones. This is not possible in plain PHP and might be a PDO bug. + $this->assertSame( array_map( 'strval', array_keys( $expected ) ), array_keys( $result ) ); + $this->assertSame( array_values( $expected ), array_values( $result ) ); } else { $this->assertSame( $expected, $result ); } From 90f96efc9e0b2762a52d2a156ae807daf5524587 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 14 Jan 2026 21:17:19 +0100 Subject: [PATCH 15/23] Address differences between PDO on PHP < 8.1 and PDO on PHP >= 8.1 --- .../WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 146 ++++++++++++------ .../class-wp-pdo-mysql-on-sqlite.php | 8 + 2 files changed, 111 insertions(+), 43 deletions(-) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index 0e2c7511..99abb0eb 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -3,15 +3,17 @@ use PHPUnit\Framework\TestCase; class WP_PDO_MySQL_On_SQLite_PDO_API_Tests extends TestCase { + /** + * On PHP < 8.1, some PDO behavior is notably different from PHP >= 8.1. + * To address that, we need to use conditional assertions in some cases. + */ + const LEGACY_PDO = PHP_VERSION_ID < 80100; + /** @var WP_PDO_MySQL_On_SQLite */ private $driver; public function setUp(): void { $this->driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;' ); - - // Set "PDO::ATTR_STRINGIFY_FETCHES" to "false" explicitly, so the tests - // are consistent across PHP versions ("false" is the default from 8.1). - $this->driver->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, false ); } public function test_connection(): void { @@ -44,14 +46,26 @@ public function test_dsn_parsing(): void { public function test_query(): void { $result = $this->driver->query( "SELECT 1, 'abc'" ); $this->assertInstanceOf( PDOStatement::class, $result ); - $this->assertSame( - array( - 1 => 1, - 0 => 1, - 'abc' => 'abc', - ), - $result->fetch() - ); + if ( self::LEGACY_PDO ) { + $this->assertSame( + array( + 1 => '1', + 2 => '1', + 'abc' => 'abc', + 3 => 'abc', + ), + $result->fetch() + ); + } else { + $this->assertSame( + array( + 1 => 1, + 0 => 1, + 'abc' => 'abc', + ), + $result->fetch() + ); + } } /** @@ -60,9 +74,10 @@ public function test_query(): void { public function test_query_with_fetch_mode( $query, $mode, $expected ): void { $stmt = $this->driver->query( $query, $mode ); $result = $stmt->fetch(); + if ( is_object( $expected ) ) { $this->assertInstanceOf( get_class( $expected ), $result ); - $this->assertEquals( $expected, $result ); + $this->assertSame( (array) $expected, (array) $result ); } elseif ( PDO::FETCH_NAMED === $mode ) { // PDO::FETCH_NAMED returns all array keys as strings, even numeric // ones. This is not possible in plain PHP and might be a PDO bug. @@ -77,13 +92,23 @@ public function test_query_with_fetch_mode( $query, $mode, $expected ): void { public function test_query_fetch_mode_not_set(): void { $result = $this->driver->query( 'SELECT 1' ); - $this->assertSame( - array( - '1' => 1, - 0 => 1, - ), - $result->fetch() - ); + if ( self::LEGACY_PDO ) { + $this->assertSame( + array( + 1 => '1', + 2 => '1', + ), + $result->fetch() + ); + } else { + $this->assertSame( + array( + 1 => 1, + 0 => 1, + ), + $result->fetch() + ); + } $this->assertFalse( $result->fetch() ); } @@ -94,6 +119,16 @@ public function test_query_fetch_mode_invalid_arg_count(): void { } public function test_query_fetch_default_mode_allow_any_args(): void { + if ( self::LEGACY_PDO ) { + // On PHP < 8.1, fetch mode value of NULL is not allowed. + $result = @$this->driver->query( 'SELECT 1', null, 1, 2, 'abc', array(), true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $this->assertFalse( $result ); + $this->assertSame( 'PDO::query(): SQLSTATE[HY000]: General error: mode must be an integer', error_get_last()['message'] ); + return; + } + + // On PHP >= 8.1, NULL fetch mode is allowed to use the default fetch mode. + // In such cases, any additional arguments are ignored and not validated. $expected_result = array( array( 1 => 1, @@ -234,15 +269,28 @@ public function test_rollback_no_active_transaction(): void { public function test_fetch_default(): void { // Default fetch mode is PDO::FETCH_BOTH. $result = $this->driver->query( "SELECT 1, 'abc', 2" ); - $this->assertSame( - array( - 1 => 1, - 0 => 1, - 'abc' => 'abc', - '2' => 2, - ), - $result->fetch() - ); + if ( self::LEGACY_PDO ) { + $this->assertSame( + array( + 1 => '1', + 2 => '2', + 'abc' => 'abc', + 3 => 'abc', + 4 => '2', + ), + $result->fetch() + ); + } else { + $this->assertSame( + array( + 1 => 1, + 0 => 1, + 'abc' => 'abc', + '2' => 2, + ), + $result->fetch() + ); + } } /** @@ -251,6 +299,7 @@ public function test_fetch_default(): void { public function test_fetch( $query, $mode, $expected ): void { $stmt = $this->driver->query( $query ); $result = $stmt->fetch( $mode ); + if ( is_object( $expected ) ) { $this->assertInstanceOf( get_class( $expected ), $result ); $this->assertEquals( $expected, $result ); @@ -269,20 +318,31 @@ public function data_pdo_fetch_methods(): Generator { yield 'PDO::FETCH_BOTH' => array( "SELECT 1, 'abc', 2, 'two' as `2`", PDO::FETCH_BOTH, - array( - 1 => 1, - 0 => 1, - 'abc' => 'abc', - '2' => 'two', - '3' => 'two', - ), + self::LEGACY_PDO + ? array( + 1 => '1', + 2 => 'two', + 'abc' => 'abc', + 3 => 'abc', + 4 => '2', + 5 => 'two', + ) + : array( + 1 => 1, + 0 => 1, + 'abc' => 'abc', + 2 => 'two', + 3 => 'two', + ), ); // PDO::FETCH_NUM yield 'PDO::FETCH_NUM' => array( "SELECT 1, 'abc', 2, 'two' as `2`", PDO::FETCH_NUM, - array( 1, 'abc', 2, 'two' ), + self::LEGACY_PDO + ? array( '1', 'abc', '2', 'two' ) + : array( 1, 'abc', 2, 'two' ), ); // PDO::FETCH_ASSOC @@ -290,9 +350,9 @@ public function data_pdo_fetch_methods(): Generator { "SELECT 1, 'abc', 2, 'two' as `2`", PDO::FETCH_ASSOC, array( - '1' => 1, + 1 => self::LEGACY_PDO ? '1' : 1, 'abc' => 'abc', - '2' => 'two', + 2 => 'two', ), ); @@ -301,9 +361,9 @@ public function data_pdo_fetch_methods(): Generator { "SELECT 1, 'abc', 2, 'two' as `2`", PDO::FETCH_NAMED, array( - '1' => 1, + 1 => self::LEGACY_PDO ? '1' : 1, 'abc' => 'abc', - '2' => array( 2, 'two' ), + 2 => array( self::LEGACY_PDO ? '2' : 2, 'two' ), ), ); @@ -312,9 +372,9 @@ public function data_pdo_fetch_methods(): Generator { "SELECT 1, 'abc', 2, 'two' as `2`", PDO::FETCH_OBJ, (object) array( - '1' => 1, + 1 => self::LEGACY_PDO ? '1' : 1, 'abc' => 'abc', - '2' => 'two', + 2 => 'two', ), ); } diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 4918a8a1..42a3b81b 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -777,6 +777,14 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar }; if ( null === $fetch_mode ) { + if ( PHP_VERSION_ID < 80100 && func_num_args() > 1 ) { + trigger_error( + 'PDO::query(): SQLSTATE[HY000]: General error: mode must be an integer', + E_USER_WARNING + ); + return false; + } + // When the default FETCH_BOTH is not set explicitly, additional // arguments are ignored, and the argument count is not validated. $fetch_mode = PDO::FETCH_BOTH; From 717a3c82758585759e3d1c12307dc77f94775490 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 14 Jan 2026 21:36:43 +0100 Subject: [PATCH 16/23] Add support for PDO::ATTR_DEFAULT_FETCH_MODE, simplify attribute handling --- .../WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 20 ++++++++++++++ .../class-wp-pdo-mysql-on-sqlite.php | 27 ++++++++----------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index 99abb0eb..75f54b8b 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -313,6 +313,26 @@ public function test_fetch( $query, $mode, $expected ): void { } } + public function test_attr_default_fetch_mode(): void { + $this->driver->setAttribute( PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_NUM ); + $result = $this->driver->query( "SELECT 'a', 'b', 'c'" ); + $this->assertSame( + array( 'a', 'b', 'c' ), + $result->fetch() + ); + + $this->driver->setAttribute( PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC ); + $result = $this->driver->query( "SELECT 'a', 'b', 'c'" ); + $this->assertSame( + array( + 'a' => 'a', + 'b' => 'b', + 'c' => 'c', + ), + $result->fetch() + ); + } + public function data_pdo_fetch_methods(): Generator { // PDO::FETCH_BOTH yield 'PDO::FETCH_BOTH' => array( diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 42a3b81b..e24105f8 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -443,18 +443,6 @@ class WP_PDO_MySQL_On_SQLite extends PDO { */ private $information_schema_builder; - /** - * PDO API: The PDO attributes of the connection. - * - * TODO: Add PDO default attribute values. - * - * @var array - */ - private $pdo_attributes = array( - // On PHP < 8.1, PDO::ATTR_STRINGIFY_FETCHES is enabled by default. - PDO::ATTR_STRINGIFY_FETCHES => PHP_VERSION_ID < 80100 ? true : false, - ); - /** * Last executed MySQL query. * @@ -787,7 +775,7 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar // When the default FETCH_BOTH is not set explicitly, additional // arguments are ignored, and the argument count is not validated. - $fetch_mode = PDO::FETCH_BOTH; + $fetch_mode = $this->connection->get_pdo()->getAttribute( PDO::ATTR_DEFAULT_FETCH_MODE ); $fetch_mode_args = array(); } elseif ( PDO::FETCH_COLUMN === $fetch_mode ) { if ( 3 !== $arg_count ) { @@ -1014,24 +1002,31 @@ public function inTransaction(): bool { /** * PDO API: Set a PDO attribute. * + * TODO: Evaluate whether we should pass all PDO attributes to the PDO SQLite + * instance, or whether some of them require special handling. + * See: https://github.com/php/php-src/blob/b391c28f903536e3bc6a0021ae0976ddbc2745f8/ext/pdo/php_pdo_driver.h#L103 + * * @param int $attribute The attribute to set. * @param mixed $value The value of the attribute. * @return bool True on success, false on failure. */ public function setAttribute( $attribute, $value ): bool { - $this->pdo_attributes[ $attribute ] = $value; - return true; + return $this->connection->get_pdo()->setAttribute( $attribute, $value ); } /** * PDO API: Get a PDO attribute. * + * TODO: Evaluate whether we should get all PDO attributes from the PDO SQLite + * instance, or whether some of them require special handling. + * See: https://github.com/php/php-src/blob/b391c28f903536e3bc6a0021ae0976ddbc2745f8/ext/pdo/php_pdo_driver.h#L103 + * * @param int $attribute The attribute to get. * @return mixed The value of the attribute. */ #[ReturnTypeWillChange] public function getAttribute( $attribute ) { - return $this->pdo_attributes[ $attribute ] ?? null; + return $this->connection->get_pdo()->getAttribute( $attribute ); } /** From 5da4935ca5cb0ea3c1ddb5bbfd97b837848341b4 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 14 Jan 2026 21:51:04 +0100 Subject: [PATCH 17/23] Fix PDO compatibility issues with older PHP versions --- .../WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 82 ++++++++++++------- .../class-wp-pdo-proxy-statement.php | 10 +++ 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index 75f54b8b..1a2dacd6 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -3,17 +3,16 @@ use PHPUnit\Framework\TestCase; class WP_PDO_MySQL_On_SQLite_PDO_API_Tests extends TestCase { - /** - * On PHP < 8.1, some PDO behavior is notably different from PHP >= 8.1. - * To address that, we need to use conditional assertions in some cases. - */ - const LEGACY_PDO = PHP_VERSION_ID < 80100; - /** @var WP_PDO_MySQL_On_SQLite */ private $driver; public function setUp(): void { $this->driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;' ); + + // Run all tests with stringified fetch mode results, so we can use + // assertions that are consistent across all tested PHP versions. + // The "PDO::ATTR_STRINGIFY_FETCHES" mode is tested in separately. + $this->driver->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); } public function test_connection(): void { @@ -46,7 +45,7 @@ public function test_dsn_parsing(): void { public function test_query(): void { $result = $this->driver->query( "SELECT 1, 'abc'" ); $this->assertInstanceOf( PDOStatement::class, $result ); - if ( self::LEGACY_PDO ) { + if ( PHP_VERSION_ID < 80000 ) { $this->assertSame( array( 1 => '1', @@ -59,8 +58,8 @@ public function test_query(): void { } else { $this->assertSame( array( - 1 => 1, - 0 => 1, + 1 => '1', + 0 => '1', 'abc' => 'abc', ), $result->fetch() @@ -92,7 +91,7 @@ public function test_query_with_fetch_mode( $query, $mode, $expected ): void { public function test_query_fetch_mode_not_set(): void { $result = $this->driver->query( 'SELECT 1' ); - if ( self::LEGACY_PDO ) { + if ( PHP_VERSION_ID < 80000 ) { $this->assertSame( array( 1 => '1', @@ -103,8 +102,8 @@ public function test_query_fetch_mode_not_set(): void { } else { $this->assertSame( array( - 1 => 1, - 0 => 1, + 1 => '1', + 0 => '1', ), $result->fetch() ); @@ -119,7 +118,7 @@ public function test_query_fetch_mode_invalid_arg_count(): void { } public function test_query_fetch_default_mode_allow_any_args(): void { - if ( self::LEGACY_PDO ) { + if ( PHP_VERSION_ID < 80100 ) { // On PHP < 8.1, fetch mode value of NULL is not allowed. $result = @$this->driver->query( 'SELECT 1', null, 1, 2, 'abc', array(), true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $this->assertFalse( $result ); @@ -131,8 +130,8 @@ public function test_query_fetch_default_mode_allow_any_args(): void { // In such cases, any additional arguments are ignored and not validated. $expected_result = array( array( - 1 => 1, - 0 => 1, + 1 => '1', + 0 => '1', ), ); @@ -269,7 +268,7 @@ public function test_rollback_no_active_transaction(): void { public function test_fetch_default(): void { // Default fetch mode is PDO::FETCH_BOTH. $result = $this->driver->query( "SELECT 1, 'abc', 2" ); - if ( self::LEGACY_PDO ) { + if ( PHP_VERSION_ID < 80000 ) { $this->assertSame( array( 1 => '1', @@ -283,10 +282,10 @@ public function test_fetch_default(): void { } else { $this->assertSame( array( - 1 => 1, - 0 => 1, + 1 => '1', + 0 => '1', 'abc' => 'abc', - '2' => 2, + '2' => '2', ), $result->fetch() ); @@ -333,12 +332,39 @@ public function test_attr_default_fetch_mode(): void { ); } + public function test_attr_stringify_fetches(): void { + $this->driver->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + $result = $this->driver->query( "SELECT 123, 1.23, 'abc', true, false" ); + $this->assertSame( + array( '123', '1.23', 'abc', '1', '0' ), + $result->fetch( PDO::FETCH_NUM ) + ); + + $this->driver->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, false ); + $result = $this->driver->query( "SELECT 123, 1.23, 'abc', true, false" ); + $this->assertSame( + /* + * On PHP < 8.1, "PDO::ATTR_STRINGIFY_FETCHES" set to "false" has no + * effect when "PDO::ATTR_EMULATE_PREPARES" is "true" (the default). + * + * TODO: Consider supporting non-string values on PHP < 8.1 when both + * "PDO::ATTR_STRINGIFY_FETCHES" and "PDO::ATTR_EMULATE_PREPARES" + * are set to "false". This would require emulating the behavior, + * as PDO SQLite on PHP < 8.1 seems to always return strings. + */ + PHP_VERSION_ID < 80100 + ? array( '123', '1.23', 'abc', '1', '0' ) + : array( 123, 1.23, 'abc', 1, 0 ), + $result->fetch( PDO::FETCH_NUM ) + ); + } + public function data_pdo_fetch_methods(): Generator { // PDO::FETCH_BOTH yield 'PDO::FETCH_BOTH' => array( "SELECT 1, 'abc', 2, 'two' as `2`", PDO::FETCH_BOTH, - self::LEGACY_PDO + PHP_VERSION_ID < 80000 ? array( 1 => '1', 2 => 'two', @@ -348,8 +374,8 @@ public function data_pdo_fetch_methods(): Generator { 5 => 'two', ) : array( - 1 => 1, - 0 => 1, + 1 => '1', + 0 => '1', 'abc' => 'abc', 2 => 'two', 3 => 'two', @@ -360,9 +386,7 @@ public function data_pdo_fetch_methods(): Generator { yield 'PDO::FETCH_NUM' => array( "SELECT 1, 'abc', 2, 'two' as `2`", PDO::FETCH_NUM, - self::LEGACY_PDO - ? array( '1', 'abc', '2', 'two' ) - : array( 1, 'abc', 2, 'two' ), + array( '1', 'abc', '2', 'two' ), ); // PDO::FETCH_ASSOC @@ -370,7 +394,7 @@ public function data_pdo_fetch_methods(): Generator { "SELECT 1, 'abc', 2, 'two' as `2`", PDO::FETCH_ASSOC, array( - 1 => self::LEGACY_PDO ? '1' : 1, + 1 => '1', 'abc' => 'abc', 2 => 'two', ), @@ -381,9 +405,9 @@ public function data_pdo_fetch_methods(): Generator { "SELECT 1, 'abc', 2, 'two' as `2`", PDO::FETCH_NAMED, array( - 1 => self::LEGACY_PDO ? '1' : 1, + 1 => '1', 'abc' => 'abc', - 2 => array( self::LEGACY_PDO ? '2' : 2, 'two' ), + 2 => array( '2', 'two' ), ), ); @@ -392,7 +416,7 @@ public function data_pdo_fetch_methods(): Generator { "SELECT 1, 'abc', 2, 'two' as `2`", PDO::FETCH_OBJ, (object) array( - 1 => self::LEGACY_PDO ? '1' : 1, + 1 => '1', 'abc' => 'abc', 2 => 'two', ), diff --git a/wp-includes/sqlite-ast/class-wp-pdo-proxy-statement.php b/wp-includes/sqlite-ast/class-wp-pdo-proxy-statement.php index 5d32628c..c5efd2b4 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-proxy-statement.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-proxy-statement.php @@ -35,6 +35,11 @@ trait WP_PDO_Proxy_Statement_PHP_Compat { * @return bool True on success, false on failure. */ public function setFetchMode( $mode, $params = null ): bool { + // Do not pass additional arguments when they are NULL to prevent + // "fetch mode doesn't allow any extra arguments" error. + if ( null === $params ) { + return $this->setDefaultFetchMode( $mode ); + } return $this->setDefaultFetchMode( $mode, $params ); } @@ -47,6 +52,11 @@ public function setFetchMode( $mode, $params = null ): bool { * @return array The result set as an array of rows. */ public function fetchAll( $mode = null, $class_name = null, $constructor_args = null ): array { + // Do not pass additional arguments when they are NULL to prevent + // "Extraneous additional parameters" error. + if ( null === $class_name && null === $constructor_args ) { + return $this->fetchAllRows( $mode ); + } return $this->fetchAllRows( $mode, $class_name, $constructor_args ); } } From f7248a356ba9e258c824d4655d9e3fba8cb6bae4 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 15 Jan 2026 16:31:45 +0100 Subject: [PATCH 18/23] Use SQLite query PDO statement as a proxy statement base when possible --- tests/WP_SQLite_Driver_Metadata_Tests.php | 5 +- .../class-wp-pdo-mysql-on-sqlite.php | 237 ++++++------------ .../sqlite-ast/class-wp-sqlite-driver.php | 2 +- 3 files changed, 79 insertions(+), 165 deletions(-) diff --git a/tests/WP_SQLite_Driver_Metadata_Tests.php b/tests/WP_SQLite_Driver_Metadata_Tests.php index 9b201905..daff6ecd 100644 --- a/tests/WP_SQLite_Driver_Metadata_Tests.php +++ b/tests/WP_SQLite_Driver_Metadata_Tests.php @@ -687,10 +687,7 @@ public function testTruncateTable() { 'TRUNCATE TABLE wp_comments;' ); $actual = $this->engine->get_query_results(); - $this->assertEquals( - true, - $actual - ); + $this->assertNull( $actual ); $this->assertTableEmpty( 'wp_comments', true ); } diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index e24105f8..48160709 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -465,18 +465,15 @@ class WP_PDO_MySQL_On_SQLite extends PDO { private $last_result_statement; /** - * Results of the last emulated query. + * Override for the number of affected rows by the last emulated query. * - * @var array|null - */ - private $last_result; - - /** - * Return value of the last emulated query. + * By default, the number of affected rows is carried by the row count value + * of "$this->last_result_statement". This property serves as an override for + * when the row count of the emulated query and statement don't match. * - * @var mixed + * @var int|null */ - private $last_return_value; + private $last_affected_rows; /** * SQLite column metadata for the last emulated query. @@ -488,7 +485,7 @@ class WP_PDO_MySQL_On_SQLite extends PDO { /** * Number of rows found by the last SQL_CALC_FOUND_ROW query. * - * @var int + * @var int|null */ private $last_sql_calc_found_rows = null; @@ -836,15 +833,6 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar $this->flush(); $this->last_mysql_query = $query; - /** - * Use "PDO::FETCH_NUM" fetch mode, as "create_result_statement_from_data()" - * expects the row data to be passed as an array of values. - * - * @TODO: We can remove this when we use the SQLite PDOStatements directly, - * likely via a proxy, and will stop fetching the results eagerly. - */ - $this->pdo_fetch_mode = PDO::FETCH_NUM; - try { // Parse the MySQL query. $parser = $this->create_parser( $query ); @@ -887,20 +875,11 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar $this->commit_wrapper_transaction(); } - /* - * For now, create all statements from data loaded in memory. This is - * a temporary solution until all queries set their result statement. - * - * TODO: Use "$this->last_result_statement" with an actual PDO SQLite - * statement whenever possible rather than loading all data. - */ - $columns = is_array( $this->last_column_meta ) ? array_column( $this->last_column_meta, 'name' ) : array(); - $rows = is_array( $this->last_result ) ? $this->last_result : array(); - $affected_rows = is_int( $this->last_return_value ) ? $this->last_return_value : 0; - - $this->last_result_statement = $this->create_result_statement_from_data( $columns, $rows ); + if ( null === $this->last_result_statement ) { + $this->last_result_statement = $this->create_result_statement_from_data( array(), array() ); + } - $stmt = new WP_PDO_Proxy_Statement( $this->last_result_statement, $affected_rows ); + $stmt = new WP_PDO_Proxy_Statement( $this->last_result_statement, $this->last_affected_rows ); $stmt->setFetchMode( $fetch_mode, ...$fetch_mode_args ); return $stmt; } catch ( Throwable $e ) { @@ -1132,24 +1111,6 @@ public function create_parser( string $query ): WP_MySQL_Parser { return new WP_MySQL_Parser( self::$mysql_grammar, $tokens ); } - /** - * Get results of the last query. - * - * @return mixed - */ - public function get_query_results() { - return $this->last_result; - } - - /** - * Get return value of the last query() function call. - * - * @return mixed - */ - public function get_last_return_value() { - return $this->last_return_value; - } - /** * Get the number of columns returned by the last emulated query. * @@ -1443,9 +1404,8 @@ private function execute_mysql_query( WP_Parser_Node $node ): void { $this->execute_drop_index_statement( $node ); break; default: - $query = $this->translate( $node ); - $this->execute_sqlite_query( $query ); - $this->set_result_from_affected_rows(); + $query = $this->translate( $node ); + $this->last_result_statement = $this->execute_sqlite_query( $query ); } break; case 'truncateTableStatement': @@ -1829,9 +1789,7 @@ private function execute_select_statement( WP_Parser_Node $node ): void { // Store column meta info. This must be done before fetching data, which // seems to erase type information for expressions in the SELECT clause. $this->store_last_column_meta_from_statement( $stmt ); - $this->set_results_from_fetched_data( - $stmt->fetchAll( $this->pdo_fetch_mode ) - ); + $this->last_result_statement = $stmt; } /** @@ -1916,8 +1874,7 @@ private function execute_insert_or_replace_statement( WP_Parser_Node $node ): vo */ if ( null !== $on_conflict_update_list ) { try { - $this->execute_sqlite_query( $query ); - $this->set_result_from_affected_rows(); + $this->last_result_statement = $this->execute_sqlite_query( $query ); } catch ( PDOException $e ) { $unique_key_violation_prefix = 'SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: '; if ( '23000' === $e->getCode() && str_contains( $e->getMessage(), $unique_key_violation_prefix ) ) { @@ -1932,22 +1889,21 @@ private function execute_insert_or_replace_statement( WP_Parser_Node $node ): vo * prefix and the "." part for the first column, and * then split the rest of the list by ",
." sequence. */ - $column_list = substr( $e->getMessage(), strlen( $unique_key_violation_prefix ) + strlen( $table_name ) + 1 ); - $column_names = explode( ", $table_name.", $column_list ); - $quoted_column_names = array_map( + $column_list = substr( $e->getMessage(), strlen( $unique_key_violation_prefix ) + strlen( $table_name ) + 1 ); + $column_names = explode( ", $table_name.", $column_list ); + $quoted_column_names = array_map( function ( $column ) { return $this->quote_sqlite_identifier( $column ); }, $column_names ); - $this->execute_sqlite_query( + $this->last_result_statement = $this->execute_sqlite_query( $query . sprintf( ' ON CONFLICT(%s) DO UPDATE SET %s', implode( ', ', $quoted_column_names ), $on_conflict_update_list ) ); - $this->set_result_from_affected_rows(); } else { throw $e; } @@ -1955,8 +1911,7 @@ function ( $column ) { return; } - $this->execute_sqlite_query( $query ); - $this->set_result_from_affected_rows(); + $this->last_result_statement = $this->execute_sqlite_query( $query ); } /** @@ -2192,8 +2147,7 @@ private function execute_update_statement( WP_Parser_Node $node ): void { ); $query = implode( ' ', array_filter( $parts ) ); - $this->execute_sqlite_query( $query ); - $this->set_result_from_affected_rows(); + $this->last_result_statement = $this->execute_sqlite_query( $query ); } /** @@ -2273,10 +2227,10 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { )->fetchAll( PDO::FETCH_ASSOC ); // 4. Execute DELETE statements for each table. - $rows = 0; + $affected_rows = 0; if ( count( $ids ) > 0 ) { foreach ( $table_aliases as $table ) { - $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( sprintf( 'DELETE FROM %s AS %s WHERE rowid IN ( %s )', $this->quote_sqlite_identifier( $alias_map[ $table ] ), @@ -2284,12 +2238,12 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { implode( ', ', array_column( $ids, "{$table}_rowid" ) ) ) ); - $this->set_result_from_affected_rows(); - $rows += $this->last_result; + $affected_rows += $stmt->rowCount(); } } - $this->set_result_from_affected_rows( $rows ); + $this->last_result_statement = $this->create_result_statement_from_data( array(), array() ); + $this->last_affected_rows = $affected_rows; return; } @@ -2301,9 +2255,8 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { throw $this->new_access_denied_to_information_schema_exception(); } - $query = $this->translate( $node ); - $this->execute_sqlite_query( $query ); - $this->set_result_from_affected_rows(); + $query = $this->translate( $node ); + $this->last_result_statement = $this->execute_sqlite_query( $query ); } /** @@ -2354,7 +2307,7 @@ private function execute_create_table_statement( WP_Parser_Node $node ): void { )->fetchColumn(); if ( $table_exists ) { - $this->set_result_from_affected_rows( 0 ); + $this->last_result_statement = $this->create_result_statement_from_data( array(), array() ); return; } } @@ -2522,7 +2475,10 @@ private function execute_truncate_table_statement( WP_Parser_Node $node ): void sprintf( 'DELETE FROM %s', $this->quote_sqlite_identifier( $table_name ) ) ); try { - $this->execute_sqlite_query( 'DELETE FROM sqlite_sequence WHERE name = ?', array( $table_name ) ); + $this->last_result_statement = $this->execute_sqlite_query( + 'DELETE FROM sqlite_sequence WHERE name = ?', + array( $table_name ) + ); } catch ( PDOException $e ) { if ( str_contains( $e->getMessage(), 'no such table' ) ) { // The table might not exist if no sequences are used in the DB. @@ -2530,7 +2486,6 @@ private function execute_truncate_table_statement( WP_Parser_Node $node ): void throw $e; } } - $this->set_result_from_affected_rows(); } /** @@ -2672,18 +2627,6 @@ private function execute_show_statement( WP_Parser_Node $node ): void { $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); $sql = $this->get_mysql_create_table_statement( $table_is_temporary, $table_name ); - if ( null === $sql ) { - $this->set_results_from_fetched_data( array() ); - } else { - $this->set_results_from_fetched_data( - array( - array( - 'Table' => $table_name, - 'Create Table' => $sql, - ), - ) - ); - } $this->last_column_meta = array( array( @@ -2705,6 +2648,11 @@ private function execute_show_statement( WP_Parser_Node $node ): void { 'precision' => 31, ), ); + + $this->last_result_statement = $this->create_result_statement_from_data( + array_column( $this->last_column_meta, 'name' ), + null === $sql ? array() : array( array( $table_name, $sql ) ) + ); return; } break; @@ -2714,14 +2662,11 @@ private function execute_show_statement( WP_Parser_Node $node ): void { $this->execute_show_index_statement( $node ); return; case WP_MySQL_Lexer::GRANTS_SYMBOL: - $this->set_results_from_fetched_data( - array( - array( - 'Grants for root@%' => 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION', - ), - ) + $this->last_result_statement = $this->create_result_statement_from_data( + array( 'Grants for root@%' ), + array( array( 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION' ) ) ); - $this->last_column_meta = array( + $this->last_column_meta = array( array( 'native_type' => 'STRING', 'pdo_type' => PDO::PARAM_STR, @@ -2740,8 +2685,7 @@ private function execute_show_statement( WP_Parser_Node $node ): void { $this->execute_show_tables_statement( $node ); return; case WP_MySQL_Lexer::VARIABLES_SYMBOL: - $this->last_result = true; - $this->last_column_meta = array( + $this->last_column_meta = array( array( 'native_type' => 'STRING', 'pdo_type' => PDO::PARAM_STR, @@ -2761,6 +2705,10 @@ private function execute_show_statement( WP_Parser_Node $node ): void { 'precision' => 0, ), ); + $this->last_result_statement = $this->create_result_statement_from_data( + array_column( $this->last_column_meta, 'name' ), + array() + ); return; } @@ -2805,7 +2753,7 @@ private function execute_show_collation_statement( WP_Parser_Node $node ): void ) ); $this->store_last_column_meta_from_statement( $stmt ); - $this->set_results_from_fetched_data( $stmt->fetchAll( $this->pdo_fetch_mode ) ); + $this->last_result_statement = $stmt; } /** @@ -2839,8 +2787,7 @@ private function execute_show_databases_statement( WP_Parser_Node $node ): void ); $this->store_last_column_meta_from_statement( $stmt ); - $databases = $stmt->fetchAll( $this->pdo_fetch_mode ); - $this->set_results_from_fetched_data( $databases ); + $this->last_result_statement = $stmt; } /** @@ -2925,8 +2872,7 @@ private function execute_show_index_statement( WP_Parser_Node $node ): void { ); $this->store_last_column_meta_from_statement( $stmt ); - $index_info = $stmt->fetchAll( $this->pdo_fetch_mode ); - $this->set_results_from_fetched_data( $index_info ); + $this->last_result_statement = $stmt; } /** @@ -2988,11 +2934,7 @@ private function execute_show_table_status_statement( WP_Parser_Node $node ): vo ); $this->store_last_column_meta_from_statement( $stmt ); - $table_info = $stmt->fetchAll( $this->pdo_fetch_mode ); - if ( false === $table_info ) { - $this->set_results_from_fetched_data( array() ); - } - $this->set_results_from_fetched_data( $table_info ); + $this->last_result_statement = $stmt; } /** @@ -3040,11 +2982,7 @@ private function execute_show_tables_statement( WP_Parser_Node $node ): void { ); $this->store_last_column_meta_from_statement( $stmt ); - $table_info = $stmt->fetchAll( $this->pdo_fetch_mode ); - if ( false === $table_info ) { - $this->set_results_from_fetched_data( array() ); - } - $this->set_results_from_fetched_data( $table_info ); + $this->last_result_statement = $stmt; } /** @@ -3113,11 +3051,7 @@ private function execute_show_columns_statement( WP_Parser_Node $node ): void { ); $this->store_last_column_meta_from_statement( $stmt ); - $column_info = $stmt->fetchAll( $this->pdo_fetch_mode ); - if ( false === $column_info ) { - $this->set_results_from_fetched_data( array() ); - } - $this->set_results_from_fetched_data( $column_info ); + $this->last_result_statement = $stmt; } /** @@ -3152,8 +3086,7 @@ private function execute_describe_statement( WP_Parser_Node $node ): void { ); $this->store_last_column_meta_from_statement( $stmt ); - $column_info = $stmt->fetchAll( $this->pdo_fetch_mode ); - $this->set_results_from_fetched_data( $column_info ); + $this->last_result_statement = $stmt; } /** @@ -3288,7 +3221,7 @@ private function execute_set_statement( WP_Parser_Node $node ): void { } } - $this->last_result = 0; + $this->last_result_statement = $this->create_result_statement_from_data( array(), array() ); } /** @@ -3489,7 +3422,7 @@ private function execute_administration_statement( WP_Parser_Node $node ): void ); } - $this->last_column_meta = array( + $this->last_column_meta = array( array( 'native_type' => 'STRING', 'pdo_type' => PDO::PARAM_STR, @@ -3527,7 +3460,10 @@ private function execute_administration_statement( WP_Parser_Node $node ): void 'precision' => 31, ), ); - $this->set_results_from_fetched_data( $results ); + $this->last_result_statement = $this->create_result_statement_from_data( + array_column( $this->last_column_meta, 'name' ), + $results + ); } /** @@ -4405,10 +4341,21 @@ private function translate_function_call( WP_Parser_Node $node ): string { return '(' . implode( ' || ', $args ) . ')'; case 'FOUND_ROWS': $found_rows = $this->last_sql_calc_found_rows; - if ( null === $found_rows && is_array( $this->last_result ) ) { - $found_rows = count( $this->last_result ); - } - return $found_rows; + + /* + * TODO: Handle case when "null === $found_rows". + * + * From MySQL documentation: + * + * In the absence of the SQL_CALC_FOUND_ROWS option in the most + * recent successful SELECT statement, FOUND_ROWS() returns the + * number of rows in the result set returned by that statement. + * + * To support this case without exhausting the last PDO statement + * instance, we need to be able to re-execute the last MySQL query + * (for read-only statements) and use 1 for all other statements. + */ + return $found_rows ?? 1; case 'VERSION': $version = (string) $this->mysql_version; $value = sprintf( @@ -6484,8 +6431,8 @@ private function quote_mysql_utf8_string_literal( string $utf8_literal ): string private function flush(): void { $this->last_mysql_query = ''; $this->last_sqlite_queries = array(); - $this->last_result = null; - $this->last_return_value = null; + $this->last_result_statement = null; + $this->last_affected_rows = null; $this->last_column_meta = array(); $this->is_readonly = false; $this->wrapper_transaction_type = null; @@ -6570,36 +6517,6 @@ private function create_result_statement_from_data( array $columns, array $rows return $pdo->query( $query ); } - /** - * Set results of a query() call using fetched data. - * - * @param array $data The data to set. - */ - private function set_results_from_fetched_data( array $data ): void { - $this->last_result = $data; - $this->last_return_value = $this->last_result; - } - - /** - * Set results of a query() call using the number of affected rows. - * - * @param int|null $override Override the affected rows. - */ - private function set_result_from_affected_rows( ?int $override = null ): void { - /* - * SELECT CHANGES() is a workaround for the fact that $stmt->rowCount() - * returns "0" (zero) with the SQLite driver at all times. - * See: https://www.php.net/manual/en/pdostatement.rowcount.php - */ - if ( null === $override ) { - $affected_rows = (int) $this->execute_sqlite_query( 'SELECT CHANGES()' )->fetch()[0]; - } else { - $affected_rows = $override; - } - $this->last_result = $affected_rows; - $this->last_return_value = $affected_rows; - } - /** * Create a new SQLite driver exception. * diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index 6471f768..1f427509 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -193,7 +193,7 @@ public function get_query_results() { * @return mixed */ public function get_last_return_value() { - return $this->mysql_on_sqlite_driver->get_last_return_value(); + return $this->last_result; } /** From b6368247cee806189a291cb189ca7bf2d8547934 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 19 Jan 2026 10:42:49 +0100 Subject: [PATCH 19/23] Improve DNS parsing by correctly handling "\0" bytes --- tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 12 ++++++++++++ .../sqlite-ast/class-wp-pdo-mysql-on-sqlite.php | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index 1a2dacd6..a80328ec 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -40,6 +40,18 @@ public function test_dsn_parsing(): void { // DSN with semicolon in the database name. $driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;dbname=w;;p;' ); $this->assertSame( 'w;p', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); + + // DSN with semicolon in the database name and a terminating semicolon. + $driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=w;;;p' ); + $this->assertSame( 'w;', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); + + // DSN with two semicolons in the database name. + $driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=w;;;;p' ); + $this->assertSame( 'w;;p', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); + + // DSN with a "\0" byte (always terminates the DSN string). + $driver = new WP_PDO_MySQL_On_SQLite( "mysql-on-sqlite:path=:memory:;dbname=w\0p;" ); + $this->assertSame( 'w', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); } public function test_query(): void { diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 48160709..0d7a863d 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -598,6 +598,12 @@ public function __construct( ?string $password = null, array $options = array() ) { + // PDO DSN can't include "\0" bytes; parsing stops at the first one. + $first_null_byte_index = strpos( $dsn, "\0" ); + if ( false !== $first_null_byte_index ) { + $dsn = substr( $dsn, 0, $first_null_byte_index ); + } + // Parse the DSN. $dsn_parts = explode( ':', $dsn, 2 ); if ( count( $dsn_parts ) < 2 ) { From 3f9c9673a76b842c49e5d08d89d0c7dc147157ad Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 19 Jan 2026 11:06:50 +0100 Subject: [PATCH 20/23] Improve DSN whitespace parsing --- tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 2 +- wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index a80328ec..b06d6d0e 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -30,7 +30,7 @@ public function test_dsn_parsing(): void { $this->assertSame( 'wp', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); // DSN with whitespace before argument names. - $driver = new WP_PDO_MySQL_On_SQLite( "mysql-on-sqlite: path=:memory:;\t dbname=wp" ); + $driver = new WP_PDO_MySQL_On_SQLite( "mysql-on-sqlite: path=:memory:; \n\r\t\v\fdbname=wp" ); $this->assertSame( 'wp', $driver->query( 'SELECT DATABASE()' )->fetch()[0] ); // DSN with whitespace in the database name. diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 0d7a863d..d0314ace 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -623,8 +623,9 @@ public function __construct( // Restore quoted semicolons that were replaced with "\0". $arg = str_replace( "\0", ';', $arg ); - // PDO DSN allows whitespace before argument name. - $arg = ltrim( $arg ); + // PDO DSN allows whitespace before argument name. Trim characters + // as per the "isspace()" C function (in the default "C" locale). + $arg = ltrim( $arg, " \n\r\t\v\f" ); if ( '' === $arg ) { continue; From 3c8543bc7a82966c95020d1c052c4720ddac4f16 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 19 Jan 2026 15:45:40 +0100 Subject: [PATCH 21/23] Add support for FOUND_ROWS() without SQL_CALC_FOUND_ROWS --- tests/WP_SQLite_Driver_Tests.php | 75 +++++ .../class-wp-pdo-mysql-on-sqlite.php | 277 ++++++++++-------- 2 files changed, 225 insertions(+), 127 deletions(-) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 794a9350..71cd0686 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -3067,6 +3067,81 @@ public function testCalcFoundRows() { ); } + public function testFoundRowsWithoutSqlCalcFoundRows(): void { + $this->assertQuery( 'DROP TABLE _dates' ); + $this->assertQuery( 'DROP TABLE _options' ); + + // CREATE TABLE + $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY, value TEXT, INDEX idx_value (value))' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $result[0]->{'FOUND_ROWS()'} ); + + // INSERT + $this->assertQuery( 'INSERT INTO t (id) VALUES (1), (2), (3)' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $result[0]->{'FOUND_ROWS()'} ); + + // SELECT + $this->assertQuery( 'SELECT * FROM t' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '3', $result[0]->{'FOUND_ROWS()'} ); + + // DESCRIBE + $this->assertQuery( 'DESCRIBE t' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $result[0]->{'FOUND_ROWS()'} ); + + // SHOW COLLATION + $this->assertQuery( 'SHOW COLLATION' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '7', $result[0]->{'FOUND_ROWS()'} ); + + // SHOW DATABASES + $this->assertQuery( 'SHOW DATABASES' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $result[0]->{'FOUND_ROWS()'} ); + + // SHOW COLUMNS + $this->assertQuery( 'SHOW COLUMNS FROM t' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $result[0]->{'FOUND_ROWS()'} ); + + // SHOW CREATE TABLE + $this->assertQuery( 'SHOW CREATE TABLE t' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $result[0]->{'FOUND_ROWS()'} ); + + // SHOW CREATE TABLE with non-existent table + $this->assertQuery( 'SHOW CREATE TABLE non_existent_table' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $result[0]->{'FOUND_ROWS()'} ); + + // SHOW INDEX + $this->assertQuery( 'SHOW INDEX FROM t' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $result[0]->{'FOUND_ROWS()'} ); + + // SHOW GRANTS + $this->assertQuery( 'SHOW GRANTS' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $result[0]->{'FOUND_ROWS()'} ); + + // SHOW TABLE STATUS + $r = $this->assertQuery( 'SHOW TABLE STATUS' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $result[0]->{'FOUND_ROWS()'} ); + + // SHOW TABLES + $this->assertQuery( 'SHOW TABLES' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $result[0]->{'FOUND_ROWS()'} ); + + // SHOW VARIABLES + $this->assertQuery( 'SHOW VARIABLES' ); + $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $result[0]->{'FOUND_ROWS()'} ); + } + public function testComplexSelectBasedOnDates() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('first', '2003-05-27 10:08:48');" diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index d0314ace..885da160 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -483,11 +483,26 @@ class WP_PDO_MySQL_On_SQLite extends PDO { private $last_column_meta = array(); /** - * Number of rows found by the last SQL_CALC_FOUND_ROW query. + * Data for emulating the "FOUND_ROWS()" function. * - * @var int|null + * When "SQL_CALC_FOUND_ROWS" is used, the appropriate value is stored here. + * Otherwise, it's used to store the last number of found rows, or a query + * that returns the rows that need to be counted for usage in "FOUND_ROWS()". + * + * From MySQL documentation: + * In the absence of the SQL_CALC_FOUND_ROWS option in the most recent + * successful SELECT statement, FOUND_ROWS() returns the number of rows + * in the result set returned by that statement. + * + * In reality, this applies to SHOW and DESCRIBE statements as well. + * + * The value can be: + * - integer: The number of rows to be directly returned by "FOUND_ROWS()". + * - string: A SQLite query whose result set rows need to be counted. + * + * @var int|string */ - private $last_sql_calc_found_rows = null; + private $found_rows = 0; /** * Whether the current MySQL query is read-only. @@ -902,6 +917,11 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar throw $this->convert_information_schema_exception( $e ); } throw $this->new_driver_exception( $e->getMessage(), $e->getCode(), $e ); + } finally { + // A query that doesn't return any rows or fails sets found rows to 0. + if ( ! $this->is_readonly || isset( $e ) ) { + $this->found_rows = 0; + } } } @@ -1785,9 +1805,9 @@ private function execute_select_statement( WP_Parser_Node $node ): void { 'SELECT COUNT(*) AS cnt FROM (' . $this->translate( $count_expr ) . ')' ); - $this->last_sql_calc_found_rows = $result->fetchColumn(); + $this->found_rows = (int) $result->fetchColumn(); } else { - $this->last_sql_calc_found_rows = null; + $this->found_rows = $query; } // Execute the query. @@ -2660,6 +2680,7 @@ private function execute_show_statement( WP_Parser_Node $node ): void { array_column( $this->last_column_meta, 'name' ), null === $sql ? array() : array( array( $table_name, $sql ) ) ); + $this->found_rows = null === $sql ? 0 : 1; return; } break; @@ -2684,6 +2705,7 @@ private function execute_show_statement( WP_Parser_Node $node ): void { 'precision' => 31, ), ); + $this->found_rows = 1; return; case WP_MySQL_Lexer::TABLE_SYMBOL: $this->execute_show_table_status_statement( $node ); @@ -2716,6 +2738,7 @@ private function execute_show_statement( WP_Parser_Node $node ): void { array_column( $this->last_column_meta, 'name' ), array() ); + $this->found_rows = 0; return; } @@ -2743,24 +2766,24 @@ private function execute_show_collation_statement( WP_Parser_Node $node ): void $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'collation_name' ); } - $stmt = $this->execute_sqlite_query( - sprintf( - 'SELECT - COLLATION_NAME AS `Collation`, - CHARACTER_SET_NAME AS `Charset`, - ID AS `Id`, - IS_DEFAULT AS `Default`, - IS_COMPILED AS `Compiled`, - SORTLEN AS `Sortlen`, - PAD_ATTRIBUTE AS `Pad_attribute` - FROM (%s) - WHERE TRUE %s', - $definition, - $condition ?? '' - ) + $query = sprintf( + 'SELECT + COLLATION_NAME AS `Collation`, + CHARACTER_SET_NAME AS `Charset`, + ID AS `Id`, + IS_DEFAULT AS `Default`, + IS_COMPILED AS `Compiled`, + SORTLEN AS `Sortlen`, + PAD_ATTRIBUTE AS `Pad_attribute` + FROM (%s) + WHERE TRUE %s', + $definition, + $condition ?? '' ); + $stmt = $this->execute_sqlite_query( $query ); $this->store_last_column_meta_from_statement( $stmt ); $this->last_result_statement = $stmt; + $this->found_rows = $query; } /** @@ -2776,25 +2799,23 @@ private function execute_show_databases_statement( WP_Parser_Node $node ): void if ( $like_or_where ) { $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'schema_name' ); } - $stmt = $this->execute_sqlite_query( - sprintf( - 'SELECT SCHEMA_NAME AS Database - FROM ( - SELECT CASE WHEN SCHEMA_NAME = ? THEN ? ELSE SCHEMA_NAME END AS SCHEMA_NAME - FROM %s - ORDER BY SCHEMA_NAME - )%s', - $this->quote_sqlite_identifier( $schemata_table ), - isset( $condition ) ? ( ' WHERE TRUE ' . $condition ) : '' - ), - array( - $this->get_saved_db_name(), - $this->main_db_name, - ) + $query = sprintf( + 'SELECT SCHEMA_NAME AS Database + FROM ( + SELECT CASE WHEN SCHEMA_NAME = %s THEN %s ELSE SCHEMA_NAME END AS SCHEMA_NAME + FROM %s + ORDER BY SCHEMA_NAME + )%s', + $this->connection->quote( $this->get_saved_db_name() ), + $this->connection->quote( $this->main_db_name ), + $this->quote_sqlite_identifier( $schemata_table ), + isset( $condition ) ? ( ' WHERE TRUE ' . $condition ) : '' ); + $stmt = $this->execute_sqlite_query( $query ); $this->store_last_column_meta_from_statement( $stmt ); $this->last_result_statement = $stmt; + $this->found_rows = $query; } /** @@ -2844,8 +2865,8 @@ private function execute_show_index_statement( WP_Parser_Node $node ): void { */ $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' ); - $stmt = $this->execute_sqlite_query( - ' + $query = sprintf( + " SELECT TABLE_NAME AS `Table`, NON_UNIQUE AS `Non_unique`, @@ -2862,10 +2883,10 @@ private function execute_show_index_statement( WP_Parser_Node $node ): void { INDEX_COMMENT AS `Index_comment`, IS_VISIBLE AS `Visible`, EXPRESSION AS `Expression` - FROM ' . $this->quote_sqlite_identifier( $statistics_table ) . " - WHERE table_schema = ? - AND table_name = ? - $condition + FROM %s + WHERE table_schema = %s + AND table_name = %s + %s ORDER BY INDEX_NAME = 'PRIMARY' DESC, NON_UNIQUE = '0' DESC, @@ -2875,11 +2896,16 @@ private function execute_show_index_statement( WP_Parser_Node $node ): void { ROWID, SEQ_IN_INDEX ", - array( $this->get_saved_db_name( $database ), $table_name ) + $this->quote_sqlite_identifier( $statistics_table ), + $this->connection->quote( $this->get_saved_db_name( $database ) ), + $this->connection->quote( $table_name ), + $condition ); + $stmt = $this->execute_sqlite_query( $query ); $this->store_last_column_meta_from_statement( $stmt ); $this->last_result_statement = $stmt; + $this->found_rows = $query; } /** @@ -2910,38 +2936,38 @@ private function execute_show_table_status_statement( WP_Parser_Node $node ): vo false, // SHOW TABLE STATUS lists only non-temporary tables. 'tables' ); - $stmt = $this->execute_sqlite_query( - sprintf( - 'SELECT - table_name AS `Name`, - engine AS `Engine`, - version AS `Version`, - row_format AS `Row_format`, - table_rows AS `Rows`, - avg_row_length AS `Avg_row_length`, - data_length AS `Data_length`, - max_data_length AS `Max_data_length`, - index_length AS `Index_length`, - data_free AS `Data_free`, - auto_increment AS `Auto_increment`, - create_time AS `Create_time`, - update_time AS `Update_time`, - check_time AS `Check_time`, - table_collation AS `Collation`, - checksum AS `Checksum`, - create_options AS `Create_options`, - table_comment AS `Comment` - FROM %s - WHERE table_schema = ? %s - ORDER BY table_name', - $this->quote_sqlite_identifier( $tables_tables ), - $condition ?? '' - ), - array( $this->get_saved_db_name( $database ) ) + $query = sprintf( + 'SELECT + table_name AS `Name`, + engine AS `Engine`, + version AS `Version`, + row_format AS `Row_format`, + table_rows AS `Rows`, + avg_row_length AS `Avg_row_length`, + data_length AS `Data_length`, + max_data_length AS `Max_data_length`, + index_length AS `Index_length`, + data_free AS `Data_free`, + auto_increment AS `Auto_increment`, + create_time AS `Create_time`, + update_time AS `Update_time`, + check_time AS `Check_time`, + table_collation AS `Collation`, + checksum AS `Checksum`, + create_options AS `Create_options`, + table_comment AS `Comment` + FROM %s + WHERE table_schema = %s %s + ORDER BY table_name', + $this->quote_sqlite_identifier( $tables_tables ), + $this->connection->quote( $this->get_saved_db_name( $database ) ), + $condition ?? '' ); + $stmt = $this->execute_sqlite_query( $query ); $this->store_last_column_meta_from_statement( $stmt ); $this->last_result_statement = $stmt; + $this->found_rows = $query; } /** @@ -2976,20 +3002,20 @@ private function execute_show_tables_statement( WP_Parser_Node $node ): void { false, // SHOW TABLES lists only non-temporary tables. 'tables' ); - $stmt = $this->execute_sqlite_query( - sprintf( - 'SELECT %s FROM %s WHERE table_schema = ? %s ORDER BY table_name', - $is_full - ? sprintf( 'table_name AS `Tables_in_%s`, table_type AS `Table_type`', $database ) - : sprintf( 'table_name AS `Tables_in_%s`', $database ), - $this->quote_sqlite_identifier( $table_tables ), - $condition ?? '' - ), - array( $this->get_saved_db_name( $database ) ) + $query = sprintf( + 'SELECT %s FROM %s WHERE table_schema = %s %s ORDER BY table_name', + $is_full + ? sprintf( 'table_name AS `Tables_in_%s`, table_type AS `Table_type`', $database ) + : sprintf( 'table_name AS `Tables_in_%s`', $database ), + $this->quote_sqlite_identifier( $table_tables ), + $this->connection->quote( $this->get_saved_db_name( $database ) ), + $condition ?? '' ); + $stmt = $this->execute_sqlite_query( $query ); $this->store_last_column_meta_from_statement( $stmt ); $this->last_result_statement = $stmt; + $this->found_rows = $query; } /** @@ -3039,26 +3065,27 @@ private function execute_show_columns_statement( WP_Parser_Node $node ): void { // Fetch column information. $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' ); - $stmt = $this->execute_sqlite_query( - sprintf( - 'SELECT - column_name AS `Field`, - column_type AS `Type`, - is_nullable AS `Null`, - column_key AS `Key`, - column_default AS `Default`, - extra AS `Extra` - FROM %s - WHERE table_schema = ? AND table_name = ? %s - ORDER BY ordinal_position', - $this->quote_sqlite_identifier( $columns_table ), - $condition ?? '' - ), - array( $this->get_saved_db_name( $database ), $table_name ) + $query = sprintf( + 'SELECT + column_name AS `Field`, + column_type AS `Type`, + is_nullable AS `Null`, + column_key AS `Key`, + column_default AS `Default`, + extra AS `Extra` + FROM %s + WHERE table_schema = %s AND table_name = %s %s + ORDER BY ordinal_position', + $this->quote_sqlite_identifier( $columns_table ), + $this->connection->quote( $this->get_saved_db_name( $database ) ), + $this->connection->quote( $table_name ), + $condition ?? '' ); + $stmt = $this->execute_sqlite_query( $query ); $this->store_last_column_meta_from_statement( $stmt ); $this->last_result_statement = $stmt; + $this->found_rows = $query; } /** @@ -3075,25 +3102,27 @@ private function execute_describe_statement( WP_Parser_Node $node ): void { $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' ); - $stmt = $this->execute_sqlite_query( - ' - SELECT - column_name AS `Field`, - column_type AS `Type`, - is_nullable AS `Null`, - column_key AS `Key`, - column_default AS `Default`, - extra AS Extra - FROM ' . $this->quote_sqlite_identifier( $columns_table ) . ' - WHERE table_schema = ? - AND table_name = ? - ORDER BY ordinal_position - ', - array( $this->get_saved_db_name( $database ), $table_name ) + $query = sprintf( + 'SELECT + column_name AS `Field`, + column_type AS `Type`, + is_nullable AS `Null`, + column_key AS `Key`, + column_default AS `Default`, + extra AS `Extra` + FROM %s + WHERE table_schema = %s + AND table_name = %s + ORDER BY ordinal_position', + $this->quote_sqlite_identifier( $columns_table ), + $this->connection->quote( $this->get_saved_db_name( $database ) ), + $this->connection->quote( $table_name ) ); + $stmt = $this->execute_sqlite_query( $query ); $this->store_last_column_meta_from_statement( $stmt ); $this->last_result_statement = $stmt; + $this->found_rows = $query; } /** @@ -4347,22 +4376,16 @@ private function translate_function_call( WP_Parser_Node $node ): string { case 'CONCAT': return '(' . implode( ' || ', $args ) . ')'; case 'FOUND_ROWS': - $found_rows = $this->last_sql_calc_found_rows; - - /* - * TODO: Handle case when "null === $found_rows". - * - * From MySQL documentation: - * - * In the absence of the SQL_CALC_FOUND_ROWS option in the most - * recent successful SELECT statement, FOUND_ROWS() returns the - * number of rows in the result set returned by that statement. - * - * To support this case without exhausting the last PDO statement - * instance, we need to be able to re-execute the last MySQL query - * (for read-only statements) and use 1 for all other statements. - */ - return $found_rows ?? 1; + $found_rows = $this->found_rows; + if ( is_int( $found_rows ) ) { + return $found_rows; + } elseif ( is_string( $found_rows ) ) { + return (int) $this->execute_sqlite_query( + sprintf( 'SELECT COUNT(*) FROM (%s)', $found_rows ) + )->fetchColumn()[0]; + } else { + return 0; + } case 'VERSION': $version = (string) $this->mysql_version; $value = sprintf( From 722d119ee6d2ab9a2dfbd51a374eb7ee4865eaee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 19 Jan 2026 16:19:50 +0100 Subject: [PATCH 22/23] Fix typo in comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Adam ZieliƄski --- tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php index b06d6d0e..d9b474fb 100644 --- a/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php @@ -11,7 +11,7 @@ public function setUp(): void { // Run all tests with stringified fetch mode results, so we can use // assertions that are consistent across all tested PHP versions. - // The "PDO::ATTR_STRINGIFY_FETCHES" mode is tested in separately. + // The "PDO::ATTR_STRINGIFY_FETCHES" mode is tested separately. $this->driver->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); } From 4215423a59d04f5aa329ee68d3756c8554346fd9 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 19 Jan 2026 16:20:04 +0100 Subject: [PATCH 23/23] Improve comments --- wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 885da160..80cb1f67 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -630,12 +630,12 @@ public function __construct( throw new PDOException( 'could not find driver' ); } - // PDO DSN supports semicolon quoting using double semicolon sequences. - // Replace ";;" with "\0" to preserve quoted semicolons in "explode()". + // PDO DSN supports semicolon escaping using double semicolon sequences. + // Replace ";;" with "\0" to preserve escaped semicolons in "explode()". $args_string = str_replace( ';;', "\0", $dsn_parts[1] ); $args = array(); foreach ( explode( ';', $args_string ) as $arg ) { - // Restore quoted semicolons that were replaced with "\0". + // Restore escaped semicolons that were replaced with "\0". $arg = str_replace( "\0", ';', $arg ); // PDO DSN allows whitespace before argument name. Trim characters