From 52ac378a6738ad7d6fa6a8468cc1ed485ad5ad44 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 10 Dec 2025 11:30:40 +0100 Subject: [PATCH 01/11] Add a loader file for the MySQL-on-SQLite driver --- .../src/Adapter/class-sqlite-adapter.php | 19 +---------------- tests/bootstrap.php | 16 +------------- tests/tools/dump-sqlite-query.php | 16 +------------- wp-includes/sqlite/class-wp-sqlite-db.php | 15 +------------ wp-pdo-mysql-on-sqlite.php | 21 +++++++++++++++++++ 5 files changed, 25 insertions(+), 62 deletions(-) create mode 100644 wp-pdo-mysql-on-sqlite.php diff --git a/packages/wp-mysql-proxy/src/Adapter/class-sqlite-adapter.php b/packages/wp-mysql-proxy/src/Adapter/class-sqlite-adapter.php index 1df4165c..adcf88ef 100644 --- a/packages/wp-mysql-proxy/src/Adapter/class-sqlite-adapter.php +++ b/packages/wp-mysql-proxy/src/Adapter/class-sqlite-adapter.php @@ -9,24 +9,7 @@ use WP_SQLite_Driver; use WP_MySQL_Proxy\MySQL_Protocol; -define( 'SQLITE_DRIVER_PATH', __DIR__ . '/../../../..' ); - -require_once SQLITE_DRIVER_PATH . '/version.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/parser/class-wp-parser-grammar.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/parser/class-wp-parser.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/parser/class-wp-parser-node.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/parser/class-wp-parser-token.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/mysql/class-wp-mysql-token.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/mysql/class-wp-mysql-lexer.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/mysql/class-wp-mysql-parser.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-connection.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-configurator.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-driver.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-driver-exception.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-exception.php'; -require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-reconstructor.php'; +require_once __DIR__ . '/../../../../wp-pdo-mysql-on-sqlite.php'; class SQLite_Adapter implements Adapter { /** @var WP_SQLite_Driver */ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6acb7baf..10d15e9c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,26 +1,12 @@ ':memory:' ) ), diff --git a/wp-includes/sqlite/class-wp-sqlite-db.php b/wp-includes/sqlite/class-wp-sqlite-db.php index 1bc00aaa..0af6ac70 100644 --- a/wp-includes/sqlite/class-wp-sqlite-db.php +++ b/wp-includes/sqlite/class-wp-sqlite-db.php @@ -315,20 +315,7 @@ public function db_connect( $allow_bail = true ) { return false; } - require_once __DIR__ . '/../../wp-includes/parser/class-wp-parser-grammar.php'; - require_once __DIR__ . '/../../wp-includes/parser/class-wp-parser.php'; - require_once __DIR__ . '/../../wp-includes/parser/class-wp-parser-node.php'; - require_once __DIR__ . '/../../wp-includes/parser/class-wp-parser-token.php'; - require_once __DIR__ . '/../../wp-includes/mysql/class-wp-mysql-token.php'; - require_once __DIR__ . '/../../wp-includes/mysql/class-wp-mysql-lexer.php'; - require_once __DIR__ . '/../../wp-includes/mysql/class-wp-mysql-parser.php'; - require_once __DIR__ . '/../../wp-includes/sqlite-ast/class-wp-sqlite-connection.php'; - require_once __DIR__ . '/../../wp-includes/sqlite-ast/class-wp-sqlite-configurator.php'; - require_once __DIR__ . '/../../wp-includes/sqlite-ast/class-wp-sqlite-driver.php'; - require_once __DIR__ . '/../../wp-includes/sqlite-ast/class-wp-sqlite-driver-exception.php'; - require_once __DIR__ . '/../../wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php'; - 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-pdo-mysql-on-sqlite.php'; $this->ensure_database_directory( FQDB ); try { diff --git a/wp-pdo-mysql-on-sqlite.php b/wp-pdo-mysql-on-sqlite.php new file mode 100644 index 00000000..265d082e --- /dev/null +++ b/wp-pdo-mysql-on-sqlite.php @@ -0,0 +1,21 @@ + Date: Fri, 5 Dec 2025 13:52:41 +0100 Subject: [PATCH 02/11] Rename WP_SQLite_Driver to WP_PDO_MySQL_On_SQLite --- ...ss-wp-sqlite-driver.php => class-wp-pdo-mysql-on-sqlite.php} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename wp-includes/sqlite-ast/{class-wp-sqlite-driver.php => class-wp-pdo-mysql-on-sqlite.php} (99%) diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php similarity index 99% rename from wp-includes/sqlite-ast/class-wp-sqlite-driver.php rename to wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 3a9c13e6..ce98b510 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.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_SQLite_Driver { +class WP_PDO_MySQL_On_SQLite { /** * The path to the MySQL SQL grammar file. */ From f704b1a3d0647f34d560edc8598d59e334a99597 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 5 Dec 2025 15:19:44 +0100 Subject: [PATCH 03/11] Reintroduce WP_SQLite_Driver as a temporary proxy exposing legacy API --- ...Information_Schema_Reconstructor_Tests.php | 2 +- .../class-wp-sqlite-configurator.php | 12 +- .../class-wp-sqlite-driver-exception.php | 14 +- .../sqlite-ast/class-wp-sqlite-driver.php | 136 ++++++++++++++++++ ...qlite-information-schema-reconstructor.php | 12 +- wp-pdo-mysql-on-sqlite.php | 1 + 6 files changed, 157 insertions(+), 20 deletions(-) create mode 100644 wp-includes/sqlite-ast/class-wp-sqlite-driver.php diff --git a/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php b/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php index 6bf93e85..df88e2e5 100644 --- a/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php +++ b/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php @@ -49,7 +49,7 @@ public function setUp(): void { ); $builder = new WP_SQLite_Information_Schema_Builder( - WP_SQLite_Driver::RESERVED_PREFIX, + WP_PDO_MySQL_On_SQLite::RESERVED_PREFIX, $this->engine->get_connection() ); diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-configurator.php b/wp-includes/sqlite-ast/class-wp-sqlite-configurator.php index 62d35d5b..7590c02e 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-configurator.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-configurator.php @@ -14,7 +14,7 @@ class WP_SQLite_Configurator { /** * The SQLite driver instance. * - * @var WP_SQLite_Driver + * @var WP_PDO_MySQL_On_SQLite */ private $driver; @@ -35,11 +35,11 @@ class WP_SQLite_Configurator { /** * Constructor. * - * @param WP_SQLite_Driver $driver The SQLite driver instance. + * @param WP_PDO_MySQL_On_SQLite $driver The SQLite driver instance. * @param WP_SQLite_Information_Schema_Builder $schema_builder The information schema builder instance. */ public function __construct( - WP_SQLite_Driver $driver, + WP_PDO_MySQL_On_SQLite $driver, WP_SQLite_Information_Schema_Builder $schema_builder ) { $this->driver = $driver; @@ -100,7 +100,7 @@ private function ensure_global_variables_table(): void { sprintf( 'CREATE TABLE IF NOT EXISTS %s (name TEXT PRIMARY KEY, value TEXT)', $this->driver->get_connection()->quote_identifier( - WP_SQLite_Driver::GLOBAL_VARIABLES_TABLE_NAME + WP_PDO_MySQL_On_SQLite::GLOBAL_VARIABLES_TABLE_NAME ) ) ); @@ -260,11 +260,11 @@ private function save_current_driver_version(): void { sprintf( 'INSERT INTO %s (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = ?', $this->driver->get_connection()->quote_identifier( - WP_SQLite_Driver::GLOBAL_VARIABLES_TABLE_NAME + WP_PDO_MySQL_On_SQLite::GLOBAL_VARIABLES_TABLE_NAME ) ), array( - WP_SQLite_Driver::DRIVER_VERSION_VARIABLE_NAME, + WP_PDO_MySQL_On_SQLite::DRIVER_VERSION_VARIABLE_NAME, SQLITE_DRIVER_VERSION, SQLITE_DRIVER_VERSION, ) diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver-exception.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver-exception.php index 91918bdf..c43aec8a 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver-exception.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver-exception.php @@ -4,20 +4,20 @@ class WP_SQLite_Driver_Exception extends PDOException { /** * The SQLite driver that originated the exception. * - * @var WP_SQLite_Driver + * @var WP_PDO_MySQL_On_SQLite */ private $driver; /** * Constructor. * - * @param WP_SQLite_Driver $driver The SQLite driver that originated the exception. - * @param string $message The exception message. - * @param int|string $code The exception code. In PDO, it can be a string with value of SQLSTATE. - * @param Throwable|null $previous The previous throwable used for the exception chaining. + * @param WP_PDO_MySQL_On_SQLite $driver The SQLite driver that originated the exception. + * @param string $message The exception message. + * @param int|string $code The exception code. In PDO, it can be a string with value of SQLSTATE. + * @param Throwable|null $previous The previous throwable used for the exception chaining. */ public function __construct( - WP_SQLite_Driver $driver, + WP_PDO_MySQL_On_SQLite $driver, string $message, $code = 0, ?Throwable $previous = null @@ -27,7 +27,7 @@ public function __construct( $this->driver = $driver; } - public function getDriver(): WP_SQLite_Driver { + public function getDriver(): WP_PDO_MySQL_On_SQLite { return $this->driver; } } diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php new file mode 100644 index 00000000..f7b25e1e --- /dev/null +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -0,0 +1,136 @@ +mysql_on_sqlite_driver = new WP_PDO_MySQL_On_SQLite( $connection, $database, $mysql_version ); + $this->main_db_name = $database; + $this->client_info = $this->mysql_on_sqlite_driver->client_info; + } + + public function get_connection(): WP_SQLite_Connection { + return $this->mysql_on_sqlite_driver->get_connection(); + } + + public function get_sqlite_version(): string { + return $this->mysql_on_sqlite_driver->get_sqlite_version(); + } + + public function get_saved_driver_version(): string { + return $this->mysql_on_sqlite_driver->get_saved_driver_version(); + } + + public function is_sql_mode_active( string $mode ): bool { + return $this->mysql_on_sqlite_driver->is_sql_mode_active( $mode ); + } + + public function get_last_mysql_query(): ?string { + return $this->mysql_on_sqlite_driver->get_last_mysql_query(); + } + + public function get_last_sqlite_queries(): array { + return $this->mysql_on_sqlite_driver->get_last_sqlite_queries(); + } + + /** @return int|string */ + public function get_insert_id() { + return $this->mysql_on_sqlite_driver->get_insert_id(); + } + + /** + * @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. + */ + 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 ); + } + + public function create_parser( string $query ): WP_MySQL_Parser { + return $this->mysql_on_sqlite_driver->create_parser( $query ); + } + + /** + * @return mixed + */ + public function get_query_results() { + return $this->mysql_on_sqlite_driver->get_query_results(); + } + + /** + * @return mixed + */ + public function get_last_return_value() { + return $this->mysql_on_sqlite_driver->get_last_return_value(); + } + + public function get_last_column_count(): int { + return $this->mysql_on_sqlite_driver->get_last_column_count(); + } + + public function get_last_column_meta(): array { + return $this->mysql_on_sqlite_driver->get_last_column_meta(); + } + + public function execute_sqlite_query( string $sql, array $params = array() ): PDOStatement { + return $this->mysql_on_sqlite_driver->execute_sqlite_query( $sql, $params ); + } + + public function begin_transaction(): void { + $this->mysql_on_sqlite_driver->begin_transaction(); + } + + public function commit(): void { + $this->mysql_on_sqlite_driver->commit(); + } + + public function rollback(): void { + $this->mysql_on_sqlite_driver->rollback(); + } + + /** + * Proxy also the private property "$main_db_name", as it is used in tests. + */ + public function __set( string $name, $value ): void { + if ( 'main_db_name' === $name ) { + $closure = function ( string $value ) { + $this->main_db_name = $value; + }; + $closure->call( $this->mysql_on_sqlite_driver, $value ); + } + } + + /** + * Proxy also this private method, as it is used in tests. + */ + private function quote_mysql_utf8_string_literal( string $utf8_literal ): string { + $closure = function ( string $utf8_literal ) { + return $this->quote_mysql_utf8_string_literal( $utf8_literal ); + }; + return $closure->call( $this->mysql_on_sqlite_driver, $utf8_literal ); + } +} diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-reconstructor.php b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-reconstructor.php index c739234b..603e5115 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-reconstructor.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-reconstructor.php @@ -19,7 +19,7 @@ class WP_SQLite_Information_Schema_Reconstructor { /** * The SQLite driver instance. * - * @var WP_SQLite_Driver + * @var WP_PDO_MySQL_On_SQLite */ private $driver; @@ -40,11 +40,11 @@ class WP_SQLite_Information_Schema_Reconstructor { /** * Constructor. * - * @param WP_SQLite_Driver $driver The SQLite driver instance. + * @param WP_PDO_MySQL_On_SQLite $driver The SQLite driver instance. * @param WP_SQLite_Information_Schema_Builder $schema_builder The information schema builder instance. */ public function __construct( - WP_SQLite_Driver $driver, + $driver, WP_SQLite_Information_Schema_Builder $schema_builder ) { $this->driver = $driver; @@ -137,7 +137,7 @@ private function get_sqlite_table_names(): array { array( '_mysql_data_types_cache', 'sqlite\_%', - str_replace( '_', '\_', WP_SQLite_Driver::RESERVED_PREFIX ) . '%', + str_replace( '_', '\_', WP_PDO_MySQL_On_SQLite::RESERVED_PREFIX ) . '%', ) )->fetchAll( PDO::FETCH_COLUMN ); } @@ -692,9 +692,9 @@ private function get_mysql_column_type( string $column_type ): string { /** * Format a MySQL UTF-8 string literal for output in a CREATE TABLE statement. * - * See WP_SQLite_Driver::quote_mysql_utf8_string_literal(). + * See WP_PDO_MySQL_On_SQLite::quote_mysql_utf8_string_literal(). * - * TODO: This is a copy of WP_SQLite_Driver::quote_mysql_utf8_string_literal(). + * TODO: This is a copy of WP_PDO_MySQL_On_SQLite::quote_mysql_utf8_string_literal(). * We may consider extracing it to reusable MySQL helpers. * * @param string $utf8_literal The UTF-8 string literal to escape. diff --git a/wp-pdo-mysql-on-sqlite.php b/wp-pdo-mysql-on-sqlite.php index 265d082e..2061de07 100644 --- a/wp-pdo-mysql-on-sqlite.php +++ b/wp-pdo-mysql-on-sqlite.php @@ -19,3 +19,4 @@ require_once __DIR__ . '/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php'; 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'; From 2413c956129be563490dc402976939b748483cfc Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 10 Dec 2025 13:32:42 +0100 Subject: [PATCH 04/11] Improve transaction MySQL compatibility, add PDO-like APIs --- tests/WP_MySQL_On_SQLite_PDO_API_Tests.php | 53 +++ tests/WP_SQLite_Driver_Tests.php | 83 ----- .../class-wp-pdo-mysql-on-sqlite.php | 335 ++++++++++++------ .../sqlite-ast/class-wp-sqlite-connection.php | 14 + .../sqlite-ast/class-wp-sqlite-driver.php | 4 +- 5 files changed, 291 insertions(+), 198 deletions(-) create mode 100644 tests/WP_MySQL_On_SQLite_PDO_API_Tests.php diff --git a/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php new file mode 100644 index 00000000..c6ac2017 --- /dev/null +++ b/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -0,0 +1,53 @@ + ':memory:' ) ); + $this->driver = new WP_PDO_MySQL_On_SQLite( $connection, 'wp' ); + } + + public function test_begin_transaction(): void { + $result = $this->driver->beginTransaction(); + $this->assertTrue( $result ); + } + + public function test_begin_transaction_already_active(): void { + $this->driver->beginTransaction(); + + $this->expectException( PDOException::class ); + $this->expectExceptionMessage( 'There is already an active transaction' ); + $this->expectExceptionCode( 0 ); + $this->driver->beginTransaction(); + } + + public function test_commit(): void { + $this->driver->beginTransaction(); + $result = $this->driver->commit(); + $this->assertTrue( $result ); + } + + public function test_commit_no_active_transaction(): void { + $this->expectException( PDOException::class ); + $this->expectExceptionMessage( 'There is no active transaction' ); + $this->expectExceptionCode( 0 ); + $this->driver->commit(); + } + + public function test_rollback(): void { + $this->driver->beginTransaction(); + $result = $this->driver->rollBack(); + $this->assertTrue( $result ); + } + + public function test_rollback_no_active_transaction(): void { + $this->expectException( PDOException::class ); + $this->expectExceptionMessage( 'There is no active transaction' ); + $this->expectExceptionCode( 0 ); + $this->driver->rollBack(); + } +} diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 542c5ea2..764b99a8 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -2490,89 +2490,6 @@ public function testStartTransactionCommand() { $this->assertCount( 0, $this->engine->get_query_results() ); } - public function testNestedTransactionWork() { - $this->assertQuery( 'BEGIN' ); - $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('first');" ); - $this->assertQuery( 'START TRANSACTION' ); - $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('second');" ); - $this->assertQuery( 'START TRANSACTION' ); - $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('third');" ); - $this->assertQuery( 'SELECT * FROM _options;' ); - $this->assertCount( 3, $this->engine->get_query_results() ); - - $this->assertQuery( 'ROLLBACK' ); - $this->assertQuery( 'SELECT * FROM _options;' ); - $this->assertCount( 2, $this->engine->get_query_results() ); - - $this->assertQuery( 'ROLLBACK' ); - $this->assertQuery( 'SELECT * FROM _options;' ); - $this->assertCount( 1, $this->engine->get_query_results() ); - - $this->assertQuery( 'COMMIT' ); - $this->assertQuery( 'SELECT * FROM _options;' ); - $this->assertCount( 1, $this->engine->get_query_results() ); - } - - public function testNestedTransactionWorkComplexModify() { - $this->assertQuery( 'BEGIN' ); - // Create a complex ALTER Table query where the first - // column is added successfully, but the second fails. - // Behind the scenes, this single MySQL query is split - // into multiple SQLite queries – some of them will - // succeed, some will fail. - $error = ''; - try { - $this->engine->query( - ' - ALTER TABLE _options - ADD COLUMN test varchar(20), - ADD COLUMN test varchar(20) - ' - ); - } catch ( Throwable $e ) { - $error = $e->getMessage(); - } - $this->assertStringContainsString( "Duplicate column name 'test'", $error ); - - // Commit the transaction. - $this->assertQuery( 'COMMIT' ); - - // Confirm the entire query failed atomically and no column was - // added to the table. - $this->assertQuery( 'DESCRIBE _options;' ); - $fields = $this->engine->get_query_results(); - - $this->assertEquals( - array( - (object) array( - 'Field' => 'ID', - 'Type' => 'int', - 'Null' => 'NO', - 'Key' => 'PRI', - 'Default' => null, - 'Extra' => 'auto_increment', - ), - (object) array( - 'Field' => 'option_name', - 'Type' => 'text', - 'Null' => 'NO', - 'Key' => '', - 'Default' => '', - 'Extra' => '', - ), - (object) array( - 'Field' => 'option_value', - 'Type' => 'text', - 'Null' => 'NO', - 'Key' => '', - 'Default' => '', - 'Extra' => '', - ), - ), - $fields - ); - } - public function testCount() { $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('first');" ); $this->assertQuery( "INSERT INTO _options (option_name) VALUES ('second');" ); 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 ce98b510..cdf796fb 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 @@ -493,11 +493,16 @@ class WP_PDO_MySQL_On_SQLite { private $is_readonly; /** - * Transaction nesting level of the executed SQLite queries. + * Type of wrapper transaction that is active for the MySQL query emulation. * - * @var int + * Possible values: + * - null: No wrapper transaction is active. + * - 'transaction': A top-level transaction is active. + * - 'savepoint': A nested savepoint is active. + * + * @var null|'transaction'|'savepoint' */ - private $transaction_level = 0; + private $wrapper_transaction_type = null; /** * Whether a MySQL table lock is active. @@ -666,6 +671,57 @@ function ( string $sql, array $params ) { ); } + /** + * PDO API: Begin a new transaction or nested transaction. + * + * @return bool True on success, false on failure. + */ + // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + public function beginTransaction(): bool { + if ( $this->inTransaction() ) { + throw $this->new_driver_exception( 'There is already an active transaction' ); + } + $this->begin_user_transaction(); + return true; + } + + /** + * PDO API: Commit the current transaction or nested transaction. + * + * @return bool True on success, false on failure. + */ + public function commit(): bool { + if ( ! $this->inTransaction() ) { + throw $this->new_driver_exception( 'There is no active transaction' ); + } + $this->commit_user_transaction(); + return true; + } + + /** + * PDO API: Rollback the current transaction or nested transaction. + * + * @return bool True on success, false on failure. + */ + // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + public function rollBack(): bool { + if ( ! $this->inTransaction() ) { + throw $this->new_driver_exception( 'There is no active transaction' ); + } + $this->rollback_user_transaction(); + return true; + } + + /** + * PDO API: Check if a transaction is active. + * + * @return bool True if a transaction is active, false otherwise. + */ + // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + public function inTransaction(): bool { + return $this->connection->get_pdo()->inTransaction(); + } + /** * Get the SQLite connection instance. * @@ -809,18 +865,19 @@ public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mo } if ( $wrap_in_transaction ) { - $this->begin_transaction(); + $this->begin_wrapper_transaction(); } $this->execute_mysql_query( $ast ); if ( $wrap_in_transaction ) { - $this->commit(); + $this->commit_wrapper_transaction(); } return $this->last_return_value; } catch ( Throwable $e ) { try { - $this->rollback(); + $this->rollback_user_transaction(); + $this->table_lock_active = false; } catch ( Throwable $rollback_exception ) { // Ignore rollback errors. } @@ -1049,92 +1106,6 @@ public function execute_sqlite_query( string $sql, array $params = array() ): PD return $this->connection->query( $sql, $params ); } - /** - * Begin a new transaction or nested transaction. - */ - public function begin_transaction(): void { - if ( 0 === $this->transaction_level ) { - /* - * When we're executing a statement that will write to the database, - * we need to use "BEGIN IMMEDIATE" to open a write transaction. - * - * This is needed to avoid the "database is locked" error (SQLITE_BUSY) - * when SQLite can't upgrade a read transaction to a write transaction, - * because another connection is modifying the database. - * - * From the SQLite documentation: - * - * ## Read transactions versus write transactions - * - * If a write statement occurs while a read transaction is active, - * then the read transaction is upgraded to a write transaction if - * possible. If some other database connection has already modified - * the database or is already in the process of modifying the database, - * then upgrading to a write transaction is not possible and the write - * statement will fail with SQLITE_BUSY. - * - * ## DEFERRED, IMMEDIATE, and EXCLUSIVE transactions - * - * Transactions can be DEFERRED, IMMEDIATE, or EXCLUSIVE. The default - * transaction behavior is DEFERRED. - * - * DEFERRED means that the transaction does not actually start until - * the database is first accessed. - * - * IMMEDIATE causes the database connection to start a new write - * immediately, without waiting for a write statement. The BEGIN - * IMMEDIATE might fail with SQLITE_BUSY if another write transaction - * is already active on another database connection. - * - * See: - * - https://www.sqlite.org/lang_transaction.html - * - https://www.sqlite.org/rescode.html#busy - * - * For better performance, we could also consider opening the write - * transaction later in the session - just before the first write. - */ - $this->execute_sqlite_query( $this->is_readonly ? 'BEGIN' : 'BEGIN IMMEDIATE' ); - } else { - $savepoint_name = $this->get_internal_savepoint_name( $this->transaction_level ); - $this->execute_sqlite_query( sprintf( 'SAVEPOINT %s', $savepoint_name ) ); - } - ++$this->transaction_level; - } - - /** - * Commit the current transaction or nested transaction. - */ - public function commit(): void { - if ( 0 === $this->transaction_level ) { - return; - } - - --$this->transaction_level; - if ( 0 === $this->transaction_level ) { - $this->execute_sqlite_query( 'COMMIT' ); - } else { - $savepoint_name = $this->get_internal_savepoint_name( $this->transaction_level ); - $this->execute_sqlite_query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); - } - } - - /** - * Rollback the current transaction or nested transaction. - */ - public function rollback(): void { - if ( 0 === $this->transaction_level ) { - return; - } - - --$this->transaction_level; - if ( 0 === $this->transaction_level ) { - $this->execute_sqlite_query( 'ROLLBACK' ); - } else { - $savepoint_name = $this->get_internal_savepoint_name( $this->transaction_level ); - $this->execute_sqlite_query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) ); - } - } - /** * Translate and execute a MySQL query in SQLite. * @@ -1162,7 +1133,7 @@ private function execute_mysql_query( WP_Parser_Node $node ): void { } if ( 'beginWork' === $children[0]->rule_name ) { - $this->begin_transaction(); + $this->begin_user_transaction(); return; } @@ -1291,6 +1262,146 @@ private function execute_mysql_query( WP_Parser_Node $node ): void { } } + /** + * Begin a wrapper transaction. + * + * A wrapper transaction is used to ensure consistency by encapsulating SQLite + * statements that are executed during a single MySQL query emulation process. + * + * TOP-LEVEL TRANSACTION vs. SAVEPOINT: + * + * When no transaction is active, we can use a top-level TRANSACTION to wrap + * the emulated MySQL statement. However, if a transaction is already active, + * we must use a SAVEPOINT, as SQLite doesn't support transaction nesting. + * + * BEGIN vs. BEGIN IMMEDIATE: + * + * When we're executing a statement that will need to write to the database, + * we must use "BEGIN IMMEDIATE" to immediately open a write transaction. + * + * This is needed to avoid the "database is locked" error (SQLITE_BUSY) when + * SQLite can't upgrade a read transaction to a write transaction, because + * another connection is already modifying the database. + * + * From the SQLite documentation: + * + * ## Read transactions versus write transactions + * + * If a write statement occurs while a read transaction is active, + * then the read transaction is upgraded to a write transaction if + * possible. If some other database connection has already modified + * the database or is already in the process of modifying the database, + * then upgrading to a write transaction is not possible and the write + * statement will fail with SQLITE_BUSY. + * + * ## DEFERRED, IMMEDIATE, and EXCLUSIVE transactions + * + * Transactions can be DEFERRED, IMMEDIATE, or EXCLUSIVE. The default + * transaction behavior is DEFERRED. + * + * DEFERRED means that the transaction does not actually start until + * the database is first accessed. + * + * IMMEDIATE causes the database connection to start a new write + * immediately, without waiting for a write statement. The BEGIN + * IMMEDIATE might fail with SQLITE_BUSY if another write transaction + * is already active on another database connection. + * + * See: + * - https://www.sqlite.org/lang_transaction.html + * - https://www.sqlite.org/rescode.html#busy + * + * For better performance, we could also consider opening the write + * transaction later in the session - just before the first write. + */ + private function begin_wrapper_transaction(): void { + if ( null !== $this->wrapper_transaction_type ) { + return; + } + + $wrapper_transaction_type = $this->wrapper_transaction_type; + if ( $this->inTransaction() ) { + $savepoint_name = $this->get_internal_savepoint_name( 'wrapper' ); + $stmt = $this->connection->prepare( sprintf( 'SAVEPOINT %s', $savepoint_name ) ); + $wrapper_transaction_type = 'savepoint'; + } else { + // For write transactions, we must use "BEGIN IMMEDIATE". + // @see self::begin_user_transaction() method comments. + $stmt = $this->connection->prepare( $this->is_readonly ? 'BEGIN' : 'BEGIN IMMEDIATE' ); + $wrapper_transaction_type = 'transaction'; + } + + if ( ! $stmt->execute() ) { + throw $this->new_driver_exception( 'Failed to begin wrapper transaction.' ); + } + $this->wrapper_transaction_type = $wrapper_transaction_type; + } + + /** + * Commit a wrapper transaction. + */ + private function commit_wrapper_transaction(): void { + if ( null === $this->wrapper_transaction_type ) { + return; + } + + if ( 'savepoint' === $this->wrapper_transaction_type ) { + $savepoint_name = $this->get_internal_savepoint_name( 'wrapper' ); + $stmt = $this->connection->prepare( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); + } else { + $stmt = $this->connection->prepare( 'COMMIT' ); + } + + if ( ! $stmt->execute() ) { + throw $this->new_driver_exception( 'Failed to commit wrapper transaction.' ); + } + $this->wrapper_transaction_type = null; + } + + /** + * Execute the "BEGIN" or "START TRANSACTION" MySQL statement in SQLite. + */ + private function begin_user_transaction(): void { + // MySQL implicitly commits previous transaction when starting a new one. + if ( $this->inTransaction() ) { + $this->commit_user_transaction(); + } + + /* + * Since we don't know whether the user will write to the database, we + * must use "BEGIN IMMEDIATE" to immediately open a write transaction. + * + * This is needed to avoid the "database is locked" error (SQLITE_BUSY) + * when SQLite can't upgrade a read transaction to a write transaction, + * because another connection is already modifying the database. + * + * @see self::begin_wrapper_transaction() + */ + $this->connection->query( 'BEGIN IMMEDIATE' ); + } + + /** + * Execute the "COMMIT" MySQL statement in SQLite. + */ + private function commit_user_transaction(): void { + // MySQL doesn't throw an error if there is no active transaction. + if ( ! $this->inTransaction() ) { + return; + } + $this->connection->query( 'COMMIT' ); + } + + /** + * Execute the "ROLLBACK" MySQL statement in SQLite. + */ + private function rollback_user_transaction(): void { + // MySQL doesn't throw an error if there is no active transaction. + if ( ! $this->inTransaction() ) { + return; + } + $this->connection->query( 'ROLLBACK' ); + } + /** * Execute a MySQL transaction or locking statement in SQLite. * @@ -1305,13 +1416,13 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node case 'transactionStatement': // START TRANSACTION. if ( WP_MySQL_Lexer::START_SYMBOL === $token->id ) { - $this->begin_transaction(); + $this->begin_user_transaction(); return; } // COMMIT. if ( WP_MySQL_Lexer::COMMIT_SYMBOL === $token->id ) { - $this->commit(); + $this->commit_user_transaction(); return; } @@ -1322,7 +1433,7 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node // ROLLBACK/ROLLBACK TO SAVEPOINT . if ( WP_MySQL_Lexer::ROLLBACK_SYMBOL === $token->id ) { if ( null === $savepoint_name ) { - $this->rollback(); + $this->rollback_user_transaction(); } else { $this->execute_sqlite_query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) ); } @@ -1375,11 +1486,8 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node } } - // Start a transaction when no top-level transaction is active. - if ( 0 === $this->transaction_level ) { - $this->begin_transaction(); - $this->table_lock_active = true; - } + $this->begin_user_transaction(); + $this->table_lock_active = true; return; } @@ -1392,8 +1500,8 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node ) ) { // Commit the transaction when created by the LOCK statement. - if ( 1 === $this->transaction_level && $this->table_lock_active ) { - $this->commit(); + if ( $this->table_lock_active && $this->inTransaction() ) { + $this->commit_user_transaction(); $this->table_lock_active = false; } return; @@ -5867,11 +5975,11 @@ private function get_sqlite_index_name( string $mysql_table_name, string $mysql_ * Internal savepoints are used to emulate MySQL transactions that are run * inside a wrapping SQLite transaction, as transactions can't be nested. * - * @param int $level The transaction nesting level. - * @return string The internal savepoint name. + * @param string $name The name of the savepoint. + * @return string The internal savepoint name. */ - private function get_internal_savepoint_name( int $level ): string { - return sprintf( '%ssavepoint_%d', self::RESERVED_PREFIX, $level ); + private function get_internal_savepoint_name( string $name ): string { + return sprintf( '%ssavepoint_%s', self::RESERVED_PREFIX, $name ); } /** @@ -5994,12 +6102,13 @@ private function quote_mysql_utf8_string_literal( string $utf8_literal ): string * Clear the state of the driver. */ private function flush(): void { - $this->last_mysql_query = ''; - $this->last_sqlite_queries = array(); - $this->last_result = null; - $this->last_return_value = null; - $this->last_column_meta = array(); - $this->is_readonly = false; + $this->last_mysql_query = ''; + $this->last_sqlite_queries = array(); + $this->last_result = null; + $this->last_return_value = null; + $this->last_column_meta = array(); + $this->is_readonly = false; + $this->wrapper_transaction_type = null; } /** diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-connection.php b/wp-includes/sqlite-ast/class-wp-sqlite-connection.php index b015d7c9..ba607e09 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-connection.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-connection.php @@ -118,6 +118,20 @@ public function query( string $sql, array $params = array() ): PDOStatement { return $stmt; } + /** + * Prepare a SQLite query for execution. + * + * @param string $sql The query to prepare. + * @return PDOStatement The prepared statement. + * @throws PDOException When the query preparation fails. + */ + public function prepare( string $sql ): PDOStatement { + if ( $this->query_logger ) { + ( $this->query_logger )( $sql, array() ); + } + return $this->pdo->prepare( $sql ); + } + /** * Returns the ID of the last inserted row. * diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index f7b25e1e..3d28ab90 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -101,7 +101,7 @@ public function execute_sqlite_query( string $sql, array $params = array() ): PD } public function begin_transaction(): void { - $this->mysql_on_sqlite_driver->begin_transaction(); + $this->mysql_on_sqlite_driver->beginTransaction(); } public function commit(): void { @@ -109,7 +109,7 @@ public function commit(): void { } public function rollback(): void { - $this->mysql_on_sqlite_driver->rollback(); + $this->mysql_on_sqlite_driver->rollBack(); } /** From 7bfa0e4648cb76c6a78f4e3f4de6fe144e47f0ac Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 10 Dec 2025 14:44:28 +0100 Subject: [PATCH 05/11] Fix PDO::inTransaction() for SQLite on PHP < 8.4 --- .../class-wp-pdo-mysql-on-sqlite.php | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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 cdf796fb..36e12c13 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 @@ -504,6 +504,19 @@ class WP_PDO_MySQL_On_SQLite { */ private $wrapper_transaction_type = null; + /** + * Whether an SQLite transaction is active in the current session. + * + * This is a polyfill of the "PDO::inTransaction()" method for PHP < 8.4, + * where the "PDO::inTransaction()" method is not reliable with SQLite. + * + * @see https://bugs.php.net/bug.php?id=81227 + * @see https://github.com/php/php-src/pull/14268 + * + * @var bool + */ + private $in_transaction = false; + /** * Whether a MySQL table lock is active. * @@ -719,6 +732,15 @@ public function rollBack(): bool { */ // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid public function inTransaction(): bool { + if ( PHP_VERSION_ID < 80400 ) { + /* + * On PHP < 8.4, the "PDO::inTransaction()" method is not reliable. + * + * @see https://bugs.php.net/bug.php?id=81227 + * @see https://github.com/php/php-src/pull/14268 + */ + return $this->in_transaction; + } return $this->connection->get_pdo()->inTransaction(); } @@ -1335,6 +1357,7 @@ private function begin_wrapper_transaction(): void { throw $this->new_driver_exception( 'Failed to begin wrapper transaction.' ); } $this->wrapper_transaction_type = $wrapper_transaction_type; + $this->in_transaction = true; } /** @@ -1345,17 +1368,20 @@ private function commit_wrapper_transaction(): void { return; } + $in_transaction = $this->in_transaction; if ( 'savepoint' === $this->wrapper_transaction_type ) { $savepoint_name = $this->get_internal_savepoint_name( 'wrapper' ); $stmt = $this->connection->prepare( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); } else { - $stmt = $this->connection->prepare( 'COMMIT' ); + $stmt = $this->connection->prepare( 'COMMIT' ); + $in_transaction = false; } if ( ! $stmt->execute() ) { throw $this->new_driver_exception( 'Failed to commit wrapper transaction.' ); } $this->wrapper_transaction_type = null; + $this->in_transaction = $in_transaction; } /** @@ -1378,6 +1404,7 @@ private function begin_user_transaction(): void { * @see self::begin_wrapper_transaction() */ $this->connection->query( 'BEGIN IMMEDIATE' ); + $this->in_transaction = true; } /** @@ -1389,6 +1416,7 @@ private function commit_user_transaction(): void { return; } $this->connection->query( 'COMMIT' ); + $this->in_transaction = false; } /** @@ -1400,6 +1428,7 @@ private function rollback_user_transaction(): void { return; } $this->connection->query( 'ROLLBACK' ); + $this->in_transaction = false; } /** From 41b56d5c615ba1bdb568e1142b34339eeb90048c Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 10 Dec 2025 15:09:44 +0100 Subject: [PATCH 06/11] Make WP_PDO_MySQL_On_SQLite extend PDO, update query() API --- tests/WP_MySQL_On_SQLite_PDO_API_Tests.php | 14 ++- .../class-wp-pdo-mysql-on-sqlite.php | 63 +++++++++---- .../class-wp-pdo-synthetic-statement.php | 91 +++++++++++++++++++ .../sqlite-ast/class-wp-sqlite-driver.php | 13 ++- wp-pdo-mysql-on-sqlite.php | 1 + 5 files changed, 160 insertions(+), 22 deletions(-) create mode 100644 wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php diff --git a/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php index c6ac2017..5473e5b5 100644 --- a/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -7,8 +7,18 @@ 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=WordPress;' ); + } + + 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' ); + $this->assertInstanceOf( PDOStatement::class, $result ); + $this->assertEquals( 1, $result->fetchColumn() ); } 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 36e12c13..49187ed4 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 ) { @@ -832,7 +859,7 @@ public function get_insert_id() { } /** - * 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. * @@ -843,13 +870,9 @@ public function get_insert_id() { * @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; @@ -895,7 +918,11 @@ 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; + + $affected_rows = is_int( $this->last_return_value ) ? $this->last_return_value : 0; + $rows = is_array( $this->last_result ) ? $this->last_result : array(); + $column_meta = is_array( $this->last_column_meta ) ? $this->last_column_meta : array(); + return new WP_PDO_Synthetic_Statement( $rows, $column_meta, $affected_rows ); } 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..550cb60d --- /dev/null +++ b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php @@ -0,0 +1,91 @@ +rows = $rows; + $this->columns = $columns; + $this->affected_rows = $affected_rows; + } + + public function execute( ?array $params = null ): bool { + return true; + } + + public function columnCount(): int { + return count( $this->columns ); + } + + public function rowCount(): int { + return $this->affected_rows; + } + + #[ReturnTypeWillChange] + public function fetch( + int $mode = PDO::FETCH_DEFAULT, + int $cursorOrientation = PDO::FETCH_ORI_NEXT, + int $cursorOffset = 0 + ) { + // TODO: $cursorOffset + return $this->rows[ $this->row_offset++ ]; + } + + public function fetchAll( int $mode = PDO::FETCH_DEFAULT, ...$args ): array { + return array_slice( $this->rows, $this->row_offset, count( $this->rows ) - $this->row_offset ); + } + + #[ReturnTypeWillChange] + public function fetchColumn( int $column = 0 ) { + return $this->rows[ $this->row_offset ][ $column ] ?? null; + } + + #[ReturnTypeWillChange] + public function fetchObject( ?string $class = 'stdClass', array $constructorArgs = array() ) { + return new $class( $this->rows[ $this->row_offset ], $constructorArgs ); + } + + public function getColumnMeta( int $column ): array { + return $this->columns[ $column ] ?? array(); + } + + 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-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index 3d28ab90..ecda7cfa 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -23,7 +23,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; } @@ -67,7 +75,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(); } public function create_parser( string $query ): WP_MySQL_Parser { 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 e68d1ec210d4c86ba95e59c8787337289edd3fd8 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 10 Dec 2025 15:11:37 +0100 Subject: [PATCH 07/11] Move query() method to collocate PDO API methods --- .../class-wp-pdo-mysql-on-sqlite.php | 162 +++++++++--------- 1 file changed, 81 insertions(+), 81 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 49187ed4..b7ce272b 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 @@ -711,6 +711,87 @@ 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 = PDO::FETCH_COLUMN, ...$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(); + } + + $affected_rows = is_int( $this->last_return_value ) ? $this->last_return_value : 0; + $rows = is_array( $this->last_result ) ? $this->last_result : array(); + $column_meta = is_array( $this->last_column_meta ) ? $this->last_column_meta : array(); + return new WP_PDO_Synthetic_Statement( $rows, $column_meta, $affected_rows ); + } 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: Begin a new transaction or nested transaction. * @@ -858,87 +939,6 @@ public function get_insert_id() { return $last_insert_id; } - /** - * 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 = PDO::FETCH_COLUMN, ...$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(); - } - - $affected_rows = is_int( $this->last_return_value ) ? $this->last_return_value : 0; - $rows = is_array( $this->last_result ) ? $this->last_result : array(); - $column_meta = is_array( $this->last_column_meta ) ? $this->last_column_meta : array(); - return new WP_PDO_Synthetic_Statement( $rows, $column_meta, $affected_rows ); - } 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. * From 0250a2c10986580ecb2d72bd1140ee32eb41d772 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 10 Dec 2025 15:12:31 +0100 Subject: [PATCH 08/11] Fix tests that are incorrectly using direct SQLite queries --- tests/WP_SQLite_Driver_Tests.php | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 764b99a8..2fede95a 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -3218,18 +3218,18 @@ public function testCreateTemporaryTableIfNotExists(): void { } public function testTranslatesComplexDelete() { - $this->sqlite->query( + $this->assertQuery( "CREATE TABLE wptests_dummy ( - ID INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + ID INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL, user_login TEXT NOT NULL default '', option_name TEXT NOT NULL default '', option_value TEXT NOT NULL default '' );" ); - $this->sqlite->query( + $this->assertQuery( "INSERT INTO wptests_dummy (user_login, option_name, option_value) VALUES ('admin', '_transient_timeout_test', '1675963960');" ); - $this->sqlite->query( + $this->assertQuery( "INSERT INTO wptests_dummy (user_login, option_name, option_value) VALUES ('admin', '_transient_test', '1675963960');" ); @@ -3239,10 +3239,7 @@ public function testTranslatesComplexDelete() { AND a.option_name NOT LIKE '\_transient\_timeout_%' AND b.option_name = CONCAT( '_transient_timeout_', SUBSTRING( a.option_name, 12 ) );" ); - $this->assertEquals( - 2, - $result - ); + $this->assertEquals( 2, $result ); } public function testTranslatesDoubleAlterTable() { @@ -3321,11 +3318,11 @@ public function testTranslatesUtf8Insert() { public function testTranslatesRandom() { $this->assertIsNumeric( - $this->sqlite->query( 'SELECT RAND() AS rand' )->fetchColumn() + $this->assertQuery( 'SELECT RAND() AS rand' )[0]->rand ); $this->assertIsNumeric( - $this->sqlite->query( 'SELECT RAND(5) AS rand' )->fetchColumn() + $this->assertQuery( 'SELECT RAND(5) AS rand' )[0]->rand ); } From 878891ad492704fb415ba41179f0e2da9ec7d2e3 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 11 Dec 2025 14:48:34 +0100 Subject: [PATCH 09/11] Implement PDO::exec() API --- tests/WP_MySQL_On_SQLite_PDO_API_Tests.php | 32 +++++++++++++++++++ .../class-wp-pdo-mysql-on-sqlite.php | 11 +++++++ 2 files changed, 43 insertions(+) diff --git a/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php index 5473e5b5..da9bc372 100644 --- a/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -21,6 +21,38 @@ public function test_query(): void { $this->assertEquals( 1, $result->fetchColumn() ); } + 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 b7ce272b..64281358 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 @@ -792,6 +792,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( string $query ) { + $stmt = $this->query( $query ); + return $stmt->rowCount(); + } + /** * PDO API: Begin a new transaction or nested transaction. * From a30b1660e3dbbcd9266baf5cf92ae0f183446a26 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 11 Dec 2025 16:50:58 +0100 Subject: [PATCH 10/11] wip --- tests/WP_MySQL_On_SQLite_PDO_API_Tests.php | 84 ++++++++++++++++++- .../class-wp-pdo-mysql-on-sqlite.php | 71 +++++++++++++++- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php index da9bc372..fcc89163 100644 --- a/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -16,9 +16,91 @@ public function test_connection(): void { } public function test_query(): void { + // No extra parameters. $result = $this->driver->query( 'SELECT 1' ); $this->assertInstanceOf( PDOStatement::class, $result ); - $this->assertEquals( 1, $result->fetchColumn() ); + $this->assertSame( '1', $result->fetchColumn() ); // TODO: This should be int (without ATTR_STRINGIFY_FETCHES). + } + + public function test_query_fetch_mode(): void { + // With FETCH_ASSOC fetch mode. + $result = $this->driver->query( 'SELECT 1 AS col', PDO::FETCH_ASSOC ); + $this->assertSame( array( array( 'col' => '1' ) ), $result->fetchAll() ); // TODO: This should be int (without ATTR_STRINGIFY_FETCHES). + + // With FETCH_COLUMN mode. + $result = $this->driver->query( 'SELECT 1 AS col', PDO::FETCH_COLUMN, 0 ); + $this->assertSame( array( '1' ), $result->fetchAll() ); // TODO: This should be int (without ATTR_STRINGIFY_FETCHES). + + // With FETCH_COLUMN mode and column number. + $result = $this->driver->query( 'SELECT 1, 2', PDO::FETCH_COLUMN, 1 ); + //$this->assertSame( array( '2' ), $result->fetchAll() ); // TODO: This should be int (without ATTR_STRINGIFY_FETCHES). + } + + 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 { + // TODO: All the results below should be int (without ATTR_STRINGIFY_FETCHES). + + $result = $this->driver->query( 'SELECT 1' ); + $this->assertSame( array( '1' ), $result->fetchAll() ); + + $result = $this->driver->query( 'SELECT 1', null ); + $this->assertSame( array( '1' ), $result->fetchAll() ); + + $result = $this->driver->query( 'SELECT 1', null, 1 ); + $this->assertSame( array( '1' ), $result->fetchAll() ); + + $result = $this->driver->query( 'SELECT 1', null, 'abc' ); + $this->assertSame( array( '1' ), $result->fetchAll() ); + + $result = $this->driver->query( 'SELECT 1', null, 1, 2, 'abc', array(), true ); + $this->assertSame( array( '1' ), $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_exec(): 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 64281358..85b9b518 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 @@ -725,7 +725,76 @@ 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(); + + $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_COLUMN is not set explicitly, additional + // arguments are ignored, and the argument count is not validated. + $fetch_mode = PDO::FETCH_COLUMN; + } elseif ( PDO::FETCH_COLUMN === $fetch_mode ) { + // When FETCH_COLUMN is set explicitly, it requires exactly 3 arguments. + if ( $arg_count !== 3 ) { + 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 ( $arg_count === 4 && ! 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 ) { + // TODO: .... + } 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; From ffb1e5c0461b8afa87c15b45bfdabd3520bc1662 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 12 Dec 2025 16:57:29 +0100 Subject: [PATCH 11/11] wip --- tests/WP_MySQL_On_SQLite_PDO_API_Tests.php | 132 ++++++++++++++++-- .../class-wp-pdo-mysql-on-sqlite.php | 54 ++++--- .../class-wp-pdo-synthetic-statement.php | 57 +++++++- .../sqlite-ast/class-wp-sqlite-connection.php | 3 - .../sqlite-ast/class-wp-sqlite-driver.php | 20 ++- 5 files changed, 224 insertions(+), 42 deletions(-) diff --git a/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php index fcc89163..26788ae9 100644 --- a/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php +++ b/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -17,23 +17,33 @@ public function test_connection(): void { public function test_query(): void { // No extra parameters. - $result = $this->driver->query( 'SELECT 1' ); + $result = $this->driver->query( "SELECT 1, 'abc'" ); $this->assertInstanceOf( PDOStatement::class, $result ); - $this->assertSame( '1', $result->fetchColumn() ); // TODO: This should be int (without ATTR_STRINGIFY_FETCHES). + $this->assertSame( + //array( + array( + 1 => '1', // TODO: The '1' should be an int (ATTR_STRINGIFY_FETCHES not set). + 0 => '1', // TODO: The '1' should be an int (ATTR_STRINGIFY_FETCHES not set). + 'abc' => 'abc', + ), + //), + $result->fetch( PDO::FETCH_BOTH ) + ); } - public function test_query_fetch_mode(): void { - // With FETCH_ASSOC fetch mode. - $result = $this->driver->query( 'SELECT 1 AS col', PDO::FETCH_ASSOC ); - $this->assertSame( array( array( 'col' => '1' ) ), $result->fetchAll() ); // TODO: This should be int (without ATTR_STRINGIFY_FETCHES). - - // With FETCH_COLUMN mode. - $result = $this->driver->query( 'SELECT 1 AS col', PDO::FETCH_COLUMN, 0 ); - $this->assertSame( array( '1' ), $result->fetchAll() ); // TODO: This should be int (without ATTR_STRINGIFY_FETCHES). + /** + * @dataProvider data_pdo_fetch_methods + */ + public function test_query_fetch_mode( $query, $mode, $expected ): void { + $result = $this->driver->query( $query, $mode ); + $this->assertSame( $expected, $result->fetch() ); + $this->assertFalse( $result->fetch() ); + } - // With FETCH_COLUMN mode and column number. - $result = $this->driver->query( 'SELECT 1, 2', PDO::FETCH_COLUMN, 1 ); - //$this->assertSame( array( '2' ), $result->fetchAll() ); // TODO: This should be int (without ATTR_STRINGIFY_FETCHES). + 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 { @@ -43,8 +53,6 @@ public function test_query_fetch_mode_invalid_arg_count(): void { } public function test_query_fetch_default_mode_allow_any_args(): void { - // TODO: All the results below should be int (without ATTR_STRINGIFY_FETCHES). - $result = $this->driver->query( 'SELECT 1' ); $this->assertSame( array( '1' ), $result->fetchAll() ); @@ -103,6 +111,18 @@ public function test_query_fetch_class_invalid_constructor_args_type(): void { $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 ); @@ -174,4 +194,86 @@ 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->assertEquals( $expected, $result ); + } else { + $this->assertSame( $expected, $result ); + } + } + + private function data_pdo_fetch_methods() { + // PDO::FETCH_BOTH + yield 'PDO::FETCH_BOTH' => array( + "SELECT 1, 'abc', 2, 'two' as `2`", + PDO::FETCH_BOTH, + array( + 1 => '1', // int + 0 => '1', // int + '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', // int + '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', // int + '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', // int + '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 85b9b518..72ac1bf8 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 @@ -731,8 +731,9 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar $arg_colno = 0; $arg_class = null; $arg_constructor_args = array(); + $arg_into = null; - $get_type = function( $value ) { + $get_type = function ( $value ) { $type = gettype( $value ); if ( 'boolean' === $type ) { return 'bool'; @@ -745,11 +746,10 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar }; if ( null === $fetch_mode ) { - // When the default FETCH_COLUMN is not set explicitly, additional + // When the default FETCH_BOTH is not set explicitly, additional // arguments are ignored, and the argument count is not validated. - $fetch_mode = PDO::FETCH_COLUMN; + $fetch_mode = PDO::FETCH_BOTH; } elseif ( PDO::FETCH_COLUMN === $fetch_mode ) { - // When FETCH_COLUMN is set explicitly, it requires exactly 3 arguments. if ( $arg_count !== 3 ) { throw new ArgumentCountError( sprintf( 'PDO::query() expects exactly 3 arguments for the fetch mode provided, %d given', $arg_count ) @@ -788,7 +788,17 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar $arg_class = $fetch_mode_args[0]; $arg_constructor_args = $fetch_mode_args[1] ?? array(); } elseif ( PDO::FETCH_INTO === $fetch_mode ) { - // TODO: .... + if ( $arg_count !== 3 ) { + 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 ) @@ -796,7 +806,7 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar } $this->flush(); - $this->pdo_fetch_mode = $fetch_mode; + $this->pdo_fetch_mode = PDO::FETCH_NUM; // TODO $this->last_mysql_query = $query; try { @@ -844,7 +854,7 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar $affected_rows = is_int( $this->last_return_value ) ? $this->last_return_value : 0; $rows = is_array( $this->last_result ) ? $this->last_result : array(); $column_meta = is_array( $this->last_column_meta ) ? $this->last_column_meta : array(); - return new WP_PDO_Synthetic_Statement( $rows, $column_meta, $affected_rows ); + return new WP_PDO_Synthetic_Statement( $rows, $column_meta, $affected_rows, $fetch_mode ); } catch ( Throwable $e ) { try { $this->rollback_user_transaction(); @@ -2506,7 +2516,8 @@ 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, ), ) @@ -2544,7 +2555,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', ), ) @@ -2633,7 +2644,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 ) ); } /** @@ -2665,7 +2676,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 ); } @@ -2751,7 +2762,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 ); } @@ -2814,7 +2825,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() ); } @@ -2866,7 +2877,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() ); } @@ -2939,7 +2950,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() ); } @@ -2978,7 +2989,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 ); } @@ -3300,14 +3311,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', @@ -4344,6 +4355,13 @@ public function translate_select_item( WP_Parser_Node $node ): string { return $item; } + $text_string_literal = $node->get_first_descendant_node( 'textStringLiteral' ); + $is_text_string_literal = $text_string_literal && $item === $this->translate( $text_string_literal ); + if ( $is_text_string_literal ) { + $alias = $text_string_literal->get_first_child_token()->get_value(); + return sprintf( '%s AS %s', $item, $alias ); + } + /* * When the select item has no explicit alias, we need to ensure that the * returned column name is equivalent to what MySQL infers from the input. 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 550cb60d..e977166a 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-synthetic-statement.php @@ -18,15 +18,18 @@ class WP_PDO_Synthetic_Statement extends PDOStatement { private $row_offset = 0; private $affected_rows; + private $fetch_mode; public function __construct( array $rows, array $columns, - int $affected_rows = 0 + int $affected_rows = 0, + int $fetch_mode = PDO::FETCH_DEFAULT ) { $this->rows = $rows; $this->columns = $columns; $this->affected_rows = $affected_rows; + $this->fetch_mode = $fetch_mode; } public function execute( ?array $params = null ): bool { @@ -47,12 +50,60 @@ public function fetch( int $cursorOrientation = PDO::FETCH_ORI_NEXT, int $cursorOffset = 0 ) { + if ( ! array_key_exists( $this->row_offset, $this->rows ) ) { + return false; + } // TODO: $cursorOffset - return $this->rows[ $this->row_offset++ ]; + + if ( PDO::FETCH_DEFAULT === $mode ) { + $mode = $this->fetch_mode; + } + + $row = $this->rows[ $this->row_offset ]; + $this->row_offset += 1; + + switch ( $mode ) { + case PDO::FETCH_BOTH: + $values = array(); + foreach ( $row as $i => $value ) { + $name = $this->columns[ $i ]['name']; + $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( array_column( $this->columns, 'name' ), $row ); + case PDO::FETCH_NAMED: + $values = array(); + foreach ( $row as $i => $value ) { + $name = $this->columns[ $i ]['name']; + 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: + $assoc = array_combine( array_column( $this->columns, 'name' ), $row ); + return (object) $assoc; + } + + return $row; // TODO: Error? } public function fetchAll( int $mode = PDO::FETCH_DEFAULT, ...$args ): array { - return array_slice( $this->rows, $this->row_offset, count( $this->rows ) - $this->row_offset ); + $rows = array(); + while ( $row = $this->fetch( $mode, ...$args ) ) { + $rows[] = $row; + } + return $rows; } #[ReturnTypeWillChange] 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 ecda7cfa..21c3070f 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -18,6 +18,8 @@ class WP_SQLite_Driver { /** @var WP_PDO_MySQL_On_SQLite */ private $mysql_on_sqlite_driver; + private $last_result; + public function __construct( WP_SQLite_Connection $connection, string $database, @@ -34,6 +36,9 @@ 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 ); + $connection->get_pdo()->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); } public function get_connection(): WP_SQLite_Connection { @@ -75,8 +80,17 @@ 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; + } + //$this->last_return_value = $this->last_result; + return $this->last_result; } public function create_parser( string $query ): WP_MySQL_Parser { @@ -87,7 +101,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; } /**