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..a8d1ee99 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,170 @@ 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;' ); + $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( PDO::FETCH_BOTH ) + ); + } + + /** + * @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_column_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_COLUMN ); + } + + public function test_query_fetch_column_invalid_colno_type(): void { + $this->expectException( TypeError::class ); + $this->expectExceptionMessage( 'PDO::query(): Argument #3 must be of type int, string given' ); + $this->driver->query( 'SELECT 1', PDO::FETCH_COLUMN, '0' ); + } + + 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 +212,94 @@ 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 test_fetch_column_invalid_colno_value(): void { + $stmt = $this->driver->query( 'SELECT 1' ); + $this->expectException( ValueError::class ); + $this->expectExceptionMessage( 'Invalid column index' ); + $stmt->fetchColumn( 1 ); + } + + 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 8220045b..03512cf8 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,15 @@ class WP_PDO_MySQL_On_SQLite { */ private $information_schema_builder; + /** + * PDO API: The PDO attributes of the connection. + * + * @var array + */ + private $pdo_attributes = array( + PDO::ATTR_STRINGIFY_FETCHES => PHP_VERSION_ID < 80100 ? true : false, + ); + /** * Last executed MySQL query. * @@ -579,24 +588,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 ) { @@ -684,6 +720,180 @@ function ( string $sql, array $params ) { ); } + /** + * PDO API: Translate and execute a MySQL query in SQLite. + * + * A single MySQL query can be translated into zero or more SQLite queries. + * + * @param string $query Full SQL statement string. + * @param int $fetch_mode PDO fetch mode. Default is PDO::FETCH_OBJ. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * + * @return mixed Return value, depending on the query type. + * + * @throws WP_SQLite_Driver_Exception When the query execution fails. + */ + #[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 = PDO::FETCH_NUM; // TODO + $this->last_mysql_query = $query; + + try { + // Parse the MySQL query. + $parser = $this->create_parser( $query ); + $parser->next_query(); + $ast = $parser->get_query_ast(); + if ( null === $ast ) { + throw $this->new_driver_exception( 'Failed to parse the MySQL query.' ); + } + + if ( $parser->next_query() ) { + throw $this->new_driver_exception( 'Multi-query is not supported.' ); + } + + /* + * Determine if we need to wrap the translated queries in a transaction. + * + * [GRAMMAR] + * query: + * EOF + * | (simpleStatement | beginWork) (SEMICOLON_SYMBOL EOF? | EOF) + */ + $child_node = $ast->get_first_child_node(); + if ( + null === $child_node + || 'beginWork' === $child_node->rule_name + || $child_node->has_child_node( 'transactionOrLockingStatement' ) + ) { + $wrap_in_transaction = false; + } else { + $wrap_in_transaction = true; + } + + if ( $wrap_in_transaction ) { + $this->begin_wrapper_transaction(); + } + + $this->execute_mysql_query( $ast ); + + if ( $wrap_in_transaction ) { + $this->commit_wrapper_transaction(); + } + + $affected_rows = is_int( $this->last_return_value ) ? $this->last_return_value : 0; + $rows = is_array( $this->last_result ) ? $this->last_result : array(); + $columns = is_array( $this->last_column_meta ) ? $this->last_column_meta : array(); + + $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(); + $this->table_lock_active = false; + } catch ( Throwable $rollback_exception ) { + // Ignore rollback errors. + } + if ( $e instanceof WP_SQLite_Driver_Exception ) { + throw $e; + } elseif ( $e instanceof WP_SQLite_Information_Schema_Exception ) { + throw $this->convert_information_schema_exception( $e ); + } + throw $this->new_driver_exception( $e->getMessage(), $e->getCode(), $e ); + } + } + + /** + * 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. * @@ -753,6 +963,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, null when not set. + */ + #[ReturnTypeWillChange] + public function getAttribute( $attribute ) { + return $this->pdo_attributes[ $attribute ] ?? null; + } + /** * Get the SQLite connection instance. * @@ -840,87 +1073,6 @@ public function get_insert_id() { return $last_insert_id; } - /** - * Translate and execute a MySQL query in SQLite. - * - * A single MySQL query can be translated into zero or more SQLite queries. - * - * @param string $query Full SQL statement string. - * @param int $fetch_mode PDO fetch mode. Default is PDO::FETCH_OBJ. - * @param array ...$fetch_mode_args Additional fetch mode arguments. - * - * @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 ) { - $this->flush(); - $this->pdo_fetch_mode = $fetch_mode; - $this->last_mysql_query = $query; - - try { - // Parse the MySQL query. - $parser = $this->create_parser( $query ); - $parser->next_query(); - $ast = $parser->get_query_ast(); - if ( null === $ast ) { - throw $this->new_driver_exception( 'Failed to parse the MySQL query.' ); - } - - if ( $parser->next_query() ) { - throw $this->new_driver_exception( 'Multi-query is not supported.' ); - } - - /* - * Determine if we need to wrap the translated queries in a transaction. - * - * [GRAMMAR] - * query: - * EOF - * | (simpleStatement | beginWork) (SEMICOLON_SYMBOL EOF? | EOF) - */ - $child_node = $ast->get_first_child_node(); - if ( - null === $child_node - || 'beginWork' === $child_node->rule_name - || $child_node->has_child_node( 'transactionOrLockingStatement' ) - ) { - $wrap_in_transaction = false; - } else { - $wrap_in_transaction = true; - } - - if ( $wrap_in_transaction ) { - $this->begin_wrapper_transaction(); - } - - $this->execute_mysql_query( $ast ); - - if ( $wrap_in_transaction ) { - $this->commit_wrapper_transaction(); - } - return $this->last_return_value; - } catch ( Throwable $e ) { - try { - $this->rollback_user_transaction(); - $this->table_lock_active = false; - } catch ( Throwable $rollback_exception ) { - // Ignore rollback errors. - } - if ( $e instanceof WP_SQLite_Driver_Exception ) { - throw $e; - } elseif ( $e instanceof WP_SQLite_Information_Schema_Exception ) { - throw $this->convert_information_schema_exception( $e ); - } - throw $this->new_driver_exception( $e->getMessage(), $e->getCode(), $e ); - } - } - /** * Tokenize a MySQL query and initialize a parser. * @@ -2408,7 +2560,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 +2599,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 +2688,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 +2720,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 +2806,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 +2869,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 +2921,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 +2994,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 +3033,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 +3355,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-proxy-statement.php b/wp-includes/sqlite-ast/class-wp-pdo-proxy-statement.php new file mode 100644 index 00000000..77d6fd8c --- /dev/null +++ b/wp-includes/sqlite-ast/class-wp-pdo-proxy-statement.php @@ -0,0 +1,82 @@ +stmt = $stmt; + } + + public function execute( ?array $params = null ): bool { + return $this->stmt->execute( $params ); + } + + public function columnCount(): int { + return $this->stmt->columnCount(); + } + + public function rowCount(): int { + return $this->stmt->rowCount(); + } + + #[ReturnTypeWillChange] + public function fetch( + int $mode = PDO::FETCH_DEFAULT, + int $cursorOrientation = PDO::FETCH_ORI_NEXT, + int $cursorOffset = 0 + ) { + return $this->stmt->fetch( $mode, $cursorOrientation, $cursorOffset ); + } + + public function fetchAll( int $mode = PDO::FETCH_DEFAULT, ...$args ): array { + return $this->stmt->fetchAll( $mode, ...$args ); + } + + #[ReturnTypeWillChange] + public function fetchColumn( int $column = 0 ) { + return $this->stmt->fetchColumn( $column ); + } + + #[ReturnTypeWillChange] + public function fetchObject( ?string $class = 'stdClass', array $constructorArgs = array() ) { + return $this->stmt->fetchObject( $class, $constructorArgs ); + } + + public function getColumnMeta( int $column ): array { + return $this->stmt->getColumnMeta( $column ); + } + + public function errorCode(): ?string { + return null; + } + + public function errorInfo(): array { + return array( '00000', '00000', '00000' ); + } + + // TODO: + // public function bindColumn() + // public function bindParam() + // public function bindValue() + // public function closeCursor() + // public function debugDumpParams() + // public function setFetchMode() + // public function setAttribute() + // public function getAttribute() + // public function getIterator() + // public function nextRowset() +} 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..e3d7d218 --- /dev/null +++ b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php @@ -0,0 +1,342 @@ +fetch_mode = $mode; + return true; + } + + /** + * 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 { + $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; + + /** + * The PDO connection. + * + * @var PDO + */ + private $pdo; + + /** + * Column metadata as reported by SQLite. + * + * @var array + */ + private $sqlite_column_meta; + + /** + * Rows of the result set. + * + * @var array> + */ + private $rows; + + /** + * The number of affected rows. + * + * @var int + */ + private $affected_rows; + + /** + * The current row offset. + * + * @var int + */ + private $row_offset = 0; + + /** + * The current fetch mode. + * + * The PDO::FETCH_DEFAULT constant is available from PHP 8.0. + * + * @var int + */ + private $fetch_mode = 0; // PDO::FETCH_DEFAULT + + /** + * The PDO attributes of the statement. + * + * @var array + */ + private $attributes = array(); + + /** + * Additional arguments for the current fetch mode. + * + * @var array + */ + private $fetch_mode_args = array(); + + /** + * Constructor. + * + * @param PDO $pdo The PDO connection. + * @param array $sqlite_column_metadata Column metadata as reported by SQLite. + * @param array $rows Rows of the result set. + * @param int $affected_rows The number of affected rows. Default: 0 + */ + public function __construct( + PDO $pdo, + array $sqlite_column_metadata, + array $rows, + int $affected_rows = 0 + ) { + $this->pdo = $pdo; + $this->sqlite_column_meta = $sqlite_column_metadata; + $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->sqlite_column_meta ); + } + + /** + * 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->row_offset, $this->rows ) ) { + return false; + } + + // TODO: $cursorOffset + $row = $this->rows[ $this->row_offset ]; + $this->row_offset += 1; + + $column_names = array_column( $this->sqlite_column_meta, 'name' ); + + if ( PHP_VERSION_ID < 80100 && ! $this->getAttribute( PDO::ATTR_STRINGIFY_FETCHES ) ) { + foreach ( $row as $i => $value ) { + $type = $this->sqlite_column_meta[ $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 ); + default: + throw new ValueError( sprintf( 'Unsupported fetch mode: %d', $mode ) ); + } + } + + /** + * Fetch a single column from the next row of a result set. + * + * @param int|false $column The index of the column to fetch. + * @return mixed The value of the column; false if there are no more rows. + */ + #[ReturnTypeWillChange] + public function fetchColumn( $column = 0 ) { + if ( $this->row_offset >= count( $this->rows ) ) { + return false; + } + if ( ! array_key_exists( $column, $this->rows[ $this->row_offset ] ) ) { + throw new ValueError( 'Invalid column index' ); + } + return $this->rows[ $this->row_offset ][ $column ]; + } + + #[ReturnTypeWillChange] + public function fetchObject( $class = 'stdClass', $constructorArgs = array() ) { + return new $class( $this->rows[ $this->row_offset ], $constructorArgs ); + } + + public function getColumnMeta( $column ): array { + throw new RuntimeException( 'Not implemented' ); + } + + public function errorCode(): ?string { + throw new RuntimeException( 'Not implemented' ); + } + + public function errorInfo(): array { + throw new RuntimeException( 'Not implemented' ); + } + + #[ReturnTypeWillChange] + public function getAttribute( $attribute ) { + return $this->attributes[ $attribute ] ?? $this->pdo->getAttribute( $attribute ); + } + + public function setAttribute( $attribute, $value ): bool { + $this->attributes[ $attribute ] = $value; + return true; + } + + 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; + } + + // TODO: + // public function bindColumn() + // public function bindParam() + // public function bindValue() + // public function closeCursor() + // public function debugDumpParams() + // public function getIterator() + // public function nextRowset() +} 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..28b23af3 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,20 @@ 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 ); + $connection->get_pdo()->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); } /** @@ -139,7 +157,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 +185,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';