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..b50bb46a 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,161 @@ 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;' ); + + // 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 { + $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 ); + $this->assertSame( + array( + 1 => 1, + 0 => 1, + 'abc' => 'abc', + ), + $result->fetch() + ); + } + + /** + * @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 ); + + $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 { @@ -50,4 +203,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 285fb7f0..9d2b0b7e 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. */ @@ -443,6 +443,17 @@ class WP_PDO_MySQL_On_SQLite { */ private $information_schema_builder; + /** + * PDO API: The PDO attributes of the connection. + * + * TODO: Add PDO default attribute values. + * + * @var array + */ + private $pdo_attributes = array( + PDO::ATTR_STRINGIFY_FETCHES => PHP_VERSION_ID < 80100 ? true : false, + ); + /** * Last executed MySQL query. * @@ -579,24 +590,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 +723,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,17 +734,100 @@ 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 = 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->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 ); @@ -748,7 +869,14 @@ 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; + + $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; + + $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(); @@ -765,6 +893,17 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo } } + /** + * 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. * @@ -834,6 +973,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. * @@ -2408,7 +2570,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, ), @@ -2447,7 +2609,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', ), ) @@ -2536,7 +2698,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 ) ); } /** @@ -2568,7 +2730,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 ); } @@ -2654,7 +2816,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 ); } @@ -2717,7 +2879,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() ); } @@ -2769,7 +2931,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() ); } @@ -2842,7 +3004,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() ); } @@ -2881,7 +3043,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 ); } @@ -3203,14 +3365,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 new file mode 100644 index 00000000..d3b949df --- /dev/null +++ b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php @@ -0,0 +1,502 @@ +setDefaultFetchMode( $mode, $params ); + } + + /** + * Fetch all remaining rows from the result set. + * + * @param int $mode The fetch mode to use. + * @param mixed $class_name With PDO::FETCH_CLASS, the name of the class to instantiate. + * @param mixed $constructor_args With PDO::FETCH_CLASS, the parameters to pass to the class constructor. + * @return array The result set as an array of rows. + */ + public function fetchAll( $mode = null, $class_name = null, $constructor_args = null ): array { + return $this->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 { + return $this->setDefaultFetchMode( $mode, $args ); + } + + /** + * 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; + + /** + * The PDO connection. + * + * @var PDO + */ + 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; + + /** + * The number of affected rows. + * + * @var int + */ + 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. + */ + 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; + } + + /** + * 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 { + return count( $this->columns ); + } + + /** + * Get the number of rows affected by the statement. + * + * @return int The number of rows affected by the statement. + */ + public function rowCount(): int { + return $this->affected_rows; + } + + /** + * 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 + ) { + 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 ) ); + } + } + + /** + * 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 ) { + return $this->attributes[ $attribute ] ?? $this->pdo->getAttribute( $attribute ); + } + + /** + * 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 { + $this->attributes[ $attribute ] = $value; + return true; + } + + /** + * 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 { + if ( null === $mode || 0 === $mode ) { + $mode = $this->fetch_mode; + } + + $rows = array(); + while ( $row = $this->fetch( $mode, ...$args ) ) { + $rows[] = $row; + } + 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; + } +} + +/** + * 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-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 a562ed59..6471f768 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. * @@ -56,9 +63,19 @@ 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; + + $connection->get_pdo()->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); } /** @@ -139,7 +156,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 ) { - return $this->mysql_on_sqlite_driver->query( $query, $fetch_mode, ...$fetch_mode_args ); + $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; } /** @@ -158,7 +184,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; } /** 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';