From 806d76db6cccb5bf24df6bbf951f922d229553bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 15:29:27 +0000 Subject: [PATCH 001/317] Add experimental DuckDB backend support --- .github/workflows/duckdb-phpunit-tests.yml | 86 + AGENTS.md | 11 + README.md | 66 + .../src/duckdb/class-wp-duckdb-connection.php | 205 ++ .../class-wp-duckdb-driver-exception.php | 7 + .../src/duckdb/class-wp-duckdb-driver.php | 1658 +++++++++++++++++ .../class-wp-duckdb-prepared-statement.php | 45 + .../class-wp-duckdb-result-statement.php | 242 +++ .../src/duckdb/class-wp-duckdb-runtime.php | 60 + packages/mysql-on-sqlite/src/load.php | 6 + .../duckdb/WP_DuckDB_Connection_Tests.php | 81 + .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 290 +++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 517 +++++ .../duckdb/WP_DuckDB_Runtime_Gate_Tests.php | 36 + .../tests/duckdb/WP_DuckDB_TestCase.php | 34 + .../constants.php | 35 +- .../db-duckdb.copy | 31 + .../db.copy | 11 +- .../wp-includes/db.php | 22 + .../wp-includes/duckdb/class-wp-duckdb-db.php | 339 ++++ .../wp-includes/duckdb/db.php | 39 + 21 files changed, 3814 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/duckdb-phpunit-tests.yml create mode 100644 packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php create mode 100644 packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver-exception.php create mode 100644 packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php create mode 100644 packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-prepared-statement.php create mode 100644 packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php create mode 100644 packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-runtime.php create mode 100644 packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php create mode 100644 packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php create mode 100644 packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php create mode 100644 packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Runtime_Gate_Tests.php create mode 100644 packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_TestCase.php create mode 100644 packages/plugin-sqlite-database-integration/db-duckdb.copy create mode 100644 packages/plugin-sqlite-database-integration/wp-includes/db.php create mode 100644 packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php create mode 100644 packages/plugin-sqlite-database-integration/wp-includes/duckdb/db.php diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml new file mode 100644 index 000000000..44797ce35 --- /dev/null +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -0,0 +1,86 @@ +name: DuckDB PHPUnit Tests + +on: + push: + branches: + - trunk + paths: + - '.github/workflows/duckdb-phpunit-tests.yml' + - 'packages/mysql-on-sqlite/composer.json' + - 'packages/mysql-on-sqlite/composer.lock' + - 'packages/mysql-on-sqlite/src/load.php' + - 'packages/mysql-on-sqlite/src/duckdb/**' + - 'packages/mysql-on-sqlite/tests/duckdb/**' + - 'packages/plugin-sqlite-database-integration/constants.php' + - 'packages/plugin-sqlite-database-integration/db-duckdb.copy' + - 'packages/plugin-sqlite-database-integration/wp-includes/db.php' + - 'packages/plugin-sqlite-database-integration/wp-includes/duckdb/**' + pull_request: + paths: + - '.github/workflows/duckdb-phpunit-tests.yml' + - 'packages/mysql-on-sqlite/composer.json' + - 'packages/mysql-on-sqlite/composer.lock' + - 'packages/mysql-on-sqlite/src/load.php' + - 'packages/mysql-on-sqlite/src/duckdb/**' + - 'packages/mysql-on-sqlite/tests/duckdb/**' + - 'packages/plugin-sqlite-database-integration/constants.php' + - 'packages/plugin-sqlite-database-integration/db-duckdb.copy' + - 'packages/plugin-sqlite-database-integration/wp-includes/db.php' + - 'packages/plugin-sqlite-database-integration/wp-includes/duckdb/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +jobs: + duckdb-test: + name: PHP 8.3 / DuckDB + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read # Required to clone the repo. + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: ffi, pdo_sqlite + ini-values: ffi.enable=true + coverage: none + tools: phpunit-polyfills + + - name: Install Composer dependencies + uses: ramsey/composer-install@v3 + with: + working-directory: packages/mysql-on-sqlite + ignore-cache: "yes" + composer-options: "--optimize-autoloader" + + - name: Install DuckDB PHP client + run: | + composer config --no-plugins allow-plugins.satur.io/duckdb-auto true + composer require --dev --no-plugins --no-interaction --with-all-dependencies satur.io/duckdb-auto + composer dump-autoload + ./vendor/bin/install-c-lib + working-directory: packages/mysql-on-sqlite + + - name: Verify DuckDB runtime + run: php -d ffi.enable=true -r "require 'vendor/autoload.php'; require 'src/load.php'; WP_DuckDB_Runtime::assert_available( true );" + working-directory: packages/mysql-on-sqlite + + - name: Run DuckDB PHPUnit suite + env: + WP_DUCKDB_TESTS: '1' + WP_DUCKDB_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + run: php -d ffi.enable=true ./vendor/bin/phpunit -c ./phpunit.xml.dist --group duckdb + working-directory: packages/mysql-on-sqlite diff --git a/AGENTS.md b/AGENTS.md index eb9f2d085..81bedf7d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,8 +55,19 @@ composer run wp-test-start # Start WordPress environment (Docker) composer run wp-test-php # Run WordPress PHPUnit tests composer run wp-test-e2e # Run WordPress E2E tests (Playwright) composer run wp-test-clean # Clean up WordPress environment (Docker and DB) + +# Optional DuckDB driver tests (under packages/mysql-on-sqlite; PHP >=8.3 with ext-ffi) +cd packages/mysql-on-sqlite +composer run test -- --group duckdb-runtime +WP_DUCKDB_TESTS=1 WP_DUCKDB_AUTOLOAD=/path/to/duckdb/vendor/autoload.php composer run test -- --group duckdb ``` +DuckDB support is experimental and optional. Do not add `satur.io/duckdb-auto` +or any DuckDB PHP client package to the default project dependency graph unless +that compatibility decision is explicitly requested. The WordPress drop-in uses +`DB_ENGINE=duckdb`/`define( 'DB_ENGINE', 'duckdb' )` and +`DUCKDB_PHP_AUTOLOAD`; PHPUnit uses `WP_DUCKDB_AUTOLOAD`. + ## Release workflow Release is streamlined with a local preparation script and GitHub Actions: diff --git a/README.md b/README.md index 15928b57a..514bc466b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ It is a monorepo that includes the following components: - **MySQL lexer** — A fast MySQL lexer with multi-version support. - **MySQL parser** — An exhaustive MySQL parser with multi-version support. - **SQLite driver** — A MySQL emulation layer on top of SQLite with a PDO-compatible API. +- **DuckDB driver** — An experimental, optional DuckDB backend for targeted development and testing. - **MySQL proxy** — A MySQL binary protocol implementation to support MySQL-based projects beyond PHP. - **WordPress plugin** — A plugin that adds SQLite support to WordPress. - **Test suites** — A set of extensive test suites to cover MySQL syntax and functionality. @@ -26,6 +27,9 @@ The codebase is pure PHP with zero dependencies. It supports PHP 7.2 through 8.5 MySQL syntax from version 5.7 onward, and requires SQLite 3.37.0 or newer (with legacy mode down to 3.27.0). +The default SQLite path remains the zero-dependency runtime. DuckDB support is +optional and must be installed and enabled explicitly. + ## Quick start The codebase is written in PHP and Composer is used to manage the project. The following commands are useful for development and testing: @@ -64,6 +68,68 @@ The default code path is pure PHP. For environments that can load PHP extensions Latest local measurement (Apple Silicon macOS, PHP 8.4.5 CLI, 2026-05-26): the native lexer path processed the MySQL test corpus at ~343k QPS versus ~72k QPS for pure PHP (~4.80x), and the native parser path processed it at ~108k QPS versus ~7k QPS for pure PHP (~15.45x). +## Optional: DuckDB backend + +DuckDB support is experimental and is not part of the default SQLite runtime or +default CI matrix. It uses the third-party DuckDB PHP client documented by +DuckDB. That client uses FFI, requires PHP 8.3 or newer and `ext-ffi`, and +DuckDB recommends the automatic Composer install with `satur.io/duckdb-auto`. +See the [DuckDB PHP client docs](https://duckdb.org/docs/lts/clients/php) and +the [saturio/duckdb-php installation docs](https://duckdb-php.readthedocs.io/en/latest/installation/). + +Install the DuckDB PHP client outside the default project dependency graph, then +point this project at that Composer autoloader. The commands below install +`satur.io/duckdb-auto` but run the bundled C-library installer explicitly, which +avoids Composer plugin hook ordering failures before `vendor/autoload.php` +exists. + +```bash +REPO_DIR=$(pwd) +mkdir -p /tmp/wp-duckdb-php +cd /tmp/wp-duckdb-php +composer init --no-interaction --name=wp/duckdb-runtime +composer config allow-plugins.satur.io/duckdb-auto true +composer require --no-plugins --no-interaction satur.io/duckdb-auto +composer dump-autoload +./vendor/bin/install-c-lib +cd "$REPO_DIR" +``` + +For WordPress manual testing, select the DuckDB backend and load that autoloader +before the drop-in initializes: + +```php +define( 'DB_ENGINE', 'duckdb' ); +define( 'DUCKDB_PHP_AUTOLOAD', '/tmp/wp-duckdb-php/vendor/autoload.php' ); +``` + +If your local WordPress launcher maps environment variables into `wp-config.php`, +set `DB_ENGINE=duckdb` and `DUCKDB_PHP_AUTOLOAD=/tmp/wp-duckdb-php/vendor/autoload.php`. + +Exact local verification commands for this branch: + +```bash +cd packages/mysql-on-sqlite +composer run test -- --group duckdb-runtime + +WP_DUCKDB_TESTS=1 \ +WP_DUCKDB_AUTOLOAD=/tmp/wp-duckdb-php/vendor/autoload.php \ +DUCKDB_PHP_AUTOLOAD=/tmp/wp-duckdb-php/vendor/autoload.php \ +composer run test -- --group duckdb +``` + +Current limitations: +- DuckDB support is a first-stage adapter. It is intended for targeted local + verification and isolated CI, not production WordPress traffic. +- The branch currently verifies runtime gating, connections, query execution, + persistence, result handling, prepared statements, a focused MySQL-to-DuckDB + driver subset, and WordPress-style schema DDL including secondary indexes. It + does not yet run the full WordPress PHPUnit or E2E suites against DuckDB. +- DuckDB's concurrency model allows one process to read and write, or multiple + read-only processes. The DuckDB docs state that automatic writes from multiple + processes are not supported and that many small transactions are not its + primary design goal; see the [DuckDB concurrency docs](https://duckdb.org/docs/lts/connect/concurrency/). + ## Release workflow Release is streamlined with a local preparation script and GitHub Actions: diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php new file mode 100644 index 000000000..a2287067b --- /dev/null +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php @@ -0,0 +1,205 @@ +duckdb = $options['duckdb']; + return; + } + + WP_DuckDB_Runtime::assert_available(); + + $path = $options['path'] ?? null; + if ( ':memory:' === $path ) { + $path = null; + } + if ( null !== $path && ! is_string( $path ) ) { + throw new InvalidArgumentException( 'DuckDB option "path" must be a string or null.' ); + } + + try { + $client_class = WP_DuckDB_Runtime::CLIENT_CLASS; + $this->duckdb = $client_class::create( $path ); + } catch ( Throwable $e ) { + throw new WP_DuckDB_Driver_Exception( 'Failed to open DuckDB database: ' . $e->getMessage(), 0, $e ); + } + } + + /** + * Execute a DuckDB query. + * + * @param string $sql SQL query. + * @param array $params Optional parameters. + * @return WP_DuckDB_Result_Statement + * + * @throws WP_DuckDB_Driver_Exception When execution fails. + */ + public function query( string $sql, array $params = array() ): WP_DuckDB_Result_Statement { + if ( $this->query_logger ) { + ( $this->query_logger )( $sql, $params ); + } + + if ( count( $params ) > 0 ) { + return $this->prepare( $sql )->execute( $params ); + } + + try { + return $this->create_statement_from_result( $this->duckdb->query( $sql ) ); + } catch ( Throwable $e ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB query failed: ' . $e->getMessage(), 0, $e ); + } + } + + /** + * Prepare a DuckDB query. + * + * @param string $sql SQL query. + * @return WP_DuckDB_Prepared_Statement + * + * @throws WP_DuckDB_Driver_Exception When preparation fails. + */ + public function prepare( string $sql ): WP_DuckDB_Prepared_Statement { + if ( $this->query_logger ) { + ( $this->query_logger )( $sql, array() ); + } + + try { + return new WP_DuckDB_Prepared_Statement( $this, $this->duckdb->preparedStatement( $sql ) ); + } catch ( Throwable $e ) { + throw new WP_DuckDB_Driver_Exception( 'Failed to prepare DuckDB query: ' . $e->getMessage(), 0, $e ); + } + } + + /** + * Create a statement wrapper from a DuckDB PHP ResultSet. + * + * @param object $result DuckDB PHP ResultSet. + * @return WP_DuckDB_Result_Statement + */ + public function create_statement_from_result( $result ): WP_DuckDB_Result_Statement { + $columns = iterator_to_array( $result->columnNames() ); + $columns = array_values( $columns ); + $rows = array(); + + foreach ( $result->rows( true ) as $row ) { + $rows[] = $this->normalize_row( $columns, $row ); + } + + $affected_rows = 0; + if ( array( 'Count' ) === $columns ) { + $affected_rows = isset( $rows[0][0] ) ? (int) $rows[0][0] : 0; + return new WP_DuckDB_Result_Statement( array(), array(), $affected_rows ); + } + if ( array( 'Success' ) === $columns ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + return new WP_DuckDB_Result_Statement( $columns, $rows, $affected_rows ); + } + + /** + * Quote a value for SQL. + * + * @param mixed $value Value to quote. + * @return string + */ + public function quote( $value ): string { + if ( null === $value ) { + return 'NULL'; + } + if ( is_bool( $value ) ) { + return $value ? '1' : '0'; + } + if ( is_int( $value ) || is_float( $value ) ) { + return (string) $value; + } + return "'" . str_replace( "'", "''", (string) $value ) . "'"; + } + + /** + * Quote a DuckDB identifier. + * + * @param string $unquoted_identifier Unquoted identifier. + * @return string + * + * @throws InvalidArgumentException When the identifier contains a NUL byte. + */ + public function quote_identifier( string $unquoted_identifier ): string { + if ( false !== strpos( $unquoted_identifier, "\0" ) ) { + throw new InvalidArgumentException( 'DuckDB identifiers cannot contain NUL bytes.' ); + } + return '"' . str_replace( '"', '""', $unquoted_identifier ) . '"'; + } + + /** + * Set a query logger. + * + * @param callable(string,array):void $logger Query logger. + */ + public function set_query_logger( callable $logger ): void { + $this->query_logger = $logger; + } + + /** + * Get the raw DuckDB PHP client object. + * + * @return object + */ + public function get_client() { + return $this->duckdb; + } + + /** + * Normalize a DuckDB PHP row into column order. + * + * @param string[] $columns Column names. + * @param array $row DuckDB row keyed by column name. + * @return array + */ + private function normalize_row( array $columns, array $row ): array { + $normalized = array(); + foreach ( $columns as $column ) { + $normalized[] = $this->normalize_value( $row[ $column ] ?? null ); + } + return $normalized; + } + + /** + * Normalize DuckDB PHP typed values to scalar values where possible. + * + * @param mixed $value DuckDB PHP value. + * @return mixed + */ + private function normalize_value( $value ) { + if ( is_object( $value ) ) { + if ( method_exists( $value, 'data' ) ) { + return $value->data(); + } + if ( method_exists( $value, '__toString' ) ) { + return (string) $value; + } + } + return $value; + } +} diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver-exception.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver-exception.php new file mode 100644 index 000000000..45148b9a3 --- /dev/null +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver-exception.php @@ -0,0 +1,7 @@ + 'BOOLEAN', + WP_MySQL_Lexer::BOOLEAN_SYMBOL => 'BOOLEAN', + WP_MySQL_Lexer::TINYINT_SYMBOL => 'TINYINT', + WP_MySQL_Lexer::SMALLINT_SYMBOL => 'SMALLINT', + WP_MySQL_Lexer::MEDIUMINT_SYMBOL => 'INTEGER', + WP_MySQL_Lexer::INT_SYMBOL => 'INTEGER', + WP_MySQL_Lexer::INTEGER_SYMBOL => 'INTEGER', + WP_MySQL_Lexer::BIGINT_SYMBOL => 'BIGINT', + WP_MySQL_Lexer::FLOAT_SYMBOL => 'FLOAT', + WP_MySQL_Lexer::DOUBLE_SYMBOL => 'DOUBLE', + WP_MySQL_Lexer::DECIMAL_SYMBOL => 'DECIMAL', + WP_MySQL_Lexer::NUMERIC_SYMBOL => 'DECIMAL', + WP_MySQL_Lexer::CHAR_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::VARCHAR_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::TEXT_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::TINYTEXT_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::LONGTEXT_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::DATE_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::TIME_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::DATETIME_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::TIMESTAMP_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::BLOB_SYMBOL => 'BLOB', + WP_MySQL_Lexer::TINYBLOB_SYMBOL => 'BLOB', + WP_MySQL_Lexer::MEDIUMBLOB_SYMBOL => 'BLOB', + WP_MySQL_Lexer::LONGBLOB_SYMBOL => 'BLOB', + ); + + /** + * @var WP_Parser_Grammar|null + */ + private static $mysql_grammar; + + /** + * @var WP_DuckDB_Connection + */ + private $connection; + + /** + * @var string + */ + private $database; + + /** + * @var int + */ + private $mysql_version; + + /** + * @var WP_MySQL_Parser|null + */ + private $mysql_parser; + + /** + * @var string|null + */ + private $last_mysql_query; + + /** + * @var string[] + */ + private $last_duckdb_queries = array(); + + /** + * MySQL-compatible server version string. + * + * @var string + */ + public $client_info; + + /** + * @param array $options Driver and connection options. + * + * @throws InvalidArgumentException When options are invalid. + * @throws WP_DuckDB_Driver_Exception When DuckDB cannot be initialized. + */ + public function __construct( array $options = array() ) { + $this->database = isset( $options['database'] ) ? (string) $options['database'] : self::DEFAULT_DATABASE; + $this->mysql_version = isset( $options['mysql_version'] ) ? (int) $options['mysql_version'] : self::DEFAULT_MYSQL_VERSION; + + if ( '' === $this->database ) { + throw new InvalidArgumentException( 'DuckDB driver option "database" must not be empty.' ); + } + + if ( isset( $options['connection'] ) ) { + if ( ! $options['connection'] instanceof WP_DuckDB_Connection ) { + throw new InvalidArgumentException( 'DuckDB driver option "connection" must be a WP_DuckDB_Connection.' ); + } + $this->connection = $options['connection']; + } else { + $this->connection = new WP_DuckDB_Connection( $options ); + } + + if ( null === self::$mysql_grammar ) { + self::$mysql_grammar = new WP_Parser_Grammar( require self::MYSQL_GRAMMAR_PATH ); + } + + $this->initialize_session_macros(); + + $this->client_info = $this->format_mysql_version(); + } + + /** + * Translate and execute a supported MySQL query in DuckDB. + * + * @param string $query MySQL query. + * @return WP_DuckDB_Result_Statement + * + * @throws WP_DuckDB_Driver_Exception When the statement is unsupported or fails. + */ + public function query( string $query ): WP_DuckDB_Result_Statement { + $this->last_mysql_query = $query; + $this->last_duckdb_queries = array(); + + $tokens = $this->tokenize_and_validate( $query ); + if ( count( $tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DuckDB MySQL-emulation statement: empty query.' ); + } + + switch ( $tokens[0]->id ) { + case WP_MySQL_Lexer::SELECT_SYMBOL: + return $this->execute_select( $tokens ); + case WP_MySQL_Lexer::CREATE_SYMBOL: + return $this->execute_create_table( $tokens ); + case WP_MySQL_Lexer::INSERT_SYMBOL: + return $this->execute_insert( $tokens ); + case WP_MySQL_Lexer::UPDATE_SYMBOL: + return $this->execute_update( $tokens ); + case WP_MySQL_Lexer::DELETE_SYMBOL: + return $this->execute_delete( $tokens ); + case WP_MySQL_Lexer::SHOW_SYMBOL: + return $this->execute_show( $tokens ); + case WP_MySQL_Lexer::DESCRIBE_SYMBOL: + case WP_MySQL_Lexer::DESC_SYMBOL: + return $this->execute_describe( $tokens ); + } + + throw $this->new_unsupported_statement_exception( $tokens[0] ); + } + + /** + * Get the underlying DuckDB connection. + * + * @return WP_DuckDB_Connection + */ + public function get_connection(): WP_DuckDB_Connection { + return $this->connection; + } + + /** + * Get the last MySQL query. + * + * @return string|null + */ + public function get_last_mysql_query(): ?string { + return $this->last_mysql_query; + } + + /** + * Get DuckDB SQL statements executed for the last query. + * + * @return string[] + */ + public function get_last_duckdb_queries(): array { + return $this->last_duckdb_queries; + } + + /** + * Tokenize and parse a single MySQL statement. + * + * @param string $query MySQL query. + * @return WP_Parser_Token[] + * + * @throws WP_DuckDB_Driver_Exception When parsing fails or multiple statements are supplied. + */ + private function tokenize_and_validate( string $query ): array { + $lexer = new WP_MySQL_Lexer( $query, $this->mysql_version ); + $raw_tokens = class_exists( 'WP_MySQL_Native_Lexer', false ) && $lexer instanceof WP_MySQL_Native_Lexer + ? $lexer->native_token_stream() + : $lexer->remaining_tokens(); + $tokens = is_array( $raw_tokens ) ? array_values( $raw_tokens ) : array_values( iterator_to_array( $raw_tokens ) ); + + $this->assert_single_statement( $tokens ); + $this->parse_tokens( $tokens ); + + $tokens = $this->without_eof( $tokens ); + if ( count( $tokens ) > 0 && WP_MySQL_Lexer::SEMICOLON_SYMBOL === $tokens[ count( $tokens ) - 1 ]->id ) { + array_pop( $tokens ); + } + + return array_values( $tokens ); + } + + /** + * Ensure the token stream contains at most one trailing semicolon. + * + * @param WP_Parser_Token[] $tokens Token stream. + * + * @throws WP_DuckDB_Driver_Exception When multiple statements are supplied. + */ + private function assert_single_statement( $tokens ): void { + $significant = $this->without_eof( $tokens ); + foreach ( $significant as $index => $token ) { + if ( + WP_MySQL_Lexer::SEMICOLON_SYMBOL === $token->id + && count( $significant ) - 1 !== $index + ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB driver supports one MySQL statement at a time.' ); + } + } + } + + /** + * Parse token stream with the MySQL parser. + * + * @param WP_Parser_Token[] $tokens Token stream. + * + * @throws WP_DuckDB_Driver_Exception When parsing fails. + */ + private function parse_tokens( $tokens ): void { + try { + if ( null === $this->mysql_parser || ! method_exists( $this->mysql_parser, 'reset_tokens' ) ) { + $this->mysql_parser = new WP_MySQL_Parser( self::$mysql_grammar, $tokens ); + } else { + $this->mysql_parser->reset_tokens( $tokens ); + } + $ast = $this->mysql_parser->parse(); + } catch ( Throwable $e ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB driver could not parse MySQL statement: ' . $e->getMessage(), 0, $e ); + } + + if ( ! $ast instanceof WP_Parser_Node ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB driver could not parse MySQL statement.' ); + } + } + + /** + * Remove EOF from a token stream. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return WP_Parser_Token[] + */ + private function without_eof( array $tokens ): array { + $filtered = array(); + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::EOF === $token->id ) { + continue; + } + $filtered[] = $token; + } + return $filtered; + } + + /** + * Execute a supported SELECT statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { + return $this->execute_duckdb_query( + $this->translate_tokens_to_duckdb_sql( $tokens ), + 'Unsupported DuckDB MySQL-emulation SELECT statement' + ); + } + + /** + * Execute a supported CREATE TABLE statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + * + * @throws WP_DuckDB_Driver_Exception When the CREATE TABLE shape is unsupported. + */ + private function execute_create_table( array $tokens ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CREATE_SYMBOL, 'Expected CREATE.' ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::TABLE_SYMBOL, 'Only CREATE TABLE is supported by the DuckDB driver.' ); + ++$index; + + $if_not_exists = false; + if ( + isset( $tokens[ $index + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $index ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $index + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $index + 2 ]->id + ) { + $if_not_exists = true; + $index += 3; + } + + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Expected column list in CREATE TABLE.' ); + ++$index; + + list( $items, $index ) = $this->collect_parenthesized_items( $tokens, $index ); + $this->assert_supported_create_table_options( array_slice( $tokens, $index ) ); + + $columns = array(); + $constraints = array(); + $indexes = array(); + $sequences = array(); + + foreach ( $items as $item ) { + if ( count( $item ) === 0 ) { + continue; + } + + if ( WP_MySQL_Lexer::PRIMARY_SYMBOL === $item[0]->id ) { + $constraints[] = $this->translate_table_primary_key( $item ); + continue; + } + + if ( $this->is_create_table_index_item( $item ) ) { + $indexes[] = $this->translate_create_table_index( $table_name, $item ); + continue; + } + + if ( WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $item[0]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE constraint in DuckDB driver: ' . $item[0]->get_bytes() . '.' ); + } + + list( $column_sql, $sequence_sql ) = $this->translate_create_table_column( $table_name, $item ); + $columns[] = $column_sql; + if ( null !== $sequence_sql ) { + $sequences[] = $sequence_sql; + } + } + + if ( count( $columns ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'CREATE TABLE requires at least one column.' ); + } + + foreach ( $sequences as $sequence_sql ) { + $this->execute_duckdb_query( $sequence_sql, 'Failed to create DuckDB AUTO_INCREMENT sequence' ); + } + + $table_sql = 'CREATE TABLE '; + if ( $if_not_exists ) { + $table_sql .= 'IF NOT EXISTS '; + } + $table_sql .= $this->connection->quote_identifier( $table_name ); + $table_sql .= ' ('; + $table_sql .= implode( ', ', array_merge( $columns, $constraints ) ); + $table_sql .= ')'; + + $result = $this->execute_duckdb_query( $table_sql, 'Failed to create DuckDB table' ); + foreach ( $indexes as $index_definition ) { + $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); + $this->record_index_metadata( $index_definition ); + } + + return $result; + } + + /** + * Execute a supported INSERT statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { + $index = 1; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + $this->identifier_value( $tokens[ $index ] ?? null ); + + $has_values = false; + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::VALUES_SYMBOL === $token->id ) { + $has_values = true; + break; + } + if ( WP_MySQL_Lexer::SELECT_SYMBOL === $token->id || WP_MySQL_Lexer::SET_SYMBOL === $token->id ) { + break; + } + } + + if ( ! $has_values ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT statement in DuckDB driver. Only INSERT ... VALUES is supported.' ); + } + + return $this->execute_duckdb_query( + $this->translate_tokens_to_duckdb_sql( $tokens ), + 'Failed to execute DuckDB INSERT' + ); + } + + /** + * Execute a supported UPDATE statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_update( array $tokens ): WP_DuckDB_Result_Statement { + $this->identifier_value( $tokens[1] ?? null ); + if ( ! isset( $tokens[2] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[2]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only simple single-table UPDATE is supported.' ); + } + + return $this->execute_duckdb_query( + $this->translate_tokens_to_duckdb_sql( $tokens ), + 'Failed to execute DuckDB UPDATE' + ); + } + + /** + * Execute a supported DELETE statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_delete( array $tokens ): WP_DuckDB_Result_Statement { + if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[1]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Only DELETE FROM table is supported.' ); + } + $this->identifier_value( $tokens[2] ?? null ); + if ( isset( $tokens[3] ) && WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[3]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Only simple single-table DELETE is supported.' ); + } + + return $this->execute_duckdb_query( + $this->translate_tokens_to_duckdb_sql( $tokens ), + 'Failed to execute DuckDB DELETE' + ); + } + + /** + * Execute supported SHOW statements. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_show( array $tokens ): WP_DuckDB_Result_Statement { + if ( 2 === count( $tokens ) && WP_MySQL_Lexer::TABLES_SYMBOL === $tokens[1]->id ) { + return $this->execute_show_tables(); + } + + if ( + isset( $tokens[1] ) + && in_array( $tokens[1]->id, array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::INDEXES_SYMBOL, WP_MySQL_Lexer::KEYS_SYMBOL ), true ) + ) { + return $this->execute_show_index( $tokens ); + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW statement in DuckDB driver.' ); + } + + /** + * Execute SHOW TABLES. + * + * @return WP_DuckDB_Result_Statement + */ + private function execute_show_tables(): WP_DuckDB_Result_Statement { + $column = 'Tables_in_' . $this->database; + return $this->execute_duckdb_query( + 'SELECT table_name AS ' . $this->connection->quote_identifier( $column ) + . " FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' AND table_name <> " + . $this->connection->quote( self::INDEX_METADATA_TABLE ) + . ' ORDER BY table_name', + 'Failed to execute SHOW TABLES' + ); + } + + /** + * Execute SHOW INDEX FROM table. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_show_index( array $tokens ): WP_DuckDB_Result_Statement { + if ( + 4 !== count( $tokens ) + || ( WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[2]->id && WP_MySQL_Lexer::IN_SYMBOL !== $tokens[2]->id ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW INDEX statement in DuckDB driver. Use SHOW INDEX FROM table.' ); + } + + $table_name = $this->identifier_value( $tokens[3] ); + + return new WP_DuckDB_Result_Statement( + array( + 'Table', + 'Non_unique', + 'Key_name', + 'Seq_in_index', + 'Column_name', + 'Collation', + 'Cardinality', + 'Sub_part', + 'Packed', + 'Null', + 'Index_type', + 'Comment', + 'Index_comment', + 'Visible', + 'Expression', + ), + array_merge( + $this->primary_key_index_rows( $table_name ), + $this->secondary_index_rows( $table_name ) + ) + ); + } + + /** + * Build SHOW INDEX rows for the primary key. + * + * @param string $table_name Table name. + * @return array> + */ + private function primary_key_index_rows( string $table_name ): array { + $pragma = $this->execute_duckdb_query( + 'SELECT name, pk FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ') WHERE pk > 0 ORDER BY pk', + 'Failed to inspect DuckDB primary key' + ); + + $rows = array(); + foreach ( $pragma->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $rows[] = $this->show_index_row( + $table_name, + 0, + 'PRIMARY', + (int) $row['pk'], + (string) $row['name'], + null + ); + } + + return $rows; + } + + /** + * Build SHOW INDEX rows for secondary indexes. + * + * @param string $table_name Table name. + * @return array> + */ + private function secondary_index_rows( string $table_name ): array { + $this->ensure_index_metadata_table(); + + $stmt = $this->execute_duckdb_query( + 'SELECT index_name, non_unique, seq_in_index, column_name, sub_part FROM ' + . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' ORDER BY index_name, seq_in_index', + 'Failed to inspect DuckDB secondary indexes' + ); + + $rows = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $rows[] = $this->show_index_row( + $table_name, + (int) $row['non_unique'], + (string) $row['index_name'], + (int) $row['seq_in_index'], + (string) $row['column_name'], + null === $row['sub_part'] ? null : (int) $row['sub_part'] + ); + } + + return $rows; + } + + /** + * Build one MySQL-compatible SHOW INDEX row. + * + * @param string $table_name Table name. + * @param int $non_unique Non-unique flag. + * @param string $key_name Key name. + * @param int $seq_in_index Sequence in index. + * @param string $column_name Column name. + * @param int|null $sub_part Optional prefix length. + * @return array + */ + private function show_index_row( string $table_name, int $non_unique, string $key_name, int $seq_in_index, string $column_name, ?int $sub_part ): array { + return array( + $table_name, + $non_unique, + $key_name, + $seq_in_index, + $column_name, + 'A', + null, + $sub_part, + null, + '', + 'BTREE', + '', + '', + 'YES', + null, + ); + } + + /** + * Execute DESCRIBE table. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_describe( array $tokens ): WP_DuckDB_Result_Statement { + if ( 2 !== count( $tokens ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DESCRIBE statement in DuckDB driver. Only DESCRIBE table is supported.' ); + } + + $table_name = $this->identifier_value( $tokens[1] ); + $pragma = $this->execute_duckdb_query( + 'SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ') ORDER BY cid', + 'Failed to inspect DuckDB table' + ); + $rows = $pragma->fetchAll( PDO::FETCH_ASSOC ); + + if ( count( $rows ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB table does not exist: ' . $table_name . '.' ); + } + + $describe_rows = array(); + foreach ( $rows as $row ) { + $is_primary_key = isset( $row['pk'] ) && (int) $row['pk'] > 0; + $is_not_null = $is_primary_key || ( isset( $row['notnull'] ) && (bool) $row['notnull'] ); + $is_auto = isset( $row['dflt_value'] ) && is_string( $row['dflt_value'] ) && false !== stripos( $row['dflt_value'], 'nextval(' ); + + $describe_rows[] = array( + $row['name'], + $row['type'], + $is_not_null ? 'NO' : 'YES', + $is_primary_key ? 'PRI' : '', + $is_auto ? null : $this->normalize_describe_default( $row['dflt_value'] ?? null ), + $is_auto ? 'auto_increment' : '', + ); + } + + return new WP_DuckDB_Result_Statement( + array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + $describe_rows + ); + } + + /** + * Collect comma-separated items inside a CREATE TABLE column list. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Index after the opening parenthesis. + * @return array{0: array>, 1: int} + */ + private function collect_parenthesized_items( array $tokens, int $index ): array { + $items = array(); + $current = array(); + $depth = 1; + + for ( ; $index < count( $tokens ); ++$index ) { + $token = $tokens[ $index ]; + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + ++$depth; + $current[] = $token; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id ) { + --$depth; + if ( 0 === $depth ) { + $items[] = $current; + return array( $items, $index + 1 ); + } + $current[] = $token; + continue; + } + if ( 1 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $token->id ) { + $items[] = $current; + $current = array(); + continue; + } + $current[] = $token; + } + + throw new WP_DuckDB_Driver_Exception( 'Unterminated CREATE TABLE column list.' ); + } + + /** + * Translate a column definition. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Column definition tokens. + * @return array{0:string,1:string|null} + */ + private function translate_create_table_column( string $table_name, array $tokens ): array { + $index = 0; + $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( ! isset( $tokens[ $index ] ) || ! isset( self::DATA_TYPE_MAP[ $tokens[ $index ]->id ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported MySQL column type for DuckDB column: ' . $column_name . '.' ); + } + + $type_token = $tokens[ $index ]; + $duck_type = self::DATA_TYPE_MAP[ $type_token->id ]; + ++$index; + $index = $this->skip_type_modifiers( $tokens, $index ); + + $not_null = false; + $primary_key = false; + $auto_increment = false; + $default_sql = null; + + while ( $index < count( $tokens ) ) { + $token = $tokens[ $index ]; + switch ( $token->id ) { + case WP_MySQL_Lexer::NOT_SYMBOL: + $this->expect_token( $tokens, $index + 1, WP_MySQL_Lexer::NULL_SYMBOL, 'Expected NULL after NOT in column definition.' ); + $not_null = true; + $index += 2; + break; + case WP_MySQL_Lexer::NULL_SYMBOL: + case WP_MySQL_Lexer::NULL2_SYMBOL: + $not_null = false; + ++$index; + break; + case WP_MySQL_Lexer::DEFAULT_SYMBOL: + ++$index; + $default_sql = $this->translate_default_literal( $tokens, $index ); + break; + case WP_MySQL_Lexer::PRIMARY_SYMBOL: + $this->expect_token( $tokens, $index + 1, WP_MySQL_Lexer::KEY_SYMBOL, 'Expected KEY after PRIMARY in column definition.' ); + $primary_key = true; + $not_null = true; + $index += 2; + break; + case WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL: + $auto_increment = true; + $not_null = true; + ++$index; + break; + case WP_MySQL_Lexer::COMMENT_SYMBOL: + $index = $this->skip_option_value( $tokens, $index + 1 ); + break; + case WP_MySQL_Lexer::COLLATE_SYMBOL: + case WP_MySQL_Lexer::CHARSET_SYMBOL: + $index = $this->skip_option_value( $tokens, $index + 1 ); + break; + case WP_MySQL_Lexer::CHAR_SYMBOL: + case WP_MySQL_Lexer::CHARACTER_SYMBOL: + if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $index + 1 ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CHARACTER column option in DuckDB driver.' ); + } + $index = $this->skip_option_value( $tokens, $index + 2 ); + break; + default: + throw new WP_DuckDB_Driver_Exception( 'Unsupported column attribute in DuckDB driver: ' . $token->get_bytes() . '.' ); + } + } + + if ( $auto_increment && null !== $default_sql ) { + throw new WP_DuckDB_Driver_Exception( 'AUTO_INCREMENT columns cannot also declare DEFAULT in the DuckDB driver.' ); + } + + if ( $auto_increment && ! $this->is_integer_duckdb_type( $duck_type ) ) { + throw new WP_DuckDB_Driver_Exception( 'AUTO_INCREMENT requires an integer column in the DuckDB driver.' ); + } + + if ( $auto_increment ) { + $duck_type = 'BIGINT'; + } + + $column_sql = $this->connection->quote_identifier( $column_name ) . ' ' . $duck_type; + $sequence = null; + + if ( $auto_increment ) { + $sequence_name = $this->sequence_name( $table_name, $column_name ); + $sequence = 'CREATE SEQUENCE IF NOT EXISTS ' . $this->connection->quote_identifier( $sequence_name ) . ' START 1'; + $column_sql .= ' DEFAULT nextval(' . $this->connection->quote( $sequence_name ) . ')'; + } elseif ( null !== $default_sql ) { + $column_sql .= ' DEFAULT ' . $default_sql; + } + + if ( $not_null ) { + $column_sql .= ' NOT NULL'; + } + if ( $primary_key ) { + $column_sql .= ' PRIMARY KEY'; + } + + return array( $column_sql, $sequence ); + } + + /** + * Skip MySQL type display widths and modifiers. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index. + * @return int New index. + */ + private function skip_type_modifiers( array $tokens, int $index ): int { + while ( $index < count( $tokens ) ) { + $token = $tokens[ $index ]; + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + $index = $this->skip_balanced_parentheses( $tokens, $index ); + continue; + } + if ( WP_MySQL_Lexer::UNSIGNED_SYMBOL === $token->id || WP_MySQL_Lexer::ZEROFILL_SYMBOL === $token->id ) { + ++$index; + continue; + } + if ( WP_MySQL_Lexer::CHARSET_SYMBOL === $token->id || WP_MySQL_Lexer::COLLATE_SYMBOL === $token->id ) { + $index = $this->skip_option_value( $tokens, $index + 1 ); + continue; + } + if ( WP_MySQL_Lexer::CHAR_SYMBOL === $token->id || WP_MySQL_Lexer::CHARACTER_SYMBOL === $token->id ) { + if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $index + 1 ]->id ) { + return $index; + } + $index = $this->skip_option_value( $tokens, $index + 2 ); + continue; + } + return $index; + } + return $index; + } + + /** + * Translate a table-level PRIMARY KEY constraint. + * + * @param WP_Parser_Token[] $tokens Constraint tokens. + * @return string + */ + private function translate_table_primary_key( array $tokens ): string { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::PRIMARY_SYMBOL, 'Expected PRIMARY KEY constraint.' ); + $this->expect_token( $tokens, $index + 1, WP_MySQL_Lexer::KEY_SYMBOL, 'Expected PRIMARY KEY constraint.' ); + $this->expect_token( $tokens, $index + 2, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Expected PRIMARY KEY column list.' ); + $index = 3; + + $columns = array(); + while ( $index < count( $tokens ) ) { + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + break; + } + $columns[] = $this->connection->quote_identifier( $this->identifier_value( $tokens[ $index ] ) ); + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + } + + if ( count( $columns ) === 0 || count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported PRIMARY KEY constraint in DuckDB driver.' ); + } + + return 'PRIMARY KEY (' . implode( ', ', $columns ) . ')'; + } + + /** + * Check whether an item in CREATE TABLE is a secondary index. + * + * @param WP_Parser_Token[] $tokens Item tokens. + * @return bool + */ + private function is_create_table_index_item( array $tokens ): bool { + return isset( $tokens[0] ) + && in_array( + $tokens[0]->id, + array( + WP_MySQL_Lexer::FULLTEXT_SYMBOL, + WP_MySQL_Lexer::INDEX_SYMBOL, + WP_MySQL_Lexer::KEY_SYMBOL, + WP_MySQL_Lexer::SPATIAL_SYMBOL, + WP_MySQL_Lexer::UNIQUE_SYMBOL, + ), + true + ); + } + + /** + * Translate a table-level MySQL index definition into CREATE INDEX. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Index definition tokens. + * @return array{sql:string,table_name:string,index_name:string,unique:bool,columns:array} + */ + private function translate_create_table_index( string $table_name, array $tokens ): array { + $index = 0; + $unique = false; + + if ( WP_MySQL_Lexer::FULLTEXT_SYMBOL === $tokens[0]->id || WP_MySQL_Lexer::SPATIAL_SYMBOL === $tokens[0]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE index type in DuckDB driver: ' . $tokens[0]->get_bytes() . '.' ); + } + + if ( WP_MySQL_Lexer::UNIQUE_SYMBOL === $tokens[ $index ]->id ) { + $unique = true; + ++$index; + if ( + isset( $tokens[ $index ] ) + && ( WP_MySQL_Lexer::KEY_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::INDEX_SYMBOL === $tokens[ $index ]->id ) + ) { + ++$index; + } + } elseif ( WP_MySQL_Lexer::KEY_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::INDEX_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } else { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE index in DuckDB driver.' ); + } + + $index = $this->skip_optional_index_type( $tokens, $index ); + + $mysql_index_name = null; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index ]->id ) { + $mysql_index_name = $this->identifier_value( $tokens[ $index ] ); + ++$index; + } + + $index = $this->skip_optional_index_type( $tokens, $index ); + + list( $columns, $column_metadata, $index ) = $this->translate_index_column_list( $tokens, $index ); + $this->assert_supported_index_options( $tokens, $index ); + + if ( null === $mysql_index_name ) { + $mysql_index_name = 'unnamed_' . substr( hash( 'sha256', serialize( $column_metadata ) ), 0, 8 ); + } + + return array( + 'sql' => 'CREATE ' + . ( $unique ? 'UNIQUE ' : '' ) + . 'INDEX IF NOT EXISTS ' + . $this->connection->quote_identifier( $this->index_name( $table_name, $mysql_index_name ) ) + . ' ON ' + . $this->connection->quote_identifier( $table_name ) + . ' (' + . implode( ', ', $columns ) + . ')', + 'table_name' => $table_name, + 'index_name' => $mysql_index_name, + 'unique' => $unique, + 'columns' => $column_metadata, + ); + } + + /** + * Translate an index column list. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Index at opening parenthesis. + * @return array{0:string[],1:array,2:int} + */ + private function translate_index_column_list( array $tokens, int $index ): array { + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Expected index column list in CREATE TABLE.' ); + ++$index; + + $columns = array(); + $column_metadata = array(); + while ( $index < count( $tokens ) ) { + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + break; + } + + $column_name = $this->identifier_value( $tokens[ $index ] ); + ++$index; + + $sub_part = null; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + $sub_part = $this->index_prefix_length( $tokens, $index ); + $index = $this->skip_balanced_parentheses( $tokens, $index ); + } + + if ( + isset( $tokens[ $index ] ) + && ( WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $index ]->id ) + ) { + ++$index; + } + + $columns[] = $this->connection->quote_identifier( $column_name ); + $column_metadata[] = array( + 'name' => $column_name, + 'sub_part' => $sub_part, + ); + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + continue; + } + + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported index column list in DuckDB driver.' ); + } + } + + if ( count( $columns ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'CREATE TABLE index requires at least one column.' ); + } + + return array( $columns, $column_metadata, $index ); + } + + /** + * Skip optional USING BTREE/HASH syntax. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index. + * @return int New index. + */ + private function skip_optional_index_type( array $tokens, int $index ): int { + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::USING_SYMBOL !== $tokens[ $index ]->id ) { + return $index; + } + + if ( ! isset( $tokens[ $index + 1 ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Expected index type after USING in DuckDB driver.' ); + } + + if ( + WP_MySQL_Lexer::BTREE_SYMBOL !== $tokens[ $index + 1 ]->id + && WP_MySQL_Lexer::HASH_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + $this->identifier_value( $tokens[ $index + 1 ] ); + } + + return $index + 2; + } + + /** + * Read an optional MySQL index prefix length. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Index at opening parenthesis. + * @return int|null Prefix length. + */ + private function index_prefix_length( array $tokens, int $index ): ?int { + if ( + isset( $tokens[ $index + 1 ], $tokens[ $index + 2 ] ) + && $this->is_number_token( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index + 2 ]->id + ) { + return (int) $tokens[ $index + 1 ]->get_bytes(); + } + + return null; + } + + /** + * Assert supported trailing index options. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index. + */ + private function assert_supported_index_options( array $tokens, int $index ): void { + while ( $index < count( $tokens ) ) { + if ( WP_MySQL_Lexer::COMMENT_SYMBOL === $tokens[ $index ]->id ) { + $index = $this->skip_option_value( $tokens, $index + 1 ); + continue; + } + + if ( WP_MySQL_Lexer::USING_SYMBOL === $tokens[ $index ]->id ) { + $index = $this->skip_optional_index_type( $tokens, $index ); + continue; + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported index option in DuckDB driver: ' . $tokens[ $index ]->get_bytes() . '.' ); + } + } + + /** + * Assert supported CREATE TABLE tail options. + * + * @param WP_Parser_Token[] $tokens Tail tokens after the column list. + */ + private function assert_supported_create_table_options( array $tokens ): void { + $index = 0; + while ( $index < count( $tokens ) ) { + $token = $tokens[ $index ]; + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + ++$index; + continue; + } + if ( + WP_MySQL_Lexer::ENGINE_SYMBOL === $token->id + || WP_MySQL_Lexer::CHARSET_SYMBOL === $token->id + || WP_MySQL_Lexer::COLLATE_SYMBOL === $token->id + || WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL === $token->id + || WP_MySQL_Lexer::COMMENT_SYMBOL === $token->id + || WP_MySQL_Lexer::ROW_FORMAT_SYMBOL === $token->id + ) { + $index = $this->skip_option_value( $tokens, $index + 1 ); + continue; + } + if ( WP_MySQL_Lexer::CHAR_SYMBOL === $token->id || WP_MySQL_Lexer::CHARACTER_SYMBOL === $token->id ) { + if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $index + 1 ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE option in DuckDB driver: ' . $token->get_bytes() . '.' ); + } + $index = $this->skip_option_value( $tokens, $index + 2 ); + continue; + } + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE option in DuckDB driver: ' . $token->get_bytes() . '.' ); + } + } + + /** + * Translate a DEFAULT literal and advance the index. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index, passed by reference. + * @return string DuckDB SQL literal. + */ + private function translate_default_literal( array $tokens, int &$index ): string { + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'DEFAULT requires a literal in the DuckDB driver.' ); + } + + $token = $tokens[ $index ]; + if ( + ( WP_MySQL_Lexer::MINUS_OPERATOR === $token->id || WP_MySQL_Lexer::PLUS_OPERATOR === $token->id ) + && isset( $tokens[ $index + 1 ] ) + && $this->is_number_token( $tokens[ $index + 1 ] ) + ) { + $literal = ( WP_MySQL_Lexer::MINUS_OPERATOR === $token->id ? '-' : '+' ) . $tokens[ $index + 1 ]->get_bytes(); + $index += 2; + return $literal; + } + + ++$index; + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $this->connection->quote( $token->get_value() ); + } + if ( $this->is_number_token( $token ) ) { + return $token->get_bytes(); + } + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id || WP_MySQL_Lexer::NULL2_SYMBOL === $token->id ) { + return 'NULL'; + } + if ( WP_MySQL_Lexer::TRUE_SYMBOL === $token->id || WP_MySQL_Lexer::FALSE_SYMBOL === $token->id ) { + return strtoupper( $token->get_bytes() ); + } + + throw new WP_DuckDB_Driver_Exception( 'Only DEFAULT literals are supported by the DuckDB driver.' ); + } + + /** + * Convert MySQL tokens to DuckDB SQL. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return string DuckDB SQL. + */ + private function translate_tokens_to_duckdb_sql( array $tokens ): string { + $pieces = array(); + + for ( $index = 0; $index < count( $tokens ); ++$index ) { + $token = $tokens[ $index ]; + + if ( + WP_MySQL_Lexer::NOT_SYMBOL === $token->id + && isset( $tokens[ $index + 1 ], $tokens[ $index + 2 ] ) + && $this->is_regexp_operator( $tokens[ $index + 1 ] ) + ) { + $pieces[] = $this->translate_regexp_predicate( $pieces, $tokens[ $index + 2 ], true ); + $index += 2; + continue; + } + + if ( + $this->is_regexp_operator( $token ) + && isset( $tokens[ $index + 1 ] ) + ) { + $pieces[] = $this->translate_regexp_predicate( $pieces, $tokens[ $index + 1 ], false ); + ++$index; + continue; + } + + if ( $this->is_empty_function_call( $tokens, $index, 'DATABASE' ) ) { + $pieces[] = $this->connection->quote( $this->database ); + $index += 2; + continue; + } + + if ( $this->is_empty_function_call( $tokens, $index, 'VERSION' ) ) { + $pieces[] = $this->connection->quote( $this->format_mysql_version() ); + $index += 2; + continue; + } + + if ( $this->is_empty_function_call( $tokens, $index, 'RAND' ) ) { + $pieces[] = 'random()'; + $index += 2; + continue; + } + + if ( $this->is_empty_function_call( $tokens, $index, 'UNIX_TIMESTAMP' ) ) { + $pieces[] = 'CAST(epoch(current_timestamp) AS BIGINT)'; + $index += 2; + continue; + } + + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + $pieces[] = $this->connection->quote_identifier( $token->get_value() ); + continue; + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + $pieces[] = $this->connection->quote( $token->get_value() ); + continue; + } + + $pieces[] = $token->get_bytes(); + } + + return $this->join_sql_pieces( $pieces ); + } + + /** + * Initialize DuckDB macros that emulate simple MySQL functions. + */ + private function initialize_session_macros(): void { + try { + $this->connection->query( 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(d, f)' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + throw new WP_DuckDB_Driver_Exception( 'Failed to initialize DuckDB MySQL compatibility macros: ' . $e->getMessage(), 0, $e ); + } + } + + /** + * Check whether a token is a regexp operator. + * + * @param WP_Parser_Token $token Token. + * @return bool + */ + private function is_regexp_operator( WP_Parser_Token $token ): bool { + return WP_MySQL_Lexer::REGEXP_SYMBOL === $token->id || WP_MySQL_Lexer::RLIKE_SYMBOL === $token->id; + } + + /** + * Translate a simple regexp predicate. + * + * @param string[] $pieces SQL pieces translated so far. + * @param WP_Parser_Token $pattern Pattern token. + * @param bool $negated Whether this is NOT REGEXP. + * @return string + */ + private function translate_regexp_predicate( array &$pieces, WP_Parser_Token $pattern, bool $negated ): string { + if ( count( $pieces ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'REGEXP requires a left-hand expression in the DuckDB driver.' ); + } + + $left = array_pop( $pieces ); + $predicate = sprintf( + 'regexp_matches(%s, %s)', + $left, + $this->translate_token_to_duckdb_sql( $pattern ) + ); + + return $negated ? 'NOT ' . $predicate : $predicate; + } + + /** + * Translate a single token to DuckDB SQL. + * + * @param WP_Parser_Token $token Token. + * @return string + */ + private function translate_token_to_duckdb_sql( WP_Parser_Token $token ): string { + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return $this->connection->quote_identifier( $token->get_value() ); + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $this->connection->quote( $token->get_value() ); + } + + return $token->get_bytes(); + } + + /** + * Check if a token starts an empty function call by name. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index. + * @param string $name Function name. + * @return bool + */ + private function is_empty_function_call( array $tokens, int $index, string $name ): bool { + return isset( $tokens[ $index + 2 ] ) + && 0 === strcasecmp( $tokens[ $index ]->get_value(), $name ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index + 1 ]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index + 2 ]->id; + } + + /** + * Join SQL pieces with conservative whitespace. + * + * @param string[] $pieces SQL pieces. + * @return string + */ + private function join_sql_pieces( array $pieces ): string { + $sql = ''; + $previous = null; + + foreach ( $pieces as $piece ) { + if ( '' === $sql ) { + $sql = $piece; + } elseif ( in_array( $piece, array( ',', ')', ';' ), true ) ) { + $sql = rtrim( $sql ) . $piece; + } elseif ( '.' === $piece || '.' === $previous || '(' === $previous ) { + $sql .= $piece; + } elseif ( '(' === $piece ) { + if ( $this->should_omit_space_before_open_parenthesis( $previous ) ) { + $sql .= $piece; + } else { + $sql .= ' ' . $piece; + } + } else { + $sql .= ' ' . $piece; + } + $previous = $piece; + } + + return $sql; + } + + /** + * Decide whether an open parenthesis belongs directly to the previous piece. + * + * @param string|null $previous Previous SQL piece. + * @return bool + */ + private function should_omit_space_before_open_parenthesis( ?string $previous ): bool { + if ( null === $previous || ',' === $previous ) { + return false; + } + + if ( ! preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$|^"[^"]+"$/', $previous ) ) { + return false; + } + + return ! in_array( + strtoupper( trim( $previous, '"' ) ), + array( + 'AND', + 'AS', + 'BY', + 'CREATE', + 'DELETE', + 'FROM', + 'GROUP', + 'IN', + 'INSERT', + 'INTO', + 'KEY', + 'LIMIT', + 'NOT', + 'OFFSET', + 'ON', + 'ORDER', + 'PRIMARY', + 'SELECT', + 'SET', + 'TABLE', + 'UPDATE', + 'VALUES', + 'WHERE', + ), + true + ); + } + + /** + * Execute DuckDB SQL and preserve inspectable SQL output. + * + * @param string $sql DuckDB SQL. + * @param string $context Failure context. + * @return WP_DuckDB_Result_Statement + */ + private function execute_duckdb_query( string $sql, string $context ): WP_DuckDB_Result_Statement { + $this->last_duckdb_queries[] = $sql; + + try { + return $this->connection->query( $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + throw new WP_DuckDB_Driver_Exception( $context . ': ' . $e->getMessage(), 0, $e ); + } + } + + /** + * Ensure the internal index metadata table exists. + */ + private function ensure_index_metadata_table(): void { + $this->execute_duckdb_query( + 'CREATE TABLE IF NOT EXISTS ' + . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . ' (table_name VARCHAR, index_name VARCHAR, non_unique INTEGER, seq_in_index INTEGER, column_name VARCHAR, sub_part INTEGER)', + 'Failed to initialize DuckDB index metadata' + ); + } + + /** + * Record MySQL index metadata for SHOW INDEX. + * + * @param array{table_name:string,index_name:string,unique:bool,columns:array} $index_definition Index definition. + */ + private function record_index_metadata( array $index_definition ): void { + $this->ensure_index_metadata_table(); + + $table_name = $index_definition['table_name']; + $index_name = $index_definition['index_name']; + + $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' AND index_name = ' + . $this->connection->quote( $index_name ), + 'Failed to reset DuckDB index metadata' + ); + + foreach ( $index_definition['columns'] as $offset => $column ) { + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . ' (table_name, index_name, non_unique, seq_in_index, column_name, sub_part) VALUES (' + . $this->connection->quote( $table_name ) + . ', ' + . $this->connection->quote( $index_name ) + . ', ' + . ( $index_definition['unique'] ? '0' : '1' ) + . ', ' + . ( $offset + 1 ) + . ', ' + . $this->connection->quote( $column['name'] ) + . ', ' + . $this->connection->quote( $column['sub_part'] ) + . ')', + 'Failed to store DuckDB index metadata' + ); + } + } + + /** + * Read an identifier token value. + * + * @param WP_Parser_Token|null $token Token. + * @return string Identifier value. + */ + private function identifier_value( $token ): string { + if ( ! $token instanceof WP_Parser_Token || $this->is_non_identifier_token( $token ) ) { + throw new WP_DuckDB_Driver_Exception( 'Expected a MySQL identifier in DuckDB driver statement.' ); + } + return $token->get_value(); + } + + /** + * Check whether a token cannot be an identifier in the supported subset. + * + * @param WP_Parser_Token $token Token. + * @return bool + */ + private function is_non_identifier_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::EOF, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + WP_MySQL_Lexer::DECIMAL_NUMBER, + WP_MySQL_Lexer::FLOAT_NUMBER, + WP_MySQL_Lexer::EQUAL_OPERATOR, + ), + true + ); + } + + /** + * Expect a specific token at an index. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Index. + * @param int $token_id Expected token ID. + * @param string $message Error message. + */ + private function expect_token( array $tokens, int $index, int $token_id, string $message ): void { + if ( ! isset( $tokens[ $index ] ) || $token_id !== $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( $message ); + } + } + + /** + * Skip an option value, accepting optional equals. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Index at optional equals or value. + * @return int New index. + */ + private function skip_option_value( array $tokens, int $index ): int { + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $index ]->id ) { + ++$index; + } + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Expected option value in DuckDB driver statement.' ); + } + return $index + 1; + } + + /** + * Skip a balanced parenthesized token sequence. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Index at opening parenthesis. + * @return int Index after the closing parenthesis. + */ + private function skip_balanced_parentheses( array $tokens, int $index ): int { + $depth = 0; + for ( ; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + } elseif ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + if ( 0 === $depth ) { + return $index + 1; + } + } + } + throw new WP_DuckDB_Driver_Exception( 'Unbalanced parentheses in DuckDB driver statement.' ); + } + + /** + * Check if a token is a number literal. + * + * @param WP_Parser_Token $token Token. + * @return bool + */ + private function is_number_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + WP_MySQL_Lexer::DECIMAL_NUMBER, + WP_MySQL_Lexer::FLOAT_NUMBER, + ), + true + ); + } + + /** + * Check if a DuckDB type is integer-like. + * + * @param string $duck_type DuckDB type. + * @return bool + */ + private function is_integer_duckdb_type( string $duck_type ): bool { + return in_array( $duck_type, array( 'TINYINT', 'SMALLINT', 'INTEGER', 'BIGINT' ), true ); + } + + /** + * Create a deterministic sequence name for AUTO_INCREMENT. + * + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string Sequence name. + */ + private function sequence_name( string $table_name, string $column_name ): string { + return self::SEQUENCE_PREFIX . substr( hash( 'sha256', $table_name . "\0" . $column_name ), 0, 16 ); + } + + /** + * Create a deterministic schema-safe index name. + * + * @param string $table_name Table name. + * @param string $mysql_index_name MySQL index name. + * @return string DuckDB index name. + */ + private function index_name( string $table_name, string $mysql_index_name ): string { + return self::INDEX_PREFIX . substr( hash( 'sha256', $table_name ), 0, 8 ) . '_' . $mysql_index_name; + } + + /** + * Normalize a DuckDB default expression for DESCRIBE output. + * + * @param mixed $default_value Default expression. + * @return string|null + */ + private function normalize_describe_default( $default_value ): ?string { + if ( null === $default_value ) { + return null; + } + + $default_value = (string) $default_value; + if ( 0 === strcasecmp( $default_value, 'NULL' ) ) { + return null; + } + + if ( strlen( $default_value ) >= 2 && "'" === $default_value[0] && "'" === $default_value[ strlen( $default_value ) - 1 ] ) { + return str_replace( "''", "'", substr( $default_value, 1, -1 ) ); + } + + return $default_value; + } + + /** + * Format the emulated MySQL version. + * + * @return string + */ + private function format_mysql_version(): string { + $major = (int) floor( $this->mysql_version / 10000 ); + $minor = (int) floor( ( $this->mysql_version % 10000 ) / 100 ); + $patch = $this->mysql_version % 100; + + return sprintf( '%d.%d.%d-DuckDB', $major, $minor, $patch ); + } + + /** + * Build an unsupported statement exception. + * + * @param WP_Parser_Token $token First token. + * @return WP_DuckDB_Driver_Exception + */ + private function new_unsupported_statement_exception( WP_Parser_Token $token ): WP_DuckDB_Driver_Exception { + return new WP_DuckDB_Driver_Exception( + 'Unsupported DuckDB MySQL-emulation statement: ' . strtoupper( $token->get_bytes() ) . '.' + ); + } +} diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-prepared-statement.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-prepared-statement.php new file mode 100644 index 000000000..f26ded0a0 --- /dev/null +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-prepared-statement.php @@ -0,0 +1,45 @@ +connection = $connection; + $this->statement = $statement; + } + + /** + * Execute the prepared statement. + * + * @param array|null $params Parameters to bind before execution. + * @return WP_DuckDB_Result_Statement + * + * @throws WP_DuckDB_Driver_Exception When binding or execution fails. + */ + public function execute( $params = null ): WP_DuckDB_Result_Statement { + try { + foreach ( (array) $params as $key => $value ) { + $parameter = is_string( $key ) ? ltrim( $key, ':$' ) : $key + 1; + $this->statement->bindParam( $parameter, $value ); + } + return $this->connection->create_statement_from_result( $this->statement->execute() ); + } catch ( Throwable $e ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB prepared statement failed: ' . $e->getMessage(), 0, $e ); + } + } +} diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php new file mode 100644 index 000000000..c878a21d1 --- /dev/null +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php @@ -0,0 +1,242 @@ +> + */ + private $rows; + + /** + * @var int + */ + private $affected_rows; + + /** + * @var int + */ + private $cursor = 0; + + /** + * @var int + */ + private $default_fetch_mode = PDO::FETCH_BOTH; + + /** + * @param string[] $columns Column names. + * @param array> $rows Numeric rows. + * @param int $affected_rows Affected row count. + */ + public function __construct( array $columns, array $rows, int $affected_rows = 0 ) { + $this->columns = array_values( $columns ); + $this->rows = array_values( + array_map( + function ( array $row ): array { + return array_values( $row ); + }, + $rows + ) + ); + $this->affected_rows = $affected_rows; + } + + /** + * Set the default fetch mode. + * + * @param int $mode Fetch mode. + * @param mixed ...$args Additional fetch-mode arguments. + * @return bool + */ + public function setFetchMode( $mode, ...$args ): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $this->default_fetch_mode = (int) $mode; + return true; + } + + /** + * Get the number of columns. + * + * @return int + */ + public function columnCount(): int { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return count( $this->columns ); + } + + /** + * Get affected rows. + * + * @return int + */ + public function rowCount(): int { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->affected_rows; + } + + /** + * Fetch the next row. + * + * @param int|null $mode Fetch mode. + * @return mixed + */ + public function fetch( $mode = null ) { + if ( ! isset( $this->rows[ $this->cursor ] ) ) { + return false; + } + + $row = $this->rows[ $this->cursor ]; + ++$this->cursor; + + return $this->format_row( $row, $mode ?? $this->default_fetch_mode ); + } + + /** + * Fetch all remaining rows. + * + * @param int|null $mode Fetch mode. + * @param mixed ...$args Additional fetch-mode arguments. + * @return array + */ + public function fetchAll( $mode = null, ...$args ): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $rows = array(); + while ( false !== ( $row = $this->fetch( $mode ) ) ) { + $rows[] = $row; + } + return $rows; + } + + /** + * Fetch a column from the next row. + * + * @param int $column Column index. + * @return mixed + */ + public function fetchColumn( $column = 0 ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + if ( ! isset( $this->rows[ $this->cursor ] ) ) { + return false; + } + + $row = $this->rows[ $this->cursor ]; + ++$this->cursor; + + return $row[ (int) $column ] ?? false; + } + + /** + * Fetch the next row as an object. + * + * @param string $class Class name. + * @param array $constructor_args Constructor arguments. + * @return object|false + */ + public function fetchObject( $class = 'stdClass', $constructor_args = array() ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $row = $this->fetch( PDO::FETCH_ASSOC ); + if ( false === $row ) { + return false; + } + + $object = new $class( ...$constructor_args ); + foreach ( $row as $name => $value ) { + $object->$name = $value; + } + return $object; + } + + /** + * Get a column metadata subset. + * + * @param int $column Column index. + * @return array|false + */ + public function getColumnMeta( $column ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + if ( ! isset( $this->columns[ $column ] ) ) { + return false; + } + + return array( + 'name' => $this->columns[ $column ], + ); + } + + /** + * Return rows as an iterator from the current cursor. + * + * @return Traversable + */ + public function getIterator(): Traversable { + while ( false !== ( $row = $this->fetch() ) ) { + yield $row; + } + } + + /** + * Format a row for a fetch mode. + * + * @param array $row Numeric row. + * @param int $mode Fetch mode. + * @return mixed + */ + private function format_row( array $row, int $mode ) { + if ( defined( 'PDO::FETCH_DEFAULT' ) && PDO::FETCH_DEFAULT === $mode ) { + $mode = $this->default_fetch_mode; + } + + switch ( $mode ) { + case PDO::FETCH_ASSOC: + return $this->assoc_row( $row ); + case PDO::FETCH_NUM: + return $row; + case PDO::FETCH_OBJ: + return (object) $this->assoc_row( $row ); + case PDO::FETCH_COLUMN: + return $row[0] ?? false; + case PDO::FETCH_BOTH: + default: + return $this->both_row( $row ); + } + } + + /** + * Build an associative row. + * + * @param array $row Numeric row. + * @return array + */ + private function assoc_row( array $row ): array { + $assoc = array(); + foreach ( $this->columns as $index => $name ) { + $assoc[ $name ] = $row[ $index ] ?? null; + } + return $assoc; + } + + /** + * Build a PDO::FETCH_BOTH row. + * + * @param array $row Numeric row. + * @return array + */ + private function both_row( array $row ): array { + $both = $this->assoc_row( $row ); + foreach ( $row as $index => $value ) { + $both[ $index ] = $value; + } + return $both; + } +} diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-runtime.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-runtime.php new file mode 100644 index 000000000..277a04e1b --- /dev/null +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-runtime.php @@ -0,0 +1,60 @@ +query( 'SELECT 1 AS value' ); + foreach ( $result->rows( true ) as $row ) { + if ( isset( $row['value'] ) && 1 === (int) $row['value'] ) { + return null; + } + } + } catch ( Throwable $e ) { + return 'DuckDB runtime probe failed: ' . $e->getMessage(); + } + + return 'DuckDB runtime probe did not return the expected result.'; + } + + /** + * Assert that DuckDB is available. + * + * @param bool $probe_connection Whether to open an in-memory database and run SELECT 1. + * @throws WP_DuckDB_Driver_Exception When DuckDB is unavailable. + */ + public static function assert_available( bool $probe_connection = false ): void { + $reason = self::get_unavailable_reason( $probe_connection ); + if ( null !== $reason ) { + throw new WP_DuckDB_Driver_Exception( $reason ); + } + } +} diff --git a/packages/mysql-on-sqlite/src/load.php b/packages/mysql-on-sqlite/src/load.php index 62387a2e7..db21c9e25 100644 --- a/packages/mysql-on-sqlite/src/load.php +++ b/packages/mysql-on-sqlite/src/load.php @@ -43,3 +43,9 @@ require_once __DIR__ . '/sqlite/class-wp-sqlite-pdo-user-defined-functions.php'; require_once __DIR__ . '/sqlite/class-wp-pdo-mysql-on-sqlite.php'; require_once __DIR__ . '/sqlite/class-wp-pdo-proxy-statement.php'; +require_once __DIR__ . '/duckdb/class-wp-duckdb-driver-exception.php'; +require_once __DIR__ . '/duckdb/class-wp-duckdb-runtime.php'; +require_once __DIR__ . '/duckdb/class-wp-duckdb-result-statement.php'; +require_once __DIR__ . '/duckdb/class-wp-duckdb-prepared-statement.php'; +require_once __DIR__ . '/duckdb/class-wp-duckdb-connection.php'; +require_once __DIR__ . '/duckdb/class-wp-duckdb-driver.php'; diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php new file mode 100644 index 000000000..86fdae022 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -0,0 +1,81 @@ +requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $stmt = $connection->query( "SELECT 1 AS one, 'abc' AS label" ); + + $this->assertSame( 2, $stmt->columnCount() ); + $this->assertSame( + array( + 'one' => 1, + 'label' => 'abc', + ), + $stmt->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertFalse( $stmt->fetch() ); + } + + public function test_insert_result_reports_affected_rows(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->query( 'CREATE TABLE t (id INTEGER, label VARCHAR)' ); + $stmt = $connection->query( "INSERT INTO t VALUES (1, 'one'), (2, 'two')" ); + + $this->assertSame( 0, $stmt->columnCount() ); + $this->assertSame( 2, $stmt->rowCount() ); + $this->assertFalse( $stmt->fetch() ); + } + + public function test_prepared_statement_binds_positional_parameters(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->query( 'CREATE TABLE t (id INTEGER, label VARCHAR)' ); + $connection->prepare( 'INSERT INTO t VALUES (?, ?)' )->execute( array( 1, 'first' ) ); + + $stmt = $connection->prepare( 'SELECT label FROM t WHERE id = ?' )->execute( array( 1 ) ); + $this->assertSame( 'first', $stmt->fetchColumn() ); + } + + public function test_file_connection_persists_data(): void { + $this->requireDuckDBRuntime(); + + $path = tempnam( sys_get_temp_dir(), 'wp_duckdb_' ); + unlink( $path ); + + try { + $connection = new WP_DuckDB_Connection( array( 'path' => $path ) ); + $connection->query( 'CREATE TABLE t (id INTEGER)' ); + $connection->query( 'INSERT INTO t VALUES (7)' ); + unset( $connection ); + + $connection = new WP_DuckDB_Connection( array( 'path' => $path ) ); + $this->assertSame( 7, $connection->query( 'SELECT id FROM t' )->fetchColumn() ); + } finally { + $files = glob( $path . '*' ); + if ( false === $files ) { + $files = array(); + } + foreach ( $files as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); + } + } + } + } + + public function test_quote_identifier_uses_duckdb_double_quotes(): void { + $duckdb = new WP_DuckDB_Connection( array( 'duckdb' => new stdClass() ) ); + + $this->assertSame( '"table""name"', $duckdb->quote_identifier( 'table"name' ) ); + } +} diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php new file mode 100644 index 000000000..dcc13a38c --- /dev/null +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -0,0 +1,290 @@ +requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp_tests', + ) + ); + + $row = $driver->query( 'SELECT DATABASE() AS db_name, VERSION() AS mysql_version, RAND() AS r, UNIX_TIMESTAMP() AS unix_time' )->fetch( PDO::FETCH_ASSOC ); + + $this->assertSame( 'wp_tests', $row['db_name'] ); + $this->assertSame( '8.0.38-DuckDB', $row['mysql_version'] ); + $this->assertIsFloat( $row['r'] ); + $this->assertGreaterThanOrEqual( 0, $row['r'] ); + $this->assertLessThan( 1, $row['r'] ); + $this->assertIsInt( $row['unix_time'] ); + $this->assertGreaterThan( time() - 60, $row['unix_time'] ); + $this->assertLessThan( time() + 60, $row['unix_time'] ); + } + + public function test_date_format_function_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $row = $driver->query( "SELECT DATE_FORMAT(DATE '2026-06-26', '%Y-%m-%d') AS formatted_date" )->fetch( PDO::FETCH_ASSOC ); + + $this->assertSame( '2026-06-26', $row['formatted_date'] ); + } + + public function test_create_table_insert_update_delete_show_and_describe(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + $create = $driver->query( + "CREATE TABLE `users` ( + `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL DEFAULT 'anonymous', + `visits` INT DEFAULT 0 + ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci" + ); + + $this->assertSame( 0, $create->rowCount() ); + $this->assertStringStartsWith( 'CREATE TABLE "users"', $this->lastDuckDBQuery( $driver ) ); + + $insert = $driver->query( "INSERT INTO `users` (`name`) VALUES ('Ada'), ('Grace')" ); + $this->assertSame( 2, $insert->rowCount() ); + $this->assertSame( 'INSERT INTO "users"("name") VALUES (\'Ada\'), (\'Grace\')', $this->lastDuckDBQuery( $driver ) ); + + $rows = $driver->query( 'SELECT id, name, visits FROM `users` ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'Ada', + 'visits' => 0, + ), + array( + 'id' => 2, + 'name' => 'Grace', + 'visits' => 0, + ), + ), + $rows + ); + + $update = $driver->query( "UPDATE `users` SET `visits` = 3 WHERE `name` = 'Ada'" ); + $this->assertSame( 1, $update->rowCount() ); + + $delete = $driver->query( "DELETE FROM `users` WHERE `name` = 'Grace'" ); + $this->assertSame( 1, $delete->rowCount() ); + + $remaining = $driver->query( 'SELECT name, visits FROM `users` ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'name' => 'Ada', + 'visits' => 3, + ), + ), + $remaining + ); + + $this->assertSame( + array( array( 'Tables_in_wp' => 'users' ) ), + $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $describe = $driver->query( 'DESCRIBE `users`' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + 'Field' => 'id', + 'Type' => 'BIGINT', + 'Null' => 'NO', + 'Key' => 'PRI', + 'Default' => null, + 'Extra' => 'auto_increment', + ), + $describe[0] + ); + $this->assertSame( 'name', $describe[1]['Field'] ); + $this->assertSame( 'NO', $describe[1]['Null'] ); + $this->assertSame( 'anonymous', $describe[1]['Default'] ); + } + + public function test_regexp_predicates_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE options (option_name VARCHAR(100))' ); + $driver->query( "INSERT INTO options VALUES ('rss_123'), ('transient')" ); + + $regexp_rows = $driver->query( "SELECT option_name FROM options WHERE option_name REGEXP '^rss_.+$'" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( array( 'option_name' => 'rss_123' ) ), $regexp_rows ); + + $not_regexp_rows = $driver->query( "SELECT option_name FROM options WHERE option_name NOT REGEXP '^rss_.+$'" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( array( 'option_name' => 'transient' ) ), $not_regexp_rows ); + } + + public function test_table_level_primary_key_is_supported(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE memberships ( + user_id BIGINT NOT NULL, + site_id BIGINT NOT NULL, + role VARCHAR(20) DEFAULT 'subscriber', + PRIMARY KEY (user_id, site_id) + )" + ); + + $driver->query( 'INSERT INTO memberships (user_id, site_id) VALUES (1, 2)' ); + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $driver->query( 'INSERT INTO memberships (user_id, site_id) VALUES (1, 2)' ); + } + + public function test_wordpress_style_schema_can_be_created(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + foreach ( $this->wordpressStyleSchemaQueries() as $query ) { + $driver->query( $query ); + } + + $this->assertSame( + array( + array( 'Tables_in_wp' => 'wp_options' ), + array( 'Tables_in_wp' => 'wp_postmeta' ), + array( 'Tables_in_wp' => 'wp_posts' ), + array( 'Tables_in_wp' => 'wp_usermeta' ), + array( 'Tables_in_wp' => 'wp_users' ), + ), + $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $options_indexes = $driver->query( 'SHOW INDEX FROM wp_options' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'PRIMARY', 'autoload', 'option_name' ), array_column( $options_indexes, 'Key_name' ) ); + $this->assertSame( array( 0, 1, 0 ), array_map( 'intval', array_column( $options_indexes, 'Non_unique' ) ) ); + + $usermeta_indexes = $driver->query( 'SHOW INDEX FROM wp_usermeta' )->fetchAll( PDO::FETCH_ASSOC ); + $meta_key_rows = array_filter( + $usermeta_indexes, + function ( array $row ): bool { + return 'meta_key' === $row['Key_name']; + } + ); + $meta_key_row = array_values( $meta_key_rows )[0]; + $this->assertSame( 191, $meta_key_row['Sub_part'] ); + + $driver->query( "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('siteurl', 'https://example.test', 'yes')" ); + + try { + $driver->query( "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('siteurl', 'duplicate', 'yes')" ); + $this->fail( 'Expected the wp_options option_name unique key to reject duplicates.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + } + + public function test_unsupported_statement_throws_driver_exception(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $this->expectExceptionMessage( 'Unsupported DuckDB MySQL-emulation statement: ALTER.' ); + $driver->query( 'ALTER TABLE users ADD COLUMN email VARCHAR(255)' ); + } + + private function lastDuckDBQuery( WP_DuckDB_Driver $driver ): string { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $queries = $driver->get_last_duckdb_queries(); + return $queries[ count( $queries ) - 1 ]; + } + + /** + * WordPress-style schema statements that exercise core DDL shapes. + * + * @return string[] + */ + private function wordpressStyleSchemaQueries(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return array( + "CREATE TABLE wp_users ( + ID bigint(20) unsigned NOT NULL auto_increment, + user_login varchar(60) NOT NULL default '', + user_pass varchar(255) NOT NULL default '', + user_nicename varchar(50) NOT NULL default '', + user_email varchar(100) NOT NULL default '', + user_url varchar(100) NOT NULL default '', + user_registered datetime NOT NULL default '0000-00-00 00:00:00', + user_activation_key varchar(255) NOT NULL default '', + user_status int(11) NOT NULL default '0', + display_name varchar(250) NOT NULL default '', + PRIMARY KEY (ID), + KEY user_login_key (user_login), + KEY user_nicename (user_nicename), + KEY user_email (user_email) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "CREATE TABLE wp_usermeta ( + umeta_id bigint(20) unsigned NOT NULL auto_increment, + user_id bigint(20) unsigned NOT NULL default '0', + meta_key varchar(255) default NULL, + meta_value longtext, + PRIMARY KEY (umeta_id), + KEY user_id (user_id), + KEY meta_key (meta_key(191)) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "CREATE TABLE wp_posts ( + ID bigint(20) unsigned NOT NULL auto_increment, + post_author bigint(20) unsigned NOT NULL default '0', + post_date datetime NOT NULL default '0000-00-00 00:00:00', + post_date_gmt datetime NOT NULL default '0000-00-00 00:00:00', + post_content longtext NOT NULL, + post_title text NOT NULL, + post_excerpt text NOT NULL, + post_status varchar(20) NOT NULL default 'publish', + comment_status varchar(20) NOT NULL default 'open', + ping_status varchar(20) NOT NULL default 'open', + post_password varchar(255) NOT NULL default '', + post_name varchar(200) NOT NULL default '', + to_ping text NOT NULL, + pinged text NOT NULL, + post_modified datetime NOT NULL default '0000-00-00 00:00:00', + post_modified_gmt datetime NOT NULL default '0000-00-00 00:00:00', + post_content_filtered longtext NOT NULL, + post_parent bigint(20) unsigned NOT NULL default '0', + guid varchar(255) NOT NULL default '', + menu_order int(11) NOT NULL default '0', + post_type varchar(20) NOT NULL default 'post', + post_mime_type varchar(100) NOT NULL default '', + comment_count bigint(20) NOT NULL default '0', + PRIMARY KEY (ID), + KEY post_name (post_name(191)), + KEY type_status_date (post_type,post_status,post_date,ID), + KEY post_parent (post_parent), + KEY post_author (post_author), + KEY type_status_author (post_type,post_status,post_author) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "CREATE TABLE wp_postmeta ( + meta_id bigint(20) unsigned NOT NULL auto_increment, + post_id bigint(20) unsigned NOT NULL default '0', + meta_key varchar(255) default NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY post_id (post_id), + KEY meta_key (meta_key(191)) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "CREATE TABLE wp_options ( + option_id bigint(20) unsigned NOT NULL auto_increment, + option_name varchar(191) NOT NULL default '', + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL default 'yes', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + ); + } +} diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php new file mode 100644 index 000000000..32ab54aef --- /dev/null +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -0,0 +1,517 @@ +temp_dirs ) as $temp_dir ) { + $this->remove_temp_dir( $temp_dir ); + } + $this->temp_dirs = array(); + + parent::tearDown(); + } + + public function test_dispatcher_leaves_mysql_engine_to_wordpress(): void { + $result = $this->run_dispatcher_script( "'mysql'" ); + + $this->assertFalse( $result['died'] ); + $this->assertSame( 'mysql', $result['db_engine'] ); + $this->assertNull( $result['wpdb_class'] ); + $this->assertFalse( $result['sqlite_loaded'] ); + $this->assertFalse( $result['duckdb_loaded'] ); + } + + public function test_dispatcher_loads_sqlite_adapter_for_sqlite_engine(): void { + $result = $this->run_dispatcher_script( "'sqlite'" ); + + $this->assertFalse( $result['died'] ); + $this->assertSame( 'sqlite', $result['db_engine'] ); + $this->assertSame( 'WP_SQLite_DB', $result['wpdb_class'] ); + $this->assertTrue( $result['sqlite_loaded'] ); + $this->assertFalse( $result['duckdb_loaded'] ); + } + + public function test_dispatcher_reports_missing_duckdb_runtime(): void { + if ( null === WP_DuckDB_Runtime::get_unavailable_reason() ) { + $this->markTestSkipped( 'DuckDB runtime is available in this environment.' ); + } + + $result = $this->run_dispatcher_script( "'duckdb'" ); + + $this->assertTrue( $result['died'] ); + $this->assertSame( 'duckdb', $result['db_engine'] ); + $this->assertSame( 'duckdb_runtime_unavailable', $result['wp_die_code'] ); + $this->assertSame( 'DuckDB runtime is unavailable.', $result['wp_die_title'] ); + $this->assertFalse( $result['sqlite_loaded'] ); + } + + public function test_db_copy_defaults_to_sqlite(): void { + $result = $this->run_dropin_copy_script( 'db.copy' ); + + $this->assertFalse( $result['died'] ); + $this->assertSame( 'sqlite', $result['db_engine'] ); + $this->assertSame( 'sqlite', $result['database_type'] ); + $this->assertSame( '1.8.0', $result['sqlite_dropin_version'] ); + $this->assertNull( $result['duckdb_dropin_version'] ); + $this->assertSame( 'WP_SQLite_DB', $result['wpdb_class'] ); + } + + public function test_db_copy_respects_explicit_duckdb_override(): void { + $result = $this->run_dropin_copy_script( 'db.copy', "define( 'DB_ENGINE', 'duckdb' );" ); + + $this->assertSame( 'duckdb', $result['db_engine'] ); + $this->assertSame( 'duckdb', $result['database_type'] ); + $this->assertSame( '1.8.0', $result['sqlite_dropin_version'] ); + + if ( null !== WP_DuckDB_Runtime::get_unavailable_reason() ) { + $this->assertTrue( $result['died'] ); + $this->assertSame( 'duckdb_runtime_unavailable', $result['wp_die_code'] ); + return; + } + + $this->assertFalse( $result['died'] ); + $this->assertSame( 'WP_DuckDB_DB', $result['wpdb_class'] ); + } + + public function test_duckdb_copy_defaults_to_duckdb(): void { + $result = $this->run_dropin_copy_script( 'db-duckdb.copy' ); + + $this->assertSame( 'duckdb', $result['db_engine'] ); + $this->assertSame( 'duckdb', $result['database_type'] ); + $this->assertSame( '0.1.0', $result['duckdb_dropin_version'] ); + + if ( null !== WP_DuckDB_Runtime::get_unavailable_reason() ) { + $this->assertTrue( $result['died'] ); + $this->assertSame( 'duckdb_runtime_unavailable', $result['wp_die_code'] ); + return; + } + + $this->assertFalse( $result['died'] ); + $this->assertSame( 'WP_DuckDB_DB', $result['wpdb_class'] ); + } + + public function test_duckdb_wpdb_query_updates_raw_query_state(): void { + $result = $this->run_raw_query_state_script(); + + $this->assertTrue( $result['connected'] ); + $this->assertSame( 1, $result['select_return'] ); + $this->assertSame( 'SELECT 42 AS answer', $result['select_last_query'] ); + $this->assertSame( 1, $result['select_num_rows'] ); + $this->assertSame( 42, $result['select_answer'] ); + $this->assertSame( 2, $result['insert_return'] ); + $this->assertSame( 2, $result['insert_rows_affected'] ); + $this->assertTrue( $result['create_return'] ); + $this->assertSame( 3, $result['num_queries'] ); + $this->assertSame( + array( + 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(d, f)', + 'SELECT 42 AS answer', + 'INSERT INTO t VALUES (1), (2)', + 'CREATE TABLE "t" ("id" INTEGER)', + ), + $result['client_queries'] + ); + } + + private function run_dispatcher_script( string $engine_expression ): array { + $plugin_dir = $this->get_plugin_dir(); + $code = $this->get_wordpress_stub_code(); + $code .= "\ndefine( 'DB_ENGINE', {$engine_expression} );\n"; + $code .= $this->get_dispatcher_include_code( $plugin_dir . '/wp-includes/db.php' ); + + return $this->run_isolated_php( $code ); + } + + private function run_dropin_copy_script( string $copy_file, string $prepend_code = '' ): array { + $plugin_dir = $this->get_plugin_dir(); + $dropin_source = file_get_contents( $plugin_dir . '/' . $copy_file ); + $dropin_source = str_replace( + array( + '{SQLITE_IMPLEMENTATION_FOLDER_PATH}', + '{SQLITE_PLUGIN}', + ), + array( + $plugin_dir, + 'sqlite-database-integration/load.php', + ), + $dropin_source + ); + $dropin_file = $this->create_temp_file( $dropin_source ); + $code = $this->get_wordpress_stub_code(); + $code .= "\n{$prepend_code}\n"; + $code .= $this->get_dispatcher_include_code( $dropin_file ); + + try { + return $this->run_isolated_php( $code ); + } finally { + if ( is_file( $dropin_file ) ) { + unlink( $dropin_file ); + } + } + } + + private function run_raw_query_state_script(): array { + $plugin_dir = $this->get_plugin_dir(); + $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; + $code = $this->get_wordpress_stub_code(); + $code .= "\nrequire_once " . var_export( $driver_load, true ) . ";\n"; + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= <<<'PHP' + +class WP_DuckDB_Plugin_Test_Result { + private $columns; + private $rows; + + public function __construct( array $columns, array $rows ) { + $this->columns = $columns; + $this->rows = $rows; + } + + public function columnNames() { + return new ArrayIterator( $this->columns ); + } + + public function rows( $assoc = false ) { + return new ArrayIterator( $this->rows ); + } +} + +class WP_DuckDB_Plugin_Test_Client { + public $queries = array(); + + public function query( $sql ) { + $this->queries[] = $sql; + $normalized = strtolower( trim( $sql ) ); + + if ( 0 === strpos( $normalized, 'select' ) ) { + return new WP_DuckDB_Plugin_Test_Result( + array( 'answer' ), + array( + array( 'answer' => 42 ), + ) + ); + } + + if ( 0 === strpos( $normalized, 'insert' ) ) { + return new WP_DuckDB_Plugin_Test_Result( + array( 'Count' ), + array( + array( 'Count' => 2 ), + ) + ); + } + + if ( 0 === strpos( $normalized, 'create' ) ) { + return new WP_DuckDB_Plugin_Test_Result( + array( 'Success' ), + array( + array( 'Success' => true ), + ) + ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } +} + +$client = new WP_DuckDB_Plugin_Test_Client(); +$GLOBALS['@duckdb'] = new WP_DuckDB_Connection( array( 'duckdb' => $client ) ); +$db = new WP_DuckDB_DB( 'wordpress_test' ); +$connected = $db->db_connect( false ); +$select_return = $db->query( 'SELECT 42 AS answer' ); +$select_state = array( + 'last_query' => $db->last_query, + 'num_rows' => $db->num_rows, + 'answer' => $db->last_result[0]->answer, +); +$insert_return = $db->query( 'INSERT INTO t VALUES (1), (2)' ); +$insert_state = array( + 'rows_affected' => $db->rows_affected, +); +$create_return = $db->query( 'CREATE TABLE t (id INTEGER)' ); + +echo json_encode( + array( + 'connected' => $connected, + 'select_return' => $select_return, + 'select_last_query' => $select_state['last_query'], + 'select_num_rows' => $select_state['num_rows'], + 'select_answer' => $select_state['answer'], + 'insert_return' => $insert_return, + 'insert_rows_affected' => $insert_state['rows_affected'], + 'create_return' => $create_return, + 'num_queries' => $db->num_queries, + 'client_queries' => $client->queries, + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + + private function get_dispatcher_include_code( string $include_file ): string { + return "\ntry {\n" + . "\trequire " . var_export( $include_file, true ) . ";\n" + . "\techo json_encode( wp_duckdb_plugin_test_state( false ) );\n" + . "} catch ( RuntimeException \$e ) {\n" + . "\techo json_encode( wp_duckdb_plugin_test_state( true ) );\n" + . "}\n"; + } + + private function get_wordpress_stub_code(): string { + $temp_dir = $this->create_temp_dir(); + + return "code = $code; + $this->message = $message; + } + + public function get_error_code() { + return $this->code; + } + + public function get_error_message() { + return $this->message; + } +} + +class wpdb { + public $charset; + public $col_info = null; + public $dbname = ''; + public $func_call = ''; + public $insert_id = 0; + public $last_error = ''; + public $last_query = null; + public $last_result = array(); + public $num_queries = 0; + public $num_rows = 0; + public $options = null; + public $prefix = ''; + public $ready = false; + public $result = null; + public $rows_affected = 0; + public $show_errors = false; + public $suppress_errors = false; + public $time_start = 0; + + public function __construct( $dbuser = '', $dbpassword = '', $dbname = '', $dbhost = '' ) { + $this->dbname = $dbname; + } + + public function add_placeholder_escape( $query ) { + return $query; + } + + public function bail( $message, $error_code = '500' ) { + wp_die( $message, $error_code ); + } + + public function flush() { + $this->last_result = array(); + $this->col_info = null; + $this->last_query = null; + $this->rows_affected = 0; + $this->num_rows = 0; + $this->last_error = ''; + $this->result = null; + } + + public function get_caller() { + return ''; + } + + public function get_row( $query ) { + return null; + } + + public function hide_errors() { + $show_errors = $this->show_errors; + $this->show_errors = false; + return $show_errors; + } + + public function log_query( $query, $query_time, $query_callstack, $query_start, $query_data ) { + } + + public function prepare( $query, ...$args ) { + return $query; + } + + public function print_error( $str = '' ) { + $this->last_error = $str ? $str : $this->last_error; + } + + public function set_prefix( $prefix ) { + $this->prefix = $prefix; + $this->options = $prefix . 'options'; + } + + public function show_errors( $show = true ) { + $old_show_errors = $this->show_errors; + $this->show_errors = $show; + return $old_show_errors; + } + + public function timer_start() { + $this->time_start = microtime( true ); + return true; + } + + public function timer_stop() { + return microtime( true ) - $this->time_start; + } +} + +function __( $text ) { + return $text; +} + +function add_action( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) { + $GLOBALS['wp_duckdb_plugin_test_actions'][] = array( $hook_name, $priority, $accepted_args ); + return true; +} + +function apply_filters( $hook_name, $value ) { + return $value; +} + +function is_admin() { + return false; +} + +function is_multisite() { + return false; +} + +function trailingslashit( $path ) { + return rtrim( $path, '/\\' ) . '/'; +} + +function wp_die( $message = '', $title = '', $args = array() ) { + if ( $message instanceof WP_Error ) { + $GLOBALS['wp_duckdb_plugin_test_die'] = array( + 'code' => $message->get_error_code(), + 'message' => $message->get_error_message(), + 'title' => $title, + ); + } else { + $GLOBALS['wp_duckdb_plugin_test_die'] = array( + 'code' => null, + 'message' => (string) $message, + 'title' => $title, + ); + } + + throw new RuntimeException( 'wp_die' ); +} + +function wp_duckdb_plugin_test_state( $died ) { + return array( + 'died' => $died, + 'db_engine' => defined( 'DB_ENGINE' ) ? DB_ENGINE : null, + 'database_type' => defined( 'DATABASE_TYPE' ) ? DATABASE_TYPE : null, + 'sqlite_dropin_version' => defined( 'SQLITE_DB_DROPIN_VERSION' ) ? SQLITE_DB_DROPIN_VERSION : null, + 'duckdb_dropin_version' => defined( 'DUCKDB_DB_DROPIN_VERSION' ) ? DUCKDB_DB_DROPIN_VERSION : null, + 'wpdb_class' => isset( $GLOBALS['wpdb'] ) && is_object( $GLOBALS['wpdb'] ) ? get_class( $GLOBALS['wpdb'] ) : null, + 'sqlite_loaded' => class_exists( 'WP_SQLite_DB', false ), + 'duckdb_loaded' => class_exists( 'WP_DuckDB_DB', false ), + 'wp_die_code' => isset( $GLOBALS['wp_duckdb_plugin_test_die']['code'] ) ? $GLOBALS['wp_duckdb_plugin_test_die']['code'] : null, + 'wp_die_message' => isset( $GLOBALS['wp_duckdb_plugin_test_die']['message'] ) ? $GLOBALS['wp_duckdb_plugin_test_die']['message'] : null, + 'wp_die_title' => isset( $GLOBALS['wp_duckdb_plugin_test_die']['title'] ) ? $GLOBALS['wp_duckdb_plugin_test_die']['title'] : null, + ); +} +PHP; + } + + private function run_isolated_php( string $code ): array { + $script_file = $this->create_temp_file( $code ); + $output = array(); + $exit_code = 0; + $command = escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( $script_file ) . ' 2>&1'; + + try { + exec( $command, $output, $exit_code ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + } finally { + if ( is_file( $script_file ) ) { + unlink( $script_file ); + } + } + + $output = implode( "\n", $output ); + $this->assertSame( 0, $exit_code, $output ); + + $result = json_decode( $output, true ); + $this->assertIsArray( $result, $output ); + + return $result; + } + + private function create_temp_file( string $contents ): string { + $file = tempnam( sys_get_temp_dir(), 'wp_duckdb_plugin_' ); + $this->assertIsString( $file ); + $this->assertNotFalse( file_put_contents( $file, $contents ) ); + + return $file; + } + + private function create_temp_dir(): string { + $dir = sys_get_temp_dir() . '/wp_duckdb_plugin_' . str_replace( '.', '_', uniqid( '', true ) ); + $this->assertTrue( mkdir( $dir, 0700, true ) ); + $this->temp_dirs[] = $dir; + + return $dir; + } + + private function remove_temp_dir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + $items = scandir( $dir ); + if ( false === $items ) { + return; + } + + foreach ( $items as $item ) { + if ( '.' === $item || '..' === $item ) { + continue; + } + + $path = $dir . DIRECTORY_SEPARATOR . $item; + if ( is_dir( $path ) && ! is_link( $path ) ) { + $this->remove_temp_dir( $path ); + } elseif ( file_exists( $path ) || is_link( $path ) ) { + unlink( $path ); + } + } + + rmdir( $dir ); + } + + private function get_plugin_dir(): string { + return dirname( __DIR__, 3 ) . '/plugin-sqlite-database-integration'; + } +} diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Runtime_Gate_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Runtime_Gate_Tests.php new file mode 100644 index 000000000..0db9f52fd --- /dev/null +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Runtime_Gate_Tests.php @@ -0,0 +1,36 @@ +assertTrue( class_exists( WP_SQLite_Connection::class ) ); + $this->assertTrue( class_exists( WP_DuckDB_Runtime::class ) ); + } + + public function test_runtime_gate_reports_unavailable_client(): void { + if ( PHP_VERSION_ID < 80300 || ! extension_loaded( 'ffi' ) ) { + $this->assertIsString( WP_DuckDB_Runtime::get_unavailable_reason() ); + return; + } + + if ( class_exists( WP_DuckDB_Runtime::CLIENT_CLASS ) ) { + $this->markTestSkipped( 'DuckDB PHP client is loaded in this environment.' ); + } + + $this->assertSame( + 'DuckDB support requires the satur.io/duckdb PHP client to be installed and autoloaded.', + WP_DuckDB_Runtime::get_unavailable_reason() + ); + } + + public function test_connection_constructor_throws_clear_exception_when_runtime_missing(): void { + if ( null === WP_DuckDB_Runtime::get_unavailable_reason() ) { + $this->markTestSkipped( 'DuckDB runtime is available in this environment.' ); + } + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + } +} diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_TestCase.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_TestCase.php new file mode 100644 index 000000000..6203e063e --- /dev/null +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_TestCase.php @@ -0,0 +1,34 @@ +markTestSkipped( 'DuckDB tests require WP_DUCKDB_TESTS=1.' ); + } + + $reason = WP_DuckDB_Runtime::get_unavailable_reason(); + if ( null !== $reason ) { + $this->markTestSkipped( $reason ); + } + } +} diff --git a/packages/plugin-sqlite-database-integration/constants.php b/packages/plugin-sqlite-database-integration/constants.php index 15e6772a1..d6f27fba4 100644 --- a/packages/plugin-sqlite-database-integration/constants.php +++ b/packages/plugin-sqlite-database-integration/constants.php @@ -6,13 +6,33 @@ * @package wp-sqlite-integration */ +if ( ! function_exists( 'wp_sqlite_database_integration_normalize_db_engine' ) ) { + /** + * Normalize supported database engine names. + * + * @param string $engine Database engine name. + * @return string Canonical database engine name. + */ + function wp_sqlite_database_integration_normalize_db_engine( $engine ) { + $engine = strtolower( (string) $engine ); + + if ( in_array( $engine, array( 'duck', 'duckdb' ), true ) ) { + return 'duckdb'; + } + + return $engine; + } +} + // Temporary - This will be in wp-config.php once SQLite is merged in Core. if ( ! defined( 'DB_ENGINE' ) ) { if ( defined( 'SQLITE_DB_DROPIN_VERSION' ) ) { define( 'DB_ENGINE', 'sqlite' ); + } elseif ( defined( 'DUCKDB_DB_DROPIN_VERSION' ) ) { + define( 'DB_ENGINE', 'duckdb' ); } elseif ( defined( 'DATABASE_ENGINE' ) ) { // backwards compatibility with previous versions of the plugin. - define( 'DB_ENGINE', DATABASE_ENGINE ); + define( 'DB_ENGINE', wp_sqlite_database_integration_normalize_db_engine( DATABASE_ENGINE ) ); } else { define( 'DB_ENGINE', 'mysql' ); } @@ -40,6 +60,19 @@ } } +/** + * FQDUCKDB is a DuckDB database file name. If DUCKDB_FILE is defined, it is used + * as the basename; otherwise DuckDB uses a protected default file next to the + * SQLite database file. + */ +if ( ! defined( 'FQDUCKDB' ) ) { + if ( defined( 'DUCKDB_FILE' ) ) { + define( 'FQDUCKDB', FQDBDIR . DUCKDB_FILE ); + } else { + define( 'FQDUCKDB', FQDBDIR . '.ht.duckdb' ); + } +} + /** * FQDB is a database file name. If DB_FILE is defined, it is used * as FQDB. diff --git a/packages/plugin-sqlite-database-integration/db-duckdb.copy b/packages/plugin-sqlite-database-integration/db-duckdb.copy new file mode 100644 index 000000000..37e61a63c --- /dev/null +++ b/packages/plugin-sqlite-database-integration/db-duckdb.copy @@ -0,0 +1,31 @@ +charset = 'utf8mb4'; + } + + /** + * Noop charset setter. + * + * @param resource $dbh Database handle. + * @param string $charset Optional charset. + * @param string $collate Optional collation. + */ + public function set_charset( $dbh, $charset = null, $collate = null ) { + } + + /** + * Return the column charset. + * + * @param string $table Table name. + * @param string $column Column name. + * @return string + */ + public function get_col_charset( $table, $column ) { + return 'utf8mb4'; + } + + /** + * Close the database connection. + * + * @return bool + */ + public function close() { + return true; + } + + /** + * Select the database. + * + * @param string $db Database name. + * @param resource|null $dbh Optional database handle. + */ + public function select( $db, $dbh = null ) { + $this->ready = true; + } + + /** + * Escape data. + * + * @param string $data Data to escape. + * @return string + */ + public function _real_escape( $data ) { + if ( ! is_scalar( $data ) ) { + return ''; + } + return $this->add_placeholder_escape( addslashes( $data ) ); + } + + /** + * Connect to DuckDB. + * + * @param bool $allow_bail Whether to bail on error. + * @return bool|null + */ + public function db_connect( $allow_bail = true ) { + if ( isset( $GLOBALS['@duckdb_driver'] ) && $GLOBALS['@duckdb_driver'] instanceof WP_DuckDB_Driver ) { + $this->dbh = $GLOBALS['@duckdb_driver']; + } elseif ( isset( $GLOBALS['@duckdb'] ) && $GLOBALS['@duckdb'] instanceof WP_DuckDB_Connection ) { + $this->dbh = $GLOBALS['@duckdb']; + } + + if ( null === $this->dbname || '' === $this->dbname ) { + $this->bail( + 'The database name was not set. The DuckDB driver requires a database name to be set.', + 'db_connect_fail' + ); + return false; + } + + $this->ensure_database_directory( FQDUCKDB ); + + try { + if ( ! $this->dbh ) { + $connection = new WP_DuckDB_Connection( array( 'path' => FQDUCKDB ) ); + $this->dbh = new WP_DuckDB_Driver( + array( + 'connection' => $connection, + 'database' => $this->dbname, + ) + ); + } elseif ( $this->dbh instanceof WP_DuckDB_Connection ) { + $this->dbh = new WP_DuckDB_Driver( + array( + 'connection' => $this->dbh, + 'database' => $this->dbname, + ) + ); + } + $GLOBALS['@duckdb_driver'] = $this->dbh; + } catch ( Throwable $e ) { + $this->last_error = $e->getMessage(); + } + + if ( $this->last_error ) { + return false; + } + + $this->ready = true; + return true; + } + + /** + * Check the connection. + * + * @param bool $allow_bail Not used. + * @return bool + */ + public function check_connection( $allow_bail = true ) { + return true; + } + + /** + * Perform a query. + * + * @param string $query Database query. + * @return int|bool + */ + public function query( $query ) { + if ( ! $this->ready ) { + return false; + } + + $query = apply_filters( 'query', $query ); + if ( ! $query ) { + $this->insert_id = 0; + return false; + } + + $this->flush(); + $this->func_call = "\$db->query(\"$query\")"; + $this->last_query = $query; + + $this->_do_query( $query ); + + if ( $this->last_error ) { + $this->print_error(); + return false; + } + + if ( preg_match( '/^\s*(create|alter|truncate|drop)\s/i', $query ) ) { + return true; + } + + if ( preg_match( '/^\s*(insert|delete|update|replace)\s/i', $query ) ) { + $this->rows_affected = $this->last_statement ? $this->last_statement->rowCount() : 0; + return $this->rows_affected; + } + + $this->last_result = is_array( $this->result ) ? $this->result : array(); + $this->num_rows = count( $this->last_result ); + return $this->num_rows; + } + + /** + * Internal query runner. + * + * @param string $query Query to run. + */ + private function _do_query( $query ) { + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { + $this->timer_start(); + } + + try { + $this->last_statement = $this->dbh->query( $query ); + if ( $this->last_statement->columnCount() > 0 ) { + $this->result = $this->last_statement->fetchAll( PDO::FETCH_OBJ ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + } else { + $this->result = null; + } + } catch ( Throwable $e ) { + $this->last_error = $e->getMessage(); + } + + ++$this->num_queries; + + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { + $this->log_query( + $query, + $this->timer_stop(), + $this->get_caller(), + $this->time_start, + array() + ); + } + } + + /** + * Load column metadata. + */ + protected function load_col_info() { + if ( $this->col_info ) { + return; + } + + $this->col_info = array(); + if ( ! $this->last_statement ) { + return; + } + + for ( $i = 0; $i < $this->last_statement->columnCount(); ++$i ) { + $meta = $this->last_statement->getColumnMeta( $i ); + $this->col_info[] = (object) array( + 'name' => $meta['name'], + 'orgname' => $meta['name'], + 'table' => '', + 'orgtable' => '', + 'def' => '', + 'db' => $this->dbname, + 'catalog' => 'def', + 'max_length' => 0, + 'length' => 0, + 'charsetnr' => 224, + 'flags' => 0, + 'type' => 253, + 'decimals' => 0, + ); + } + } + + /** + * Report supported database capabilities. + * + * @param string $db_cap Capability name. + * @return bool + */ + public function has_cap( $db_cap ) { + return 'subqueries' === strtolower( $db_cap ); + } + + /** + * Return a MySQL-compatible version string. + * + * @return string + */ + public function db_version() { + return '8.0'; + } + + /** + * Return DuckDB server information. + * + * @return string + */ + public function db_server_info() { + try { + $stmt = $this->dbh->get_connection()->query( 'SELECT version() AS version' ); + return 'DuckDB ' . $stmt->fetchColumn(); + } catch ( Throwable $e ) { + return 'DuckDB'; + } + } + + /** + * Ensure database directory exists and is protected. + * + * @param string $database_path Database file path. + */ + private function ensure_database_directory( $database_path ) { + $dir = dirname( $database_path ); + $umask = umask( 0 ); + + if ( ! is_dir( $dir ) ) { + if ( ! @mkdir( $dir, 0700, true ) ) { + wp_die( sprintf( 'Failed to create database directory: %s', $dir ), 'Error!' ); + } + } + if ( ! is_writable( $dir ) ) { + wp_die( sprintf( 'Database directory is not writable: %s', $dir ), 'Error!' ); + } + + $path = $dir . DIRECTORY_SEPARATOR . '.htaccess'; + if ( ! is_file( $path ) ) { + $result = file_put_contents( $path, 'DENY FROM ALL', LOCK_EX ); + if ( false === $result ) { + wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' ); + } + chmod( $path, 0600 ); + } + + $path = $dir . DIRECTORY_SEPARATOR . 'index.php'; + if ( ! is_file( $path ) ) { + $result = file_put_contents( $path, '', LOCK_EX ); + if ( false === $result ) { + wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' ); + } + chmod( $path, 0600 ); + } + + umask( $umask ); + } +} diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/db.php new file mode 100644 index 000000000..f7f29dfc7 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/db.php @@ -0,0 +1,39 @@ +%1$s

%2$s

', + 'DuckDB runtime is unavailable', + $duckdb_unavailable_reason + ) + ), + 'DuckDB runtime is unavailable.' + ); +} + +require_once __DIR__ . '/class-wp-duckdb-db.php'; + +$db_name = defined( 'DB_NAME' ) ? DB_NAME : ''; + +$GLOBALS['wpdb'] = new WP_DuckDB_DB( $db_name ); From 50adc9d88c133a94b57329c2a4e5ce806e173b74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 16:18:40 +0000 Subject: [PATCH 002/317] Expand DuckDB parity for transactions and indexes --- .../src/duckdb/class-wp-duckdb-connection.php | 84 +++++++ .../src/duckdb/class-wp-duckdb-driver.php | 217 ++++++++++++++++-- .../duckdb/WP_DuckDB_Connection_Tests.php | 45 ++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 87 ++++++- 4 files changed, 415 insertions(+), 18 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php index a2287067b..fa20dd75e 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php @@ -14,6 +14,13 @@ class WP_DuckDB_Connection { */ private $query_logger; + /** + * Whether this connection is inside an explicit transaction. + * + * @var bool + */ + private $in_transaction = false; + /** * Constructor. * @@ -91,6 +98,83 @@ public function prepare( string $sql ): WP_DuckDB_Prepared_Statement { } } + /** + * Begin a transaction. + * + * @return bool + * + * @throws WP_DuckDB_Driver_Exception When a transaction is already active or BEGIN fails. + */ + public function beginTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + if ( $this->in_transaction ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB transaction is already active.' ); + } + + $this->query( 'BEGIN TRANSACTION' ); + $this->in_transaction = true; + return true; + } + + /** + * WordPress-style transaction alias. + * + * @return void + */ + public function begin_transaction(): void { + $this->beginTransaction(); + } + + /** + * Commit the active transaction. + * + * @return bool + * + * @throws WP_DuckDB_Driver_Exception When no transaction is active or COMMIT fails. + */ + public function commit(): bool { + if ( ! $this->in_transaction ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB transaction is not active.' ); + } + + try { + $this->query( 'COMMIT' ); + } finally { + $this->in_transaction = false; + } + + return true; + } + + /** + * Roll back the active transaction. + * + * @return bool + * + * @throws WP_DuckDB_Driver_Exception When no transaction is active or ROLLBACK fails. + */ + public function rollback(): bool { + if ( ! $this->in_transaction ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB transaction is not active.' ); + } + + try { + $this->query( 'ROLLBACK' ); + } finally { + $this->in_transaction = false; + } + + return true; + } + + /** + * Check whether a transaction is active. + * + * @return bool + */ + public function inTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->in_transaction; + } + /** * Create a statement wrapper from a DuckDB PHP ResultSet. * diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 8f9b498e1..ce5406148 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -145,13 +145,17 @@ public function query( string $query ): WP_DuckDB_Result_Statement { case WP_MySQL_Lexer::SELECT_SYMBOL: return $this->execute_select( $tokens ); case WP_MySQL_Lexer::CREATE_SYMBOL: - return $this->execute_create_table( $tokens ); + return $this->execute_create( $tokens ); case WP_MySQL_Lexer::INSERT_SYMBOL: return $this->execute_insert( $tokens ); + case WP_MySQL_Lexer::REPLACE_SYMBOL: + return $this->execute_replace( $tokens ); case WP_MySQL_Lexer::UPDATE_SYMBOL: return $this->execute_update( $tokens ); case WP_MySQL_Lexer::DELETE_SYMBOL: return $this->execute_delete( $tokens ); + case WP_MySQL_Lexer::ALTER_SYMBOL: + return $this->execute_alter_table( $tokens ); case WP_MySQL_Lexer::SHOW_SYMBOL: return $this->execute_show( $tokens ); case WP_MySQL_Lexer::DESCRIBE_SYMBOL: @@ -288,6 +292,24 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { ); } + /** + * Execute a supported CREATE statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_create( array $tokens ): WP_DuckDB_Result_Statement { + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[1]->id ) { + return $this->execute_create_table( $tokens ); + } + + if ( $this->is_create_index_statement( $tokens ) ) { + return $this->execute_create_index( $tokens ); + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE statement in DuckDB driver. Only CREATE TABLE and CREATE INDEX are supported.' ); + } + /** * Execute a supported CREATE TABLE statement. * @@ -379,6 +401,54 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme return $result; } + /** + * Execute a supported CREATE INDEX statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_create_index( array $tokens ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CREATE_SYMBOL, 'Expected CREATE.' ); + ++$index; + + $unique = false; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::UNIQUE_SYMBOL === $tokens[ $index ]->id ) { + $unique = true; + ++$index; + } + + if ( + isset( $tokens[ $index ] ) + && ( WP_MySQL_Lexer::FULLTEXT_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::SPATIAL_SYMBOL === $tokens[ $index ]->id ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE INDEX type in DuckDB driver: ' . $tokens[ $index ]->get_bytes() . '.' ); + } + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::INDEX_SYMBOL, 'Expected INDEX in CREATE INDEX statement.' ); + ++$index; + + $mysql_index_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + $index = $this->skip_optional_index_type( $tokens, $index ); + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ON_SYMBOL, 'Expected ON in CREATE INDEX statement.' ); + ++$index; + + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + $index = $this->skip_optional_index_type( $tokens, $index ); + list( $columns, $column_metadata, $index ) = $this->translate_index_column_list( $tokens, $index ); + $this->assert_supported_index_options( $tokens, $index ); + + $index_definition = $this->build_secondary_index_definition( $table_name, $mysql_index_name, $unique, $columns, $column_metadata ); + $result = $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); + $this->record_index_metadata( $index_definition ); + + return $result; + } + /** * Execute a supported INSERT statement. * @@ -390,26 +460,30 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $index ]->id ) { ++$index; } - $this->identifier_value( $tokens[ $index ] ?? null ); + $this->assert_values_write_statement( $tokens, $index, 'INSERT' ); - $has_values = false; - foreach ( $tokens as $token ) { - if ( WP_MySQL_Lexer::VALUES_SYMBOL === $token->id ) { - $has_values = true; - break; - } - if ( WP_MySQL_Lexer::SELECT_SYMBOL === $token->id || WP_MySQL_Lexer::SET_SYMBOL === $token->id ) { - break; - } - } + return $this->execute_duckdb_query( + $this->translate_tokens_to_duckdb_sql( $tokens ), + 'Failed to execute DuckDB INSERT' + ); + } - if ( ! $has_values ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT statement in DuckDB driver. Only INSERT ... VALUES is supported.' ); + /** + * Execute a supported REPLACE statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_replace( array $tokens ): WP_DuckDB_Result_Statement { + $index = 1; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $index ]->id ) { + ++$index; } + $this->assert_values_write_statement( $tokens, $index, 'REPLACE' ); return $this->execute_duckdb_query( - $this->translate_tokens_to_duckdb_sql( $tokens ), - 'Failed to execute DuckDB INSERT' + $this->translate_replace_tokens_to_duckdb_sql( $tokens ), + 'Failed to execute DuckDB REPLACE' ); } @@ -452,6 +526,65 @@ private function execute_delete( array $tokens ): WP_DuckDB_Result_Statement { ); } + /** + * Assert that INSERT/REPLACE use the supported VALUES form. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index expected to contain the table identifier. + * @param string $statement Statement name for errors. + */ + private function assert_values_write_statement( array $tokens, int $table_index, string $statement ): void { + $this->identifier_value( $tokens[ $table_index ] ?? null ); + + $has_values = false; + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::VALUES_SYMBOL === $token->id ) { + $has_values = true; + break; + } + if ( WP_MySQL_Lexer::SELECT_SYMBOL === $token->id || WP_MySQL_Lexer::SET_SYMBOL === $token->id ) { + break; + } + } + + if ( ! $has_values ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only ' . $statement . ' ... VALUES is supported.' ); + } + } + + /** + * Execute a supported ALTER TABLE statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ALTER_SYMBOL, 'Expected ALTER.' ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::TABLE_SYMBOL, 'Only ALTER TABLE is supported by the DuckDB driver.' ); + ++$index; + + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::ADD_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $alter_item = array_slice( $tokens, $index ); + if ( ! $this->is_create_table_index_item( $alter_item ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD INDEX is supported.' ); + } + + $index_definition = $this->translate_create_table_index( $table_name, $alter_item ); + $result = $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); + $this->record_index_metadata( $index_definition ); + + return $result; + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD INDEX is supported.' ); + } + /** * Execute supported SHOW statements. * @@ -898,6 +1031,29 @@ private function is_create_table_index_item( array $tokens ): bool { ); } + /** + * Check whether a CREATE statement starts a CREATE INDEX variant. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return bool + */ + private function is_create_index_statement( array $tokens ): bool { + if ( ! isset( $tokens[1] ) ) { + return false; + } + + $index = 1; + if ( + WP_MySQL_Lexer::UNIQUE_SYMBOL === $tokens[ $index ]->id + || WP_MySQL_Lexer::FULLTEXT_SYMBOL === $tokens[ $index ]->id + || WP_MySQL_Lexer::SPATIAL_SYMBOL === $tokens[ $index ]->id + ) { + ++$index; + } + + return isset( $tokens[ $index ] ) && WP_MySQL_Lexer::INDEX_SYMBOL === $tokens[ $index ]->id; + } + /** * Translate a table-level MySQL index definition into CREATE INDEX. * @@ -945,6 +1101,20 @@ private function translate_create_table_index( string $table_name, array $tokens $mysql_index_name = 'unnamed_' . substr( hash( 'sha256', serialize( $column_metadata ) ), 0, 8 ); } + return $this->build_secondary_index_definition( $table_name, $mysql_index_name, $unique, $columns, $column_metadata ); + } + + /** + * Build a DuckDB secondary index definition and SHOW INDEX metadata. + * + * @param string $table_name Table name. + * @param string $mysql_index_name MySQL index name. + * @param bool $unique Whether the index is unique. + * @param string[] $columns DuckDB column SQL fragments. + * @param array $column_metadata MySQL column metadata. + * @return array{sql:string,table_name:string,index_name:string,unique:bool,columns:array} + */ + private function build_secondary_index_definition( string $table_name, string $mysql_index_name, bool $unique, array $columns, array $column_metadata ): array { return array( 'sql' => 'CREATE ' . ( $unique ? 'UNIQUE ' : '' ) @@ -1234,6 +1404,21 @@ private function translate_tokens_to_duckdb_sql( array $tokens ): string { return $this->join_sql_pieces( $pieces ); } + /** + * Translate MySQL REPLACE ... VALUES to DuckDB INSERT OR REPLACE. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return string DuckDB SQL. + */ + private function translate_replace_tokens_to_duckdb_sql( array $tokens ): string { + $index = 1; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + + return 'INSERT OR REPLACE INTO ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $index ) ); + } + /** * Initialize DuckDB macros that emulate simple MySQL functions. */ diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index 86fdae022..631062e03 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -46,6 +46,51 @@ public function test_prepared_statement_binds_positional_parameters(): void { $this->assertSame( 'first', $stmt->fetchColumn() ); } + public function test_transactions_commit_and_rollback(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->query( 'CREATE TABLE t (id INTEGER)' ); + + $this->assertFalse( $connection->inTransaction() ); + $this->assertTrue( $connection->beginTransaction() ); + $this->assertTrue( $connection->inTransaction() ); + $connection->query( 'INSERT INTO t VALUES (1)' ); + $this->assertTrue( $connection->rollback() ); + $this->assertFalse( $connection->inTransaction() ); + $this->assertSame( 0, $connection->query( 'SELECT COUNT(*) AS c FROM t' )->fetchColumn() ); + + $connection->begin_transaction(); + $connection->query( 'INSERT INTO t VALUES (2)' ); + $this->assertTrue( $connection->commit() ); + $this->assertSame( 1, $connection->query( 'SELECT COUNT(*) AS c FROM t' )->fetchColumn() ); + } + + public function test_transaction_state_errors_are_explicit(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $this->expectExceptionMessage( 'DuckDB transaction is not active.' ); + $connection->commit(); + } + + public function test_nested_transaction_errors_are_explicit(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->beginTransaction(); + + try { + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $this->expectExceptionMessage( 'DuckDB transaction is already active.' ); + $connection->beginTransaction(); + } finally { + $connection->rollback(); + } + } + public function test_file_connection_persists_data(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index dcc13a38c..a618e12c1 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -146,6 +146,43 @@ public function test_table_level_primary_key_is_supported(): void { $driver->query( 'INSERT INTO memberships (user_id, site_id) VALUES (1, 2)' ); } + public function test_replace_values_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE items ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL DEFAULT '', + hits INTEGER NOT NULL DEFAULT 0 + )" + ); + $driver->query( "INSERT INTO items (id, name, hits) VALUES (1, 'old', 1)" ); + + $replace = $driver->query( "REPLACE INTO items (id, name, hits) VALUES (1, 'new', 2)" ); + $this->assertSame( 1, $replace->rowCount() ); + $this->assertSame( "INSERT OR REPLACE INTO items(id, name, hits) VALUES (1, 'new', 2)", $this->lastDuckDBQuery( $driver ) ); + + $replace_without_into = $driver->query( "REPLACE items (id, name) VALUES (2, 'second')" ); + $this->assertSame( 1, $replace_without_into->rowCount() ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'new', + 'hits' => 2, + ), + array( + 'id' => 2, + 'name' => 'second', + 'hits' => 0, + ), + ), + $driver->query( 'SELECT id, name, hits FROM items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_wordpress_style_schema_can_be_created(): void { $this->requireDuckDBRuntime(); @@ -189,13 +226,59 @@ function ( array $row ): bool { } } - public function test_unsupported_statement_throws_driver_exception(): void { + public function test_create_index_statement_updates_show_index_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_posts ( + ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + post_name VARCHAR(200) NOT NULL DEFAULT '', + post_type VARCHAR(20) NOT NULL DEFAULT 'post' + )" + ); + + $result = $driver->query( 'CREATE INDEX post_name ON wp_posts (post_name(191))' ); + + $this->assertSame( 0, $result->rowCount() ); + $this->assertStringStartsWith( 'CREATE INDEX IF NOT EXISTS "wp_duckdb_idx_', $driver->get_last_duckdb_queries()[0] ); + + $indexes = $driver->query( 'SHOW INDEX FROM wp_posts' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'PRIMARY', 'post_name' ), array_column( $indexes, 'Key_name' ) ); + $this->assertSame( 191, $indexes[1]['Sub_part'] ); + } + + public function test_alter_table_add_unique_index_is_supported(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL + )" + ); + $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('siteurl', 'https://example.test')" ); + + $result = $driver->query( 'ALTER TABLE wp_options ADD UNIQUE INDEX option_name (option_name)' ); + + $this->assertSame( 0, $result->rowCount() ); + $indexes = $driver->query( 'SHOW INDEX FROM wp_options' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'PRIMARY', 'option_name' ), array_column( $indexes, 'Key_name' ) ); + $this->assertSame( 0, (int) $indexes[1]['Non_unique'] ); + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('siteurl', 'duplicate')" ); + } + + public function test_unsupported_alter_table_shape_throws_driver_exception(): void { $this->requireDuckDBRuntime(); $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); $this->expectException( WP_DuckDB_Driver_Exception::class ); - $this->expectExceptionMessage( 'Unsupported DuckDB MySQL-emulation statement: ALTER.' ); + $this->expectExceptionMessage( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD INDEX is supported.' ); $driver->query( 'ALTER TABLE users ADD COLUMN email VARCHAR(255)' ); } From eac038786d556478607712d2011bd1c5221a0904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 16:23:19 +0000 Subject: [PATCH 003/317] Add DuckDB REPLACE and wpdb flush parity --- .../src/duckdb/class-wp-duckdb-driver.php | 181 +++++++++++++++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 53 +++++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 137 ++++++++++++- .../wp-includes/duckdb/class-wp-duckdb-db.php | 14 ++ 4 files changed, 369 insertions(+), 16 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index ce5406148..b4eed5ded 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -596,6 +596,20 @@ private function execute_show( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_show_tables(); } + if ( + isset( $tokens[1] ) + && ( + WP_MySQL_Lexer::COLUMNS_SYMBOL === $tokens[1]->id + || ( + isset( $tokens[2] ) + && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::COLUMNS_SYMBOL === $tokens[2]->id + ) + ) + ) { + return $this->execute_show_columns( $tokens ); + } + if ( isset( $tokens[1] ) && in_array( $tokens[1]->id, array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::INDEXES_SYMBOL, WP_MySQL_Lexer::KEYS_SYMBOL ), true ) @@ -606,6 +620,87 @@ private function execute_show( array $tokens ): WP_DuckDB_Result_Statement { throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW statement in DuckDB driver.' ); } + /** + * Execute SHOW [FULL] COLUMNS FROM table. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_show_columns( array $tokens ): WP_DuckDB_Result_Statement { + $index = 1; + $full = false; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $index ]->id ) { + $full = true; + ++$index; + } + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::COLUMNS_SYMBOL, 'Expected COLUMNS in SHOW COLUMNS statement.' ); + ++$index; + + if ( + ! isset( $tokens[ $index ] ) + || ( WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $index ]->id && WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $index ]->id ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW COLUMNS statement in DuckDB driver. Use SHOW COLUMNS FROM table.' ); + } + ++$index; + + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + $like_pattern = null; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $index ]->id ) { + if ( + ! isset( $tokens[ $index + 1 ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $index + 1 ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $index + 1 ]->id + ) + ) { + throw new WP_DuckDB_Driver_Exception( 'SHOW COLUMNS LIKE requires a string pattern in the DuckDB driver.' ); + } + $like_pattern = $tokens[ $index + 1 ]->get_value(); + $index += 2; + } + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW COLUMNS statement in DuckDB driver. Only optional LIKE is supported.' ); + } + + $rows = $this->describe_column_rows( $table_name ); + if ( null !== $like_pattern ) { + $rows = $this->filter_column_rows_by_like( $rows, $like_pattern ); + } + + if ( ! $full ) { + return new WP_DuckDB_Result_Statement( + array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + $rows + ); + } + + $full_rows = array(); + foreach ( $rows as $row ) { + $full_rows[] = array( + $row[0], + $row[1], + null, + $row[2], + $row[3], + $row[4], + $row[5], + 'select,insert,update,references', + '', + ); + } + + return new WP_DuckDB_Result_Statement( + array( 'Field', 'Type', 'Collation', 'Null', 'Key', 'Default', 'Extra', 'Privileges', 'Comment' ), + $full_rows + ); + } + /** * Execute SHOW TABLES. * @@ -765,12 +860,24 @@ private function execute_describe( array $tokens ): WP_DuckDB_Result_Statement { throw new WP_DuckDB_Driver_Exception( 'Unsupported DESCRIBE statement in DuckDB driver. Only DESCRIBE table is supported.' ); } - $table_name = $this->identifier_value( $tokens[1] ); - $pragma = $this->execute_duckdb_query( + return new WP_DuckDB_Result_Statement( + array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + $this->describe_column_rows( $this->identifier_value( $tokens[1] ) ) + ); + } + + /** + * Build MySQL DESCRIBE/SHOW COLUMNS rows from DuckDB table metadata. + * + * @param string $table_name Table name. + * @return array> + */ + private function describe_column_rows( string $table_name ): array { + $pragma = $this->execute_duckdb_query( 'SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ') ORDER BY cid', 'Failed to inspect DuckDB table' ); - $rows = $pragma->fetchAll( PDO::FETCH_ASSOC ); + $rows = $pragma->fetchAll( PDO::FETCH_ASSOC ); if ( count( $rows ) === 0 ) { throw new WP_DuckDB_Driver_Exception( 'DuckDB table does not exist: ' . $table_name . '.' ); @@ -792,10 +899,70 @@ private function execute_describe( array $tokens ): WP_DuckDB_Result_Statement { ); } - return new WP_DuckDB_Result_Statement( - array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), - $describe_rows - ); + return $describe_rows; + } + + /** + * Filter DESCRIBE-style rows using a MySQL LIKE pattern against Field. + * + * @param array> $rows DESCRIBE-style rows. + * @param string $pattern LIKE pattern. + * @return array> + */ + private function filter_column_rows_by_like( array $rows, string $pattern ): array { + $filtered = array(); + foreach ( $rows as $row ) { + if ( $this->mysql_like_matches( (string) $row[0], $pattern ) ) { + $filtered[] = $row; + } + } + return $filtered; + } + + /** + * Match a string with MySQL LIKE wildcards. + * + * @param string $value Candidate value. + * @param string $pattern LIKE pattern. + * @return bool + */ + private function mysql_like_matches( string $value, string $pattern ): bool { + $regex = ''; + $escaping = false; + $length = strlen( $pattern ); + + for ( $index = 0; $index < $length; ++$index ) { + $char = $pattern[ $index ]; + + if ( $escaping ) { + $regex .= preg_quote( $char, '/' ); + $escaping = false; + continue; + } + + if ( '\\' === $char ) { + $escaping = true; + continue; + } + + if ( '%' === $char ) { + $regex .= '.*'; + continue; + } + + if ( '_' === $char ) { + $regex .= '.'; + continue; + } + + $regex .= preg_quote( $char, '/' ); + } + + if ( $escaping ) { + $regex .= preg_quote( '\\', '/' ); + } + + return 1 === preg_match( '/\A' . $regex . '\z/s', $value ); } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index a618e12c1..06672418b 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -113,6 +113,59 @@ public function test_create_table_insert_update_delete_show_and_describe(): void $this->assertSame( 'anonymous', $describe[1]['Default'] ); } + public function test_show_columns_and_full_fields_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE `metadata` ( + `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `val1` INT DEFAULT NULL, + `val2` INT NOT NULL DEFAULT 0, + `title` VARCHAR(100) NOT NULL DEFAULT 'untitled' COMMENT 'DuckDB does not persist this yet' + )" + ); + + $this->assertSame( + array( + array( + 'Field' => 'val1', + 'Type' => 'INTEGER', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'val2', + 'Type' => 'INTEGER', + 'Null' => 'NO', + 'Key' => '', + 'Default' => '0', + 'Extra' => '', + ), + ), + $driver->query( "SHOW COLUMNS FROM `metadata` LIKE 'val_'" )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Field' => 'title', + 'Type' => 'VARCHAR', + 'Collation' => null, + 'Null' => 'NO', + 'Key' => '', + 'Default' => 'untitled', + 'Extra' => '', + 'Privileges' => 'select,insert,update,references', + 'Comment' => '', + ), + ), + $driver->query( "SHOW FULL FIELDS IN `metadata` LIKE 'title'" )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_regexp_predicates_are_emulated(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index 32ab54aef..dffcd4f9b 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -111,15 +111,31 @@ public function test_duckdb_wpdb_query_updates_raw_query_state(): void { $this->assertSame( 2, $result['insert_rows_affected'] ); $this->assertTrue( $result['create_return'] ); $this->assertSame( 3, $result['num_queries'] ); - $this->assertSame( - array( - 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(d, f)', - 'SELECT 42 AS answer', - 'INSERT INTO t VALUES (1), (2)', - 'CREATE TABLE "t" ("id" INTEGER)', - ), - $result['client_queries'] - ); + $this->assertSame( + array( + 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(d, f)', + 'SELECT 42 AS answer', + 'INSERT INTO t VALUES (1), (2)', + 'CREATE TABLE "t" ("id" INTEGER)', + ), + $result['client_queries'] + ); + } + + public function test_duckdb_wpdb_flush_clears_statement_metadata(): void { + $result = $this->run_flush_state_script(); + + $this->assertTrue( $result['connected'] ); + $this->assertSame( 1, $result['select_return'] ); + $this->assertSame( array( 'answer' ), $result['before_flush_column_names'] ); + $this->assertSame( array(), $result['last_result_after_flush'] ); + $this->assertNull( $result['col_info_after_flush'] ); + $this->assertNull( $result['last_query_after_flush'] ); + $this->assertSame( 0, $result['rows_affected_after_flush'] ); + $this->assertSame( 0, $result['num_rows_after_flush'] ); + $this->assertSame( '', $result['last_error_after_flush'] ); + $this->assertNull( $result['result_after_flush'] ); + $this->assertSame( array(), $result['after_flush_column_names'] ); } private function run_dispatcher_script( string $engine_expression ): array { @@ -258,6 +274,109 @@ public function query( $sql ) { return $this->run_isolated_php( $code ); } + private function run_flush_state_script(): array { + $plugin_dir = $this->get_plugin_dir(); + $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; + $code = $this->get_wordpress_stub_code(); + $code .= "\nrequire_once " . var_export( $driver_load, true ) . ";\n"; + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= <<<'PHP' + +class WP_DuckDB_Plugin_Flush_Test_Result { + private $columns; + private $rows; + + public function __construct( array $columns, array $rows ) { + $this->columns = $columns; + $this->rows = $rows; + } + + public function columnNames() { + return new ArrayIterator( $this->columns ); + } + + public function rows( $assoc = false ) { + return new ArrayIterator( $this->rows ); + } +} + +class WP_DuckDB_Plugin_Flush_Test_Client { + public function query( $sql ) { + $normalized = strtolower( trim( $sql ) ); + + if ( 0 === strpos( $normalized, 'select' ) ) { + return new WP_DuckDB_Plugin_Flush_Test_Result( + array( 'answer' ), + array( + array( 'answer' => 42 ), + ) + ); + } + + if ( 0 === strpos( $normalized, 'create' ) ) { + return new WP_DuckDB_Plugin_Flush_Test_Result( + array( 'Success' ), + array( + array( 'Success' => true ), + ) + ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } +} + +class WP_DuckDB_Plugin_Flush_Test_DB extends WP_DuckDB_DB { + public function exported_column_names() { + $this->load_col_info(); + return array_map( + function ( $column ) { + return $column->name; + }, + $this->col_info + ); + } +} + +$GLOBALS['@duckdb'] = new WP_DuckDB_Connection( array( 'duckdb' => new WP_DuckDB_Plugin_Flush_Test_Client() ) ); +$db = new WP_DuckDB_Plugin_Flush_Test_DB( 'wordpress_test' ); +$connected = $db->db_connect( false ); +$select_return = $db->query( 'SELECT 42 AS answer' ); +$before_columns = $db->exported_column_names(); + +$db->flush(); + +$state_after_flush = array( + 'last_result' => $db->last_result, + 'col_info' => $db->col_info, + 'last_query' => $db->last_query, + 'rows_affected' => $db->rows_affected, + 'num_rows' => $db->num_rows, + 'last_error' => $db->last_error, + 'result' => $db->result, +); +$after_columns = $db->exported_column_names(); + +echo json_encode( + array( + 'connected' => $connected, + 'select_return' => $select_return, + 'before_flush_column_names' => $before_columns, + 'last_result_after_flush' => $state_after_flush['last_result'], + 'col_info_after_flush' => $state_after_flush['col_info'], + 'last_query_after_flush' => $state_after_flush['last_query'], + 'rows_affected_after_flush' => $state_after_flush['rows_affected'], + 'num_rows_after_flush' => $state_after_flush['num_rows'], + 'last_error_after_flush' => $state_after_flush['last_error'], + 'result_after_flush' => $state_after_flush['result'], + 'after_flush_column_names' => $after_columns, + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + private function get_dispatcher_include_code( string $include_file ): string { return "\ntry {\n" . "\trequire " . var_export( $include_file, true ) . ";\n" diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index 1eec774ac..54c5cb113 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -93,6 +93,20 @@ public function _real_escape( $data ) { return $this->add_placeholder_escape( addslashes( $data ) ); } + /** + * Flush cached query state. + */ + public function flush() { + $this->last_result = array(); + $this->col_info = null; + $this->last_query = null; + $this->rows_affected = 0; + $this->num_rows = 0; + $this->last_error = ''; + $this->result = null; + $this->last_statement = null; + } + /** * Connect to DuckDB. * From bbdf6e401b3123df61654f8cbcac44b4dfcc8ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 16:26:34 +0000 Subject: [PATCH 004/317] Add DuckDB differential parity harness --- .../WP_DuckDB_Differential_TestCase.php | 173 ++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 83 +++++++++ 2 files changed, 256 insertions(+) create mode 100644 packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php create mode 100644 packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php new file mode 100644 index 000000000..91ebd511f --- /dev/null +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php @@ -0,0 +1,173 @@ +requireDuckDBRuntime(); + + $this->sqlite_driver = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), + 'wp' + ); + $this->duckdb_driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + } + + /** + * Run SQL on both engines and compare normalized rows. + * + * @param string $sql SQL query. + */ + protected function assertParityRows( string $sql ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $sqlite_rows = $this->query_sqlite_rows( $sql ); + $duckdb_rows = $this->query_duckdb_rows( $sql ); + + $this->assertSame( + $this->normalize_rows( $sqlite_rows ), + $this->normalize_rows( $duckdb_rows ), + 'Row parity failed for SQL: ' . $sql + ); + } + + /** + * Run SQL on both engines and compare selected normalized row fields. + * + * @param string $sql SQL query. + * @param string[] $columns Columns to compare. + */ + protected function assertParityRowColumns( string $sql, array $columns ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $sqlite_rows = $this->select_columns( $this->query_sqlite_rows( $sql ), $columns ); + $duckdb_rows = $this->select_columns( $this->query_duckdb_rows( $sql ), $columns ); + + $this->assertSame( + $this->normalize_rows( $sqlite_rows ), + $this->normalize_rows( $duckdb_rows ), + 'Selected row parity failed for SQL: ' . $sql + ); + } + + /** + * Run SQL on both engines and compare row counts. + * + * @param string $sql SQL query. + */ + protected function assertParityRowCount( string $sql ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $this->assertSame( + $this->query_sqlite_row_count( $sql ), + $this->query_duckdb_row_count( $sql ), + 'Row-count parity failed for SQL: ' . $sql + ); + } + + /** + * Execute setup SQL on both engines. + * + * @param string[] $queries Setup queries. + */ + protected function runParitySetup( array $queries ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + foreach ( $queries as $query ) { + $this->sqlite_driver->query( $query, PDO::FETCH_ASSOC ); + $this->duckdb_driver->query( $query ); + } + } + + /** + * Run a SELECT-like query on SQLite. + * + * @param string $sql SQL query. + * @return array + */ + private function query_sqlite_rows( string $sql ): array { + $result = $this->sqlite_driver->query( $sql, PDO::FETCH_ASSOC ); + $this->assertIsArray( $result ); + return $result; + } + + /** + * Run a SELECT-like query on DuckDB. + * + * @param string $sql SQL query. + * @return array + */ + private function query_duckdb_rows( string $sql ): array { + return $this->duckdb_driver->query( $sql )->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Run a write query on SQLite. + * + * @param string $sql SQL query. + * @return int + */ + private function query_sqlite_row_count( string $sql ): int { + return (int) $this->sqlite_driver->query( $sql, PDO::FETCH_ASSOC ); + } + + /** + * Run a write query on DuckDB. + * + * @param string $sql SQL query. + * @return int + */ + private function query_duckdb_row_count( string $sql ): int { + return $this->duckdb_driver->query( $sql )->rowCount(); + } + + /** + * Normalize rows across SQLite and DuckDB scalar fetch differences. + * + * @param array $rows Rows. + * @return array + */ + private function normalize_rows( array $rows ): array { + $normalized_rows = array(); + foreach ( $rows as $row ) { + $normalized = array(); + foreach ( $row as $key => $value ) { + $normalized[ (string) $key ] = null === $value ? null : (string) $value; + } + ksort( $normalized ); + $normalized_rows[] = $normalized; + } + return $normalized_rows; + } + + /** + * Keep only selected columns from rows. + * + * @param array $rows Rows. + * @param string[] $columns Columns to keep. + * @return array + */ + private function select_columns( array $rows, array $columns ): array { + $selected_rows = array(); + foreach ( $rows as $row ) { + $selected = array(); + foreach ( $columns as $column ) { + $selected[ $column ] = $row[ $column ] ?? null; + } + $selected_rows[] = $selected; + } + return $selected_rows; + } +} diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php new file mode 100644 index 000000000..f70a5db42 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -0,0 +1,83 @@ +assertParityRows( "SELECT 1 AS id, 'Ada' AS name" ); + } + + public function test_common_scalar_functions_match_sqlite(): void { + $this->assertParityRows( "SELECT GREATEST('a', 'b') AS greatest_value, LEAST('a', 'b') AS least_value" ); + $this->assertParityRows( "SELECT SUBSTR('abcdef', 2, 3) AS short_substr, SUBSTRING('abcdef', 2, 3) AS long_substring" ); + } + + public function test_create_insert_select_update_delete_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR(100), hits INTEGER DEFAULT 0)', + "INSERT INTO items (id, name, hits) VALUES (1, 'old', 1), (2, 'second', 2)", + ) + ); + + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + $this->assertParityRowCount( "UPDATE items SET hits = 3 WHERE name = 'old'" ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + $this->assertParityRowCount( "DELETE FROM items WHERE name = 'second'" ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + } + + public function test_regexp_and_not_regexp_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE options (option_name VARCHAR(100))', + "INSERT INTO options VALUES ('rss_123'), ('transient')", + ) + ); + + $this->assertParityRows( "SELECT option_name FROM options WHERE option_name REGEXP '^rss_.+$' ORDER BY option_name" ); + $this->assertParityRows( "SELECT option_name FROM options WHERE option_name NOT REGEXP '^rss_.+$' ORDER BY option_name" ); + } + + public function test_replace_values_match_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE items ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL DEFAULT '', + hits INTEGER NOT NULL DEFAULT 0 + )", + "INSERT INTO items (id, name, hits) VALUES (1, 'old', 1)", + ) + ); + + $this->assertParityRowCount( "REPLACE INTO items (id, name, hits) VALUES (1, 'new', 2)" ); + $this->assertParityRowCount( "REPLACE items (id, name) VALUES (2, 'second')" ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + } + + public function test_create_table_if_not_exists_with_secondary_index_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE IF NOT EXISTS wp_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL, + PRIMARY KEY (option_id), + KEY option_name (option_name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + ) + ); + + $this->assertParityRowColumns( + 'SHOW INDEX FROM wp_options', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + } +} From ef81fca1cdae7a60f262f14b677b7a7f432e4740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 16:30:37 +0000 Subject: [PATCH 005/317] Add DuckDB INSERT IGNORE parity --- .../src/duckdb/class-wp-duckdb-driver.php | 72 +++++++++++++++---- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 36 ++++++++++ 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index b4eed5ded..f9ed2bad3 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -344,10 +344,10 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme list( $items, $index ) = $this->collect_parenthesized_items( $tokens, $index ); $this->assert_supported_create_table_options( array_slice( $tokens, $index ) ); - $columns = array(); - $constraints = array(); - $indexes = array(); - $sequences = array(); + $columns = array(); + $constraints = array(); + $indexes = array(); + $sequences = array(); foreach ( $items as $item ) { if ( count( $item ) === 0 ) { @@ -368,11 +368,14 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE constraint in DuckDB driver: ' . $item[0]->get_bytes() . '.' ); } - list( $column_sql, $sequence_sql ) = $this->translate_create_table_column( $table_name, $item ); - $columns[] = $column_sql; + list( $column_sql, $sequence_sql, $column_indexes ) = $this->translate_create_table_column( $table_name, $item ); + $columns[] = $column_sql; if ( null !== $sequence_sql ) { $sequences[] = $sequence_sql; } + foreach ( $column_indexes as $index_definition ) { + $indexes[] = $index_definition; + } } if ( count( $columns ) === 0 ) { @@ -392,13 +395,13 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $table_sql .= implode( ', ', array_merge( $columns, $constraints ) ); $table_sql .= ')'; - $result = $this->execute_duckdb_query( $table_sql, 'Failed to create DuckDB table' ); + $result = $this->execute_duckdb_query( $table_sql, 'Failed to create DuckDB table' ); foreach ( $indexes as $index_definition ) { $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); $this->record_index_metadata( $index_definition ); } - return $result; + return $result; } /** @@ -456,14 +459,21 @@ private function execute_create_index( array $tokens ): WP_DuckDB_Result_Stateme * @return WP_DuckDB_Result_Statement */ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { - $index = 1; + $index = 1; + $ignore = false; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[ $index ]->id ) { + $ignore = true; + ++$index; + } if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $index ]->id ) { ++$index; } $this->assert_values_write_statement( $tokens, $index, 'INSERT' ); return $this->execute_duckdb_query( - $this->translate_tokens_to_duckdb_sql( $tokens ), + $ignore + ? $this->translate_insert_ignore_tokens_to_duckdb_sql( $tokens, $index ) + : $this->translate_tokens_to_duckdb_sql( $tokens ), 'Failed to execute DuckDB INSERT' ); } @@ -1009,7 +1019,7 @@ private function collect_parenthesized_items( array $tokens, int $index ): array * * @param string $table_name Table name. * @param WP_Parser_Token[] $tokens Column definition tokens. - * @return array{0:string,1:string|null} + * @return array{0:string,1:string|null,2:array}>} */ private function translate_create_table_column( string $table_name, array $tokens ): array { $index = 0; @@ -1027,6 +1037,7 @@ private function translate_create_table_column( string $table_name, array $token $not_null = false; $primary_key = false; + $unique_key = false; $auto_increment = false; $default_sql = null; @@ -1053,6 +1064,16 @@ private function translate_create_table_column( string $table_name, array $token $not_null = true; $index += 2; break; + case WP_MySQL_Lexer::UNIQUE_SYMBOL: + $unique_key = true; + ++$index; + if ( + isset( $tokens[ $index ] ) + && ( WP_MySQL_Lexer::KEY_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::INDEX_SYMBOL === $tokens[ $index ]->id ) + ) { + ++$index; + } + break; case WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL: $auto_increment = true; $not_null = true; @@ -1107,7 +1128,23 @@ private function translate_create_table_column( string $table_name, array $token $column_sql .= ' PRIMARY KEY'; } - return array( $column_sql, $sequence ); + $indexes = array(); + if ( $unique_key && ! $primary_key ) { + $indexes[] = $this->build_secondary_index_definition( + $table_name, + $column_name, + true, + array( $this->connection->quote_identifier( $column_name ) ), + array( + array( + 'name' => $column_name, + 'sub_part' => null, + ), + ) + ); + } + + return array( $column_sql, $sequence, $indexes ); } /** @@ -1586,6 +1623,17 @@ private function translate_replace_tokens_to_duckdb_sql( array $tokens ): string return 'INSERT OR REPLACE INTO ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $index ) ); } + /** + * Translate MySQL INSERT IGNORE ... VALUES to DuckDB INSERT OR IGNORE. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @return string DuckDB SQL. + */ + private function translate_insert_ignore_tokens_to_duckdb_sql( array $tokens, int $table_index ): string { + return 'INSERT OR IGNORE INTO ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $table_index ) ); + } + /** * Initialize DuckDB macros that emulate simple MySQL functions. */ diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 06672418b..c7cde24c0 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -236,6 +236,42 @@ public function test_replace_values_is_emulated(): void { ); } + public function test_insert_ignore_values_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR(100) UNIQUE)' ); + $driver->query( "INSERT INTO items (id, name) VALUES (1, 'first')" ); + + $indexes = $driver->query( 'SHOW INDEX FROM items' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'PRIMARY', 'name' ), array_column( $indexes, 'Key_name' ) ); + $this->assertSame( 0, (int) $indexes[1]['Non_unique'] ); + + $duplicate_primary = $driver->query( "INSERT IGNORE INTO items (id, name) VALUES (1, 'duplicate-id')" ); + $this->assertSame( 0, $duplicate_primary->rowCount() ); + $this->assertSame( "INSERT OR IGNORE INTO items(id, name) VALUES (1, 'duplicate-id')", $this->lastDuckDBQuery( $driver ) ); + + $duplicate_unique = $driver->query( "INSERT IGNORE items (id, name) VALUES (2, 'first')" ); + $this->assertSame( 0, $duplicate_unique->rowCount() ); + + $inserted = $driver->query( "INSERT IGNORE INTO items (id, name) VALUES (3, 'third')" ); + $this->assertSame( 1, $inserted->rowCount() ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'first', + ), + array( + 'id' => 3, + 'name' => 'third', + ), + ), + $driver->query( 'SELECT id, name FROM items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_wordpress_style_schema_can_be_created(): void { $this->requireDuckDBRuntime(); From 56bd6c8c35f8af8562604af15d5fc272ac03ab48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 16:37:57 +0000 Subject: [PATCH 006/317] Add DuckDB ON DUPLICATE KEY UPDATE parity --- .../src/duckdb/class-wp-duckdb-driver.php | 268 ++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 27 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 56 ++++ 3 files changed, 351 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index f9ed2bad3..3388e57da 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -470,6 +470,17 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { } $this->assert_values_write_statement( $tokens, $index, 'INSERT' ); + $on_duplicate_index = $this->find_on_duplicate_key_update_index( $tokens ); + if ( null !== $on_duplicate_index ) { + if ( $ignore ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT statement in DuckDB driver. INSERT IGNORE ... ON DUPLICATE KEY UPDATE is not supported.' ); + } + return $this->execute_duckdb_query( + $this->translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( $tokens, $index, $on_duplicate_index ), + 'Failed to execute DuckDB INSERT' + ); + } + return $this->execute_duckdb_query( $ignore ? $this->translate_insert_ignore_tokens_to_duckdb_sql( $tokens, $index ) @@ -562,6 +573,27 @@ private function assert_values_write_statement( array $tokens, int $table_index, } } + /** + * Find the ON DUPLICATE KEY UPDATE clause in an INSERT statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return int|null Index of the ON token, or null when absent. + */ + private function find_on_duplicate_key_update_index( array $tokens ): ?int { + for ( $index = 0; $index < count( $tokens ) - 3; ++$index ) { + if ( + WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $index ]->id + && WP_MySQL_Lexer::DUPLICATE_SYMBOL === $tokens[ $index + 1 ]->id + && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[ $index + 2 ]->id + && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $index + 3 ]->id + ) { + return $index; + } + } + + return null; + } + /** * Execute a supported ALTER TABLE statement. * @@ -1634,6 +1666,242 @@ private function translate_insert_ignore_tokens_to_duckdb_sql( array $tokens, in return 'INSERT OR IGNORE INTO ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $table_index ) ); } + /** + * Translate a bounded MySQL INSERT ... ON DUPLICATE KEY UPDATE statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param int $on_duplicate_index Index of the ON token. + * @return string DuckDB SQL. + */ + private function translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( array $tokens, int $table_index, int $on_duplicate_index ): string { + $insert_shape = $this->parse_on_duplicate_insert_shape( $tokens, $table_index, $on_duplicate_index ); + $target = $this->select_on_duplicate_conflict_target( $insert_shape['table_name'], $insert_shape['values_by_column'] ); + $update_sql = $this->translate_on_duplicate_update_tokens_to_duckdb_sql( array_slice( $tokens, $on_duplicate_index + 4 ) ); + + if ( '' === $update_sql ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. UPDATE list is required.' ); + } + + return 'INSERT INTO ' + . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $table_index, $on_duplicate_index - $table_index ) ) + . ' ON CONFLICT (' + . implode( + ', ', + array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ); + }, + $target + ) + ) + . ') DO UPDATE SET ' + . $update_sql; + } + + /** + * Parse the supported INSERT ... VALUES shape needed for ODKU target selection. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param int $on_duplicate_index Index of the ON token. + * @return array{table_name:string,values_by_column:array} + */ + private function parse_on_duplicate_insert_shape( array $tokens, int $table_index, int $on_duplicate_index ): array { + $table_name = $this->identifier_value( $tokens[ $table_index ] ?? null ); + $index = $table_index + 1; + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Explicit column list is required.' ); + ++$index; + + $columns = array(); + while ( $index < count( $tokens ) ) { + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + break; + } + $columns[] = $this->identifier_value( $tokens[ $index ] ); + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + continue; + } + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Explicit column list is required.' ); + } + } + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::VALUES_SYMBOL, 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Only INSERT ... VALUES is supported.' ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. A single VALUES row is required.' ); + ++$index; + + list( $value_items, $index ) = $this->collect_parenthesized_items( $tokens, $index ); + if ( $index !== $on_duplicate_index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Only a single VALUES row is supported.' ); + } + if ( count( $columns ) !== count( $value_items ) ) { + throw new WP_DuckDB_Driver_Exception( 'INSERT ... ON DUPLICATE KEY UPDATE column count does not match value count in DuckDB driver.' ); + } + + $values_by_column = array(); + foreach ( $columns as $offset => $column_name ) { + $values_by_column[ strtolower( $column_name ) ] = $this->translate_tokens_to_duckdb_sql( $value_items[ $offset ] ); + } + + return array( + 'table_name' => $table_name, + 'values_by_column' => $values_by_column, + ); + } + + /** + * Select the conflict target that MySQL would hit for a single inserted row. + * + * @param string $table_name Table name. + * @param array $values_by_column Inserted values keyed by lowercase column name. + * @return string[] Conflict target columns. + */ + private function select_on_duplicate_conflict_target( string $table_name, array $values_by_column ): array { + $eligible_targets = array(); + $matched_targets = array(); + + foreach ( $this->unique_key_column_sets( $table_name ) as $column_set ) { + $has_all_values = true; + foreach ( $column_set as $column_name ) { + if ( ! array_key_exists( strtolower( $column_name ), $values_by_column ) ) { + $has_all_values = false; + break; + } + } + + if ( ! $has_all_values ) { + continue; + } + + $eligible_targets[] = $column_set; + if ( $this->insert_values_conflict_with_target( $table_name, $column_set, $values_by_column ) ) { + $matched_targets[] = $column_set; + } + } + + if ( 1 === count( $matched_targets ) ) { + return $matched_targets[0]; + } + if ( count( $matched_targets ) > 1 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Insert values match multiple unique key targets.' ); + } + + if ( count( $eligible_targets ) > 0 ) { + return $eligible_targets[0]; + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Insert values do not include a unique key target.' ); + } + + /** + * Read primary and unique secondary key column sets. + * + * @param string $table_name Table name. + * @return array + */ + private function unique_key_column_sets( string $table_name ): array { + $sets = array(); + + $primary = $this->execute_duckdb_query( + 'SELECT name FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ') WHERE pk > 0 ORDER BY pk', + 'Failed to inspect DuckDB primary key' + )->fetchAll( PDO::FETCH_ASSOC ); + if ( count( $primary ) > 0 ) { + $sets[] = array_map( + function ( array $row ): string { + return (string) $row['name']; + }, + $primary + ); + } + + $this->ensure_index_metadata_table(); + $secondary = $this->execute_duckdb_query( + 'SELECT index_name, column_name FROM ' + . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' AND non_unique = 0 ORDER BY index_name, seq_in_index', + 'Failed to inspect DuckDB unique indexes' + )->fetchAll( PDO::FETCH_ASSOC ); + + $secondary_sets = array(); + foreach ( $secondary as $row ) { + $index_name = (string) $row['index_name']; + $secondary_sets[ $index_name ][] = (string) $row['column_name']; + } + + foreach ( $secondary_sets as $columns ) { + $sets[] = $columns; + } + + return $sets; + } + + /** + * Determine whether the inserted row conflicts with a unique target. + * + * @param string $table_name Table name. + * @param string[] $column_set Unique key columns. + * @param array $values_by_column Inserted values keyed by lowercase column name. + * @return bool Whether an existing row matches the target values. + */ + private function insert_values_conflict_with_target( string $table_name, array $column_set, array $values_by_column ): bool { + $where = array(); + foreach ( $column_set as $column_name ) { + $where[] = $this->connection->quote_identifier( $column_name ) + . ' IS NOT DISTINCT FROM (' + . $values_by_column[ strtolower( $column_name ) ] + . ')'; + } + + $stmt = $this->execute_duckdb_query( + 'SELECT 1 FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' WHERE ' + . implode( ' AND ', $where ) + . ' LIMIT 1', + 'Failed to inspect DuckDB duplicate key target' + ); + + return false !== $stmt->fetch( PDO::FETCH_NUM ); + } + + /** + * Translate an ODKU update list, rewriting MySQL VALUES(col) references. + * + * @param WP_Parser_Token[] $tokens Update-list tokens after ON DUPLICATE KEY UPDATE. + * @return string DuckDB SQL. + */ + private function translate_on_duplicate_update_tokens_to_duckdb_sql( array $tokens ): string { + $pieces = array(); + + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( + isset( $tokens[ $index + 3 ] ) + && WP_MySQL_Lexer::VALUES_SYMBOL === $tokens[ $index ]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index + 1 ]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index + 3 ]->id + ) { + $pieces[] = 'excluded'; + $pieces[] = '.'; + $pieces[] = $this->connection->quote_identifier( $this->identifier_value( $tokens[ $index + 2 ] ) ); + $index += 3; + continue; + } + + $pieces[] = $this->translate_tokens_to_duckdb_sql( array( $tokens[ $index ] ) ); + } + + return $this->join_sql_pieces( $pieces ); + } + /** * Initialize DuckDB macros that emulate simple MySQL functions. */ diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index f70a5db42..a46390b40 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -62,6 +62,33 @@ public function test_replace_values_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_on_duplicate_key_update_values_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE items ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) UNIQUE, + hits INTEGER NOT NULL DEFAULT 0 + )', + "INSERT INTO items (id, name, hits) VALUES (1, 'old', 1)", + ) + ); + + $this->assertParityRowCount( + "INSERT INTO items (id, name, hits) VALUES (1, 'renamed', 7) + ON DUPLICATE KEY UPDATE name = VALUES(name), hits = VALUES(hits)" + ); + $this->assertParityRowCount( + "INSERT INTO items (id, name, hits) VALUES (2, 'renamed', 11) + ON DUPLICATE KEY UPDATE hits = hits + VALUES(hits)" + ); + $this->assertParityRowCount( + "INSERT INTO items (id, name, hits) VALUES (3, 'third', 3) + ON DUPLICATE KEY UPDATE hits = VALUES(hits)" + ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + } + public function test_create_table_if_not_exists_with_secondary_index_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index c7cde24c0..4016846d3 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -272,6 +272,62 @@ public function test_insert_ignore_values_is_emulated(): void { ); } + public function test_insert_on_duplicate_key_update_values_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE items ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) UNIQUE, + hits INTEGER NOT NULL DEFAULT 0 + )' + ); + $driver->query( "INSERT INTO items (id, name, hits) VALUES (1, 'old', 1)" ); + + $duplicate_primary = $driver->query( + "INSERT INTO items (id, name, hits) VALUES (1, 'renamed', 7) + ON DUPLICATE KEY UPDATE name = VALUES(name), hits = VALUES(hits)" + ); + $this->assertSame( 1, $duplicate_primary->rowCount() ); + $this->assertSame( + 'INSERT INTO items(id, name, hits) VALUES (1, \'renamed\', 7) ON CONFLICT ("id") DO UPDATE SET name = excluded."name", hits = excluded."hits"', + $this->lastDuckDBQuery( $driver ) + ); + + $duplicate_unique = $driver->query( + "INSERT INTO items (id, name, hits) VALUES (2, 'renamed', 11) + ON DUPLICATE KEY UPDATE hits = hits + VALUES(hits)" + ); + $this->assertSame( 1, $duplicate_unique->rowCount() ); + $this->assertSame( + 'INSERT INTO items(id, name, hits) VALUES (2, \'renamed\', 11) ON CONFLICT ("name") DO UPDATE SET hits = hits + excluded."hits"', + $this->lastDuckDBQuery( $driver ) + ); + + $inserted = $driver->query( + "INSERT INTO items (id, name, hits) VALUES (3, 'third', 3) + ON DUPLICATE KEY UPDATE hits = VALUES(hits)" + ); + $this->assertSame( 1, $inserted->rowCount() ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'renamed', + 'hits' => 18, + ), + array( + 'id' => 3, + 'name' => 'third', + 'hits' => 3, + ), + ), + $driver->query( 'SELECT id, name, hits FROM items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_wordpress_style_schema_can_be_created(): void { $this->requireDuckDBRuntime(); From 1cafb9bf0094d7f54ad160ecd434b319632dfc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 16:41:17 +0000 Subject: [PATCH 007/317] Add DuckDB INSERT SET parity --- .../src/duckdb/class-wp-duckdb-driver.php | 104 ++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 13 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 40 +++++++ 3 files changed, 157 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 3388e57da..741f44d9d 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -468,6 +468,18 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $index ]->id ) { ++$index; } + + $set_index = $this->find_insert_set_index( $tokens, $index ); + if ( null !== $set_index ) { + if ( null !== $this->find_on_duplicate_key_update_index( $tokens ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT statement in DuckDB driver. INSERT ... SET ... ON DUPLICATE KEY UPDATE is not supported.' ); + } + return $this->execute_duckdb_query( + $this->translate_insert_set_tokens_to_duckdb_sql( $tokens, $index, $set_index, $ignore ), + 'Failed to execute DuckDB INSERT' + ); + } + $this->assert_values_write_statement( $tokens, $index, 'INSERT' ); $on_duplicate_index = $this->find_on_duplicate_key_update_index( $tokens ); @@ -594,6 +606,19 @@ private function find_on_duplicate_key_update_index( array $tokens ): ?int { return null; } + /** + * Find the SET clause in a supported INSERT ... SET statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index expected to contain the table identifier. + * @return int|null Index of the SET token, or null when absent. + */ + private function find_insert_set_index( array $tokens, int $table_index ): ?int { + return isset( $tokens[ $table_index + 1 ] ) && WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $table_index + 1 ]->id + ? $table_index + 1 + : null; + } + /** * Execute a supported ALTER TABLE statement. * @@ -1666,6 +1691,85 @@ private function translate_insert_ignore_tokens_to_duckdb_sql( array $tokens, in return 'INSERT OR IGNORE INTO ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $table_index ) ); } + /** + * Translate MySQL INSERT ... SET to DuckDB INSERT ... VALUES. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param int $set_index Index of the SET token. + * @param bool $ignore Whether INSERT IGNORE was used. + * @return string DuckDB SQL. + */ + private function translate_insert_set_tokens_to_duckdb_sql( array $tokens, int $table_index, int $set_index, bool $ignore ): string { + list( $columns, $values ) = $this->parse_insert_set_assignments( array_slice( $tokens, $set_index + 1 ) ); + + return 'INSERT ' + . ( $ignore ? 'OR IGNORE ' : '' ) + . 'INTO ' + . $this->translate_tokens_to_duckdb_sql( array( $tokens[ $table_index ] ) ) + . ' (' + . implode( ', ', $columns ) + . ') VALUES (' + . implode( ', ', $values ) + . ')'; + } + + /** + * Parse INSERT ... SET assignments. + * + * @param WP_Parser_Token[] $tokens Assignment-list tokens after SET. + * @return array{0:string[],1:string[]} + */ + private function parse_insert_set_assignments( array $tokens ): array { + $columns = array(); + $values = array(); + $index = 0; + + while ( $index < count( $tokens ) ) { + $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); + $columns[] = $this->translate_tokens_to_duckdb_sql( array( $tokens[ $index ] ) ); + ++$index; + + if ( + ! isset( $tokens[ $index ] ) + || ( WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $index ]->id && WP_MySQL_Lexer::ASSIGN_OPERATOR !== $tokens[ $index ]->id ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... SET statement in DuckDB driver. Expected assignment for column: ' . $column_name . '.' ); + } + ++$index; + + $value_tokens = array(); + $depth = 0; + while ( $index < count( $tokens ) ) { + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + break; + } + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + } elseif ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + } + $value_tokens[] = $tokens[ $index ]; + ++$index; + } + + if ( count( $value_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... SET statement in DuckDB driver. Assignment value is required for column: ' . $column_name . '.' ); + } + + $values[] = $this->translate_tokens_to_duckdb_sql( $value_tokens ); + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + } + + if ( count( $columns ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... SET statement in DuckDB driver. At least one assignment is required.' ); + } + + return array( $columns, $values ); + } + /** * Translate a bounded MySQL INSERT ... ON DUPLICATE KEY UPDATE statement. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index a46390b40..f41888ee6 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -33,6 +33,19 @@ public function test_create_insert_select_update_delete_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_insert_set_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR(100), hits INTEGER DEFAULT 0)', + ) + ); + + $this->assertParityRowCount( "INSERT INTO items SET id = 1, name = 'first', hits = 2" ); + $this->assertParityRowCount( "INSERT items SET id = 2, name = 'second'" ); + $this->assertParityRowCount( "INSERT IGNORE items SET id = 2, name = 'duplicate'" ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + } + public function test_regexp_and_not_regexp_match_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 4016846d3..0d5305085 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -272,6 +272,46 @@ public function test_insert_ignore_values_is_emulated(): void { ); } + public function test_insert_set_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE items ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL DEFAULT \'\', + hits INTEGER NOT NULL DEFAULT 0 + )' + ); + + $inserted = $driver->query( "INSERT INTO items SET id = 1, name = 'first', hits = 2" ); + $this->assertSame( 1, $inserted->rowCount() ); + $this->assertSame( "INSERT INTO items (id, name, hits) VALUES (1, 'first', 2)", $this->lastDuckDBQuery( $driver ) ); + + $inserted_without_into = $driver->query( "INSERT items SET id = 2, name = 'second'" ); + $this->assertSame( 1, $inserted_without_into->rowCount() ); + + $ignored = $driver->query( "INSERT IGNORE items SET id = 2, name = 'duplicate'" ); + $this->assertSame( 0, $ignored->rowCount() ); + $this->assertSame( "INSERT OR IGNORE INTO items (id, name) VALUES (2, 'duplicate')", $this->lastDuckDBQuery( $driver ) ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'first', + 'hits' => 2, + ), + array( + 'id' => 2, + 'name' => 'second', + 'hits' => 0, + ), + ), + $driver->query( 'SELECT id, name, hits FROM items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_insert_on_duplicate_key_update_values_is_emulated(): void { $this->requireDuckDBRuntime(); From b1cc0772d97d47d4af403b8ceb9f990d0c30a215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 16:51:03 +0000 Subject: [PATCH 008/317] Add DuckDB column metadata parity --- .../src/duckdb/class-wp-duckdb-driver.php | 370 ++++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 17 + .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 21 +- .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 11 +- 4 files changed, 386 insertions(+), 33 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 741f44d9d..34991ba1f 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -20,6 +20,7 @@ class WP_DuckDB_Driver { const SEQUENCE_PREFIX = 'wp_duckdb_ai_'; const INDEX_PREFIX = 'wp_duckdb_idx_'; const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; + const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; const DATA_TYPE_MAP = array( WP_MySQL_Lexer::BOOL_SYMBOL => 'BOOLEAN', @@ -348,6 +349,8 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $constraints = array(); $indexes = array(); $sequences = array(); + $metadata = array(); + $primary_key = array(); foreach ( $items as $item ) { if ( count( $item ) === 0 ) { @@ -355,6 +358,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme } if ( WP_MySQL_Lexer::PRIMARY_SYMBOL === $item[0]->id ) { + $primary_key = $this->table_primary_key_columns( $item ); $constraints[] = $this->translate_table_primary_key( $item ); continue; } @@ -368,8 +372,9 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE constraint in DuckDB driver: ' . $item[0]->get_bytes() . '.' ); } - list( $column_sql, $sequence_sql, $column_indexes ) = $this->translate_create_table_column( $table_name, $item ); - $columns[] = $column_sql; + list( $column_sql, $sequence_sql, $column_indexes, $column_metadata ) = $this->translate_create_table_column( $table_name, $item ); + $columns[] = $column_sql; + $metadata[] = $column_metadata; if ( null !== $sequence_sql ) { $sequences[] = $sequence_sql; } @@ -400,6 +405,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); $this->record_index_metadata( $index_definition ); } + $this->record_column_metadata( $table_name, $this->apply_column_key_metadata( $metadata, $primary_key, $indexes ) ); return $result; } @@ -735,31 +741,21 @@ private function execute_show_columns( array $tokens ): WP_DuckDB_Result_Stateme throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW COLUMNS statement in DuckDB driver. Only optional LIKE is supported.' ); } - $rows = $this->describe_column_rows( $table_name ); - if ( null !== $like_pattern ) { - $rows = $this->filter_column_rows_by_like( $rows, $like_pattern ); - } - if ( ! $full ) { + $rows = $this->describe_column_rows( $table_name ); + if ( null !== $like_pattern ) { + $rows = $this->filter_column_rows_by_like( $rows, $like_pattern ); + } + return new WP_DuckDB_Result_Statement( array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), $rows ); } - $full_rows = array(); - foreach ( $rows as $row ) { - $full_rows[] = array( - $row[0], - $row[1], - null, - $row[2], - $row[3], - $row[4], - $row[5], - 'select,insert,update,references', - '', - ); + $full_rows = $this->full_describe_column_rows( $table_name ); + if ( null !== $like_pattern ) { + $full_rows = $this->filter_column_rows_by_like( $full_rows, $like_pattern ); } return new WP_DuckDB_Result_Statement( @@ -779,6 +775,8 @@ private function execute_show_tables(): WP_DuckDB_Result_Statement { 'SELECT table_name AS ' . $this->connection->quote_identifier( $column ) . " FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' AND table_name <> " . $this->connection->quote( self::INDEX_METADATA_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::COLUMN_METADATA_TABLE ) . ' ORDER BY table_name', 'Failed to execute SHOW TABLES' ); @@ -940,6 +938,23 @@ private function execute_describe( array $tokens ): WP_DuckDB_Result_Statement { * @return array> */ private function describe_column_rows( string $table_name ): array { + $metadata_rows = $this->column_metadata_rows( $table_name ); + if ( count( $metadata_rows ) > 0 ) { + return array_map( + function ( array $row ): array { + return array( + $row['column_name'], + $row['column_type'], + $row['is_nullable'], + $row['column_key'], + $row['column_default'], + $row['extra'], + ); + }, + $metadata_rows + ); + } + $pragma = $this->execute_duckdb_query( 'SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ') ORDER BY cid', 'Failed to inspect DuckDB table' @@ -969,6 +984,51 @@ private function describe_column_rows( string $table_name ): array { return $describe_rows; } + /** + * Build MySQL SHOW FULL COLUMNS rows. + * + * @param string $table_name Table name. + * @return array> + */ + private function full_describe_column_rows( string $table_name ): array { + $metadata_rows = $this->column_metadata_rows( $table_name ); + if ( count( $metadata_rows ) > 0 ) { + return array_map( + function ( array $row ): array { + return array( + $row['column_name'], + $row['column_type'], + $row['collation_name'], + $row['is_nullable'], + $row['column_key'], + $row['column_default'], + $row['extra'], + 'select,insert,update,references', + $row['comment'], + ); + }, + $metadata_rows + ); + } + + $full_rows = array(); + foreach ( $this->describe_column_rows( $table_name ) as $row ) { + $full_rows[] = array( + $row[0], + $row[1], + null, + $row[2], + $row[3], + $row[4], + $row[5], + 'select,insert,update,references', + '', + ); + } + + return $full_rows; + } + /** * Filter DESCRIBE-style rows using a MySQL LIKE pattern against Field. * @@ -1076,7 +1136,7 @@ private function collect_parenthesized_items( array $tokens, int $index ): array * * @param string $table_name Table name. * @param WP_Parser_Token[] $tokens Column definition tokens. - * @return array{0:string,1:string|null,2:array}>} + * @return array{0:string,1:string|null,2:array}>,3:array} */ private function translate_create_table_column( string $table_name, array $tokens ): array { $index = 0; @@ -1087,6 +1147,7 @@ private function translate_create_table_column( string $table_name, array $token throw new WP_DuckDB_Driver_Exception( 'Unsupported MySQL column type for DuckDB column: ' . $column_name . '.' ); } + $type_index = $index; $type_token = $tokens[ $index ]; $duck_type = self::DATA_TYPE_MAP[ $type_token->id ]; ++$index; @@ -1201,7 +1262,18 @@ private function translate_create_table_column( string $table_name, array $token ); } - return array( $column_sql, $sequence, $indexes ); + $metadata = array( + 'column_name' => $column_name, + 'column_type' => $this->mysql_column_type_from_tokens( $tokens, $type_index ), + 'is_nullable' => $not_null ? 'NO' : 'YES', + 'column_key' => $primary_key ? 'PRI' : ( $unique_key ? 'UNI' : '' ), + 'column_default' => $auto_increment || null === $default_sql ? null : $this->normalize_describe_default( $default_sql ), + 'extra' => $auto_increment ? 'auto_increment' : '', + 'collation_name' => $this->mysql_column_collation_from_tokens( $tokens, $type_token ), + 'comment' => $this->mysql_column_comment_from_tokens( $tokens ), + ); + + return array( $column_sql, $sequence, $indexes, $metadata ); } /** @@ -1238,6 +1310,152 @@ private function skip_type_modifiers( array $tokens, int $index ): int { return $index; } + /** + * Build the MySQL-facing column type string for DESCRIBE/SHOW COLUMNS. + * + * @param WP_Parser_Token[] $tokens Column definition tokens. + * @param int $type_index Index of the type token. + * @return string MySQL column type. + */ + private function mysql_column_type_from_tokens( array $tokens, int $type_index ): string { + $pieces = array( $tokens[ $type_index ]->get_bytes() ); + $index = $type_index + 1; + + while ( $index < count( $tokens ) ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + $end = $this->skip_balanced_parentheses( $tokens, $index ); + for ( ; $index < $end; ++$index ) { + $pieces[] = $tokens[ $index ]->get_bytes(); + } + continue; + } + + if ( WP_MySQL_Lexer::UNSIGNED_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::ZEROFILL_SYMBOL === $tokens[ $index ]->id ) { + $pieces[] = $tokens[ $index ]->get_bytes(); + ++$index; + continue; + } + + if ( WP_MySQL_Lexer::CHARSET_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::COLLATE_SYMBOL === $tokens[ $index ]->id ) { + $index = $this->skip_option_value( $tokens, $index + 1 ); + continue; + } + + if ( WP_MySQL_Lexer::CHAR_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::CHARACTER_SYMBOL === $tokens[ $index ]->id ) { + if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $index + 1 ]->id ) { + break; + } + $index = $this->skip_option_value( $tokens, $index + 2 ); + continue; + } + + break; + } + + return strtolower( $this->join_sql_pieces( $pieces ) ); + } + + /** + * Read column collation metadata from a column definition. + * + * @param WP_Parser_Token[] $tokens Column definition tokens. + * @param WP_Parser_Token $type_token Type token. + * @return string|null Collation name. + */ + private function mysql_column_collation_from_tokens( array $tokens, WP_Parser_Token $type_token ): ?string { + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::COLLATE_SYMBOL === $tokens[ $index ]->id ) { + return $this->option_value( $tokens, $index + 1 ); + } + } + + return $this->mysql_type_has_collation( $type_token ) ? 'utf8mb4_unicode_ci' : null; + } + + /** + * Read column comment metadata from a column definition. + * + * @param WP_Parser_Token[] $tokens Column definition tokens. + * @return string Column comment. + */ + private function mysql_column_comment_from_tokens( array $tokens ): string { + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::COMMENT_SYMBOL === $tokens[ $index ]->id ) { + return (string) $this->option_value( $tokens, $index + 1 ); + } + } + + return ''; + } + + /** + * Read an option value token, accepting optional equals. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Index at optional equals or value. + * @return string|null Option value. + */ + private function option_value( array $tokens, int $index ): ?string { + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $index ]->id ) { + ++$index; + } + + return isset( $tokens[ $index ] ) ? $tokens[ $index ]->get_value() : null; + } + + /** + * Check whether a MySQL type normally has collation metadata. + * + * @param WP_Parser_Token $type_token Type token. + * @return bool Whether the type is collated. + */ + private function mysql_type_has_collation( WP_Parser_Token $type_token ): bool { + return in_array( + $type_token->id, + array( + WP_MySQL_Lexer::CHAR_SYMBOL, + WP_MySQL_Lexer::VARCHAR_SYMBOL, + WP_MySQL_Lexer::TEXT_SYMBOL, + WP_MySQL_Lexer::TINYTEXT_SYMBOL, + WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL, + WP_MySQL_Lexer::LONGTEXT_SYMBOL, + ), + true + ); + } + + /** + * Apply primary/secondary index key markers to column metadata. + * + * @param array> $metadata Column metadata. + * @param string[] $primary_key Table-level primary key columns. + * @param array}> $indexes Index definitions. + * @return array> + */ + private function apply_column_key_metadata( array $metadata, array $primary_key, array $indexes ): array { + foreach ( $metadata as &$column ) { + $column_name = strtolower( (string) $column['column_name'] ); + if ( in_array( $column_name, array_map( 'strtolower', $primary_key ), true ) ) { + $column['column_key'] = 'PRI'; + continue; + } + + foreach ( $indexes as $index_definition ) { + if ( count( $index_definition['columns'] ) === 0 ) { + continue; + } + if ( strtolower( $index_definition['columns'][0]['name'] ) !== $column_name ) { + continue; + } + $column['column_key'] = $index_definition['unique'] ? 'UNI' : 'MUL'; + break; + } + } + unset( $column ); + + return $metadata; + } + /** * Translate a table-level PRIMARY KEY constraint. * @@ -1245,6 +1463,28 @@ private function skip_type_modifiers( array $tokens, int $index ): int { * @return string */ private function translate_table_primary_key( array $tokens ): string { + $column_names = $this->table_primary_key_columns( $tokens ); + if ( count( $column_names ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported PRIMARY KEY constraint in DuckDB driver.' ); + } + + $columns = array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ); + }, + $column_names + ); + + return 'PRIMARY KEY (' . implode( ', ', $columns ) . ')'; + } + + /** + * Read table-level PRIMARY KEY columns. + * + * @param WP_Parser_Token[] $tokens Constraint tokens. + * @return string[] + */ + private function table_primary_key_columns( array $tokens ): array { $index = 0; $this->expect_token( $tokens, $index, WP_MySQL_Lexer::PRIMARY_SYMBOL, 'Expected PRIMARY KEY constraint.' ); $this->expect_token( $tokens, $index + 1, WP_MySQL_Lexer::KEY_SYMBOL, 'Expected PRIMARY KEY constraint.' ); @@ -1257,18 +1497,18 @@ private function translate_table_primary_key( array $tokens ): string { ++$index; break; } - $columns[] = $this->connection->quote_identifier( $this->identifier_value( $tokens[ $index ] ) ); + $columns[] = $this->identifier_value( $tokens[ $index ] ); ++$index; if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { ++$index; } } - if ( count( $columns ) === 0 || count( $tokens ) !== $index ) { + if ( count( $tokens ) !== $index ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported PRIMARY KEY constraint in DuckDB driver.' ); } - return 'PRIMARY KEY (' . implode( ', ', $columns ) . ')'; + return $columns; } /** @@ -2190,6 +2430,18 @@ private function ensure_index_metadata_table(): void { ); } + /** + * Ensure the internal column metadata table exists. + */ + private function ensure_column_metadata_table(): void { + $this->execute_duckdb_query( + 'CREATE TABLE IF NOT EXISTS ' + . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . ' (table_name VARCHAR, ordinal_position INTEGER, column_name VARCHAR, column_type VARCHAR, is_nullable VARCHAR, column_key VARCHAR, column_default VARCHAR, extra VARCHAR, collation_name VARCHAR, comment VARCHAR)', + 'Failed to initialize DuckDB column metadata' + ); + } + /** * Record MySQL index metadata for SHOW INDEX. * @@ -2233,6 +2485,74 @@ private function record_index_metadata( array $index_definition ): void { } } + /** + * Record MySQL column metadata for DESCRIBE and SHOW COLUMNS. + * + * @param string $table_name Table name. + * @param array> $metadata Column metadata. + */ + private function record_column_metadata( string $table_name, array $metadata ): void { + $this->ensure_column_metadata_table(); + + $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ), + 'Failed to reset DuckDB column metadata' + ); + + foreach ( $metadata as $offset => $column ) { + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . ' (table_name, ordinal_position, column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment) VALUES (' + . $this->connection->quote( $table_name ) + . ', ' + . ( $offset + 1 ) + . ', ' + . $this->connection->quote( $column['column_name'] ) + . ', ' + . $this->connection->quote( $column['column_type'] ) + . ', ' + . $this->connection->quote( $column['is_nullable'] ) + . ', ' + . $this->connection->quote( $column['column_key'] ) + . ', ' + . $this->connection->quote( $column['column_default'] ) + . ', ' + . $this->connection->quote( $column['extra'] ) + . ', ' + . $this->connection->quote( $column['collation_name'] ) + . ', ' + . $this->connection->quote( $column['comment'] ) + . ')', + 'Failed to store DuckDB column metadata' + ); + } + } + + /** + * Read recorded MySQL column metadata. + * + * @param string $table_name Table name. + * @return array> + */ + private function column_metadata_rows( string $table_name ): array { + $this->ensure_column_metadata_table(); + + $stmt = $this->execute_duckdb_query( + 'SELECT column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment FROM ' + . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' ORDER BY ordinal_position', + 'Failed to inspect DuckDB column metadata' + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + /** * Read an identifier token value. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index f41888ee6..29dd73b6b 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -120,4 +120,21 @@ public function test_create_table_if_not_exists_with_secondary_index_matches_sql array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) ); } + + public function test_show_columns_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'yes', + UNIQUE KEY option_name (option_name), + KEY autoload (autoload) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + ) + ); + + $this->assertParityRows( 'SHOW COLUMNS FROM metadata' ); + } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 0d5305085..b574c856d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -51,7 +51,14 @@ public function test_create_table_insert_update_delete_show_and_describe(): void ); $this->assertSame( 0, $create->rowCount() ); - $this->assertStringStartsWith( 'CREATE TABLE "users"', $this->lastDuckDBQuery( $driver ) ); + $this->assertNotEmpty( + array_filter( + $driver->get_last_duckdb_queries(), + function ( string $sql ): bool { + return 0 === strpos( $sql, 'CREATE TABLE "users"' ); + } + ) + ); $insert = $driver->query( "INSERT INTO `users` (`name`) VALUES ('Ada'), ('Grace')" ); $this->assertSame( 2, $insert->rowCount() ); @@ -100,7 +107,7 @@ public function test_create_table_insert_update_delete_show_and_describe(): void $this->assertSame( array( 'Field' => 'id', - 'Type' => 'BIGINT', + 'Type' => 'bigint(20) unsigned', 'Null' => 'NO', 'Key' => 'PRI', 'Default' => null, @@ -130,7 +137,7 @@ public function test_show_columns_and_full_fields_are_emulated(): void { array( array( 'Field' => 'val1', - 'Type' => 'INTEGER', + 'Type' => 'int', 'Null' => 'YES', 'Key' => '', 'Default' => null, @@ -138,7 +145,7 @@ public function test_show_columns_and_full_fields_are_emulated(): void { ), array( 'Field' => 'val2', - 'Type' => 'INTEGER', + 'Type' => 'int', 'Null' => 'NO', 'Key' => '', 'Default' => '0', @@ -152,14 +159,14 @@ public function test_show_columns_and_full_fields_are_emulated(): void { array( array( 'Field' => 'title', - 'Type' => 'VARCHAR', - 'Collation' => null, + 'Type' => 'varchar(100)', + 'Collation' => 'utf8mb4_unicode_ci', 'Null' => 'NO', 'Key' => '', 'Default' => 'untitled', 'Extra' => '', 'Privileges' => 'select,insert,update,references', - 'Comment' => '', + 'Comment' => 'DuckDB does not persist this yet', ), ), $driver->query( "SHOW FULL FIELDS IN `metadata` LIKE 'title'" )->fetchAll( PDO::FETCH_ASSOC ) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index dffcd4f9b..853967bc7 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -118,7 +118,7 @@ public function test_duckdb_wpdb_query_updates_raw_query_state(): void { 'INSERT INTO t VALUES (1), (2)', 'CREATE TABLE "t" ("id" INTEGER)', ), - $result['client_queries'] + array_slice( $result['client_queries'], 0, 4 ) ); } @@ -226,6 +226,15 @@ public function query( $sql ) { ); } + if ( 0 === strpos( $normalized, 'delete' ) ) { + return new WP_DuckDB_Plugin_Test_Result( + array( 'Count' ), + array( + array( 'Count' => 0 ), + ) + ); + } + if ( 0 === strpos( $normalized, 'create' ) ) { return new WP_DuckDB_Plugin_Test_Result( array( 'Success' ), From 493a740934642f3e305c624569bbade33338a57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 16:55:18 +0000 Subject: [PATCH 009/317] Add DuckDB INSERT SELECT parity --- .../src/duckdb/class-wp-duckdb-driver.php | 63 +++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 16 +++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 39 ++++++++++++ 3 files changed, 118 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 34991ba1f..e81eea43a 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -475,6 +475,17 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { ++$index; } + $select_index = $this->find_insert_select_index( $tokens, $index ); + if ( null !== $select_index ) { + if ( null !== $this->find_on_duplicate_key_update_index( $tokens ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT statement in DuckDB driver. INSERT ... SELECT ... ON DUPLICATE KEY UPDATE is not supported.' ); + } + return $this->execute_duckdb_query( + $this->translate_insert_select_tokens_to_duckdb_sql( $tokens, $index, $ignore ), + 'Failed to execute DuckDB INSERT' + ); + } + $set_index = $this->find_insert_set_index( $tokens, $index ); if ( null !== $set_index ) { if ( null !== $this->find_on_duplicate_key_update_index( $tokens ) ) { @@ -518,6 +529,14 @@ private function execute_replace( array $tokens ): WP_DuckDB_Result_Statement { if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $index ]->id ) { ++$index; } + + if ( null !== $this->find_insert_select_index( $tokens, $index ) ) { + return $this->execute_duckdb_query( + $this->translate_replace_tokens_to_duckdb_sql( $tokens ), + 'Failed to execute DuckDB REPLACE' + ); + } + $this->assert_values_write_statement( $tokens, $index, 'REPLACE' ); return $this->execute_duckdb_query( @@ -625,6 +644,26 @@ private function find_insert_set_index( array $tokens, int $table_index ): ?int : null; } + /** + * Find the top-level SELECT clause in a supported INSERT/REPLACE ... SELECT. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index expected to contain the table identifier. + * @return int|null Index of the SELECT token, or null when absent. + */ + private function find_insert_select_index( array $tokens, int $table_index ): ?int { + $this->identifier_value( $tokens[ $table_index ] ?? null ); + + $index = $table_index + 1; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + $index = $this->skip_balanced_parentheses( $tokens, $index ); + } + + return isset( $tokens[ $index ] ) && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $index ]->id + ? $index + : null; + } + /** * Execute a supported ALTER TABLE statement. * @@ -1846,6 +1885,15 @@ private function translate_tokens_to_duckdb_sql( array $tokens ): string { for ( $index = 0; $index < count( $tokens ); ++$index ) { $token = $tokens[ $index ]; + if ( + WP_MySQL_Lexer::FROM_SYMBOL === $token->id + && isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::DUAL_SYMBOL === $tokens[ $index + 1 ]->id + ) { + ++$index; + continue; + } + if ( WP_MySQL_Lexer::NOT_SYMBOL === $token->id && isset( $tokens[ $index + 1 ], $tokens[ $index + 2 ] ) @@ -1931,6 +1979,21 @@ private function translate_insert_ignore_tokens_to_duckdb_sql( array $tokens, in return 'INSERT OR IGNORE INTO ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $table_index ) ); } + /** + * Translate MySQL INSERT ... SELECT to DuckDB, normalizing optional INTO. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param bool $ignore Whether INSERT IGNORE was used. + * @return string DuckDB SQL. + */ + private function translate_insert_select_tokens_to_duckdb_sql( array $tokens, int $table_index, bool $ignore ): string { + return 'INSERT ' + . ( $ignore ? 'OR IGNORE ' : '' ) + . 'INTO ' + . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $table_index ) ); + } + /** * Translate MySQL INSERT ... SET to DuckDB INSERT ... VALUES. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 29dd73b6b..3bc4ea391 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -46,6 +46,22 @@ public function test_insert_set_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_insert_select_and_replace_select_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE source_items (id INTEGER, name VARCHAR(100))', + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR(100))', + "INSERT INTO source_items VALUES (1, 'first'), (2, 'second')", + ) + ); + + $this->assertParityRowCount( 'INSERT INTO items (id, name) SELECT id, name FROM source_items WHERE id = 1' ); + $this->assertParityRowCount( 'INSERT IGNORE items (id, name) SELECT id, name FROM source_items WHERE id = 1' ); + $this->assertParityRowCount( "INSERT items (id, name) SELECT 3, 'third' FROM DUAL WHERE (SELECT NULL FROM DUAL) IS NULL" ); + $this->assertParityRowCount( "REPLACE INTO items (id, name) SELECT 1, 'replaced' FROM DUAL" ); + $this->assertParityRows( 'SELECT id, name FROM items ORDER BY id' ); + } + public function test_regexp_and_not_regexp_match_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index b574c856d..b912bf121 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -319,6 +319,45 @@ public function test_insert_set_is_emulated(): void { ); } + public function test_insert_select_and_replace_select_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE source_items (id INTEGER, name VARCHAR(100))' ); + $driver->query( 'CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR(100))' ); + $driver->query( "INSERT INTO source_items VALUES (1, 'first'), (2, 'second')" ); + + $inserted = $driver->query( 'INSERT INTO items (id, name) SELECT id, name FROM source_items WHERE id = 1' ); + $this->assertSame( 1, $inserted->rowCount() ); + $this->assertSame( 'INSERT INTO items(id, name) SELECT id, name FROM source_items WHERE id = 1', $this->lastDuckDBQuery( $driver ) ); + + $ignored = $driver->query( 'INSERT IGNORE items (id, name) SELECT id, name FROM source_items WHERE id = 1' ); + $this->assertSame( 0, $ignored->rowCount() ); + $this->assertSame( 'INSERT OR IGNORE INTO items(id, name) SELECT id, name FROM source_items WHERE id = 1', $this->lastDuckDBQuery( $driver ) ); + + $inserted_from_dual = $driver->query( "INSERT items (id, name) SELECT 3, 'third' FROM DUAL WHERE (SELECT NULL FROM DUAL) IS NULL" ); + $this->assertSame( 1, $inserted_from_dual->rowCount() ); + $this->assertSame( "INSERT INTO items(id, name) SELECT 3, 'third' WHERE (SELECT NULL) IS NULL", $this->lastDuckDBQuery( $driver ) ); + + $replaced_from_dual = $driver->query( "REPLACE INTO items (id, name) SELECT 1, 'replaced' FROM DUAL" ); + $this->assertSame( 1, $replaced_from_dual->rowCount() ); + $this->assertSame( "INSERT OR REPLACE INTO items(id, name) SELECT 1, 'replaced'", $this->lastDuckDBQuery( $driver ) ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'replaced', + ), + array( + 'id' => 3, + 'name' => 'third', + ), + ), + $driver->query( 'SELECT id, name FROM items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_insert_on_duplicate_key_update_values_is_emulated(): void { $this->requireDuckDBRuntime(); From f449aa6d24c112804106f864db52744abb06252c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 16:56:49 +0000 Subject: [PATCH 010/317] Expand DuckDB INSERT SELECT parity tests --- .../tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php | 1 + .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 3bc4ea391..dc89455e7 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -56,6 +56,7 @@ public function test_insert_select_and_replace_select_match_sqlite(): void { ); $this->assertParityRowCount( 'INSERT INTO items (id, name) SELECT id, name FROM source_items WHERE id = 1' ); + $this->assertParityRowCount( 'INSERT INTO items SELECT id, name FROM source_items WHERE id = 2' ); $this->assertParityRowCount( 'INSERT IGNORE items (id, name) SELECT id, name FROM source_items WHERE id = 1' ); $this->assertParityRowCount( "INSERT items (id, name) SELECT 3, 'third' FROM DUAL WHERE (SELECT NULL FROM DUAL) IS NULL" ); $this->assertParityRowCount( "REPLACE INTO items (id, name) SELECT 1, 'replaced' FROM DUAL" ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index b912bf121..25c50d524 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -331,6 +331,10 @@ public function test_insert_select_and_replace_select_are_emulated(): void { $this->assertSame( 1, $inserted->rowCount() ); $this->assertSame( 'INSERT INTO items(id, name) SELECT id, name FROM source_items WHERE id = 1', $this->lastDuckDBQuery( $driver ) ); + $inserted_without_target_columns = $driver->query( 'INSERT INTO items SELECT id, name FROM source_items WHERE id = 2' ); + $this->assertSame( 1, $inserted_without_target_columns->rowCount() ); + $this->assertSame( 'INSERT INTO items SELECT id, name FROM source_items WHERE id = 2', $this->lastDuckDBQuery( $driver ) ); + $ignored = $driver->query( 'INSERT IGNORE items (id, name) SELECT id, name FROM source_items WHERE id = 1' ); $this->assertSame( 0, $ignored->rowCount() ); $this->assertSame( 'INSERT OR IGNORE INTO items(id, name) SELECT id, name FROM source_items WHERE id = 1', $this->lastDuckDBQuery( $driver ) ); @@ -349,6 +353,10 @@ public function test_insert_select_and_replace_select_are_emulated(): void { 'id' => 1, 'name' => 'replaced', ), + array( + 'id' => 2, + 'name' => 'second', + ), array( 'id' => 3, 'name' => 'third', From 6e2973d2e4d3706ed3cbea00e510f34a87bdcc79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 17:10:50 +0000 Subject: [PATCH 011/317] Add DuckDB insert ID parity --- .../src/duckdb/class-wp-duckdb-driver.php | 287 +++++++++++++++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 53 ++++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 85 +++++- .../wp-includes/duckdb/class-wp-duckdb-db.php | 7 + 4 files changed, 419 insertions(+), 13 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index e81eea43a..c85783a3f 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -86,6 +86,11 @@ class WP_DuckDB_Driver { */ private $last_duckdb_queries = array(); + /** + * @var int + */ + private $last_insert_id = 0; + /** * MySQL-compatible server version string. * @@ -136,6 +141,7 @@ public function __construct( array $options = array() ) { public function query( string $query ): WP_DuckDB_Result_Statement { $this->last_mysql_query = $query; $this->last_duckdb_queries = array(); + $this->last_insert_id = 0; $tokens = $this->tokenize_and_validate( $query ); if ( count( $tokens ) === 0 ) { @@ -194,6 +200,15 @@ public function get_last_duckdb_queries(): array { return $this->last_duckdb_queries; } + /** + * Get the auto-increment value generated by the last INSERT/REPLACE. + * + * @return int Insert ID, or 0 when none was generated. + */ + public function get_insert_id(): int { + return $this->last_insert_id; + } + /** * Tokenize and parse a single MySQL statement. * @@ -480,9 +495,12 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { if ( null !== $this->find_on_duplicate_key_update_index( $tokens ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT statement in DuckDB driver. INSERT ... SELECT ... ON DUPLICATE KEY UPDATE is not supported.' ); } - return $this->execute_duckdb_query( + return $this->execute_auto_increment_write( + $this->identifier_value( $tokens[ $index ] ?? null ), $this->translate_insert_select_tokens_to_duckdb_sql( $tokens, $index, $ignore ), - 'Failed to execute DuckDB INSERT' + 'Failed to execute DuckDB INSERT', + $tokens, + $index ); } @@ -491,9 +509,12 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { if ( null !== $this->find_on_duplicate_key_update_index( $tokens ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT statement in DuckDB driver. INSERT ... SET ... ON DUPLICATE KEY UPDATE is not supported.' ); } - return $this->execute_duckdb_query( + return $this->execute_auto_increment_write( + $this->identifier_value( $tokens[ $index ] ?? null ), $this->translate_insert_set_tokens_to_duckdb_sql( $tokens, $index, $set_index, $ignore ), - 'Failed to execute DuckDB INSERT' + 'Failed to execute DuckDB INSERT', + $tokens, + $index ); } @@ -504,17 +525,23 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { if ( $ignore ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT statement in DuckDB driver. INSERT IGNORE ... ON DUPLICATE KEY UPDATE is not supported.' ); } - return $this->execute_duckdb_query( + return $this->execute_auto_increment_write( + $this->identifier_value( $tokens[ $index ] ?? null ), $this->translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( $tokens, $index, $on_duplicate_index ), - 'Failed to execute DuckDB INSERT' + 'Failed to execute DuckDB INSERT', + $tokens, + $index ); } - return $this->execute_duckdb_query( + return $this->execute_auto_increment_write( + $this->identifier_value( $tokens[ $index ] ?? null ), $ignore ? $this->translate_insert_ignore_tokens_to_duckdb_sql( $tokens, $index ) : $this->translate_tokens_to_duckdb_sql( $tokens ), - 'Failed to execute DuckDB INSERT' + 'Failed to execute DuckDB INSERT', + $tokens, + $index ); } @@ -531,17 +558,23 @@ private function execute_replace( array $tokens ): WP_DuckDB_Result_Statement { } if ( null !== $this->find_insert_select_index( $tokens, $index ) ) { - return $this->execute_duckdb_query( + return $this->execute_auto_increment_write( + $this->identifier_value( $tokens[ $index ] ?? null ), $this->translate_replace_tokens_to_duckdb_sql( $tokens ), - 'Failed to execute DuckDB REPLACE' + 'Failed to execute DuckDB REPLACE', + $tokens, + $index ); } $this->assert_values_write_statement( $tokens, $index, 'REPLACE' ); - return $this->execute_duckdb_query( + return $this->execute_auto_increment_write( + $this->identifier_value( $tokens[ $index ] ?? null ), $this->translate_replace_tokens_to_duckdb_sql( $tokens ), - 'Failed to execute DuckDB REPLACE' + 'Failed to execute DuckDB REPLACE', + $tokens, + $index ); } @@ -2464,6 +2497,236 @@ private function should_omit_space_before_open_parenthesis( ?string $previous ): ); } + /** + * Execute an INSERT/REPLACE and record a generated auto-increment ID. + * + * @param string $table_name Table name. + * @param string $sql DuckDB SQL. + * @param string $context Failure context. + * @param array $tokens MySQL token stream. + * @param int $table_index Index of the table token in $tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_auto_increment_write( string $table_name, string $sql, string $context, array $tokens = array(), ?int $table_index = null ): WP_DuckDB_Result_Statement { + $metadata = $this->auto_increment_metadata_for_table( $table_name ); + $sequence_name = null === $metadata ? null : $metadata['sequence_name']; + $explicit_insert_id = null === $metadata || null === $table_index + ? null + : $this->explicit_auto_increment_value_for_write( $tokens, $table_index, $metadata['column_name'] ); + $before = null === $sequence_name ? null : $this->sequence_currval( $sequence_name ); + $result = $this->execute_duckdb_query( $sql, $context ); + + if ( null !== $sequence_name && $result->rowCount() > 0 ) { + $after = $this->sequence_currval( $sequence_name ); + if ( null !== $after && $after !== $before ) { + $this->last_insert_id = $after; + } elseif ( null !== $explicit_insert_id ) { + $this->last_insert_id = $explicit_insert_id; + } + } + + return $result; + } + + /** + * Find metadata for a table's recorded AUTO_INCREMENT column. + * + * @param string $table_name Table name. + * @return array{column_name:string,sequence_name:string}|null Metadata, or null when no AUTO_INCREMENT column is known. + */ + private function auto_increment_metadata_for_table( string $table_name ): ?array { + try { + $stmt = $this->connection->query( + 'SELECT column_name FROM ' + . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . " AND extra = 'auto_increment' ORDER BY ordinal_position LIMIT 1" + ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + return null; + } + + $column_name = $stmt->fetchColumn(); + if ( false === $column_name || null === $column_name ) { + return null; + } + + $column_name = (string) $column_name; + return array( + 'column_name' => $column_name, + 'sequence_name' => $this->sequence_name( $table_name, $column_name ), + ); + } + + /** + * Parse the last explicit literal assigned to an AUTO_INCREMENT column. + * + * @param WP_Parser_Token[] $tokens MySQL token stream. + * @param int $table_index Index of the table token. + * @param string $column_name AUTO_INCREMENT column name. + * @return int|null Explicit value, or null when the statement uses generated/default values. + */ + private function explicit_auto_increment_value_for_write( array $tokens, int $table_index, string $column_name ): ?int { + $set_index = $this->find_insert_set_index( $tokens, $table_index ); + if ( null !== $set_index ) { + return $this->explicit_auto_increment_value_for_set_assignments( array_slice( $tokens, $set_index + 1 ), $column_name ); + } + + $index = $table_index + 1; + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index ]->id ) { + return null; + } + + list( $column_items, $index ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); + $column_offset = null; + foreach ( $column_items as $offset => $column_tokens ) { + if ( 1 === count( $column_tokens ) && 0 === strcasecmp( $this->identifier_value( $column_tokens[0] ), $column_name ) ) { + $column_offset = $offset; + break; + } + } + + if ( null === $column_offset || ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $index ]->id ) { + return null; + } + ++$index; + + $explicit_value = null; + while ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + list( $value_items, $index ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); + if ( isset( $value_items[ $column_offset ] ) ) { + $value = $this->integer_literal_value( $value_items[ $column_offset ] ); + if ( null !== $value ) { + $explicit_value = $value; + } + } + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + continue; + } + break; + } + + return $explicit_value; + } + + /** + * Parse an explicit AUTO_INCREMENT value from INSERT ... SET assignments. + * + * @param WP_Parser_Token[] $tokens Assignment tokens after SET. + * @param string $column_name AUTO_INCREMENT column name. + * @return int|null Explicit value, or null when absent/default. + */ + private function explicit_auto_increment_value_for_set_assignments( array $tokens, string $column_name ): ?int { + $index = 0; + + while ( $index < count( $tokens ) ) { + $current_column = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( + ! isset( $tokens[ $index ] ) + || ( WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $index ]->id && WP_MySQL_Lexer::ASSIGN_OPERATOR !== $tokens[ $index ]->id ) + ) { + return null; + } + ++$index; + + $value_tokens = array(); + $depth = 0; + while ( $index < count( $tokens ) ) { + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + break; + } + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + } elseif ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + } + $value_tokens[] = $tokens[ $index ]; + ++$index; + } + + if ( 0 === strcasecmp( $current_column, $column_name ) ) { + return $this->integer_literal_value( $value_tokens ); + } + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + } + + return null; + } + + /** + * Convert a tokenized integer literal to an int. + * + * @param WP_Parser_Token[] $tokens Literal tokens. + * @return int|null Integer value, or null for expressions/defaults. + */ + private function integer_literal_value( array $tokens ): ?int { + if ( 1 === count( $tokens ) ) { + $token = $tokens[0]; + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ) + ) { + return (int) $token->get_bytes(); + } + if ( + ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) + && preg_match( '/^[+-]?\d+$/', $token->get_value() ) + ) { + return (int) $token->get_value(); + } + } + + if ( + 2 === count( $tokens ) + && ( WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[0]->id || WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[0]->id ) + && in_array( + $tokens[1]->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ) + ) { + return (int) ( ( WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[0]->id ? '-' : '+' ) . $tokens[1]->get_bytes() ); + } + + return null; + } + + /** + * Read the current value for a sequence, if one exists in this session. + * + * @param string $sequence_name Sequence name. + * @return int|null Current value. + */ + private function sequence_currval( string $sequence_name ): ?int { + try { + $stmt = $this->connection->query( 'SELECT currval(' . $this->connection->quote( $sequence_name ) . ')' ); + $value = $stmt->fetchColumn(); + } catch ( WP_DuckDB_Driver_Exception $e ) { + return null; + } + + return false === $value || null === $value ? null : (int) $value; + } + /** * Execute DuckDB SQL and preserve inspectable SQL output. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 25c50d524..1e7c89293 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -366,6 +366,59 @@ public function test_insert_select_and_replace_select_are_emulated(): void { ); } + public function test_insert_id_tracks_generated_auto_increment_values(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE auto_items ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) UNIQUE + )' + ); + $driver->query( 'CREATE TABLE source_names (name VARCHAR(100))' ); + $driver->query( + 'CREATE TABLE replace_items ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) + )' + ); + $driver->query( "INSERT INTO source_names VALUES ('fifth'), ('sixth')" ); + + $driver->query( "INSERT INTO auto_items (name) VALUES ('first')" ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $driver->query( "INSERT INTO auto_items (name) VALUES ('second'), ('third')" ); + $this->assertSame( 3, $driver->get_insert_id() ); + + $driver->query( "INSERT auto_items SET name = 'fourth'" ); + $this->assertSame( 4, $driver->get_insert_id() ); + + $driver->query( 'INSERT INTO auto_items (name) SELECT name FROM source_names ORDER BY name' ); + $this->assertSame( 6, $driver->get_insert_id() ); + + $driver->query( "REPLACE INTO replace_items (name) VALUES ('replacement')" ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $driver->query( "REPLACE INTO replace_items (id, name) VALUES (42, 'explicit-replacement')" ); + $this->assertSame( 42, $driver->get_insert_id() ); + + $driver->query( "INSERT INTO auto_items (id, name) VALUES (42, 'explicit')" ); + $this->assertSame( 42, $driver->get_insert_id() ); + + $driver->query( "INSERT IGNORE INTO auto_items (name) VALUES ('first')" ); + $this->assertSame( 0, $driver->get_insert_id() ); + + try { + $driver->query( "INSERT INTO auto_items (id, name) VALUES (42, 'duplicate-id')" ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertSame( 0, $driver->get_insert_id() ); + return; + } + + $this->fail( 'Expected duplicate insert to fail.' ); + } + public function test_insert_on_duplicate_key_update_values_is_emulated(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index 853967bc7..55363940a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -111,6 +111,15 @@ public function test_duckdb_wpdb_query_updates_raw_query_state(): void { $this->assertSame( 2, $result['insert_rows_affected'] ); $this->assertTrue( $result['create_return'] ); $this->assertSame( 3, $result['num_queries'] ); + $user_client_queries = array_values( + array_filter( + $result['client_queries'], + function ( string $sql ): bool { + return false === strpos( $sql, '__wp_duckdb_' ) + && false === strpos( $sql, 'currval(' ); + } + ) + ); $this->assertSame( array( 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(d, f)', @@ -118,10 +127,22 @@ public function test_duckdb_wpdb_query_updates_raw_query_state(): void { 'INSERT INTO t VALUES (1), (2)', 'CREATE TABLE "t" ("id" INTEGER)', ), - array_slice( $result['client_queries'], 0, 4 ) + array_slice( $user_client_queries, 0, 4 ) ); } + public function test_duckdb_wpdb_insert_id_tracks_driver_insert_id(): void { + $result = $this->run_insert_id_state_script(); + + $this->assertTrue( $result['connected'] ); + $this->assertSame( 1, $result['insert_return'] ); + $this->assertSame( 101, $result['insert_id_after_insert'] ); + $this->assertSame( 1, $result['replace_return'] ); + $this->assertSame( 102, $result['insert_id_after_replace'] ); + $this->assertFalse( $result['failed_insert_return'] ); + $this->assertSame( 0, $result['insert_id_after_failed_insert'] ); + } + public function test_duckdb_wpdb_flush_clears_statement_metadata(): void { $result = $this->run_flush_state_script(); @@ -283,6 +304,68 @@ public function query( $sql ) { return $this->run_isolated_php( $code ); } + private function run_insert_id_state_script(): array { + $plugin_dir = $this->get_plugin_dir(); + $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; + $code = $this->get_wordpress_stub_code(); + $code .= "\nrequire_once " . var_export( $driver_load, true ) . ";\n"; + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= <<<'PHP' + +class WP_DuckDB_Plugin_Insert_Id_Test_Driver extends WP_DuckDB_Driver { + private $insert_id = 0; + + public function __construct() {} + + public function query( string $sql ): WP_DuckDB_Result_Statement { + if ( false !== strpos( $sql, 'BROKEN' ) ) { + throw new RuntimeException( 'Synthetic insert failure.' ); + } + + if ( 0 === stripos( trim( $sql ), 'replace' ) ) { + $this->insert_id = 102; + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + if ( 0 === stripos( trim( $sql ), 'insert' ) ) { + $this->insert_id = 101; + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + public function get_insert_id(): int { + return $this->insert_id; + } +} + +$GLOBALS['@duckdb_driver'] = new WP_DuckDB_Plugin_Insert_Id_Test_Driver(); +$db = new WP_DuckDB_DB( 'wordpress_test' ); +$connected = $db->db_connect( false ); +$insert_return = $db->query( "INSERT INTO t (name) VALUES ('first')" ); +$insert_id = $db->insert_id; +$replace_return = $db->query( "REPLACE INTO t (name) VALUES ('second')" ); +$replace_insert_id = $db->insert_id; +$failed_insert = $db->query( 'INSERT INTO t VALUES (BROKEN)' ); +$failed_insert_id = $db->insert_id; + +echo json_encode( + array( + 'connected' => $connected, + 'insert_return' => $insert_return, + 'insert_id_after_insert' => $insert_id, + 'replace_return' => $replace_return, + 'insert_id_after_replace' => $replace_insert_id, + 'failed_insert_return' => $failed_insert, + 'insert_id_after_failed_insert' => $failed_insert_id, + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + private function run_flush_state_script(): array { $plugin_dir = $this->get_plugin_dir(); $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index 54c5cb113..d64bca022 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -194,6 +194,10 @@ public function query( $query ) { $this->_do_query( $query ); if ( $this->last_error ) { + if ( $this->insert_id && preg_match( '/^\s*(insert|replace)\s/i', $query ) ) { + $this->insert_id = 0; + } + $this->print_error(); return false; } @@ -204,6 +208,9 @@ public function query( $query ) { if ( preg_match( '/^\s*(insert|delete|update|replace)\s/i', $query ) ) { $this->rows_affected = $this->last_statement ? $this->last_statement->rowCount() : 0; + if ( preg_match( '/^\s*(insert|replace)\s/i', $query ) && method_exists( $this->dbh, 'get_insert_id' ) ) { + $this->insert_id = $this->dbh->get_insert_id(); + } return $this->rows_affected; } From 9e11a62591a5f35a85bf318ee9b049cb1a3811d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 17:19:10 +0000 Subject: [PATCH 012/317] Add DuckDB ALTER ADD COLUMN parity --- .../src/duckdb/class-wp-duckdb-driver.php | 246 ++++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 17 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 124 ++++++++- 3 files changed, 369 insertions(+), 18 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index c85783a3f..b69183cff 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -713,21 +713,110 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; - if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::ADD_SYMBOL === $tokens[ $index ]->id ) { - ++$index; - $alter_item = array_slice( $tokens, $index ); - if ( ! $this->is_create_table_index_item( $alter_item ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD INDEX is supported.' ); + $actions = $this->split_top_level_comma_items( array_slice( $tokens, $index ) ); + if ( count( $actions ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD COLUMN and ADD INDEX are supported.' ); + } + + $result = null; + foreach ( $actions as $action ) { + $this->expect_token( $action, 0, WP_MySQL_Lexer::ADD_SYMBOL, 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD actions are supported.' ); + $alter_item = array_slice( $action, 1 ); + + $result = $this->is_create_table_index_item( $alter_item ) + ? $this->execute_alter_table_add_index( $table_name, $alter_item ) + : $this->execute_alter_table_add_column( $table_name, $alter_item ); + } + + return $result ?? new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + /** + * Execute ALTER TABLE ... ADD INDEX. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Index definition tokens after ADD. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_add_index( string $table_name, array $tokens ): WP_DuckDB_Result_Statement { + $index_definition = $this->translate_create_table_index( $table_name, $tokens ); + $result = $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); + $this->record_index_metadata( $index_definition ); + + return $result; + } + + /** + * Execute a bounded ALTER TABLE ... ADD [COLUMN] statement. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Tokens after ADD. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_add_column( string $table_name, array $tokens ): WP_DuckDB_Result_Statement { + if ( isset( $tokens[0] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[0]->id ) { + $tokens = array_slice( $tokens, 1 ); + } + + if ( count( $tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN requires a column definition.' ); + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id ) { + list( $items, $index ) = $this->collect_parenthesized_items( $tokens, 1 ); + if ( 1 !== count( $items ) || count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD COLUMN definition is supported.' ); } + $tokens = $items[0]; + } - $index_definition = $this->translate_create_table_index( $table_name, $alter_item ); - $result = $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); - $this->record_index_metadata( $index_definition ); + list( $column_sql, $sequence_sql, $indexes, $metadata ) = $this->translate_create_table_column( $table_name, $tokens, false, true ); + if ( 'PRI' === $metadata['column_key'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN PRIMARY KEY is not supported.' ); + } + if ( 'auto_increment' === $metadata['extra'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN AUTO_INCREMENT is not supported.' ); + } + + if ( + 'NO' === $metadata['is_nullable'] + && null === $metadata['column_default'] + && 'auto_increment' !== $metadata['extra'] + && $this->table_has_rows( $table_name ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN NOT NULL requires a DEFAULT for non-empty tables.' ); + } + + if ( null !== $sequence_sql ) { + $this->execute_duckdb_query( $sequence_sql, 'Failed to create DuckDB AUTO_INCREMENT sequence' ); + } + + $result = $this->execute_duckdb_query( + 'ALTER TABLE ' + . $this->connection->quote_identifier( $table_name ) + . ' ADD COLUMN ' + . $column_sql, + 'Failed to add DuckDB column' + ); - return $result; + if ( 'NO' === $metadata['is_nullable'] ) { + $this->execute_duckdb_query( + 'ALTER TABLE ' + . $this->connection->quote_identifier( $table_name ) + . ' ALTER COLUMN ' + . $this->connection->quote_identifier( $metadata['column_name'] ) + . ' SET NOT NULL', + 'Failed to apply DuckDB NOT NULL column constraint' + ); } - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD INDEX is supported.' ); + foreach ( $indexes as $index_definition ) { + $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); + $this->record_index_metadata( $index_definition ); + } + $this->append_column_metadata( $table_name, $metadata ); + + return $result; } /** @@ -1203,14 +1292,62 @@ private function collect_parenthesized_items( array $tokens, int $index ): array throw new WP_DuckDB_Driver_Exception( 'Unterminated CREATE TABLE column list.' ); } + /** + * Split a token stream on top-level commas. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return array> + */ + private function split_top_level_comma_items( array $tokens ): array { + $items = array(); + $current = array(); + $depth = 0; + + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + ++$depth; + $current[] = $token; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id ) { + --$depth; + if ( $depth < 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unbalanced parentheses in DuckDB driver statement.' ); + } + $current[] = $token; + continue; + } + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $token->id ) { + if ( count( $current ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Empty comma-separated item in DuckDB driver statement.' ); + } + $items[] = $current; + $current = array(); + continue; + } + $current[] = $token; + } + + if ( 0 !== $depth ) { + throw new WP_DuckDB_Driver_Exception( 'Unbalanced parentheses in DuckDB driver statement.' ); + } + if ( count( $current ) > 0 ) { + $items[] = $current; + } + + return $items; + } + /** * Translate a column definition. * - * @param string $table_name Table name. - * @param WP_Parser_Token[] $tokens Column definition tokens. + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Column definition tokens. + * @param bool $include_inline_constraints Whether to include inline NOT NULL/PRIMARY KEY SQL. + * @param bool $allow_position_options Whether to accept FIRST/AFTER position hints. * @return array{0:string,1:string|null,2:array}>,3:array} */ - private function translate_create_table_column( string $table_name, array $tokens ): array { + private function translate_create_table_column( string $table_name, array $tokens, bool $include_inline_constraints = true, bool $allow_position_options = false ): array { $index = 0; $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; @@ -1276,6 +1413,19 @@ private function translate_create_table_column( string $table_name, array $token case WP_MySQL_Lexer::CHARSET_SYMBOL: $index = $this->skip_option_value( $tokens, $index + 1 ); break; + case WP_MySQL_Lexer::FIRST_SYMBOL: + if ( ! $allow_position_options ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported column attribute in DuckDB driver: ' . $token->get_bytes() . '.' ); + } + ++$index; + break; + case WP_MySQL_Lexer::AFTER_SYMBOL: + if ( ! $allow_position_options ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported column attribute in DuckDB driver: ' . $token->get_bytes() . '.' ); + } + $this->identifier_value( $tokens[ $index + 1 ] ?? null ); + $index += 2; + break; case WP_MySQL_Lexer::CHAR_SYMBOL: case WP_MySQL_Lexer::CHARACTER_SYMBOL: if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $index + 1 ]->id ) { @@ -1311,10 +1461,10 @@ private function translate_create_table_column( string $table_name, array $token $column_sql .= ' DEFAULT ' . $default_sql; } - if ( $not_null ) { + if ( $not_null && $include_inline_constraints ) { $column_sql .= ' NOT NULL'; } - if ( $primary_key ) { + if ( $primary_key && $include_inline_constraints ) { $column_sql .= ' PRIMARY KEY'; } @@ -2858,6 +3008,55 @@ private function record_column_metadata( string $table_name, array $metadata ): } } + /** + * Append one MySQL column metadata row when full table metadata is already recorded. + * + * @param string $table_name Table name. + * @param array $column Column metadata. + */ + private function append_column_metadata( string $table_name, array $column ): void { + $this->ensure_column_metadata_table(); + + $stmt = $this->execute_duckdb_query( + 'SELECT COUNT(*) AS column_count, COALESCE(MAX(ordinal_position), 0) AS max_ordinal FROM ' + . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ), + 'Failed to inspect DuckDB column metadata' + ); + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( ! is_array( $row ) || 0 === (int) $row['column_count'] ) { + return; + } + + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . ' (table_name, ordinal_position, column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment) VALUES (' + . $this->connection->quote( $table_name ) + . ', ' + . ( (int) $row['max_ordinal'] + 1 ) + . ', ' + . $this->connection->quote( $column['column_name'] ) + . ', ' + . $this->connection->quote( $column['column_type'] ) + . ', ' + . $this->connection->quote( $column['is_nullable'] ) + . ', ' + . $this->connection->quote( $column['column_key'] ) + . ', ' + . $this->connection->quote( $column['column_default'] ) + . ', ' + . $this->connection->quote( $column['extra'] ) + . ', ' + . $this->connection->quote( $column['collation_name'] ) + . ', ' + . $this->connection->quote( $column['comment'] ) + . ')', + 'Failed to store DuckDB column metadata' + ); + } + /** * Read recorded MySQL column metadata. * @@ -2879,6 +3078,23 @@ private function column_metadata_rows( string $table_name ): array { return $stmt->fetchAll( PDO::FETCH_ASSOC ); } + /** + * Check whether a table has at least one row. + * + * @param string $table_name Table name. + * @return bool Whether any row exists. + */ + private function table_has_rows( string $table_name ): bool { + $stmt = $this->execute_duckdb_query( + 'SELECT 1 FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' LIMIT 1', + 'Failed to inspect DuckDB table rows' + ); + + return false !== $stmt->fetch( PDO::FETCH_NUM ); + } + /** * Read an identifier token value. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index dc89455e7..2af8466b2 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -154,4 +154,21 @@ public function test_show_columns_metadata_matches_sqlite(): void { $this->assertParityRows( 'SHOW COLUMNS FROM metadata' ); } + + public function test_alter_table_add_column_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO metadata (option_name, option_value) VALUES ('siteurl', 'https://example.test')", + "ALTER TABLE metadata ADD COLUMN autoload VARCHAR(20) NOT NULL DEFAULT 'yes'", + ) + ); + + $this->assertParityRows( 'SELECT option_name, option_value, autoload FROM metadata' ); + $this->assertParityRows( 'SHOW COLUMNS FROM metadata' ); + } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 1e7c89293..824f778f9 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -564,14 +564,132 @@ public function test_alter_table_add_unique_index_is_supported(): void { $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('siteurl', 'duplicate')" ); } - public function test_unsupported_alter_table_shape_throws_driver_exception(): void { + public function test_alter_table_add_column_updates_data_and_metadata(): void { $this->requireDuckDBRuntime(); $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL + )" + ); + $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('siteurl', 'https://example.test')" ); + + $result = $driver->query( "ALTER TABLE wp_options ADD COLUMN autoload VARCHAR(20) NOT NULL DEFAULT 'yes'" ); + + $this->assertSame( 0, $result->rowCount() ); + $this->assertSame( + array( + array( + 'option_name' => 'siteurl', + 'autoload' => 'yes', + ), + ), + $driver->query( 'SELECT option_name, autoload FROM wp_options' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $describe = $driver->query( 'DESCRIBE wp_options' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'option_id', 'option_name', 'option_value', 'autoload' ), array_column( $describe, 'Field' ) ); + $this->assertSame( + array( + 'Field' => 'autoload', + 'Type' => 'varchar(20)', + 'Null' => 'NO', + 'Key' => '', + 'Default' => 'yes', + 'Extra' => '', + ), + $describe[3] + ); + + $full = $driver->query( "SHOW FULL COLUMNS FROM wp_options LIKE 'autoload'" )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( 'utf8mb4_unicode_ci', $full['Collation'] ); + } + + public function test_alter_table_add_column_without_column_keyword_and_position_hints(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE items (id INT, name VARCHAR(50))' ); + $driver->query( "INSERT INTO items VALUES (1, 'alpha')" ); + + $driver->query( 'ALTER TABLE items ADD notes LONGTEXT NULL AFTER id' ); + $driver->query( "ALTER TABLE items ADD COLUMN position_hint VARCHAR(20) DEFAULT 'tail' FIRST" ); + + $row = $driver->query( 'SELECT id, name, notes, position_hint FROM items' )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + 'id' => 1, + 'name' => 'alpha', + 'notes' => null, + 'position_hint' => 'tail', + ), + $row + ); + + $describe = $driver->query( 'SHOW COLUMNS FROM items' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'id', 'name', 'notes', 'position_hint' ), array_column( $describe, 'Field' ) ); + $this->assertSame( 'longtext', $describe[2]['Type'] ); + $this->assertSame( 'YES', $describe[2]['Null'] ); + $this->assertSame( 'tail', $describe[3]['Default'] ); + } + + public function test_alter_table_multiple_add_columns_and_index_are_supported(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE items (id INT, slug VARCHAR(50))' ); + + $result = $driver->query( + "ALTER TABLE items + ADD COLUMN label VARCHAR(50) DEFAULT 'untitled', + ADD views INT DEFAULT 0, + ADD UNIQUE INDEX slug_key (slug)" + ); + + $this->assertSame( 0, $result->rowCount() ); + $describe = $driver->query( 'DESCRIBE items' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'id', 'slug', 'label', 'views' ), array_column( $describe, 'Field' ) ); + + $indexes = $driver->query( 'SHOW INDEX FROM items' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'slug_key' ), array_column( $indexes, 'Key_name' ) ); + $this->assertSame( 0, (int) $indexes[0]['Non_unique'] ); + } + + public function test_unsupported_alter_table_add_column_constraints_throw_driver_exception(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE users (name VARCHAR(100))' ); + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $this->expectExceptionMessage( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN PRIMARY KEY is not supported.' ); + $driver->query( 'ALTER TABLE users ADD COLUMN id INT PRIMARY KEY' ); + } + + public function test_unsupported_alter_table_add_auto_increment_throws_driver_exception(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE users (name VARCHAR(100))' ); + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $this->expectExceptionMessage( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN AUTO_INCREMENT is not supported.' ); + $driver->query( 'ALTER TABLE users ADD COLUMN id BIGINT AUTO_INCREMENT' ); + } + + public function test_unsupported_alter_table_add_not_null_without_default_on_non_empty_table_throws_driver_exception(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE users (name VARCHAR(100))' ); + $driver->query( "INSERT INTO users VALUES ('Ada')" ); $this->expectException( WP_DuckDB_Driver_Exception::class ); - $this->expectExceptionMessage( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD INDEX is supported.' ); - $driver->query( 'ALTER TABLE users ADD COLUMN email VARCHAR(255)' ); + $this->expectExceptionMessage( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN NOT NULL requires a DEFAULT for non-empty tables.' ); + $driver->query( 'ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL' ); } private function lastDuckDBQuery( WP_DuckDB_Driver $driver ): string { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid From f1d74913383f474e2372dda8ee010077321dabe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 17:29:27 +0000 Subject: [PATCH 013/317] Add DuckDB information_schema columns parity --- .../src/duckdb/class-wp-duckdb-driver.php | 518 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 31 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 86 ++- 3 files changed, 615 insertions(+), 20 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index b69183cff..dbab9f591 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -14,13 +14,14 @@ * throws WP_DuckDB_Driver_Exception for statements outside that subset. */ class WP_DuckDB_Driver { - const MYSQL_GRAMMAR_PATH = __DIR__ . '/../mysql/mysql-grammar.php'; - const DEFAULT_DATABASE = 'wp'; - const DEFAULT_MYSQL_VERSION = 80038; - const SEQUENCE_PREFIX = 'wp_duckdb_ai_'; - const INDEX_PREFIX = 'wp_duckdb_idx_'; - const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; - const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; + const MYSQL_GRAMMAR_PATH = __DIR__ . '/../mysql/mysql-grammar.php'; + const DEFAULT_DATABASE = 'wp'; + const DEFAULT_MYSQL_VERSION = 80038; + const SEQUENCE_PREFIX = 'wp_duckdb_ai_'; + const INDEX_PREFIX = 'wp_duckdb_idx_'; + const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; + const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; + const INFO_SCHEMA_COLUMNS_TABLE = '__wp_duckdb_information_schema_columns'; const DATA_TYPE_MAP = array( WP_MySQL_Lexer::BOOL_SYMBOL => 'BOOLEAN', @@ -302,8 +303,13 @@ private function without_eof( array $tokens ): array { * @return WP_DuckDB_Result_Statement */ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { + $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); + if ( $rewrite_information_schema_columns ) { + $this->refresh_information_schema_columns_table(); + } + return $this->execute_duckdb_query( - $this->translate_tokens_to_duckdb_sql( $tokens ), + $this->translate_tokens_to_duckdb_sql( $tokens, $rewrite_information_schema_columns ), 'Unsupported DuckDB MySQL-emulation SELECT statement' ); } @@ -800,13 +806,18 @@ private function execute_alter_table_add_column( string $table_name, array $toke ); if ( 'NO' === $metadata['is_nullable'] ) { - $this->execute_duckdb_query( - 'ALTER TABLE ' - . $this->connection->quote_identifier( $table_name ) - . ' ALTER COLUMN ' - . $this->connection->quote_identifier( $metadata['column_name'] ) - . ' SET NOT NULL', - 'Failed to apply DuckDB NOT NULL column constraint' + $this->execute_with_secondary_indexes_rebuilt( + $table_name, + function () use ( $table_name, $metadata ): void { + $this->execute_duckdb_query( + 'ALTER TABLE ' + . $this->connection->quote_identifier( $table_name ) + . ' ALTER COLUMN ' + . $this->connection->quote_identifier( $metadata['column_name'] ) + . ' SET NOT NULL', + 'Failed to apply DuckDB NOT NULL column constraint' + ); + } ); } @@ -938,6 +949,8 @@ private function execute_show_tables(): WP_DuckDB_Result_Statement { . $this->connection->quote( self::INDEX_METADATA_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::COLUMN_METADATA_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) . ' ORDER BY table_name', 'Failed to execute SHOW TABLES' ); @@ -1591,7 +1604,7 @@ private function mysql_column_collation_from_tokens( array $tokens, WP_Parser_To } } - return $this->mysql_type_has_collation( $type_token ) ? 'utf8mb4_unicode_ci' : null; + return $this->mysql_type_has_collation( $type_token ) ? 'utf8mb4_0900_ai_ci' : null; } /** @@ -2062,12 +2075,18 @@ private function translate_default_literal( array $tokens, int &$index ): string * @param WP_Parser_Token[] $tokens MySQL tokens. * @return string DuckDB SQL. */ - private function translate_tokens_to_duckdb_sql( array $tokens ): string { + private function translate_tokens_to_duckdb_sql( array $tokens, bool $rewrite_information_schema_columns = false ): string { $pieces = array(); for ( $index = 0; $index < count( $tokens ); ++$index ) { $token = $tokens[ $index ]; + if ( $rewrite_information_schema_columns && $this->is_information_schema_columns_reference( $tokens, $index ) ) { + $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_COLUMNS_TABLE ); + $index += 2; + continue; + } + if ( WP_MySQL_Lexer::FROM_SYMBOL === $token->id && isset( $tokens[ $index + 1 ] ) @@ -2961,6 +2980,90 @@ private function record_index_metadata( array $index_definition ): void { } } + /** + * Execute a schema change while temporarily dropping secondary indexes. + * + * @param string $table_name Table name. + * @param callable $callback Schema change callback. + */ + private function execute_with_secondary_indexes_rebuilt( string $table_name, callable $callback ): void { + $index_definitions = $this->secondary_index_definitions_for_table( $table_name ); + foreach ( $index_definitions as $index_definition ) { + $this->execute_duckdb_query( + 'DROP INDEX IF EXISTS ' . $this->connection->quote_identifier( $this->index_name( $table_name, $index_definition['index_name'] ) ), + 'Failed to drop DuckDB index before schema change' + ); + } + + $exception = null; + try { + $callback(); + } catch ( Throwable $e ) { + $exception = $e; + } + + foreach ( $index_definitions as $index_definition ) { + $this->execute_duckdb_query( $index_definition['sql'], 'Failed to recreate DuckDB index after schema change' ); + $this->record_index_metadata( $index_definition ); + } + + if ( null !== $exception ) { + throw $exception; + } + } + + /** + * Read recorded secondary index definitions for a table. + * + * @param string $table_name Table name. + * @return array}> + */ + private function secondary_index_definitions_for_table( string $table_name ): array { + $this->ensure_index_metadata_table(); + + $stmt = $this->execute_duckdb_query( + 'SELECT index_name, non_unique, seq_in_index, column_name, sub_part FROM ' + . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' ORDER BY index_name, seq_in_index', + 'Failed to inspect DuckDB secondary indexes' + ); + + $grouped = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $index_name = (string) $row['index_name']; + if ( ! isset( $grouped[ $index_name ] ) ) { + $grouped[ $index_name ] = array( + 'unique' => 0 === (int) $row['non_unique'], + 'columns' => array(), + ); + } + $grouped[ $index_name ]['columns'][] = array( + 'name' => (string) $row['column_name'], + 'sub_part' => null === $row['sub_part'] ? null : (int) $row['sub_part'], + ); + } + + $definitions = array(); + foreach ( $grouped as $index_name => $definition ) { + $definitions[] = $this->build_secondary_index_definition( + $table_name, + $index_name, + $definition['unique'], + array_map( + function ( array $column ): string { + return $this->connection->quote_identifier( $column['name'] ); + }, + $definition['columns'] + ), + $definition['columns'] + ); + } + + return $definitions; + } + /** * Record MySQL column metadata for DESCRIBE and SHOW COLUMNS. * @@ -3067,7 +3170,7 @@ private function column_metadata_rows( string $table_name ): array { $this->ensure_column_metadata_table(); $stmt = $this->execute_duckdb_query( - 'SELECT column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment FROM ' + 'SELECT ordinal_position, column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment FROM ' . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) @@ -3078,6 +3181,385 @@ private function column_metadata_rows( string $table_name ): array { return $stmt->fetchAll( PDO::FETCH_ASSOC ); } + /** + * Check whether a SELECT references information_schema.columns. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether the query needs the compatibility table. + */ + private function uses_information_schema_columns( array $tokens ): bool { + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( $this->is_information_schema_columns_reference( $tokens, $index ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a token offset starts information_schema.columns. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return bool Whether the sequence is information_schema.columns. + */ + private function is_information_schema_columns_reference( array $tokens, int $index ): bool { + return isset( $tokens[ $index + 2 ] ) + && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'columns' ); + } + + /** + * Refresh a temporary MySQL-shaped information_schema.columns table. + */ + private function refresh_information_schema_columns_table(): void { + $rows = $this->information_schema_column_rows(); + $definitions = $this->information_schema_column_definitions(); + $columns = array_keys( $definitions ); + + $column_sql = array(); + foreach ( $definitions as $column_name => $type ) { + $column_sql[] = $this->connection->quote_identifier( $column_name ) . ' ' . $type; + } + + $this->execute_duckdb_query( + 'CREATE OR REPLACE TEMP TABLE ' + . $this->connection->quote_identifier( self::INFO_SCHEMA_COLUMNS_TABLE ) + . ' (' + . implode( ', ', $column_sql ) + . ')', + 'Failed to initialize DuckDB information_schema.columns compatibility table' + ); + + if ( count( $rows ) === 0 ) { + return; + } + + $quoted_columns = implode( + ', ', + array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ); + }, + $columns + ) + ); + + foreach ( $rows as $row ) { + $values = array(); + foreach ( $columns as $column_name ) { + $values[] = $this->connection->quote( $row[ $column_name ] ); + } + + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( self::INFO_SCHEMA_COLUMNS_TABLE ) + . ' (' + . $quoted_columns + . ') VALUES (' + . implode( ', ', $values ) + . ')', + 'Failed to populate DuckDB information_schema.columns compatibility table' + ); + } + } + + /** + * MySQL-shaped information_schema.columns definitions. + * + * @return array Column name to DuckDB type. + */ + private function information_schema_column_definitions(): array { + return array( + 'TABLE_CATALOG' => 'VARCHAR', + 'TABLE_SCHEMA' => 'VARCHAR', + 'TABLE_NAME' => 'VARCHAR', + 'COLUMN_NAME' => 'VARCHAR', + 'ORDINAL_POSITION' => 'INTEGER', + 'COLUMN_DEFAULT' => 'VARCHAR', + 'IS_NULLABLE' => 'VARCHAR', + 'DATA_TYPE' => 'VARCHAR', + 'CHARACTER_MAXIMUM_LENGTH' => 'BIGINT', + 'CHARACTER_OCTET_LENGTH' => 'BIGINT', + 'NUMERIC_PRECISION' => 'INTEGER', + 'NUMERIC_SCALE' => 'INTEGER', + 'DATETIME_PRECISION' => 'INTEGER', + 'CHARACTER_SET_NAME' => 'VARCHAR', + 'COLLATION_NAME' => 'VARCHAR', + 'COLUMN_TYPE' => 'VARCHAR', + 'COLUMN_KEY' => 'VARCHAR', + 'EXTRA' => 'VARCHAR', + 'PRIVILEGES' => 'VARCHAR', + 'COLUMN_COMMENT' => 'VARCHAR', + 'GENERATION_EXPRESSION' => 'VARCHAR', + 'SRS_ID' => 'INTEGER', + ); + } + + /** + * Build MySQL-shaped information_schema.columns rows. + * + * @return array> + */ + private function information_schema_column_rows(): array { + $rows = array(); + foreach ( $this->user_table_names() as $table_name ) { + $metadata_rows = $this->column_metadata_rows( $table_name ); + if ( count( $metadata_rows ) === 0 ) { + $metadata_rows = $this->pragma_column_metadata_rows( $table_name ); + } + + foreach ( $metadata_rows as $metadata ) { + $rows[] = $this->information_schema_column_row( $table_name, $metadata ); + } + } + + return $rows; + } + + /** + * List non-internal DuckDB base tables. + * + * @return string[] Table names. + */ + private function user_table_names(): array { + $stmt = $this->execute_duckdb_query( + 'SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()' + . " AND table_type = 'BASE TABLE'" + . ' AND table_name <> ' + . $this->connection->quote( self::INDEX_METADATA_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::COLUMN_METADATA_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) + . ' ORDER BY table_name', + 'Failed to inspect DuckDB tables' + ); + + return array_map( + 'strval', + $stmt->fetchAll( PDO::FETCH_COLUMN ) + ); + } + + /** + * Build metadata rows for tables created outside the MySQL-emulation DDL path. + * + * @param string $table_name Table name. + * @return array> + */ + private function pragma_column_metadata_rows( string $table_name ): array { + $stmt = $this->execute_duckdb_query( + 'SELECT cid, name, type, "notnull", dflt_value, pk FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ') ORDER BY cid', + 'Failed to inspect DuckDB table columns' + ); + + $rows = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $is_primary_key = isset( $row['pk'] ) && (int) $row['pk'] > 0; + $is_not_null = $is_primary_key || ( isset( $row['notnull'] ) && (bool) $row['notnull'] ); + $is_auto = isset( $row['dflt_value'] ) && is_string( $row['dflt_value'] ) && false !== stripos( $row['dflt_value'], 'nextval(' ); + + $rows[] = array( + 'ordinal_position' => (int) $row['cid'] + 1, + 'column_name' => (string) $row['name'], + 'column_type' => strtolower( (string) $row['type'] ), + 'is_nullable' => $is_not_null ? 'NO' : 'YES', + 'column_key' => $is_primary_key ? 'PRI' : '', + 'column_default' => $is_auto ? null : $this->normalize_describe_default( $row['dflt_value'] ?? null ), + 'extra' => $is_auto ? 'auto_increment' : '', + 'collation_name' => null, + 'comment' => '', + ); + } + + return $rows; + } + + /** + * Build one MySQL-shaped information_schema.columns row. + * + * @param string $table_name Table name. + * @param array $metadata Column metadata. + * @return array + */ + private function information_schema_column_row( string $table_name, array $metadata ): array { + $type_attributes = $this->column_type_attributes( + (string) $metadata['column_type'], + null === $metadata['collation_name'] ? null : (string) $metadata['collation_name'] + ); + + return array( + 'TABLE_CATALOG' => 'def', + 'TABLE_SCHEMA' => $this->database, + 'TABLE_NAME' => $table_name, + 'COLUMN_NAME' => $metadata['column_name'], + 'ORDINAL_POSITION' => (int) $metadata['ordinal_position'], + 'COLUMN_DEFAULT' => $metadata['column_default'], + 'IS_NULLABLE' => $metadata['is_nullable'], + 'DATA_TYPE' => $type_attributes['data_type'], + 'CHARACTER_MAXIMUM_LENGTH' => $type_attributes['character_maximum_length'], + 'CHARACTER_OCTET_LENGTH' => $type_attributes['character_octet_length'], + 'NUMERIC_PRECISION' => $type_attributes['numeric_precision'], + 'NUMERIC_SCALE' => $type_attributes['numeric_scale'], + 'DATETIME_PRECISION' => $type_attributes['datetime_precision'], + 'CHARACTER_SET_NAME' => $this->character_set_from_collation( $metadata['collation_name'] ), + 'COLLATION_NAME' => $metadata['collation_name'], + 'COLUMN_TYPE' => $metadata['column_type'], + 'COLUMN_KEY' => $metadata['column_key'], + 'EXTRA' => $metadata['extra'], + 'PRIVILEGES' => 'select,insert,update,references', + 'COLUMN_COMMENT' => $metadata['comment'], + 'GENERATION_EXPRESSION' => '', + 'SRS_ID' => null, + ); + } + + /** + * Derive information_schema.columns type attributes from a MySQL column type. + * + * @param string $column_type MySQL-facing column type. + * @param string|null $collation_name Optional collation name. + * @return array + */ + private function column_type_attributes( string $column_type, ?string $collation_name ): array { + $normalized = strtolower( trim( $column_type ) ); + $data_type = $this->data_type_from_column_type( $normalized ); + $length = $this->column_type_length( $normalized ); + $charset = $this->character_set_from_collation( $collation_name ); + $char_length = null; + $octet_length = null; + + if ( in_array( $data_type, array( 'char', 'varchar' ), true ) ) { + $char_length = $length ?? 1; + $octet_length = $char_length * $this->charset_max_bytes( $charset ); + } elseif ( 'tinytext' === $data_type || 'tinyblob' === $data_type ) { + $char_length = 255; + $octet_length = 255; + } elseif ( 'text' === $data_type || 'blob' === $data_type ) { + $char_length = 65535; + $octet_length = 65535; + } elseif ( 'mediumtext' === $data_type || 'mediumblob' === $data_type ) { + $char_length = 16777215; + $octet_length = 16777215; + } elseif ( 'longtext' === $data_type || 'longblob' === $data_type ) { + $char_length = 4294967295; + $octet_length = 4294967295; + } + + list( $numeric_precision, $numeric_scale ) = $this->numeric_attributes_from_data_type( $data_type, $normalized ); + + return array( + 'data_type' => $data_type, + 'character_maximum_length' => $char_length, + 'character_octet_length' => $octet_length, + 'numeric_precision' => $numeric_precision, + 'numeric_scale' => $numeric_scale, + 'datetime_precision' => in_array( $data_type, array( 'time', 'datetime', 'timestamp' ), true ) ? 0 : null, + ); + } + + /** + * Extract the normalized data type from a column type. + * + * @param string $column_type MySQL-facing column type. + * @return string Data type. + */ + private function data_type_from_column_type( string $column_type ): string { + if ( preg_match( '/^([a-z]+)/', $column_type, $matches ) ) { + $data_type = $matches[1]; + } else { + $data_type = $column_type; + } + + $map = array( + 'integer' => 'int', + 'boolean' => 'tinyint', + ); + + return $map[ $data_type ] ?? $data_type; + } + + /** + * Extract the first numeric length from a column type. + * + * @param string $column_type MySQL-facing column type. + * @return int|null Length. + */ + private function column_type_length( string $column_type ): ?int { + if ( preg_match( '/\((\d+)/', $column_type, $matches ) ) { + return (int) $matches[1]; + } + + return null; + } + + /** + * Derive numeric precision and scale. + * + * @param string $data_type Normalized data type. + * @param string $column_type MySQL-facing column type. + * @return array{0:int|null,1:int|null} + */ + private function numeric_attributes_from_data_type( string $data_type, string $column_type ): array { + $precision_map = array( + 'tinyint' => 3, + 'smallint' => 5, + 'mediumint' => 7, + 'int' => 10, + 'bigint' => false === strpos( $column_type, 'unsigned' ) ? 19 : 20, + 'float' => 12, + 'double' => 22, + ); + + if ( array_key_exists( $data_type, $precision_map ) ) { + return array( $precision_map[ $data_type ], 0 ); + } + + if ( 'decimal' === $data_type ) { + if ( preg_match( '/\((\d+)(?:\s*,\s*(\d+))?\)/', $column_type, $matches ) ) { + return array( (int) $matches[1], isset( $matches[2] ) ? (int) $matches[2] : 0 ); + } + return array( 10, 0 ); + } + + return array( null, null ); + } + + /** + * Derive a character set from a collation. + * + * @param mixed $collation_name Collation name. + * @return string|null Character set. + */ + private function character_set_from_collation( $collation_name ): ?string { + if ( null === $collation_name || '' === $collation_name ) { + return null; + } + + $parts = explode( '_', (string) $collation_name ); + return $parts[0] ?? null; + } + + /** + * Get max bytes per character for common charsets. + * + * @param string|null $charset Character set. + * @return int Max bytes. + */ + private function charset_max_bytes( ?string $charset ): int { + if ( 'utf8mb4' === $charset ) { + return 4; + } + if ( 'utf8' === $charset || 'utf8mb3' === $charset ) { + return 3; + } + + return 1; + } + /** * Check whether a table has at least one row. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 2af8466b2..a172732c0 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -171,4 +171,35 @@ public function test_alter_table_add_column_metadata_matches_sqlite(): void { $this->assertParityRows( 'SELECT option_name, option_value, autoload FROM metadata' ); $this->assertParityRows( 'SHOW COLUMNS FROM metadata' ); } + + public function test_information_schema_columns_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '' COMMENT 'Option name', + option_value LONGTEXT NOT NULL, + UNIQUE KEY option_name (option_name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "ALTER TABLE metadata ADD COLUMN autoload VARCHAR(20) NOT NULL DEFAULT 'yes'", + ) + ); + + $this->assertParityRows( + "SELECT TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, + COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, + CHARACTER_OCTET_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, + DATETIME_PRECISION, CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE, + COLUMN_KEY, EXTRA, PRIVILEGES, COLUMN_COMMENT, GENERATION_EXPRESSION, SRS_ID + FROM information_schema.columns + WHERE table_name = 'metadata' + ORDER BY ordinal_position" + ); + $this->assertParityRows( + "SELECT c.COLUMN_NAME, c.COLUMN_TYPE + FROM information_schema.columns c + WHERE c.TABLE_SCHEMA = 'wp' AND c.TABLE_NAME = 'metadata' + ORDER BY c.ORDINAL_POSITION" + ); + } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 824f778f9..a01ce122a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -160,7 +160,7 @@ public function test_show_columns_and_full_fields_are_emulated(): void { array( 'Field' => 'title', 'Type' => 'varchar(100)', - 'Collation' => 'utf8mb4_unicode_ci', + 'Collation' => 'utf8mb4_0900_ai_ci', 'Null' => 'NO', 'Key' => '', 'Default' => 'untitled', @@ -605,7 +605,7 @@ public function test_alter_table_add_column_updates_data_and_metadata(): void { ); $full = $driver->query( "SHOW FULL COLUMNS FROM wp_options LIKE 'autoload'" )->fetch( PDO::FETCH_ASSOC ); - $this->assertSame( 'utf8mb4_unicode_ci', $full['Collation'] ); + $this->assertSame( 'utf8mb4_0900_ai_ci', $full['Collation'] ); } public function test_alter_table_add_column_without_column_keyword_and_position_hints(): void { @@ -658,6 +658,88 @@ public function test_alter_table_multiple_add_columns_and_index_are_supported(): $this->assertSame( 0, (int) $indexes[0]['Non_unique'] ); } + public function test_information_schema_columns_exposes_mysql_shaped_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '' COMMENT 'Option name', + option_value LONGTEXT NOT NULL, + UNIQUE KEY option_name (option_name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( "ALTER TABLE metadata ADD COLUMN autoload VARCHAR(20) NOT NULL DEFAULT 'yes'" ); + + $rows = $driver->query( + "SELECT * FROM information_schema.columns + WHERE table_schema = 'wp' AND table_name = 'metadata' + ORDER BY ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'COLUMN_NAME', + 'ORDINAL_POSITION', + 'COLUMN_DEFAULT', + 'IS_NULLABLE', + 'DATA_TYPE', + 'CHARACTER_MAXIMUM_LENGTH', + 'CHARACTER_OCTET_LENGTH', + 'NUMERIC_PRECISION', + 'NUMERIC_SCALE', + 'DATETIME_PRECISION', + 'CHARACTER_SET_NAME', + 'COLLATION_NAME', + 'COLUMN_TYPE', + 'COLUMN_KEY', + 'EXTRA', + 'PRIVILEGES', + 'COLUMN_COMMENT', + 'GENERATION_EXPRESSION', + 'SRS_ID', + ), + array_keys( $rows[0] ) + ); + $this->assertSame( array( 'id', 'option_name', 'option_value', 'autoload' ), array_column( $rows, 'COLUMN_NAME' ) ); + $this->assertSame( 'bigint', $rows[0]['DATA_TYPE'] ); + $this->assertSame( 20, $rows[0]['NUMERIC_PRECISION'] ); + $this->assertSame( 'PRI', $rows[0]['COLUMN_KEY'] ); + $this->assertSame( 'auto_increment', $rows[0]['EXTRA'] ); + $this->assertSame( 'varchar', $rows[1]['DATA_TYPE'] ); + $this->assertSame( 191, $rows[1]['CHARACTER_MAXIMUM_LENGTH'] ); + $this->assertSame( 764, $rows[1]['CHARACTER_OCTET_LENGTH'] ); + $this->assertSame( 'utf8mb4', $rows[1]['CHARACTER_SET_NAME'] ); + $this->assertSame( 'utf8mb4_0900_ai_ci', $rows[1]['COLLATION_NAME'] ); + $this->assertSame( 'UNI', $rows[1]['COLUMN_KEY'] ); + $this->assertSame( 'Option name', $rows[1]['COLUMN_COMMENT'] ); + $this->assertSame( 'yes', $rows[3]['COLUMN_DEFAULT'] ); + + $aliased = $driver->query( + "SELECT c.column_name + FROM information_schema.columns c + WHERE c.table_schema = 'wp' AND c.table_name = 'metadata' + ORDER BY c.ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'id', 'option_name', 'option_value', 'autoload' ), array_column( $aliased, 'COLUMN_NAME' ) ); + + $internal = $driver->query( + "SELECT table_name + FROM information_schema.columns + WHERE table_name LIKE '__wp_duckdb_%'" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $internal ); + } + public function test_unsupported_alter_table_add_column_constraints_throw_driver_exception(): void { $this->requireDuckDBRuntime(); From dfbc136003a43bb5bb74209847be7023312a7d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 17:40:50 +0000 Subject: [PATCH 014/317] Add DuckDB information_schema statistics parity --- .../src/duckdb/class-wp-duckdb-driver.php | 294 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 33 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 89 ++++++ 3 files changed, 400 insertions(+), 16 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index dbab9f591..03d30263f 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -14,14 +14,15 @@ * throws WP_DuckDB_Driver_Exception for statements outside that subset. */ class WP_DuckDB_Driver { - const MYSQL_GRAMMAR_PATH = __DIR__ . '/../mysql/mysql-grammar.php'; - const DEFAULT_DATABASE = 'wp'; - const DEFAULT_MYSQL_VERSION = 80038; - const SEQUENCE_PREFIX = 'wp_duckdb_ai_'; - const INDEX_PREFIX = 'wp_duckdb_idx_'; - const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; - const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; - const INFO_SCHEMA_COLUMNS_TABLE = '__wp_duckdb_information_schema_columns'; + const MYSQL_GRAMMAR_PATH = __DIR__ . '/../mysql/mysql-grammar.php'; + const DEFAULT_DATABASE = 'wp'; + const DEFAULT_MYSQL_VERSION = 80038; + const SEQUENCE_PREFIX = 'wp_duckdb_ai_'; + const INDEX_PREFIX = 'wp_duckdb_idx_'; + const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; + const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; + const INFO_SCHEMA_COLUMNS_TABLE = '__wp_duckdb_information_schema_columns'; + const INFO_SCHEMA_STATISTICS_TABLE = '__wp_duckdb_information_schema_statistics'; const DATA_TYPE_MAP = array( WP_MySQL_Lexer::BOOL_SYMBOL => 'BOOLEAN', @@ -303,13 +304,21 @@ private function without_eof( array $tokens ): array { * @return WP_DuckDB_Result_Statement */ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { - $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); + $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); + $rewrite_information_schema_statistics = $this->uses_information_schema_statistics( $tokens ); if ( $rewrite_information_schema_columns ) { $this->refresh_information_schema_columns_table(); } + if ( $rewrite_information_schema_statistics ) { + $this->refresh_information_schema_statistics_table(); + } return $this->execute_duckdb_query( - $this->translate_tokens_to_duckdb_sql( $tokens, $rewrite_information_schema_columns ), + $this->translate_tokens_to_duckdb_sql( + $tokens, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics + ), 'Unsupported DuckDB MySQL-emulation SELECT statement' ); } @@ -951,6 +960,8 @@ private function execute_show_tables(): WP_DuckDB_Result_Statement { . $this->connection->quote( self::COLUMN_METADATA_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_STATISTICS_TABLE ) . ' ORDER BY table_name', 'Failed to execute SHOW TABLES' ); @@ -990,10 +1001,20 @@ private function execute_show_index( array $tokens ): WP_DuckDB_Result_Statement 'Visible', 'Expression', ), - array_merge( - $this->primary_key_index_rows( $table_name ), - $this->secondary_index_rows( $table_name ) - ) + $this->index_rows_for_table( $table_name ) + ); + } + + /** + * Build SHOW INDEX-compatible rows for all indexes on a table. + * + * @param string $table_name Table name. + * @return array> + */ + private function index_rows_for_table( string $table_name ): array { + return array_merge( + $this->primary_key_index_rows( $table_name ), + $this->secondary_index_rows( $table_name ) ); } @@ -2075,7 +2096,11 @@ private function translate_default_literal( array $tokens, int &$index ): string * @param WP_Parser_Token[] $tokens MySQL tokens. * @return string DuckDB SQL. */ - private function translate_tokens_to_duckdb_sql( array $tokens, bool $rewrite_information_schema_columns = false ): string { + private function translate_tokens_to_duckdb_sql( + array $tokens, + bool $rewrite_information_schema_columns = false, + bool $rewrite_information_schema_statistics = false + ): string { $pieces = array(); for ( $index = 0; $index < count( $tokens ); ++$index ) { @@ -2086,6 +2111,11 @@ private function translate_tokens_to_duckdb_sql( array $tokens, bool $rewrite_in $index += 2; continue; } + if ( $rewrite_information_schema_statistics && $this->is_information_schema_statistics_reference( $tokens, $index ) ) { + $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_STATISTICS_TABLE ); + $index += 2; + continue; + } if ( WP_MySQL_Lexer::FROM_SYMBOL === $token->id @@ -2140,7 +2170,10 @@ private function translate_tokens_to_duckdb_sql( array $tokens, bool $rewrite_in } if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { - $pieces[] = $this->connection->quote_identifier( $token->get_value() ); + $identifier = $rewrite_information_schema_statistics + ? $this->information_schema_statistics_column_name( $token->get_value() ) + : null; + $pieces[] = $this->connection->quote_identifier( $identifier ?? $token->get_value() ); continue; } @@ -2149,6 +2182,14 @@ private function translate_tokens_to_duckdb_sql( array $tokens, bool $rewrite_in continue; } + if ( $rewrite_information_schema_statistics ) { + $identifier = $this->information_schema_statistics_column_name( $token->get_value() ); + if ( null !== $identifier ) { + $pieces[] = $this->connection->quote_identifier( $identifier ); + continue; + } + } + $pieces[] = $token->get_bytes(); } @@ -3211,6 +3252,36 @@ private function is_information_schema_columns_reference( array $tokens, int $in && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'columns' ); } + /** + * Check whether a SELECT references information_schema.statistics. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether the query needs the compatibility table. + */ + private function uses_information_schema_statistics( array $tokens ): bool { + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( $this->is_information_schema_statistics_reference( $tokens, $index ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a token offset starts information_schema.statistics. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return bool Whether the sequence is information_schema.statistics. + */ + private function is_information_schema_statistics_reference( array $tokens, int $index ): bool { + return isset( $tokens[ $index + 2 ] ) + && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'statistics' ); + } + /** * Refresh a temporary MySQL-shaped information_schema.columns table. */ @@ -3298,6 +3369,195 @@ private function information_schema_column_definitions(): array { ); } + /** + * Refresh a temporary MySQL-shaped information_schema.statistics table. + */ + private function refresh_information_schema_statistics_table(): void { + $rows = $this->information_schema_statistics_rows(); + $definitions = $this->information_schema_statistics_definitions(); + $columns = array_keys( $definitions ); + + $column_sql = array(); + foreach ( $definitions as $column_name => $type ) { + $column_sql[] = $this->connection->quote_identifier( $column_name ) . ' ' . $type; + } + + $this->execute_duckdb_query( + 'CREATE OR REPLACE TEMP TABLE ' + . $this->connection->quote_identifier( self::INFO_SCHEMA_STATISTICS_TABLE ) + . ' (' + . implode( ', ', $column_sql ) + . ')', + 'Failed to initialize DuckDB information_schema.statistics compatibility table' + ); + + if ( count( $rows ) === 0 ) { + return; + } + + $quoted_columns = implode( + ', ', + array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ); + }, + $columns + ) + ); + + foreach ( $rows as $row ) { + $values = array(); + foreach ( $columns as $column_name ) { + $values[] = $this->connection->quote( $row[ $column_name ] ); + } + + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( self::INFO_SCHEMA_STATISTICS_TABLE ) + . ' (' + . $quoted_columns + . ') VALUES (' + . implode( ', ', $values ) + . ')', + 'Failed to populate DuckDB information_schema.statistics compatibility table' + ); + } + } + + /** + * MySQL-shaped information_schema.statistics definitions. + * + * @return array Column name to DuckDB type. + */ + private function information_schema_statistics_definitions(): array { + return array( + 'TABLE_CATALOG' => 'VARCHAR COLLATE NOCASE', + 'TABLE_SCHEMA' => 'VARCHAR COLLATE NOCASE', + 'TABLE_NAME' => 'VARCHAR COLLATE NOCASE', + 'NON_UNIQUE' => 'INTEGER', + 'INDEX_SCHEMA' => 'VARCHAR COLLATE NOCASE', + 'INDEX_NAME' => 'VARCHAR COLLATE NOCASE', + 'SEQ_IN_INDEX' => 'INTEGER', + 'COLUMN_NAME' => 'VARCHAR COLLATE NOCASE', + 'COLLATION' => 'VARCHAR COLLATE NOCASE', + 'CARDINALITY' => 'INTEGER', + 'SUB_PART' => 'INTEGER', + 'PACKED' => 'VARCHAR', + 'NULLABLE' => 'VARCHAR COLLATE NOCASE', + 'INDEX_TYPE' => 'VARCHAR', + 'COMMENT' => 'VARCHAR COLLATE NOCASE', + 'INDEX_COMMENT' => 'VARCHAR', + 'IS_VISIBLE' => 'VARCHAR COLLATE NOCASE', + 'EXPRESSION' => 'VARCHAR', + ); + } + + /** + * Build MySQL-shaped information_schema.statistics rows. + * + * @return array> + */ + private function information_schema_statistics_rows(): array { + $rows = array(); + foreach ( $this->user_table_names() as $table_name ) { + $nullable_by_column = $this->statistics_nullable_by_column( $table_name ); + foreach ( $this->index_rows_for_table( $table_name ) as $index_row ) { + $rows[] = $this->information_schema_statistics_row( $index_row, $nullable_by_column ); + } + } + + return $rows; + } + + /** + * Build one MySQL-shaped information_schema.statistics row. + * + * @param array $index_row SHOW INDEX-compatible row. + * @param array $nullable_by_column Nullability keyed by lowercase column name. + * @return array + */ + private function information_schema_statistics_row( array $index_row, array $nullable_by_column ): array { + $key_name = (string) $index_row[2]; + $column_name = null === $index_row[4] ? null : (string) $index_row[4]; + $nullable = ''; + if ( 'PRIMARY' !== $key_name && null !== $column_name ) { + $nullable = $nullable_by_column[ strtolower( $column_name ) ] ?? ''; + } + + return array( + 'TABLE_CATALOG' => 'def', + 'TABLE_SCHEMA' => $this->database, + 'TABLE_NAME' => $index_row[0], + 'NON_UNIQUE' => (int) $index_row[1], + 'INDEX_SCHEMA' => $this->database, + 'INDEX_NAME' => $key_name, + 'SEQ_IN_INDEX' => (int) $index_row[3], + 'COLUMN_NAME' => $column_name, + 'COLLATION' => $index_row[5], + 'CARDINALITY' => 0, + 'SUB_PART' => $index_row[7], + 'PACKED' => $index_row[8], + 'NULLABLE' => $nullable, + 'INDEX_TYPE' => $index_row[10], + 'COMMENT' => $index_row[11], + 'INDEX_COMMENT' => $index_row[12], + 'IS_VISIBLE' => $index_row[13], + 'EXPRESSION' => $index_row[14], + ); + } + + /** + * Read statistics nullability values for a table. + * + * @param string $table_name Table name. + * @return array Nullability keyed by lowercase column name. + */ + private function statistics_nullable_by_column( string $table_name ): array { + $metadata_rows = $this->column_metadata_rows( $table_name ); + if ( count( $metadata_rows ) === 0 ) { + $metadata_rows = $this->pragma_column_metadata_rows( $table_name ); + } + + $nullable = array(); + foreach ( $metadata_rows as $metadata ) { + $nullable[ strtolower( (string) $metadata['column_name'] ) ] = 'YES' === strtoupper( (string) $metadata['is_nullable'] ) ? 'YES' : ''; + } + + return $nullable; + } + + /** + * Return the canonical statistics column name for a token value. + * + * @param string $identifier Identifier token value. + * @return string|null Canonical column name, or null when not a statistics column. + */ + private function information_schema_statistics_column_name( string $identifier ): ?string { + $columns = array( + 'table_catalog' => 'TABLE_CATALOG', + 'table_schema' => 'TABLE_SCHEMA', + 'table_name' => 'TABLE_NAME', + 'non_unique' => 'NON_UNIQUE', + 'index_schema' => 'INDEX_SCHEMA', + 'index_name' => 'INDEX_NAME', + 'seq_in_index' => 'SEQ_IN_INDEX', + 'column_name' => 'COLUMN_NAME', + 'collation' => 'COLLATION', + 'cardinality' => 'CARDINALITY', + 'sub_part' => 'SUB_PART', + 'packed' => 'PACKED', + 'nullable' => 'NULLABLE', + 'index_type' => 'INDEX_TYPE', + 'comment' => 'COMMENT', + 'index_comment' => 'INDEX_COMMENT', + 'is_visible' => 'IS_VISIBLE', + 'expression' => 'EXPRESSION', + ); + $key = strtolower( $identifier ); + + return $columns[ $key ] ?? null; + } + /** * Build MySQL-shaped information_schema.columns rows. * @@ -3334,6 +3594,8 @@ private function user_table_names(): array { . $this->connection->quote( self::COLUMN_METADATA_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_STATISTICS_TABLE ) . ' ORDER BY table_name', 'Failed to inspect DuckDB tables' ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index a172732c0..c17519047 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -202,4 +202,37 @@ public function test_information_schema_columns_metadata_matches_sqlite(): void ORDER BY c.ORDINAL_POSITION" ); } + + public function test_information_schema_statistics_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'yes', + nullable_value VARCHAR(191), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload), + KEY nullable_value (nullable_value), + KEY option_value_prefix (option_value(12)) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + ) + ); + + $this->assertParityRows( + "SELECT TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, NON_UNIQUE, INDEX_SCHEMA, INDEX_NAME, + SEQ_IN_INDEX, COLUMN_NAME, COLLATION, CARDINALITY, SUB_PART, PACKED, NULLABLE, + INDEX_TYPE, COMMENT, INDEX_COMMENT, IS_VISIBLE, EXPRESSION + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'metadata' + ORDER BY index_name, seq_in_index" + ); + $this->assertParityRows( + "SELECT s.INDEX_NAME, s.COLUMN_NAME, s.SUB_PART + FROM information_schema.statistics s + WHERE s.TABLE_SCHEMA = 'wp' AND s.TABLE_NAME = 'metadata' + ORDER BY s.INDEX_NAME, s.SEQ_IN_INDEX" + ); + } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index a01ce122a..0bddb2846 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -740,6 +740,95 @@ public function test_information_schema_columns_exposes_mysql_shaped_metadata(): $this->assertSame( array(), $internal ); } + public function test_information_schema_statistics_exposes_mysql_shaped_index_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'yes', + nullable_value VARCHAR(191), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload), + KEY nullable_value (nullable_value), + KEY option_value_prefix (option_value(12)) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + + $rows = $driver->query( + "SELECT TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, NON_UNIQUE, INDEX_SCHEMA, INDEX_NAME, + SEQ_IN_INDEX, COLUMN_NAME, COLLATION, CARDINALITY, SUB_PART, PACKED, NULLABLE, + INDEX_TYPE, COMMENT, INDEX_COMMENT, IS_VISIBLE, EXPRESSION + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'metadata' + ORDER BY index_name, seq_in_index" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'NON_UNIQUE', + 'INDEX_SCHEMA', + 'INDEX_NAME', + 'SEQ_IN_INDEX', + 'COLUMN_NAME', + 'COLLATION', + 'CARDINALITY', + 'SUB_PART', + 'PACKED', + 'NULLABLE', + 'INDEX_TYPE', + 'COMMENT', + 'INDEX_COMMENT', + 'IS_VISIBLE', + 'EXPRESSION', + ), + array_keys( $rows[0] ) + ); + $this->assertSame( array( 'autoload', 'nullable_value', 'option_name', 'option_value_prefix', 'PRIMARY' ), array_column( $rows, 'INDEX_NAME' ) ); + $this->assertSame( array( 'autoload', 'nullable_value', 'option_name', 'option_value', 'id' ), array_column( $rows, 'COLUMN_NAME' ) ); + $this->assertSame( array( 1, 1, 0, 1, 0 ), array_map( 'intval', array_column( $rows, 'NON_UNIQUE' ) ) ); + $this->assertSame( array( null, null, null, 12, null ), array_column( $rows, 'SUB_PART' ) ); + $this->assertSame( array( '', 'YES', '', '', '' ), array_column( $rows, 'NULLABLE' ) ); + $this->assertSame( array( 0, 0, 0, 0, 0 ), array_map( 'intval', array_column( $rows, 'CARDINALITY' ) ) ); + $this->assertSame( array( 'BTREE', 'BTREE', 'BTREE', 'BTREE', 'BTREE' ), array_column( $rows, 'INDEX_TYPE' ) ); + $this->assertSame( array( 'YES', 'YES', 'YES', 'YES', 'YES' ), array_column( $rows, 'IS_VISIBLE' ) ); + + $aliased = $driver->query( + "SELECT s.INDEX_NAME, s.COLUMN_NAME, s.SUB_PART + FROM information_schema.statistics s + WHERE s.TABLE_SCHEMA = 'wp' AND s.TABLE_NAME = 'metadata' + ORDER BY s.INDEX_NAME, s.SEQ_IN_INDEX" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'autoload', 'nullable_value', 'option_name', 'option_value_prefix', 'PRIMARY' ), array_column( $aliased, 'INDEX_NAME' ) ); + + $quoted = $driver->query( + "SELECT `INDEX_NAME`, `COLLATION`, `COMMENT` + FROM information_schema.statistics + WHERE `TABLE_SCHEMA` = 'wp' AND `TABLE_NAME` = 'metadata' + ORDER BY `INDEX_NAME`, `SEQ_IN_INDEX`" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( 'A', $quoted[0]['COLLATION'] ); + $this->assertSame( '', $quoted[0]['COMMENT'] ); + + $internal = $driver->query( + "SELECT table_name + FROM information_schema.statistics + WHERE table_name LIKE '__wp_duckdb_%'" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $internal ); + } + public function test_unsupported_alter_table_add_column_constraints_throw_driver_exception(): void { $this->requireDuckDBRuntime(); From ef96c87b69e9484144f4522413413c58ca107c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 17:49:53 +0000 Subject: [PATCH 015/317] Add DuckDB information_schema tables parity --- .../src/duckdb/class-wp-duckdb-driver.php | 414 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 31 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 86 ++++ 3 files changed, 519 insertions(+), 12 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 03d30263f..cf79cf259 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -21,6 +21,8 @@ class WP_DuckDB_Driver { const INDEX_PREFIX = 'wp_duckdb_idx_'; const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; + const TABLE_METADATA_TABLE = '__wp_duckdb_table_metadata'; + const INFO_SCHEMA_TABLES_TABLE = '__wp_duckdb_information_schema_tables'; const INFO_SCHEMA_COLUMNS_TABLE = '__wp_duckdb_information_schema_columns'; const INFO_SCHEMA_STATISTICS_TABLE = '__wp_duckdb_information_schema_statistics'; @@ -304,8 +306,12 @@ private function without_eof( array $tokens ): array { * @return WP_DuckDB_Result_Statement */ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { + $rewrite_information_schema_tables = $this->uses_information_schema_tables( $tokens ); $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); $rewrite_information_schema_statistics = $this->uses_information_schema_statistics( $tokens ); + if ( $rewrite_information_schema_tables ) { + $this->refresh_information_schema_tables_table(); + } if ( $rewrite_information_schema_columns ) { $this->refresh_information_schema_columns_table(); } @@ -316,6 +322,7 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_duckdb_query( $this->translate_tokens_to_duckdb_sql( $tokens, + $rewrite_information_schema_tables, $rewrite_information_schema_columns, $rewrite_information_schema_statistics ), @@ -373,7 +380,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme ++$index; list( $items, $index ) = $this->collect_parenthesized_items( $tokens, $index ); - $this->assert_supported_create_table_options( array_slice( $tokens, $index ) ); + $table_metadata = $this->parse_create_table_options( array_slice( $tokens, $index ) ); $columns = array(); $constraints = array(); @@ -436,6 +443,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $this->record_index_metadata( $index_definition ); } $this->record_column_metadata( $table_name, $this->apply_column_key_metadata( $metadata, $primary_key, $indexes ) ); + $this->record_table_metadata( $table_name, $table_metadata ); return $result; } @@ -959,6 +967,10 @@ private function execute_show_tables(): WP_DuckDB_Result_Statement { . ' AND table_name <> ' . $this->connection->quote( self::COLUMN_METADATA_TABLE ) . ' AND table_name <> ' + . $this->connection->quote( self::TABLE_METADATA_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_TABLES_TABLE ) + . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_STATISTICS_TABLE ) @@ -2015,29 +2027,50 @@ private function assert_supported_index_options( array $tokens, int $index ): vo } /** - * Assert supported CREATE TABLE tail options. + * Parse supported CREATE TABLE tail options into MySQL-facing metadata. * * @param WP_Parser_Token[] $tokens Tail tokens after the column list. + * @return array{engine:string,row_format:string,table_collation:string,table_comment:string,create_options:string} */ - private function assert_supported_create_table_options( array $tokens ): void { - $index = 0; + private function parse_create_table_options( array $tokens ): array { + $engine = 'InnoDB'; + $table_collation = 'utf8mb4_0900_ai_ci'; + $table_comment = ''; + $index = 0; while ( $index < count( $tokens ) ) { $token = $tokens[ $index ]; if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { ++$index; continue; } + + if ( WP_MySQL_Lexer::ENGINE_SYMBOL === $token->id ) { + $engine = $this->normalize_table_engine( (string) $this->option_value( $tokens, $index + 1 ) ); + $index = $this->skip_option_value( $tokens, $index + 1 ); + continue; + } + + if ( WP_MySQL_Lexer::COLLATE_SYMBOL === $token->id ) { + $table_collation = strtolower( (string) $this->option_value( $tokens, $index + 1 ) ); + $index = $this->skip_option_value( $tokens, $index + 1 ); + continue; + } + + if ( WP_MySQL_Lexer::COMMENT_SYMBOL === $token->id ) { + $table_comment = (string) $this->option_value( $tokens, $index + 1 ); + $index = $this->skip_option_value( $tokens, $index + 1 ); + continue; + } + if ( - WP_MySQL_Lexer::ENGINE_SYMBOL === $token->id - || WP_MySQL_Lexer::CHARSET_SYMBOL === $token->id - || WP_MySQL_Lexer::COLLATE_SYMBOL === $token->id + WP_MySQL_Lexer::CHARSET_SYMBOL === $token->id || WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL === $token->id - || WP_MySQL_Lexer::COMMENT_SYMBOL === $token->id || WP_MySQL_Lexer::ROW_FORMAT_SYMBOL === $token->id ) { $index = $this->skip_option_value( $tokens, $index + 1 ); continue; } + if ( WP_MySQL_Lexer::CHAR_SYMBOL === $token->id || WP_MySQL_Lexer::CHARACTER_SYMBOL === $token->id ) { if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $index + 1 ]->id ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE option in DuckDB driver: ' . $token->get_bytes() . '.' ); @@ -2047,6 +2080,32 @@ private function assert_supported_create_table_options( array $tokens ): void { } throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE option in DuckDB driver: ' . $token->get_bytes() . '.' ); } + + return array( + 'engine' => $engine, + 'row_format' => 'MyISAM' === $engine ? 'Fixed' : 'Dynamic', + 'table_collation' => $table_collation, + 'table_comment' => $table_comment, + 'create_options' => '', + ); + } + + /** + * Normalize a MySQL storage engine value for information_schema.tables. + * + * @param string $engine Storage engine option value. + * @return string Normalized storage engine. + */ + private function normalize_table_engine( string $engine ): string { + $upper = strtoupper( $engine ); + if ( 'INNODB' === $upper ) { + return 'InnoDB'; + } + if ( 'MYISAM' === $upper ) { + return 'MyISAM'; + } + + return $upper; } /** @@ -2098,6 +2157,7 @@ private function translate_default_literal( array $tokens, int &$index ): string */ private function translate_tokens_to_duckdb_sql( array $tokens, + bool $rewrite_information_schema_tables = false, bool $rewrite_information_schema_columns = false, bool $rewrite_information_schema_statistics = false ): string { @@ -2106,6 +2166,11 @@ private function translate_tokens_to_duckdb_sql( for ( $index = 0; $index < count( $tokens ); ++$index ) { $token = $tokens[ $index ]; + if ( $rewrite_information_schema_tables && $this->is_information_schema_tables_reference( $tokens, $index ) ) { + $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_TABLES_TABLE ); + $index += 2; + continue; + } if ( $rewrite_information_schema_columns && $this->is_information_schema_columns_reference( $tokens, $index ) ) { $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_COLUMNS_TABLE ); $index += 2; @@ -2170,10 +2235,14 @@ private function translate_tokens_to_duckdb_sql( } if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { - $identifier = $rewrite_information_schema_statistics - ? $this->information_schema_statistics_column_name( $token->get_value() ) - : null; - $pieces[] = $this->connection->quote_identifier( $identifier ?? $token->get_value() ); + $identifier = null; + if ( $rewrite_information_schema_tables ) { + $identifier = $this->information_schema_tables_column_name( $token->get_value() ); + } + if ( null === $identifier && $rewrite_information_schema_statistics ) { + $identifier = $this->information_schema_statistics_column_name( $token->get_value() ); + } + $pieces[] = $this->connection->quote_identifier( $identifier ?? $token->get_value() ); continue; } @@ -2182,6 +2251,14 @@ private function translate_tokens_to_duckdb_sql( continue; } + if ( $rewrite_information_schema_tables ) { + $identifier = $this->information_schema_tables_column_name( $token->get_value() ); + if ( null !== $identifier ) { + $pieces[] = $this->connection->quote_identifier( $identifier ); + continue; + } + } + if ( $rewrite_information_schema_statistics ) { $identifier = $this->information_schema_statistics_column_name( $token->get_value() ); if ( null !== $identifier ) { @@ -2978,6 +3055,18 @@ private function ensure_column_metadata_table(): void { ); } + /** + * Ensure the internal table metadata table exists. + */ + private function ensure_table_metadata_table(): void { + $this->execute_duckdb_query( + 'CREATE TABLE IF NOT EXISTS ' + . $this->connection->quote_identifier( self::TABLE_METADATA_TABLE ) + . ' (table_name VARCHAR, engine VARCHAR, row_format VARCHAR, table_collation VARCHAR, table_comment VARCHAR, create_options VARCHAR, create_time VARCHAR)', + 'Failed to initialize DuckDB table metadata' + ); + } + /** * Record MySQL index metadata for SHOW INDEX. * @@ -3152,6 +3241,45 @@ private function record_column_metadata( string $table_name, array $metadata ): } } + /** + * Record MySQL table metadata for information_schema.tables. + * + * @param string $table_name Table name. + * @param array $metadata Table metadata. + */ + private function record_table_metadata( string $table_name, array $metadata ): void { + $this->ensure_table_metadata_table(); + + $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( self::TABLE_METADATA_TABLE ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ), + 'Failed to reset DuckDB table metadata' + ); + + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( self::TABLE_METADATA_TABLE ) + . ' (table_name, engine, row_format, table_collation, table_comment, create_options, create_time) VALUES (' + . $this->connection->quote( $table_name ) + . ', ' + . $this->connection->quote( $metadata['engine'] ) + . ', ' + . $this->connection->quote( $metadata['row_format'] ) + . ', ' + . $this->connection->quote( $metadata['table_collation'] ) + . ', ' + . $this->connection->quote( $metadata['table_comment'] ) + . ', ' + . $this->connection->quote( $metadata['create_options'] ) + . ', ' + . $this->connection->quote( gmdate( 'Y-m-d H:i:s' ) ) + . ')', + 'Failed to store DuckDB table metadata' + ); + } + /** * Append one MySQL column metadata row when full table metadata is already recorded. * @@ -3222,6 +3350,36 @@ private function column_metadata_rows( string $table_name ): array { return $stmt->fetchAll( PDO::FETCH_ASSOC ); } + /** + * Check whether a SELECT references information_schema.tables. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether the query needs the compatibility table. + */ + private function uses_information_schema_tables( array $tokens ): bool { + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( $this->is_information_schema_tables_reference( $tokens, $index ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a token offset starts information_schema.tables. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return bool Whether the sequence is information_schema.tables. + */ + private function is_information_schema_tables_reference( array $tokens, int $index ): bool { + return isset( $tokens[ $index + 2 ] ) + && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'tables' ); + } + /** * Check whether a SELECT references information_schema.columns. * @@ -3282,6 +3440,234 @@ private function is_information_schema_statistics_reference( array $tokens, int && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'statistics' ); } + /** + * Refresh a temporary MySQL-shaped information_schema.tables table. + */ + private function refresh_information_schema_tables_table(): void { + $rows = $this->information_schema_table_rows(); + $definitions = $this->information_schema_table_definitions(); + $columns = array_keys( $definitions ); + + $column_sql = array(); + foreach ( $definitions as $column_name => $type ) { + $column_sql[] = $this->connection->quote_identifier( $column_name ) . ' ' . $type; + } + + $this->execute_duckdb_query( + 'CREATE OR REPLACE TEMP TABLE ' + . $this->connection->quote_identifier( self::INFO_SCHEMA_TABLES_TABLE ) + . ' (' + . implode( ', ', $column_sql ) + . ')', + 'Failed to initialize DuckDB information_schema.tables compatibility table' + ); + + if ( count( $rows ) === 0 ) { + return; + } + + $quoted_columns = implode( + ', ', + array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ); + }, + $columns + ) + ); + + foreach ( $rows as $row ) { + $values = array(); + foreach ( $columns as $column_name ) { + $values[] = $this->connection->quote( $row[ $column_name ] ); + } + + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( self::INFO_SCHEMA_TABLES_TABLE ) + . ' (' + . $quoted_columns + . ') VALUES (' + . implode( ', ', $values ) + . ')', + 'Failed to populate DuckDB information_schema.tables compatibility table' + ); + } + } + + /** + * MySQL-shaped information_schema.tables definitions. + * + * @return array Column name to DuckDB type. + */ + private function information_schema_table_definitions(): array { + return array( + 'TABLE_CATALOG' => 'VARCHAR COLLATE NOCASE', + 'TABLE_SCHEMA' => 'VARCHAR COLLATE NOCASE', + 'TABLE_NAME' => 'VARCHAR COLLATE NOCASE', + 'TABLE_TYPE' => 'VARCHAR', + 'ENGINE' => 'VARCHAR COLLATE NOCASE', + 'VERSION' => 'INTEGER', + 'ROW_FORMAT' => 'VARCHAR', + 'TABLE_ROWS' => 'BIGINT', + 'AVG_ROW_LENGTH' => 'BIGINT', + 'DATA_LENGTH' => 'BIGINT', + 'MAX_DATA_LENGTH' => 'BIGINT', + 'INDEX_LENGTH' => 'BIGINT', + 'DATA_FREE' => 'BIGINT', + 'AUTO_INCREMENT' => 'BIGINT', + 'CREATE_TIME' => 'VARCHAR', + 'UPDATE_TIME' => 'VARCHAR', + 'CHECK_TIME' => 'VARCHAR', + 'TABLE_COLLATION' => 'VARCHAR COLLATE NOCASE', + 'CHECKSUM' => 'BIGINT', + 'CREATE_OPTIONS' => 'VARCHAR COLLATE NOCASE', + 'TABLE_COMMENT' => 'VARCHAR COLLATE NOCASE', + ); + } + + /** + * Build MySQL-shaped information_schema.tables rows. + * + * @return array> + */ + private function information_schema_table_rows(): array { + $metadata_by_table = $this->table_metadata_by_table(); + $rows = array(); + + foreach ( $this->user_table_names() as $table_name ) { + $metadata = $metadata_by_table[ $table_name ] ?? $this->fallback_table_metadata( $table_name ); + $rows[] = $this->information_schema_table_row( $table_name, $metadata ); + } + + return $rows; + } + + /** + * Build one MySQL-shaped information_schema.tables row. + * + * @param string $table_name Table name. + * @param array $metadata Table metadata. + * @return array + */ + private function information_schema_table_row( string $table_name, array $metadata ): array { + return array( + 'TABLE_CATALOG' => 'def', + 'TABLE_SCHEMA' => $this->database, + 'TABLE_NAME' => $table_name, + 'TABLE_TYPE' => 'BASE TABLE', + 'ENGINE' => $metadata['engine'], + 'VERSION' => 10, + 'ROW_FORMAT' => $metadata['row_format'], + 'TABLE_ROWS' => 0, + 'AVG_ROW_LENGTH' => 0, + 'DATA_LENGTH' => 0, + 'MAX_DATA_LENGTH' => 0, + 'INDEX_LENGTH' => 0, + 'DATA_FREE' => 0, + 'AUTO_INCREMENT' => $this->table_auto_increment_value( $table_name ), + 'CREATE_TIME' => $metadata['create_time'], + 'UPDATE_TIME' => null, + 'CHECK_TIME' => null, + 'TABLE_COLLATION' => $metadata['table_collation'], + 'CHECKSUM' => null, + 'CREATE_OPTIONS' => $metadata['create_options'], + 'TABLE_COMMENT' => $metadata['table_comment'], + ); + } + + /** + * Read recorded table metadata. + * + * @return array> Metadata keyed by table name. + */ + private function table_metadata_by_table(): array { + $this->ensure_table_metadata_table(); + + $stmt = $this->execute_duckdb_query( + 'SELECT table_name, engine, row_format, table_collation, table_comment, create_options, create_time FROM ' + . $this->connection->quote_identifier( self::TABLE_METADATA_TABLE ) + . ' ORDER BY table_name', + 'Failed to inspect DuckDB table metadata' + ); + + $metadata = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $metadata[ (string) $row['table_name'] ] = $row; + } + + return $metadata; + } + + /** + * Fallback table metadata for tables created outside the MySQL-emulation path. + * + * @param string $table_name Table name. + * @return array + */ + private function fallback_table_metadata( string $table_name ): array { + return array( + 'table_name' => $table_name, + 'engine' => 'InnoDB', + 'row_format' => 'Dynamic', + 'table_collation' => 'utf8mb4_0900_ai_ci', + 'table_comment' => '', + 'create_options' => '', + 'create_time' => gmdate( 'Y-m-d H:i:s' ), + ); + } + + /** + * Compute the MySQL-facing AUTO_INCREMENT value for a table. + * + * @param string $table_name Table name. + * @return int|null Next generated value, or null when there is no auto-increment column. + */ + private function table_auto_increment_value( string $table_name ): ?int { + $metadata = $this->auto_increment_metadata_for_table( $table_name ); + if ( null === $metadata ) { + return null; + } + + $current = $this->sequence_currval( $metadata['sequence_name'] ); + return null === $current ? 1 : $current + 1; + } + + /** + * Return the canonical tables column name for a token value. + * + * @param string $identifier Identifier token value. + * @return string|null Canonical column name, or null when not a tables column. + */ + private function information_schema_tables_column_name( string $identifier ): ?string { + $columns = array( + 'table_catalog' => 'TABLE_CATALOG', + 'table_schema' => 'TABLE_SCHEMA', + 'table_name' => 'TABLE_NAME', + 'table_type' => 'TABLE_TYPE', + 'engine' => 'ENGINE', + 'version' => 'VERSION', + 'row_format' => 'ROW_FORMAT', + 'table_rows' => 'TABLE_ROWS', + 'avg_row_length' => 'AVG_ROW_LENGTH', + 'data_length' => 'DATA_LENGTH', + 'max_data_length' => 'MAX_DATA_LENGTH', + 'index_length' => 'INDEX_LENGTH', + 'data_free' => 'DATA_FREE', + 'auto_increment' => 'AUTO_INCREMENT', + 'create_time' => 'CREATE_TIME', + 'update_time' => 'UPDATE_TIME', + 'check_time' => 'CHECK_TIME', + 'table_collation' => 'TABLE_COLLATION', + 'checksum' => 'CHECKSUM', + 'create_options' => 'CREATE_OPTIONS', + 'table_comment' => 'TABLE_COMMENT', + ); + $key = strtolower( $identifier ); + + return $columns[ $key ] ?? null; + } + /** * Refresh a temporary MySQL-shaped information_schema.columns table. */ @@ -3593,6 +3979,10 @@ private function user_table_names(): array { . ' AND table_name <> ' . $this->connection->quote( self::COLUMN_METADATA_TABLE ) . ' AND table_name <> ' + . $this->connection->quote( self::TABLE_METADATA_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_TABLES_TABLE ) + . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_STATISTICS_TABLE ) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index c17519047..73256b77a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -235,4 +235,35 @@ public function test_information_schema_statistics_metadata_matches_sqlite(): vo ORDER BY s.INDEX_NAME, s.SEQ_IN_INDEX" ); } + + public function test_information_schema_tables_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL + ) ENGINE=MyISAM CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='Options table'", + 'CREATE TABLE plain (id INT, name TEXT)', + "INSERT INTO metadata (option_name, option_value) + VALUES ('siteurl', 'https://example.test'), ('home', 'https://example.test')", + ) + ); + + $this->assertParityRows( + "SELECT TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE, ENGINE, VERSION, + ROW_FORMAT, TABLE_ROWS, AVG_ROW_LENGTH, DATA_LENGTH, MAX_DATA_LENGTH, + INDEX_LENGTH, DATA_FREE, `AUTO_INCREMENT`, UPDATE_TIME, CHECK_TIME, + TABLE_COLLATION, CHECKSUM, CREATE_OPTIONS, TABLE_COMMENT + FROM information_schema.tables + WHERE table_schema = 'wp' + ORDER BY table_name" + ); + $this->assertParityRows( + "SELECT t.TABLE_NAME, t.ENGINE, t.`AUTO_INCREMENT` + FROM information_schema.tables t + WHERE t.TABLE_SCHEMA = 'wp' + ORDER BY t.TABLE_NAME" + ); + } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 0bddb2846..0a64fcd7f 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -829,6 +829,92 @@ public function test_information_schema_statistics_exposes_mysql_shaped_index_me $this->assertSame( array(), $internal ); } + public function test_information_schema_tables_exposes_mysql_shaped_table_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL + ) ENGINE=MyISAM CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='Options table'" + ); + $driver->query( 'CREATE TABLE plain (id INT, name TEXT)' ); + $driver->query( + "INSERT INTO metadata (option_name, option_value) + VALUES ('siteurl', 'https://example.test'), ('home', 'https://example.test')" + ); + + $rows = $driver->query( + "SELECT TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE, ENGINE, VERSION, + ROW_FORMAT, TABLE_ROWS, AVG_ROW_LENGTH, DATA_LENGTH, MAX_DATA_LENGTH, + INDEX_LENGTH, DATA_FREE, AUTO_INCREMENT, CREATE_TIME, UPDATE_TIME, + CHECK_TIME, TABLE_COLLATION, CHECKSUM, CREATE_OPTIONS, TABLE_COMMENT + FROM information_schema.tables + WHERE table_schema = 'wp' + ORDER BY table_name" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'TABLE_TYPE', + 'ENGINE', + 'VERSION', + 'ROW_FORMAT', + 'TABLE_ROWS', + 'AVG_ROW_LENGTH', + 'DATA_LENGTH', + 'MAX_DATA_LENGTH', + 'INDEX_LENGTH', + 'DATA_FREE', + 'AUTO_INCREMENT', + 'CREATE_TIME', + 'UPDATE_TIME', + 'CHECK_TIME', + 'TABLE_COLLATION', + 'CHECKSUM', + 'CREATE_OPTIONS', + 'TABLE_COMMENT', + ), + array_keys( $rows[0] ) + ); + $this->assertSame( array( 'metadata', 'plain' ), array_column( $rows, 'TABLE_NAME' ) ); + $this->assertSame( 'MyISAM', $rows[0]['ENGINE'] ); + $this->assertSame( 'Fixed', $rows[0]['ROW_FORMAT'] ); + $this->assertSame( 'utf8mb4_unicode_ci', $rows[0]['TABLE_COLLATION'] ); + $this->assertSame( 'Options table', $rows[0]['TABLE_COMMENT'] ); + $this->assertSame( 3, $rows[0]['AUTO_INCREMENT'] ); + $this->assertRegExp( '/^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d$/', $rows[0]['CREATE_TIME'] ); + $this->assertSame( 'InnoDB', $rows[1]['ENGINE'] ); + $this->assertSame( 'Dynamic', $rows[1]['ROW_FORMAT'] ); + $this->assertSame( null, $rows[1]['AUTO_INCREMENT'] ); + + $aliased = $driver->query( + "SELECT t.TABLE_NAME, t.ENGINE, t.`AUTO_INCREMENT` + FROM information_schema.tables t + WHERE t.TABLE_SCHEMA = 'wp' + ORDER BY t.TABLE_NAME" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'metadata', 'plain' ), array_column( $aliased, 'TABLE_NAME' ) ); + $this->assertSame( 3, $aliased[0]['AUTO_INCREMENT'] ); + + $internal = $driver->query( + "SELECT table_name + FROM information_schema.tables + WHERE table_name LIKE '__wp_duckdb_%'" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $internal ); + } + public function test_unsupported_alter_table_add_column_constraints_throw_driver_exception(): void { $this->requireDuckDBRuntime(); From a2cf3cc2fd47eb196291af802718a568380ed12b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 18:02:00 +0000 Subject: [PATCH 016/317] Add DuckDB information_schema constraint parity --- .../src/duckdb/class-wp-duckdb-driver.php | 433 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 67 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 200 ++++++++ 3 files changed, 681 insertions(+), 19 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index cf79cf259..224f14dd1 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -14,17 +14,19 @@ * throws WP_DuckDB_Driver_Exception for statements outside that subset. */ class WP_DuckDB_Driver { - const MYSQL_GRAMMAR_PATH = __DIR__ . '/../mysql/mysql-grammar.php'; - const DEFAULT_DATABASE = 'wp'; - const DEFAULT_MYSQL_VERSION = 80038; - const SEQUENCE_PREFIX = 'wp_duckdb_ai_'; - const INDEX_PREFIX = 'wp_duckdb_idx_'; - const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; - const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; - const TABLE_METADATA_TABLE = '__wp_duckdb_table_metadata'; - const INFO_SCHEMA_TABLES_TABLE = '__wp_duckdb_information_schema_tables'; - const INFO_SCHEMA_COLUMNS_TABLE = '__wp_duckdb_information_schema_columns'; - const INFO_SCHEMA_STATISTICS_TABLE = '__wp_duckdb_information_schema_statistics'; + const MYSQL_GRAMMAR_PATH = __DIR__ . '/../mysql/mysql-grammar.php'; + const DEFAULT_DATABASE = 'wp'; + const DEFAULT_MYSQL_VERSION = 80038; + const SEQUENCE_PREFIX = 'wp_duckdb_ai_'; + const INDEX_PREFIX = 'wp_duckdb_idx_'; + const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; + const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; + const TABLE_METADATA_TABLE = '__wp_duckdb_table_metadata'; + const INFO_SCHEMA_TABLES_TABLE = '__wp_duckdb_information_schema_tables'; + const INFO_SCHEMA_COLUMNS_TABLE = '__wp_duckdb_information_schema_columns'; + const INFO_SCHEMA_STATISTICS_TABLE = '__wp_duckdb_information_schema_statistics'; + const INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE = '__wp_duckdb_information_schema_table_constraints'; + const INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE = '__wp_duckdb_information_schema_key_column_usage'; const DATA_TYPE_MAP = array( WP_MySQL_Lexer::BOOL_SYMBOL => 'BOOLEAN', @@ -306,9 +308,11 @@ private function without_eof( array $tokens ): array { * @return WP_DuckDB_Result_Statement */ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { - $rewrite_information_schema_tables = $this->uses_information_schema_tables( $tokens ); - $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); - $rewrite_information_schema_statistics = $this->uses_information_schema_statistics( $tokens ); + $rewrite_information_schema_tables = $this->uses_information_schema_tables( $tokens ); + $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); + $rewrite_information_schema_statistics = $this->uses_information_schema_statistics( $tokens ); + $rewrite_information_schema_table_constraints = $this->uses_information_schema_table_constraints( $tokens ); + $rewrite_information_schema_key_column_usage = $this->uses_information_schema_key_column_usage( $tokens ); if ( $rewrite_information_schema_tables ) { $this->refresh_information_schema_tables_table(); } @@ -318,13 +322,21 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { if ( $rewrite_information_schema_statistics ) { $this->refresh_information_schema_statistics_table(); } + if ( $rewrite_information_schema_table_constraints ) { + $this->refresh_information_schema_table_constraints_table(); + } + if ( $rewrite_information_schema_key_column_usage ) { + $this->refresh_information_schema_key_column_usage_table(); + } return $this->execute_duckdb_query( $this->translate_tokens_to_duckdb_sql( $tokens, $rewrite_information_schema_tables, $rewrite_information_schema_columns, - $rewrite_information_schema_statistics + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage ), 'Unsupported DuckDB MySQL-emulation SELECT statement' ); @@ -974,6 +986,10 @@ private function execute_show_tables(): WP_DuckDB_Result_Statement { . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_STATISTICS_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE ) . ' ORDER BY table_name', 'Failed to execute SHOW TABLES' ); @@ -1038,17 +1054,17 @@ private function index_rows_for_table( string $table_name ): array { */ private function primary_key_index_rows( string $table_name ): array { $pragma = $this->execute_duckdb_query( - 'SELECT name, pk FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ') WHERE pk > 0 ORDER BY pk', + 'SELECT name FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ') WHERE pk > 0 ORDER BY pk, cid', 'Failed to inspect DuckDB primary key' ); $rows = array(); - foreach ( $pragma->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + foreach ( $pragma->fetchAll( PDO::FETCH_ASSOC ) as $offset => $row ) { $rows[] = $this->show_index_row( $table_name, 0, 'PRIMARY', - (int) $row['pk'], + $offset + 1, (string) $row['name'], null ); @@ -2159,7 +2175,9 @@ private function translate_tokens_to_duckdb_sql( array $tokens, bool $rewrite_information_schema_tables = false, bool $rewrite_information_schema_columns = false, - bool $rewrite_information_schema_statistics = false + bool $rewrite_information_schema_statistics = false, + bool $rewrite_information_schema_table_constraints = false, + bool $rewrite_information_schema_key_column_usage = false ): string { $pieces = array(); @@ -2181,6 +2199,16 @@ private function translate_tokens_to_duckdb_sql( $index += 2; continue; } + if ( $rewrite_information_schema_table_constraints && $this->is_information_schema_table_constraints_reference( $tokens, $index ) ) { + $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE ); + $index += 2; + continue; + } + if ( $rewrite_information_schema_key_column_usage && $this->is_information_schema_key_column_usage_reference( $tokens, $index ) ) { + $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE ); + $index += 2; + continue; + } if ( WP_MySQL_Lexer::FROM_SYMBOL === $token->id @@ -2242,6 +2270,12 @@ private function translate_tokens_to_duckdb_sql( if ( null === $identifier && $rewrite_information_schema_statistics ) { $identifier = $this->information_schema_statistics_column_name( $token->get_value() ); } + if ( null === $identifier && $rewrite_information_schema_table_constraints ) { + $identifier = $this->information_schema_table_constraints_column_name( $token->get_value() ); + } + if ( null === $identifier && $rewrite_information_schema_key_column_usage ) { + $identifier = $this->information_schema_key_column_usage_column_name( $token->get_value() ); + } $pieces[] = $this->connection->quote_identifier( $identifier ?? $token->get_value() ); continue; } @@ -2267,6 +2301,22 @@ private function translate_tokens_to_duckdb_sql( } } + if ( $rewrite_information_schema_table_constraints ) { + $identifier = $this->information_schema_table_constraints_column_name( $token->get_value() ); + if ( null !== $identifier ) { + $pieces[] = $this->connection->quote_identifier( $identifier ); + continue; + } + } + + if ( $rewrite_information_schema_key_column_usage ) { + $identifier = $this->information_schema_key_column_usage_column_name( $token->get_value() ); + if ( null !== $identifier ) { + $pieces[] = $this->connection->quote_identifier( $identifier ); + continue; + } + } + $pieces[] = $token->get_bytes(); } @@ -3440,6 +3490,66 @@ private function is_information_schema_statistics_reference( array $tokens, int && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'statistics' ); } + /** + * Check whether a SELECT references information_schema.table_constraints. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether the query needs the compatibility table. + */ + private function uses_information_schema_table_constraints( array $tokens ): bool { + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( $this->is_information_schema_table_constraints_reference( $tokens, $index ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a token offset starts information_schema.table_constraints. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return bool Whether the sequence is information_schema.table_constraints. + */ + private function is_information_schema_table_constraints_reference( array $tokens, int $index ): bool { + return isset( $tokens[ $index + 2 ] ) + && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'table_constraints' ); + } + + /** + * Check whether a SELECT references information_schema.key_column_usage. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether the query needs the compatibility table. + */ + private function uses_information_schema_key_column_usage( array $tokens ): bool { + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( $this->is_information_schema_key_column_usage_reference( $tokens, $index ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a token offset starts information_schema.key_column_usage. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return bool Whether the sequence is information_schema.key_column_usage. + */ + private function is_information_schema_key_column_usage_reference( array $tokens, int $index ): bool { + return isset( $tokens[ $index + 2 ] ) + && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'key_column_usage' ); + } + /** * Refresh a temporary MySQL-shaped information_schema.tables table. */ @@ -3944,6 +4054,287 @@ private function information_schema_statistics_column_name( string $identifier ) return $columns[ $key ] ?? null; } + /** + * Refresh a temporary MySQL-shaped information_schema.table_constraints table. + */ + private function refresh_information_schema_table_constraints_table(): void { + $this->refresh_information_schema_compatibility_table( + self::INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE, + $this->information_schema_table_constraints_definitions(), + $this->information_schema_table_constraints_rows(), + 'information_schema.table_constraints' + ); + } + + /** + * MySQL-shaped information_schema.table_constraints definitions. + * + * @return array Column name to DuckDB type. + */ + private function information_schema_table_constraints_definitions(): array { + return array( + 'CONSTRAINT_CATALOG' => 'VARCHAR COLLATE NOCASE', + 'CONSTRAINT_SCHEMA' => 'VARCHAR COLLATE NOCASE', + 'CONSTRAINT_NAME' => 'VARCHAR COLLATE NOCASE', + 'TABLE_SCHEMA' => 'VARCHAR COLLATE NOCASE', + 'TABLE_NAME' => 'VARCHAR COLLATE NOCASE', + 'CONSTRAINT_TYPE' => 'VARCHAR', + 'ENFORCED' => 'VARCHAR', + ); + } + + /** + * Build MySQL-shaped information_schema.table_constraints rows. + * + * @return array> + */ + private function information_schema_table_constraints_rows(): array { + $rows = array(); + $seen = array(); + + foreach ( $this->information_schema_key_constraint_rows() as $constraint ) { + $key = $constraint['table_name'] . "\0" . $constraint['constraint_type'] . "\0" . $constraint['constraint_name']; + if ( isset( $seen[ $key ] ) ) { + continue; + } + + $seen[ $key ] = true; + $rows[] = $this->information_schema_table_constraints_row( $constraint ); + } + + return $rows; + } + + /** + * Build one MySQL-shaped information_schema.table_constraints row. + * + * @param array $constraint Normalized key constraint row. + * @return array + */ + private function information_schema_table_constraints_row( array $constraint ): array { + return array( + 'CONSTRAINT_CATALOG' => 'def', + 'CONSTRAINT_SCHEMA' => $this->database, + 'CONSTRAINT_NAME' => $constraint['constraint_name'], + 'TABLE_SCHEMA' => $this->database, + 'TABLE_NAME' => $constraint['table_name'], + 'CONSTRAINT_TYPE' => $constraint['constraint_type'], + 'ENFORCED' => 'YES', + ); + } + + /** + * Return the canonical table_constraints column name for a token value. + * + * @param string $identifier Identifier token value. + * @return string|null Canonical column name, or null when not a table_constraints column. + */ + private function information_schema_table_constraints_column_name( string $identifier ): ?string { + $columns = array( + 'constraint_catalog' => 'CONSTRAINT_CATALOG', + 'constraint_schema' => 'CONSTRAINT_SCHEMA', + 'constraint_name' => 'CONSTRAINT_NAME', + 'table_schema' => 'TABLE_SCHEMA', + 'table_name' => 'TABLE_NAME', + 'constraint_type' => 'CONSTRAINT_TYPE', + 'enforced' => 'ENFORCED', + ); + $key = strtolower( $identifier ); + + return $columns[ $key ] ?? null; + } + + /** + * Refresh a temporary MySQL-shaped information_schema.key_column_usage table. + */ + private function refresh_information_schema_key_column_usage_table(): void { + $this->refresh_information_schema_compatibility_table( + self::INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE, + $this->information_schema_key_column_usage_definitions(), + $this->information_schema_key_column_usage_rows(), + 'information_schema.key_column_usage' + ); + } + + /** + * MySQL-shaped information_schema.key_column_usage definitions. + * + * @return array Column name to DuckDB type. + */ + private function information_schema_key_column_usage_definitions(): array { + return array( + 'CONSTRAINT_CATALOG' => 'VARCHAR COLLATE NOCASE', + 'CONSTRAINT_SCHEMA' => 'VARCHAR COLLATE NOCASE', + 'CONSTRAINT_NAME' => 'VARCHAR COLLATE NOCASE', + 'TABLE_CATALOG' => 'VARCHAR COLLATE NOCASE', + 'TABLE_SCHEMA' => 'VARCHAR COLLATE NOCASE', + 'TABLE_NAME' => 'VARCHAR COLLATE NOCASE', + 'COLUMN_NAME' => 'VARCHAR COLLATE NOCASE', + 'ORDINAL_POSITION' => 'INTEGER', + 'POSITION_IN_UNIQUE_CONSTRAINT' => 'INTEGER', + 'REFERENCED_TABLE_SCHEMA' => 'VARCHAR COLLATE NOCASE', + 'REFERENCED_TABLE_NAME' => 'VARCHAR COLLATE NOCASE', + 'REFERENCED_COLUMN_NAME' => 'VARCHAR COLLATE NOCASE', + ); + } + + /** + * Build MySQL-shaped information_schema.key_column_usage rows. + * + * @return array> + */ + private function information_schema_key_column_usage_rows(): array { + $rows = array(); + foreach ( $this->information_schema_key_constraint_rows() as $constraint ) { + $rows[] = $this->information_schema_key_column_usage_row( $constraint ); + } + + return $rows; + } + + /** + * Build one MySQL-shaped information_schema.key_column_usage row. + * + * @param array $constraint Normalized key constraint row. + * @return array + */ + private function information_schema_key_column_usage_row( array $constraint ): array { + return array( + 'CONSTRAINT_CATALOG' => 'def', + 'CONSTRAINT_SCHEMA' => $this->database, + 'CONSTRAINT_NAME' => $constraint['constraint_name'], + 'TABLE_CATALOG' => 'def', + 'TABLE_SCHEMA' => $this->database, + 'TABLE_NAME' => $constraint['table_name'], + 'COLUMN_NAME' => $constraint['column_name'], + 'ORDINAL_POSITION' => $constraint['ordinal_position'], + 'POSITION_IN_UNIQUE_CONSTRAINT' => null, + 'REFERENCED_TABLE_SCHEMA' => $this->database, + 'REFERENCED_TABLE_NAME' => null, + 'REFERENCED_COLUMN_NAME' => null, + ); + } + + /** + * Return the canonical key_column_usage column name for a token value. + * + * @param string $identifier Identifier token value. + * @return string|null Canonical column name, or null when not a key_column_usage column. + */ + private function information_schema_key_column_usage_column_name( string $identifier ): ?string { + $columns = array( + 'constraint_catalog' => 'CONSTRAINT_CATALOG', + 'constraint_schema' => 'CONSTRAINT_SCHEMA', + 'constraint_name' => 'CONSTRAINT_NAME', + 'table_catalog' => 'TABLE_CATALOG', + 'table_schema' => 'TABLE_SCHEMA', + 'table_name' => 'TABLE_NAME', + 'column_name' => 'COLUMN_NAME', + 'ordinal_position' => 'ORDINAL_POSITION', + 'position_in_unique_constraint' => 'POSITION_IN_UNIQUE_CONSTRAINT', + 'referenced_table_schema' => 'REFERENCED_TABLE_SCHEMA', + 'referenced_table_name' => 'REFERENCED_TABLE_NAME', + 'referenced_column_name' => 'REFERENCED_COLUMN_NAME', + ); + $key = strtolower( $identifier ); + + return $columns[ $key ] ?? null; + } + + /** + * Build normalized primary and unique constraint column rows. + * + * @return array + */ + private function information_schema_key_constraint_rows(): array { + $rows = array(); + + foreach ( $this->user_table_names() as $table_name ) { + foreach ( $this->primary_key_index_rows( $table_name ) as $index_row ) { + $rows[] = array( + 'table_name' => $table_name, + 'constraint_name' => (string) $index_row[2], + 'constraint_type' => 'PRIMARY KEY', + 'ordinal_position' => (int) $index_row[3], + 'column_name' => (string) $index_row[4], + ); + } + + foreach ( $this->secondary_index_rows( $table_name ) as $index_row ) { + if ( 0 !== (int) $index_row[1] ) { + continue; + } + + $rows[] = array( + 'table_name' => $table_name, + 'constraint_name' => (string) $index_row[2], + 'constraint_type' => 'UNIQUE', + 'ordinal_position' => (int) $index_row[3], + 'column_name' => (string) $index_row[4], + ); + } + } + + return $rows; + } + + /** + * Refresh a temporary MySQL-shaped information_schema compatibility table. + * + * @param string $table_name Temporary table name. + * @param array $definitions Column definitions. + * @param array> $rows Rows to insert. + * @param string $label User-facing information_schema table label. + */ + private function refresh_information_schema_compatibility_table( string $table_name, array $definitions, array $rows, string $label ): void { + $columns = array_keys( $definitions ); + $column_sql = array(); + foreach ( $definitions as $column_name => $type ) { + $column_sql[] = $this->connection->quote_identifier( $column_name ) . ' ' . $type; + } + + $this->execute_duckdb_query( + 'CREATE OR REPLACE TEMP TABLE ' + . $this->connection->quote_identifier( $table_name ) + . ' (' + . implode( ', ', $column_sql ) + . ')', + 'Failed to initialize DuckDB ' . $label . ' compatibility table' + ); + + if ( count( $rows ) === 0 ) { + return; + } + + $quoted_columns = implode( + ', ', + array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ); + }, + $columns + ) + ); + + foreach ( $rows as $row ) { + $values = array(); + foreach ( $columns as $column_name ) { + $values[] = $this->connection->quote( $row[ $column_name ] ); + } + + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( $table_name ) + . ' (' + . $quoted_columns + . ') VALUES (' + . implode( ', ', $values ) + . ')', + 'Failed to populate DuckDB ' . $label . ' compatibility table' + ); + } + } + /** * Build MySQL-shaped information_schema.columns rows. * @@ -3986,6 +4377,10 @@ private function user_table_names(): array { . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_STATISTICS_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE ) . ' ORDER BY table_name', 'Failed to inspect DuckDB tables' ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 73256b77a..593bf850c 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -236,6 +236,73 @@ public function test_information_schema_statistics_metadata_matches_sqlite(): vo ); } + public function test_information_schema_constraint_metadata_matches_sqlite(): void { + $this->assertParityRows( + "SELECT COUNT(*) AS count + FROM information_schema.table_constraints + WHERE table_schema = 'wp'" + ); + $this->assertParityRows( + "SELECT COUNT(*) AS count + FROM information_schema.key_column_usage + WHERE table_schema = 'wp'" + ); + + $this->runParitySetup( + array( + 'CREATE TABLE empty_table (id INT, note TEXT)', + "CREATE TABLE metadata ( + site_id BIGINT(20) UNSIGNED NOT NULL, + option_id BIGINT(20) UNSIGNED NOT NULL, + option_name VARCHAR(191) NOT NULL DEFAULT '', + payload LONGTEXT, + PRIMARY KEY (site_id, option_id), + UNIQUE KEY unique_site_option (site_id, option_name), + KEY payload_prefix (payload(12)) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + ) + ); + + $this->assertParityRows( + "SELECT TABLE_NAME, CONSTRAINT_NAME + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'empty_table'" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME, TABLE_SCHEMA, + TABLE_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'metadata' + ORDER BY constraint_name" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME, TABLE_CATALOG, + TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, + POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_SCHEMA, + REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' AND table_name = 'metadata' + ORDER BY constraint_name, ordinal_position" + ); + $this->assertParityRows( + "SELECT tc.CONSTRAINT_NAME AS name, tc.CONSTRAINT_TYPE AS type, + k.COLUMN_NAME AS col, k.ORDINAL_POSITION AS pos + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS k + ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND k.TABLE_SCHEMA = tc.TABLE_SCHEMA + AND k.TABLE_NAME = tc.TABLE_NAME + WHERE tc.TABLE_SCHEMA = 'wp' AND tc.TABLE_NAME = 'metadata' + ORDER BY name, pos" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND constraint_name = 'payload_prefix'" + ); + } + public function test_information_schema_tables_metadata_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 0a64fcd7f..ec0501395 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -829,6 +829,206 @@ public function test_information_schema_statistics_exposes_mysql_shaped_index_me $this->assertSame( array(), $internal ); } + public function test_information_schema_constraints_expose_mysql_shaped_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $table_constraints_count = $driver->query( + "SELECT COUNT(*) AS count + FROM information_schema.table_constraints + WHERE table_schema = 'wp'" + )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( 0, (int) $table_constraints_count['count'] ); + + $key_column_usage_count = $driver->query( + "SELECT COUNT(*) AS count + FROM information_schema.key_column_usage + WHERE table_schema = 'wp'" + )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( 0, (int) $key_column_usage_count['count'] ); + + $driver->query( 'CREATE TABLE empty_table (id INT, note TEXT)' ); + $driver->query( + "CREATE TABLE metadata ( + site_id BIGINT(20) UNSIGNED NOT NULL, + option_id BIGINT(20) UNSIGNED NOT NULL, + option_name VARCHAR(191) NOT NULL DEFAULT '', + payload LONGTEXT, + PRIMARY KEY (site_id, option_id), + UNIQUE KEY unique_site_option (site_id, option_name), + KEY payload_prefix (payload(12)) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + + $empty_constraints = $driver->query( + "SELECT TABLE_NAME, CONSTRAINT_NAME + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'empty_table'" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $empty_constraints ); + + $constraints = $driver->query( + "SELECT CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME, TABLE_SCHEMA, + TABLE_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'metadata' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + 'CONSTRAINT_CATALOG', + 'CONSTRAINT_SCHEMA', + 'CONSTRAINT_NAME', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'CONSTRAINT_TYPE', + 'ENFORCED', + ), + array_keys( $constraints[0] ) + ); + $this->assertSame( + array( + array( + 'CONSTRAINT_CATALOG' => 'def', + 'CONSTRAINT_SCHEMA' => 'wp', + 'CONSTRAINT_NAME' => 'PRIMARY', + 'TABLE_SCHEMA' => 'wp', + 'TABLE_NAME' => 'metadata', + 'CONSTRAINT_TYPE' => 'PRIMARY KEY', + 'ENFORCED' => 'YES', + ), + array( + 'CONSTRAINT_CATALOG' => 'def', + 'CONSTRAINT_SCHEMA' => 'wp', + 'CONSTRAINT_NAME' => 'unique_site_option', + 'TABLE_SCHEMA' => 'wp', + 'TABLE_NAME' => 'metadata', + 'CONSTRAINT_TYPE' => 'UNIQUE', + 'ENFORCED' => 'YES', + ), + ), + $constraints + ); + + $usage = $driver->query( + "SELECT CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME, TABLE_CATALOG, + TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, + POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_SCHEMA, + REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' AND table_name = 'metadata' + ORDER BY constraint_name, ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + 'CONSTRAINT_CATALOG', + 'CONSTRAINT_SCHEMA', + 'CONSTRAINT_NAME', + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'COLUMN_NAME', + 'ORDINAL_POSITION', + 'POSITION_IN_UNIQUE_CONSTRAINT', + 'REFERENCED_TABLE_SCHEMA', + 'REFERENCED_TABLE_NAME', + 'REFERENCED_COLUMN_NAME', + ), + array_keys( $usage[0] ) + ); + $this->assertSame( array( 'PRIMARY', 'PRIMARY', 'unique_site_option', 'unique_site_option' ), array_column( $usage, 'CONSTRAINT_NAME' ) ); + $this->assertSame( array( 'site_id', 'option_id', 'site_id', 'option_name' ), array_column( $usage, 'COLUMN_NAME' ) ); + $this->assertSame( array( 1, 2, 1, 2 ), array_map( 'intval', array_column( $usage, 'ORDINAL_POSITION' ) ) ); + $this->assertSame( array( null, null, null, null ), array_column( $usage, 'POSITION_IN_UNIQUE_CONSTRAINT' ) ); + $this->assertSame( array( 'wp', 'wp', 'wp', 'wp' ), array_column( $usage, 'REFERENCED_TABLE_SCHEMA' ) ); + $this->assertSame( array( null, null, null, null ), array_column( $usage, 'REFERENCED_TABLE_NAME' ) ); + $this->assertSame( array( null, null, null, null ), array_column( $usage, 'REFERENCED_COLUMN_NAME' ) ); + + $joined = $driver->query( + "SELECT tc.CONSTRAINT_NAME AS name, tc.CONSTRAINT_TYPE AS type, + k.COLUMN_NAME AS col, k.ORDINAL_POSITION AS pos + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS k + ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND k.TABLE_SCHEMA = tc.TABLE_SCHEMA + AND k.TABLE_NAME = tc.TABLE_NAME + WHERE tc.TABLE_SCHEMA = 'wp' AND tc.TABLE_NAME = 'metadata' + ORDER BY name, pos" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'name' => 'PRIMARY', + 'type' => 'PRIMARY KEY', + 'col' => 'site_id', + 'pos' => 1, + ), + array( + 'name' => 'PRIMARY', + 'type' => 'PRIMARY KEY', + 'col' => 'option_id', + 'pos' => 2, + ), + array( + 'name' => 'unique_site_option', + 'type' => 'UNIQUE', + 'col' => 'site_id', + 'pos' => 1, + ), + array( + 'name' => 'unique_site_option', + 'type' => 'UNIQUE', + 'col' => 'option_name', + 'pos' => 2, + ), + ), + $joined + ); + + $uppercase = $driver->query( + "SELECT tc.CONSTRAINT_NAME, k.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc + JOIN information_schema.key_column_usage AS k + ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND k.TABLE_SCHEMA = tc.TABLE_SCHEMA + AND k.TABLE_NAME = tc.TABLE_NAME + WHERE tc.TABLE_SCHEMA = 'wp' AND tc.TABLE_NAME = 'metadata' + ORDER BY tc.CONSTRAINT_NAME, k.ORDINAL_POSITION" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'site_id', 'option_id', 'site_id', 'option_name' ), array_column( $uppercase, 'COLUMN_NAME' ) ); + + $non_unique = $driver->query( + "SELECT CONSTRAINT_NAME + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND constraint_name = 'payload_prefix'" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $non_unique ); + + $internal_constraints = $driver->query( + "SELECT table_name + FROM information_schema.table_constraints + WHERE table_name LIKE '__wp_duckdb_%'" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $internal_constraints ); + + $internal_usage = $driver->query( + "SELECT table_name + FROM information_schema.key_column_usage + WHERE table_name LIKE '__wp_duckdb_%'" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $internal_usage ); + } + public function test_information_schema_tables_exposes_mysql_shaped_table_metadata(): void { $this->requireDuckDBRuntime(); From a1c1d1e2bce6de2641457c8b89737e1c7608cb8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 18:12:00 +0000 Subject: [PATCH 017/317] Add DuckDB SHOW TABLE STATUS parity --- .../src/duckdb/class-wp-duckdb-driver.php | 98 +++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 42 ++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 88 +++++++++++++++++ 3 files changed, 228 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 224f14dd1..b4fc676f2 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -870,6 +870,14 @@ private function execute_show( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_show_tables(); } + if ( + isset( $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::STATUS_SYMBOL === $tokens[2]->id + ) { + return $this->execute_show_table_status( $tokens ); + } + if ( isset( $tokens[1] ) && ( @@ -894,6 +902,96 @@ private function execute_show( array $tokens ): WP_DuckDB_Result_Statement { throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW statement in DuckDB driver.' ); } + /** + * Execute SHOW TABLE STATUS. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_show_table_status( array $tokens ): WP_DuckDB_Result_Statement { + $index = 3; + $database = $this->database; + + if ( + isset( $tokens[ $index ] ) + && ( WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $index ]->id ) + ) { + ++$index; + $database = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + } + + $condition = ''; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $index ]->id ) { + if ( + ! isset( $tokens[ $index + 1 ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $index + 1 ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $index + 1 ]->id + ) + ) { + throw new WP_DuckDB_Driver_Exception( 'SHOW TABLE STATUS LIKE requires a string pattern in the DuckDB driver.' ); + } + $condition = ' AND ' . $this->connection->quote_identifier( 'Name' ) + . ' LIKE ' + . $this->connection->quote( $tokens[ $index + 1 ]->get_value() ) + . ' ESCAPE ' + . $this->connection->quote( '\\' ); + $index += 2; + } elseif ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'SHOW TABLE STATUS WHERE requires an expression in the DuckDB driver.' ); + } + $condition = ' AND ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $index ) ); + $index = count( $tokens ); + } + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW TABLE STATUS statement in DuckDB driver. Only optional FROM/IN, LIKE, and WHERE are supported.' ); + } + + $this->refresh_information_schema_tables_table(); + + $schema_condition = 0 === strcasecmp( $database, $this->database ) + ? $this->connection->quote_identifier( 'TABLE_SCHEMA' ) . ' = ' . $this->connection->quote( $this->database ) + : '1 = 0'; + + $sql = 'SELECT * FROM (' + . 'SELECT ' + . $this->connection->quote_identifier( 'TABLE_NAME' ) . ' AS ' . $this->connection->quote_identifier( 'Name' ) . ', ' + . $this->connection->quote_identifier( 'ENGINE' ) . ' AS ' . $this->connection->quote_identifier( 'Engine' ) . ', ' + . $this->connection->quote_identifier( 'VERSION' ) . ' AS ' . $this->connection->quote_identifier( 'Version' ) . ', ' + . $this->connection->quote_identifier( 'ROW_FORMAT' ) . ' AS ' . $this->connection->quote_identifier( 'Row_format' ) . ', ' + . $this->connection->quote_identifier( 'TABLE_ROWS' ) . ' AS ' . $this->connection->quote_identifier( 'Rows' ) . ', ' + . $this->connection->quote_identifier( 'AVG_ROW_LENGTH' ) . ' AS ' . $this->connection->quote_identifier( 'Avg_row_length' ) . ', ' + . $this->connection->quote_identifier( 'DATA_LENGTH' ) . ' AS ' . $this->connection->quote_identifier( 'Data_length' ) . ', ' + . $this->connection->quote_identifier( 'MAX_DATA_LENGTH' ) . ' AS ' . $this->connection->quote_identifier( 'Max_data_length' ) . ', ' + . $this->connection->quote_identifier( 'INDEX_LENGTH' ) . ' AS ' . $this->connection->quote_identifier( 'Index_length' ) . ', ' + . $this->connection->quote_identifier( 'DATA_FREE' ) . ' AS ' . $this->connection->quote_identifier( 'Data_free' ) . ', ' + . $this->connection->quote_identifier( 'AUTO_INCREMENT' ) . ' AS ' . $this->connection->quote_identifier( 'Auto_increment' ) . ', ' + . $this->connection->quote_identifier( 'CREATE_TIME' ) . ' AS ' . $this->connection->quote_identifier( 'Create_time' ) . ', ' + . $this->connection->quote_identifier( 'UPDATE_TIME' ) . ' AS ' . $this->connection->quote_identifier( 'Update_time' ) . ', ' + . $this->connection->quote_identifier( 'CHECK_TIME' ) . ' AS ' . $this->connection->quote_identifier( 'Check_time' ) . ', ' + . $this->connection->quote_identifier( 'TABLE_COLLATION' ) . ' AS ' . $this->connection->quote_identifier( 'Collation' ) . ', ' + . $this->connection->quote_identifier( 'CHECKSUM' ) . ' AS ' . $this->connection->quote_identifier( 'Checksum' ) . ', ' + . $this->connection->quote_identifier( 'CREATE_OPTIONS' ) . ' AS ' . $this->connection->quote_identifier( 'Create_options' ) . ', ' + . $this->connection->quote_identifier( 'TABLE_COMMENT' ) . ' AS ' . $this->connection->quote_identifier( 'Comment' ) + . ' FROM ' + . $this->connection->quote_identifier( self::INFO_SCHEMA_TABLES_TABLE ) + . ' WHERE ' + . $schema_condition + . ') WHERE 1 = 1' + . $condition + . ' ORDER BY ' + . $this->connection->quote_identifier( 'Name' ); + + return $this->execute_duckdb_query( + $sql, + 'Failed to execute SHOW TABLE STATUS' + ); + } + /** * Execute SHOW [FULL] COLUMNS FROM table. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 593bf850c..a71212452 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -333,4 +333,46 @@ public function test_information_schema_tables_metadata_matches_sqlite(): void { ORDER BY t.TABLE_NAME" ); } + + public function test_show_table_status_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL + ) ENGINE=MyISAM CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='Options table'", + 'CREATE TABLE plain (id INT, name TEXT)', + "INSERT INTO metadata (option_name, option_value) + VALUES ('siteurl', 'https://example.test'), ('home', 'https://example.test')", + ) + ); + + $columns = array( + 'Name', + 'Engine', + 'Version', + 'Row_format', + 'Rows', + 'Avg_row_length', + 'Data_length', + 'Max_data_length', + 'Index_length', + 'Data_free', + 'Auto_increment', + 'Update_time', + 'Check_time', + 'Collation', + 'Checksum', + 'Create_options', + 'Comment', + ); + + $this->assertParityRowColumns( 'SHOW TABLE STATUS FROM wp', $columns ); + $this->assertParityRowColumns( "SHOW TABLE STATUS IN wp LIKE 'plain'", $columns ); + $this->assertParityRowColumns( 'SHOW TABLE STATUS WHERE `Auto_increment` > 2', array( 'Name', 'Auto_increment' ) ); + $this->assertParityRowColumns( 'SHOW TABLE STATUS WHERE `Auto_increment` IS NULL', array( 'Name', 'Auto_increment' ) ); + $this->assertParityRowColumns( "SHOW TABLE STATUS WHERE SUBSTR(Name, 1, 4) = 'meta'", array( 'Name' ) ); + $this->assertParityRowColumns( 'SHOW TABLE STATUS FROM other_database', $columns ); + } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index ec0501395..f9c029b46 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1115,6 +1115,94 @@ public function test_information_schema_tables_exposes_mysql_shaped_table_metada $this->assertSame( array(), $internal ); } + public function test_show_table_status_exposes_mysql_shaped_table_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL + ) ENGINE=MyISAM CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='Options table'" + ); + $driver->query( 'CREATE TABLE plain (id INT, name TEXT)' ); + $driver->query( + "INSERT INTO metadata (option_name, option_value) + VALUES ('siteurl', 'https://example.test'), ('home', 'https://example.test')" + ); + + $rows = $driver->query( 'SHOW TABLE STATUS FROM wp' )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + 'Name', + 'Engine', + 'Version', + 'Row_format', + 'Rows', + 'Avg_row_length', + 'Data_length', + 'Max_data_length', + 'Index_length', + 'Data_free', + 'Auto_increment', + 'Create_time', + 'Update_time', + 'Check_time', + 'Collation', + 'Checksum', + 'Create_options', + 'Comment', + ), + array_keys( $rows[0] ) + ); + $this->assertSame( array( 'metadata', 'plain' ), array_column( $rows, 'Name' ) ); + $this->assertSame( 'MyISAM', $rows[0]['Engine'] ); + $this->assertSame( 10, $rows[0]['Version'] ); + $this->assertSame( 'Fixed', $rows[0]['Row_format'] ); + $this->assertSame( 0, $rows[0]['Rows'] ); + $this->assertSame( 0, $rows[0]['Avg_row_length'] ); + $this->assertSame( 0, $rows[0]['Data_length'] ); + $this->assertSame( 0, $rows[0]['Max_data_length'] ); + $this->assertSame( 0, $rows[0]['Index_length'] ); + $this->assertSame( 0, $rows[0]['Data_free'] ); + $this->assertSame( 3, $rows[0]['Auto_increment'] ); + $this->assertRegExp( '/^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d$/', $rows[0]['Create_time'] ); + $this->assertSame( null, $rows[0]['Update_time'] ); + $this->assertSame( null, $rows[0]['Check_time'] ); + $this->assertSame( 'utf8mb4_unicode_ci', $rows[0]['Collation'] ); + $this->assertSame( null, $rows[0]['Checksum'] ); + $this->assertSame( '', $rows[0]['Create_options'] ); + $this->assertSame( 'Options table', $rows[0]['Comment'] ); + $this->assertSame( 'InnoDB', $rows[1]['Engine'] ); + $this->assertSame( 'Dynamic', $rows[1]['Row_format'] ); + $this->assertSame( null, $rows[1]['Auto_increment'] ); + + $like = $driver->query( "SHOW TABLE STATUS IN wp LIKE 'plain'" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'plain' ), array_column( $like, 'Name' ) ); + + $auto_increment = $driver->query( 'SHOW TABLE STATUS WHERE `Auto_increment` > 2' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'metadata' ), array_column( $auto_increment, 'Name' ) ); + + $without_auto_increment = $driver->query( 'SHOW TABLE STATUS WHERE `Auto_increment` IS NULL' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'plain' ), array_column( $without_auto_increment, 'Name' ) ); + + $where_function = $driver->query( "SHOW TABLE STATUS WHERE SUBSTR(Name, 1, 4) = 'meta'" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'metadata' ), array_column( $where_function, 'Name' ) ); + + $other_database = $driver->query( 'SHOW TABLE STATUS FROM other_database' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $other_database ); + + $internal = $driver->query( "SHOW TABLE STATUS LIKE '__wp_duckdb_%'" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $internal ); + } + public function test_unsupported_alter_table_add_column_constraints_throw_driver_exception(): void { $this->requireDuckDBRuntime(); From 5e4e264a43d545cdf427a9b425bdc5efb7a20835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 18:23:32 +0000 Subject: [PATCH 018/317] Add DuckDB SHOW CREATE TABLE parity --- .../src/duckdb/class-wp-duckdb-driver.php | 323 ++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 33 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 105 ++++++ 3 files changed, 461 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index b4fc676f2..b08c4d0e7 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -870,6 +870,14 @@ private function execute_show( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_show_tables(); } + if ( + isset( $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::CREATE_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id + ) { + return $this->execute_show_create_table( $tokens ); + } + if ( isset( $tokens[1], $tokens[2] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[1]->id @@ -902,6 +910,109 @@ private function execute_show( array $tokens ): WP_DuckDB_Result_Statement { throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW statement in DuckDB driver.' ); } + /** + * Execute SHOW CREATE TABLE. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_show_create_table( array $tokens ): WP_DuckDB_Result_Statement { + $index = 3; + $database = null; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index ]->id ) { + $database = $table_name; + ++$index; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + } + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW CREATE TABLE statement in DuckDB driver. Use SHOW CREATE TABLE [database.]table.' ); + } + + if ( null !== $database ) { + if ( 0 === strcasecmp( $database, 'information_schema' ) ) { + throw new WP_DuckDB_Driver_Exception( sprintf( "SHOW command denied to user 'duckdb'@'%%' for table '%s'", $table_name ) ); + } + + if ( 0 !== strcasecmp( $database, $this->database ) ) { + return new WP_DuckDB_Result_Statement( + array( 'Table', 'Create Table' ), + array() + ); + } + } + + $resolved_table_name = $this->resolve_user_table_name( $table_name ); + if ( null === $resolved_table_name ) { + return new WP_DuckDB_Result_Statement( + array( 'Table', 'Create Table' ), + array() + ); + } + + return new WP_DuckDB_Result_Statement( + array( 'Table', 'Create Table' ), + array( + array( + $table_name, + $this->mysql_create_table_statement( $resolved_table_name, $table_name ), + ), + ) + ); + } + + /** + * Build a MySQL-shaped SHOW CREATE TABLE statement from DuckDB metadata. + * + * @param string $table_name Resolved physical table name. + * @param string $requested_table_name Requested MySQL table name. + * @return string MySQL CREATE TABLE statement. + */ + private function mysql_create_table_statement( string $table_name, string $requested_table_name ): string { + $metadata_by_table = $this->table_metadata_by_table(); + $table_metadata = $metadata_by_table[ $table_name ] ?? $this->fallback_table_metadata( $table_name ); + $table_info = $this->information_schema_table_row( $table_name, $table_metadata ); + $column_rows = $this->show_create_table_column_rows( $table_name ); + $rows = array(); + $has_auto_increment = false; + + foreach ( $column_rows as $column ) { + $rows[] = $this->format_show_create_table_column( $column, $has_auto_increment ); + } + + foreach ( $this->show_create_table_index_groups( $table_name ) as $index_group ) { + $rows[] = $this->format_show_create_table_index( $index_group ); + } + + $sql = 'CREATE TABLE ' . $this->quote_mysql_identifier( $requested_table_name ) . " (\n"; + $sql .= implode( ",\n", $rows ); + $sql .= "\n)"; + $sql .= ' ENGINE=' . (string) $table_info['ENGINE']; + + $auto_increment = $table_info['AUTO_INCREMENT']; + if ( $has_auto_increment && null !== $auto_increment && (int) $auto_increment > 1 ) { + $sql .= ' AUTO_INCREMENT=' . (int) $auto_increment; + } + + $collation = (string) $table_info['TABLE_COLLATION']; + if ( '' === $collation ) { + $collation = 'utf8mb4_0900_ai_ci'; + } + $charset = $this->character_set_from_collation( $collation ) ?? 'utf8mb4'; + $sql .= ' DEFAULT CHARSET=' . $charset; + $sql .= ' COLLATE=' . $collation; + + if ( '' !== $table_info['TABLE_COMMENT'] ) { + $sql .= ' COMMENT=' . $this->quote_mysql_utf8_string_literal( (string) $table_info['TABLE_COMMENT'] ); + } + + return $sql; + } + /** * Execute SHOW TABLE STATUS. * @@ -1144,6 +1255,173 @@ private function index_rows_for_table( string $table_name ): array { ); } + /** + * Build information_schema-shaped column rows for SHOW CREATE TABLE. + * + * @param string $table_name Table name. + * @return array> + */ + private function show_create_table_column_rows( string $table_name ): array { + $metadata_rows = $this->column_metadata_rows( $table_name ); + if ( count( $metadata_rows ) === 0 ) { + $metadata_rows = $this->pragma_column_metadata_rows( $table_name ); + } + + return array_map( + function ( array $metadata ) use ( $table_name ): array { + return $this->information_schema_column_row( $table_name, $metadata ); + }, + $metadata_rows + ); + } + + /** + * Format one SHOW CREATE TABLE column definition. + * + * @param array $column information_schema.columns row. + * @param bool $has_auto_increment Whether an AUTO_INCREMENT column has been seen. + * @return string MySQL column definition. + */ + private function format_show_create_table_column( array $column, bool &$has_auto_increment ): string { + $extra = (string) $column['EXTRA']; + $is_auto_increment = false !== stripos( $extra, 'auto_increment' ); + $has_auto_increment = $has_auto_increment || $is_auto_increment; + + $sql = ' ' . $this->quote_mysql_identifier( (string) $column['COLUMN_NAME'] ); + $sql .= ' ' . (string) $column['COLUMN_TYPE']; + + if ( 'NO' === $column['IS_NULLABLE'] ) { + $sql .= ' NOT NULL'; + } elseif ( 'timestamp' === $column['COLUMN_TYPE'] ) { + $sql .= ' NULL'; + } + + if ( $is_auto_increment ) { + $sql .= ' AUTO_INCREMENT'; + } elseif ( + 'CURRENT_TIMESTAMP' === $column['COLUMN_DEFAULT'] + && in_array( $column['DATA_TYPE'], array( 'timestamp', 'datetime' ), true ) + ) { + $sql .= ' DEFAULT CURRENT_TIMESTAMP'; + } elseif ( null !== $column['COLUMN_DEFAULT'] ) { + if ( false !== strpos( $extra, 'DEFAULT_GENERATED' ) ) { + $sql .= ' DEFAULT (' . $column['COLUMN_DEFAULT'] . ')'; + } else { + $sql .= ' DEFAULT ' . $this->format_show_create_table_default( $column ); + } + } elseif ( 'YES' === $column['IS_NULLABLE'] ) { + $sql .= ' DEFAULT NULL'; + } + + if ( false !== strpos( $extra, 'on update CURRENT_TIMESTAMP' ) ) { + $sql .= ' ON UPDATE CURRENT_TIMESTAMP'; + } + + if ( '' !== $column['COLUMN_COMMENT'] ) { + $sql .= ' COMMENT ' . $this->quote_mysql_utf8_string_literal( (string) $column['COLUMN_COMMENT'] ); + } + + return $sql; + } + + /** + * Format a column default for SHOW CREATE TABLE. + * + * @param array $column information_schema.columns row. + * @return string MySQL literal. + */ + private function format_show_create_table_default( array $column ): string { + if ( 'bit' === $column['DATA_TYPE'] ) { + return (string) $column['COLUMN_DEFAULT']; + } + + return $this->quote_mysql_utf8_string_literal( (string) $column['COLUMN_DEFAULT'] ); + } + + /** + * Build grouped index metadata for SHOW CREATE TABLE. + * + * @param string $table_name Table name. + * @return array}> + */ + private function show_create_table_index_groups( string $table_name ): array { + $groups = array(); + + foreach ( $this->index_rows_for_table( $table_name ) as $row ) { + $index_name = (string) $row[2]; + if ( ! isset( $groups[ $index_name ] ) ) { + $groups[ $index_name ] = array( + 'name' => $index_name, + 'non_unique' => (int) $row[1], + 'index_type' => (string) $row[10], + 'index_comment' => (string) $row[12], + 'columns' => array(), + ); + } + + $groups[ $index_name ]['columns'][] = array( + 'name' => (string) $row[4], + 'sub_part' => null === $row[7] ? null : (int) $row[7], + 'collation' => (string) $row[5], + ); + } + + $groups = array_values( $groups ); + usort( + $groups, + function ( array $left, array $right ): int { + if ( 'PRIMARY' === $left['name'] ) { + return 'PRIMARY' === $right['name'] ? 0 : -1; + } + if ( 'PRIMARY' === $right['name'] ) { + return 1; + } + if ( $left['non_unique'] !== $right['non_unique'] ) { + return $left['non_unique'] <=> $right['non_unique']; + } + return strcmp( $left['name'], $right['name'] ); + } + ); + + return $groups; + } + + /** + * Format one SHOW CREATE TABLE index definition. + * + * @param array{name:string,non_unique:int,index_type:string,index_comment:string,columns:array} $index_group Grouped index metadata. + * @return string MySQL index definition. + */ + private function format_show_create_table_index( array $index_group ): string { + $columns = array_map( + function ( array $column ): string { + $sql = $this->quote_mysql_identifier( $column['name'] ); + if ( null !== $column['sub_part'] ) { + $sql .= '(' . (int) $column['sub_part'] . ')'; + } + if ( 'D' === $column['collation'] ) { + $sql .= ' DESC'; + } + return $sql; + }, + $index_group['columns'] + ); + + if ( 'PRIMARY' === $index_group['name'] ) { + $sql = ' PRIMARY KEY (' . implode( ', ', $columns ) . ')'; + } else { + $sql = ' ' . ( 0 === $index_group['non_unique'] ? 'UNIQUE KEY ' : 'KEY ' ); + $sql .= $this->quote_mysql_identifier( $index_group['name'] ); + $sql .= ' (' . implode( ', ', $columns ) . ')'; + } + + if ( '' !== $index_group['index_comment'] ) { + $sql .= ' COMMENT ' . $this->quote_mysql_utf8_string_literal( $index_group['index_comment'] ); + } + + return $sql; + } + /** * Build SHOW INDEX rows for the primary key. * @@ -4489,6 +4767,22 @@ private function user_table_names(): array { ); } + /** + * Resolve a requested MySQL table name to a visible DuckDB table name. + * + * @param string $table_name Requested table name. + * @return string|null Actual table name, or null when no user table matches. + */ + private function resolve_user_table_name( string $table_name ): ?string { + foreach ( $this->user_table_names() as $candidate ) { + if ( 0 === strcasecmp( $candidate, $table_name ) ) { + return $candidate; + } + } + + return null; + } + /** * Build metadata rows for tables created outside the MySQL-emulation DDL path. * @@ -4735,6 +5029,35 @@ private function identifier_value( $token ): string { return $token->get_value(); } + /** + * Quote an identifier for MySQL-facing SHOW CREATE TABLE output. + * + * @param string $identifier Identifier. + * @return string Backtick-quoted identifier. + */ + private function quote_mysql_identifier( string $identifier ): string { + return '`' . str_replace( '`', '``', $identifier ) . '`'; + } + + /** + * Quote a string literal for MySQL-facing SHOW CREATE TABLE output. + * + * @param string $literal UTF-8 literal. + * @return string Single-quoted MySQL literal. + */ + private function quote_mysql_utf8_string_literal( string $literal ): string { + $backslash = chr( 92 ); + $replacements = array( + "'" => "''", + $backslash => $backslash . $backslash, + chr( 0 ) => $backslash . '0', + chr( 10 ) => $backslash . 'n', + chr( 13 ) => $backslash . 'r', + ); + + return "'" . strtr( $literal, $replacements ) . "'"; + } + /** * Check whether a token cannot be an identifier in the supported subset. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index a71212452..ce016dcc4 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -375,4 +375,37 @@ public function test_show_table_status_metadata_matches_sqlite(): void { $this->assertParityRowColumns( "SHOW TABLE STATUS WHERE SUBSTR(Name, 1, 4) = 'meta'", array( 'Name' ) ); $this->assertParityRowColumns( 'SHOW TABLE STATUS FROM other_database', $columns ); } + + public function test_show_create_table_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL DEFAULT '' COMMENT 'Option name', + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (id), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload), + KEY option_value_prefix (option_value(12)) + ) ENGINE=MyISAM DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='Options table'", + "INSERT INTO metadata (option_name, option_value) + VALUES ('siteurl', 'https://example.test'), ('home', 'https://example.test')", + "CREATE TABLE composite_pk ( + site_id BIGINT(20) UNSIGNED NOT NULL, + option_id BIGINT(20) UNSIGNED NOT NULL, + option_name VARCHAR(191) NOT NULL DEFAULT '', + PRIMARY KEY (site_id, option_id), + UNIQUE KEY unique_site_option (site_id, option_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + 'CREATE TABLE plain (id INT, name TEXT)', + ) + ); + + $this->assertParityRows( 'SHOW CREATE TABLE metadata' ); + $this->assertParityRows( 'SHOW CREATE TABLE wp.metadata' ); + $this->assertParityRows( 'SHOW CREATE TABLE composite_pk' ); + $this->assertParityRows( 'SHOW CREATE TABLE plain' ); + $this->assertParityRows( 'SHOW CREATE TABLE missing' ); + } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index f9c029b46..c48cc2f48 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1203,6 +1203,111 @@ public function test_show_table_status_exposes_mysql_shaped_table_metadata(): vo $this->assertSame( array(), $internal ); } + public function test_show_create_table_reconstructs_mysql_shaped_ddl(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL DEFAULT '' COMMENT 'Option name', + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (id), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload), + KEY option_value_prefix (option_value(12)) + ) ENGINE=MyISAM DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='Options table'" + ); + $driver->query( + "INSERT INTO metadata (option_name, option_value) + VALUES ('siteurl', 'https://example.test'), ('home', 'https://example.test')" + ); + $driver->query( + "CREATE TABLE composite_pk ( + site_id BIGINT(20) UNSIGNED NOT NULL, + option_id BIGINT(20) UNSIGNED NOT NULL, + option_name VARCHAR(191) NOT NULL DEFAULT '', + PRIMARY KEY (site_id, option_id), + UNIQUE KEY unique_site_option (site_id, option_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + $driver->query( 'CREATE TABLE plain (id INT, name TEXT)' ); + + $metadata_rows = $driver->query( 'SHOW CREATE TABLE metadata' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'Table', 'Create Table' ), array_keys( $metadata_rows[0] ) ); + $this->assertSame( 'metadata', $metadata_rows[0]['Table'] ); + $this->assertSame( + <<<'SQL' +CREATE TABLE `metadata` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `option_name` varchar(191) NOT NULL DEFAULT '' COMMENT 'Option name', + `option_value` longtext NOT NULL, + `autoload` varchar(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (`id`), + UNIQUE KEY `option_name` (`option_name`), + KEY `autoload` (`autoload`), + KEY `option_value_prefix` (`option_value`(12)) +) ENGINE=MyISAM AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Options table' +SQL, + $metadata_rows[0]['Create Table'] + ); + + $qualified_rows = $driver->query( 'SHOW CREATE TABLE wp.metadata' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( $metadata_rows, $qualified_rows ); + + $composite_rows = $driver->query( 'SHOW CREATE TABLE composite_pk' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + <<<'SQL' +CREATE TABLE `composite_pk` ( + `site_id` bigint(20) unsigned NOT NULL, + `option_id` bigint(20) unsigned NOT NULL, + `option_name` varchar(191) NOT NULL DEFAULT '', + PRIMARY KEY (`site_id`, `option_id`), + UNIQUE KEY `unique_site_option` (`site_id`, `option_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + $composite_rows[0]['Create Table'] + ); + + $plain_rows = $driver->query( 'SHOW CREATE TABLE plain' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + <<<'SQL' +CREATE TABLE `plain` ( + `id` int DEFAULT NULL, + `name` text DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci +SQL, + $plain_rows[0]['Create Table'] + ); + + $missing_rows = $driver->query( 'SHOW CREATE TABLE missing' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $missing_rows ); + + $other_database_rows = $driver->query( 'SHOW CREATE TABLE other_database.metadata' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $other_database_rows ); + } + + public function test_show_create_table_denies_information_schema_tables(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $this->expectExceptionMessage( "SHOW command denied to user 'duckdb'@'%'" ); + $driver->query( 'SHOW CREATE TABLE information_schema.tables' ); + } + public function test_unsupported_alter_table_add_column_constraints_throw_driver_exception(): void { $this->requireDuckDBRuntime(); From 3e911a6deff74fd3dd3f52b1e4b7bd2a8f77a581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 18:31:53 +0000 Subject: [PATCH 019/317] Add DuckDB limited DML parity --- .../src/duckdb/class-wp-duckdb-driver.php | 261 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 24 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 78 ++++++ 3 files changed, 351 insertions(+), 12 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index b08c4d0e7..1c5bc183f 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -620,15 +620,38 @@ private function execute_replace( array $tokens ): WP_DuckDB_Result_Statement { * @return WP_DuckDB_Result_Statement */ private function execute_update( array $tokens ): WP_DuckDB_Result_Statement { - $this->identifier_value( $tokens[1] ?? null ); - if ( ! isset( $tokens[2] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[2]->id ) { + $reference = $this->parse_single_table_dml_reference( $tokens, 1, 'UPDATE' ); + $index = $reference['next_index']; + + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $index ]->id ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only simple single-table UPDATE is supported.' ); } + ++$index; - return $this->execute_duckdb_query( - $this->translate_tokens_to_duckdb_sql( $tokens ), - 'Failed to execute DuckDB UPDATE' - ); + $clauses = $this->dml_clause_indexes( $tokens, $index ); + $update_end = $clauses['where'] ?? $clauses['order'] ?? $clauses['limit'] ?? count( $tokens ); + $update_tokens = array_slice( $tokens, $index, $update_end - $index ); + if ( count( $update_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE list is required.' ); + } + + $sql = 'UPDATE ' . $this->dml_table_reference_sql( $reference ) + . ' SET ' + . $this->translate_update_assignment_tokens_to_duckdb_sql( $update_tokens, $reference ); + + if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { + $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'UPDATE' ); + $sql .= ' WHERE rowid IN ( ' + . $this->dml_rowid_subquery_sql( $tokens, $clauses, $reference ) + . ' )'; + } elseif ( null !== $clauses['where'] ) { + $where_end = $clauses['order'] ?? $clauses['limit'] ?? count( $tokens ); + $sql .= ' WHERE ' . $this->translate_tokens_to_duckdb_sql( + array_slice( $tokens, $clauses['where'] + 1, $where_end - $clauses['where'] - 1 ) + ); + } + + return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB UPDATE' ); } /** @@ -641,15 +664,229 @@ private function execute_delete( array $tokens ): WP_DuckDB_Result_Statement { if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[1]->id ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Only DELETE FROM table is supported.' ); } - $this->identifier_value( $tokens[2] ?? null ); - if ( isset( $tokens[3] ) && WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[3]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Only simple single-table DELETE is supported.' ); + + $reference = $this->parse_single_table_dml_reference( $tokens, 2, 'DELETE' ); + $clauses = $this->dml_clause_indexes( $tokens, $reference['next_index'] ); + + if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { + $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'DELETE' ); + $sql = 'DELETE FROM ' . $this->connection->quote_identifier( $reference['table_name'] ) + . ' WHERE rowid IN ( ' + . $this->dml_rowid_subquery_sql( $tokens, $clauses, $reference ) + . ' )'; + } else { + $sql = 'DELETE FROM ' . $this->dml_table_reference_sql( $reference ); + if ( null !== $clauses['where'] ) { + $sql .= ' WHERE ' . $this->translate_tokens_to_duckdb_sql( + array_slice( $tokens, $clauses['where'] + 1 ) + ); + } } - return $this->execute_duckdb_query( - $this->translate_tokens_to_duckdb_sql( $tokens ), - 'Failed to execute DuckDB DELETE' + return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB DELETE' ); + } + + /** + * Parse a single-table UPDATE/DELETE table reference. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index of the table reference. + * @param string $statement Statement name. + * @return array{table_name:string,requested_table_name:string,alias:string|null,next_index:int} + */ + private function parse_single_table_dml_reference( array $tokens, int $index, string $statement ): array { + $database = null; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index ]->id ) { + $database = $table_name; + ++$index; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( 0 === strcasecmp( $database, 'information_schema' ) ) { + throw new WP_DuckDB_Driver_Exception( "Access denied for user 'duckdb'@'%' to database 'information_schema'" ); + } + + if ( 0 !== strcasecmp( $database, $this->database ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only the current database is supported.' ); + } + } + + $alias = null; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $alias = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + } elseif ( isset( $tokens[ $index ] ) && ! $this->is_dml_clause_start_token( $tokens[ $index ] ) ) { + $alias = $this->identifier_value( $tokens[ $index ] ); + ++$index; + } + + return array( + 'table_name' => $this->resolve_user_table_name( $table_name ) ?? $table_name, + 'requested_table_name' => $table_name, + 'alias' => $alias, + 'next_index' => $index, + ); + } + + /** + * Build a DuckDB SQL table reference for a DML target. + * + * @param array{table_name:string,requested_table_name:string,alias:string|null,next_index:int} $reference Parsed table reference. + * @return string DuckDB table reference. + */ + private function dml_table_reference_sql( array $reference ): string { + $sql = $this->connection->quote_identifier( $reference['table_name'] ); + if ( null !== $reference['alias'] ) { + $sql .= ' AS ' . $this->connection->quote_identifier( $reference['alias'] ); + } + + return $sql; + } + + /** + * Find top-level WHERE/ORDER/LIMIT clauses in a DML statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $start First token to scan. + * @return array{where:int|null,order:int|null,limit:int|null} + */ + private function dml_clause_indexes( array $tokens, int $start ): array { + $clauses = array( + 'where' => null, + 'order' => null, + 'limit' => null, + ); + $depth = 0; + + for ( $index = $start; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + continue; + } + if ( 0 !== $depth ) { + continue; + } + + if ( WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $index ]->id && null === $clauses['where'] ) { + $clauses['where'] = $index; + } elseif ( WP_MySQL_Lexer::ORDER_SYMBOL === $tokens[ $index ]->id && null === $clauses['order'] ) { + $clauses['order'] = $index; + } elseif ( WP_MySQL_Lexer::LIMIT_SYMBOL === $tokens[ $index ]->id && null === $clauses['limit'] ) { + $clauses['limit'] = $index; + } + } + + return $clauses; + } + + /** + * Check whether a token starts a supported DML clause. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token starts a DML clause. + */ + private function is_dml_clause_start_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::SET_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + ), + true + ); + } + + /** + * Translate UPDATE assignments, removing target qualifiers unsupported by DuckDB. + * + * @param WP_Parser_Token[] $tokens Update-list tokens. + * @param array{table_name:string,requested_table_name:string,alias:string|null,next_index:int} $reference Parsed table reference. + * @return string DuckDB update list SQL. + */ + private function translate_update_assignment_tokens_to_duckdb_sql( array $tokens, array $reference ): string { + $qualifiers = array_filter( + array( + $reference['alias'], + $reference['requested_table_name'], + $reference['table_name'], + ), + 'is_string' ); + + $items = array(); + foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { + if ( + isset( $item[0], $item[1] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $item[1]->id + && in_array( strtolower( $this->identifier_value( $item[0] ) ), array_map( 'strtolower', $qualifiers ), true ) + ) { + $item = array_slice( $item, 2 ); + } + $items[] = $this->translate_tokens_to_duckdb_sql( $item ); + } + + return implode( ', ', $items ); + } + + /** + * Build a rowid subquery for ordered/limited UPDATE and DELETE statements. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param array{where:int|null,order:int|null,limit:int|null} $clauses DML clause indexes. + * @param array{table_name:string,requested_table_name:string,alias:string|null,next_index:int} $reference Parsed table reference. + * @return string DuckDB rowid subquery SQL. + */ + private function dml_rowid_subquery_sql( array $tokens, array $clauses, array $reference ): string { + $sql = 'SELECT rowid FROM ' . $this->dml_table_reference_sql( $reference ); + + if ( null !== $clauses['where'] ) { + $where_end = $clauses['order'] ?? $clauses['limit'] ?? count( $tokens ); + $sql .= ' WHERE ' . $this->translate_tokens_to_duckdb_sql( + array_slice( $tokens, $clauses['where'] + 1, $where_end - $clauses['where'] - 1 ) + ); + } + + if ( null !== $clauses['order'] ) { + $order_end = $clauses['limit'] ?? count( $tokens ); + $sql .= ' ' . $this->translate_tokens_to_duckdb_sql( + array_slice( $tokens, $clauses['order'], $order_end - $clauses['order'] ) + ); + } + + if ( null !== $clauses['limit'] ) { + $sql .= ' ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $clauses['limit'] ) ); + } + + return $sql; + } + + /** + * Ensure rowid-based DML rewrites cannot target a user column named rowid. + * + * @param string $table_name Table name. + * @param string $statement Statement name. + */ + private function assert_dml_rowid_rewrite_supported( string $table_name, string $statement ): void { + $metadata_rows = $this->column_metadata_rows( $table_name ); + if ( count( $metadata_rows ) === 0 ) { + $metadata_rows = $this->pragma_column_metadata_rows( $table_name ); + } + + foreach ( $metadata_rows as $metadata ) { + if ( 0 === strcasecmp( (string) $metadata['column_name'], 'rowid' ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. ORDER BY/LIMIT rewrites require a table without a user-defined rowid column.' ); + } + } } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index ce016dcc4..d3d75227d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -33,6 +33,30 @@ public function test_create_insert_select_update_delete_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_update_delete_alias_order_limit_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR(100), hits INTEGER DEFAULT 0)', + "INSERT INTO items (id, name, hits) VALUES (1, 'b', 1), (2, 'a', 2), (3, 'c', 3)", + ) + ); + + $this->assertParityRowCount( 'UPDATE items SET hits = 9 ORDER BY name LIMIT 1' ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + $this->assertParityRowCount( "UPDATE items AS i SET i.hits = 7 WHERE i.name = 'b' LIMIT 1" ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + $this->assertParityRowCount( 'UPDATE wp.items SET hits = 6 WHERE id = 3' ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + $this->assertParityRowCount( 'UPDATE items SET hits = 5 LIMIT 0' ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + $this->assertParityRowCount( "DELETE FROM items AS i WHERE i.name = 'b' LIMIT 1" ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + $this->assertParityRowCount( 'DELETE FROM wp.items ORDER BY name LIMIT 1' ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + $this->assertParityRowCount( 'DELETE FROM items LIMIT 0' ); + $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); + } + public function test_insert_set_match_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index c48cc2f48..3194d5b99 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -120,6 +120,84 @@ function ( string $sql ): bool { $this->assertSame( 'anonymous', $describe[1]['Default'] ); } + public function test_update_delete_alias_order_limit_are_rewritten(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE items (id INT, name VARCHAR(20), hits INT)' ); + $driver->query( "INSERT INTO items VALUES (1, 'b', 1), (2, 'a', 2), (3, 'c', 3)" ); + + $update_ordered = $driver->query( 'UPDATE items SET hits = 9 ORDER BY name LIMIT 1' ); + $this->assertSame( 1, $update_ordered->rowCount() ); + + $update_alias = $driver->query( "UPDATE items AS i SET i.hits = 7 WHERE i.name = 'b' LIMIT 1" ); + $this->assertSame( 1, $update_alias->rowCount() ); + + $update_qualified = $driver->query( 'UPDATE wp.items SET hits = 6 WHERE id = 3' ); + $this->assertSame( 1, $update_qualified->rowCount() ); + + $update_limit_zero = $driver->query( 'UPDATE items SET hits = 5 LIMIT 0' ); + $this->assertSame( 0, $update_limit_zero->rowCount() ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'b', + 'hits' => 7, + ), + array( + 'id' => 2, + 'name' => 'a', + 'hits' => 9, + ), + array( + 'id' => 3, + 'name' => 'c', + 'hits' => 6, + ), + ), + $driver->query( 'SELECT id, name, hits FROM items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $delete_alias = $driver->query( "DELETE FROM items AS i WHERE i.name = 'b' LIMIT 1" ); + $this->assertSame( 1, $delete_alias->rowCount() ); + + $delete_ordered = $driver->query( 'DELETE FROM wp.items ORDER BY name LIMIT 1' ); + $this->assertSame( 1, $delete_ordered->rowCount() ); + + $delete_limit_zero = $driver->query( 'DELETE FROM items LIMIT 0' ); + $this->assertSame( 0, $delete_limit_zero->rowCount() ); + + $this->assertSame( + array( + array( + 'id' => 3, + 'name' => 'c', + 'hits' => 6, + ), + ), + $driver->query( 'SELECT id, name, hits FROM items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_ordered_limited_dml_rejects_user_defined_rowid_column(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE items (rowid INT, name VARCHAR(20), hits INT)' ); + $driver->query( "INSERT INTO items VALUES (10, 'b', 1), (20, 'a', 2)" ); + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $this->expectExceptionMessage( 'ORDER BY/LIMIT rewrites require a table without a user-defined rowid column.' ); + $driver->query( 'UPDATE items SET hits = 9 ORDER BY name LIMIT 1' ); + } + public function test_show_columns_and_full_fields_are_emulated(): void { $this->requireDuckDBRuntime(); From e0111dc3f5e508f204819a55732974759623c661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 18:51:05 +0000 Subject: [PATCH 020/317] Add DuckDB schema lifecycle parity --- .../src/duckdb/class-wp-duckdb-driver.php | 585 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 141 +++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 234 +++++++ 3 files changed, 954 insertions(+), 6 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 1c5bc183f..5c06a99d2 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -167,6 +167,10 @@ public function query( string $query ): WP_DuckDB_Result_Statement { return $this->execute_update( $tokens ); case WP_MySQL_Lexer::DELETE_SYMBOL: return $this->execute_delete( $tokens ); + case WP_MySQL_Lexer::DROP_SYMBOL: + return $this->execute_drop( $tokens ); + case WP_MySQL_Lexer::TRUNCATE_SYMBOL: + return $this->execute_truncate_table( $tokens ); case WP_MySQL_Lexer::ALTER_SYMBOL: return $this->execute_alter_table( $tokens ); case WP_MySQL_Lexer::SHOW_SYMBOL: @@ -686,6 +690,194 @@ private function execute_delete( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB DELETE' ); } + /** + * Execute a supported DROP statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_drop( array $tokens ): WP_DuckDB_Result_Statement { + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP TABLE statement in DuckDB driver. Temporary tables are not supported.' ); + } + + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[1]->id ) { + return $this->execute_drop_table( $tokens ); + } + + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::TABLES_SYMBOL === $tokens[1]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP TABLES statement in DuckDB driver. Use DROP TABLE.' ); + } + + if ( + isset( $tokens[1] ) + && in_array( $tokens[1]->id, array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::ONLINE_SYMBOL, WP_MySQL_Lexer::OFFLINE_SYMBOL ), true ) + ) { + return $this->execute_drop_index( $tokens ); + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP statement in DuckDB driver. Only DROP TABLE and DROP INDEX are supported.' ); + } + + /** + * Execute DROP TABLE. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_drop_table( array $tokens ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::DROP_SYMBOL, 'Expected DROP.' ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::TABLE_SYMBOL, 'Expected TABLE in DROP TABLE statement.' ); + ++$index; + + $if_exists = false; + if ( + isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $index ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $index + 1 ]->id + ) { + $if_exists = true; + $index += 2; + } + + $table_tokens = array_slice( $tokens, $index ); + if ( count( $table_tokens ) > 0 ) { + $last_token = $table_tokens[ count( $table_tokens ) - 1 ]; + if ( WP_MySQL_Lexer::RESTRICT_SYMBOL === $last_token->id || WP_MySQL_Lexer::CASCADE_SYMBOL === $last_token->id ) { + array_pop( $table_tokens ); + } + } + if ( count( $table_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'DROP TABLE requires at least one table name.' ); + } + + $targets = array(); + foreach ( $this->split_top_level_comma_items( $table_tokens ) as $table_item ) { + $reference = $this->parse_schema_lifecycle_table_reference( $table_item, 0, 'DROP TABLE' ); + if ( count( $table_item ) !== $reference['next_index'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP TABLE statement in DuckDB driver. Table aliases and extra table options are not supported.' ); + } + $targets[] = $reference['requested_table_name']; + } + + return $this->execute_schema_lifecycle_change( + function () use ( $targets, $if_exists ): WP_DuckDB_Result_Statement { + foreach ( $targets as $requested_table_name ) { + $table_name = $this->resolve_user_table_name( $requested_table_name ); + if ( null === $table_name ) { + if ( $if_exists ) { + continue; + } + throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$requested_table_name}' in DROP TABLE statement." ); + } + + $sequence_names = $this->auto_increment_sequences_for_table( $table_name ); + $index_names = array_map( + function ( array $index_definition ): string { + return $index_definition['index_name']; + }, + $this->secondary_index_definitions_for_table( $table_name ) + ); + + $this->execute_duckdb_query( + 'DROP TABLE ' . $this->connection->quote_identifier( $table_name ), + 'Failed to drop DuckDB table' + ); + + foreach ( $index_names as $index_name ) { + $this->drop_physical_secondary_index( $table_name, $index_name, true ); + } + $this->drop_auto_increment_sequences( $sequence_names ); + $this->delete_table_lifecycle_metadata( $table_name ); + } + + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + + /** + * Execute TRUNCATE [TABLE]. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_truncate_table( array $tokens ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::TRUNCATE_SYMBOL, 'Expected TRUNCATE.' ); + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + + $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'TRUNCATE' ); + if ( count( $tokens ) !== $reference['next_index'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported TRUNCATE statement in DuckDB driver. Only a single table target is supported.' ); + } + + return $this->execute_schema_lifecycle_change( + function () use ( $reference ): WP_DuckDB_Result_Statement { + $table_name = $this->resolve_required_lifecycle_table_name( $reference['requested_table_name'], 'TRUNCATE' ); + if ( null === $this->auto_increment_metadata_for_table( $table_name ) ) { + $this->execute_duckdb_query( + 'DELETE FROM ' . $this->connection->quote_identifier( $table_name ), + 'Failed to truncate DuckDB table' + ); + } else { + $this->rebuild_empty_auto_increment_table( $table_name ); + } + + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + + /** + * Execute DROP INDEX. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_drop_index( array $tokens ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::DROP_SYMBOL, 'Expected DROP.' ); + ++$index; + + if ( + isset( $tokens[ $index ] ) + && ( WP_MySQL_Lexer::ONLINE_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::OFFLINE_SYMBOL === $tokens[ $index ]->id ) + ) { + ++$index; + } + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::INDEX_SYMBOL, 'Expected INDEX in DROP INDEX statement.' ); + ++$index; + + $index_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ON_SYMBOL, 'Expected ON in DROP INDEX statement.' ); + ++$index; + + $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'DROP INDEX' ); + $index = $this->skip_drop_index_options( $tokens, $reference['next_index'] ); + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP INDEX statement in DuckDB driver.' ); + } + + return $this->execute_schema_lifecycle_change( + function () use ( $reference, $index_name ): WP_DuckDB_Result_Statement { + $table_name = $this->resolve_required_lifecycle_table_name( $reference['requested_table_name'], 'DROP INDEX' ); + $this->drop_secondary_index( $table_name, $index_name ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + /** * Parse a single-table UPDATE/DELETE table reference. * @@ -747,6 +939,116 @@ private function dml_table_reference_sql( array $reference ): string { return $sql; } + /** + * Parse a schema lifecycle table reference. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index of the table reference. + * @param string $statement Statement name. + * @return array{requested_table_name:string,next_index:int} + */ + private function parse_schema_lifecycle_table_reference( array $tokens, int $index, string $statement ): array { + $database = null; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index ]->id ) { + $database = $table_name; + ++$index; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( 0 === strcasecmp( $database, 'information_schema' ) ) { + throw new WP_DuckDB_Driver_Exception( "Access denied for user 'duckdb'@'%' to database 'information_schema'" ); + } + + if ( 0 !== strcasecmp( $database, $this->database ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only the current database is supported.' ); + } + } + + if ( $this->is_duckdb_internal_table_name( $table_name ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Internal DuckDB metadata tables cannot be modified.' ); + } + + return array( + 'requested_table_name' => $table_name, + 'next_index' => $index, + ); + } + + /** + * Resolve a lifecycle table target or throw a stable missing-table error. + * + * @param string $requested_table_name Requested table name. + * @param string $statement Statement name. + * @return string Resolved table name. + */ + private function resolve_required_lifecycle_table_name( string $requested_table_name, string $statement ): string { + $table_name = $this->resolve_user_table_name( $requested_table_name ); + if ( null === $table_name ) { + throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$requested_table_name}' in {$statement} statement." ); + } + + return $table_name; + } + + /** + * Execute multi-step schema lifecycle work inside a transaction. + * + * @param callable $callback Lifecycle callback. + * @return WP_DuckDB_Result_Statement + */ + private function execute_schema_lifecycle_change( callable $callback ): WP_DuckDB_Result_Statement { + $started_transaction = ! $this->connection->inTransaction(); + if ( $started_transaction ) { + $this->connection->beginTransaction(); + } + + try { + $result = $callback(); + if ( $started_transaction ) { + $this->connection->commit(); + } + } catch ( Throwable $e ) { + if ( $started_transaction && $this->connection->inTransaction() ) { + $this->connection->rollback(); + } + throw $e; + } + + return $result; + } + + /** + * Return a MySQL-shaped empty DDL result. + * + * @return WP_DuckDB_Result_Statement + */ + private function empty_ddl_result(): WP_DuckDB_Result_Statement { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + /** + * Skip supported DROP INDEX options. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index. + * @return int New index. + */ + private function skip_drop_index_options( array $tokens, int $index ): int { + while ( $index < count( $tokens ) ) { + if ( WP_MySQL_Lexer::ALGORITHM_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::LOCK_SYMBOL === $tokens[ $index ]->id ) { + $index = $this->skip_option_value( $tokens, $index + 1 ); + continue; + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP INDEX option in DuckDB driver: ' . $tokens[ $index ]->get_bytes() . '.' ); + } + + return $index; + } + /** * Find top-level WHERE/ORDER/LIMIT clauses in a DML statement. * @@ -982,17 +1284,27 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen $this->expect_token( $tokens, $index, WP_MySQL_Lexer::TABLE_SYMBOL, 'Only ALTER TABLE is supported by the DuckDB driver.' ); ++$index; - $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); - ++$index; + $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'ALTER TABLE' ); + $table_name = $this->resolve_user_table_name( $reference['requested_table_name'] ) ?? $reference['requested_table_name']; + $index = $reference['next_index']; $actions = $this->split_top_level_comma_items( array_slice( $tokens, $index ) ); if ( count( $actions ) === 0 ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD COLUMN and ADD INDEX are supported.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD COLUMN, ADD INDEX, and DROP INDEX are supported.' ); } $result = null; foreach ( $actions as $action ) { - $this->expect_token( $action, 0, WP_MySQL_Lexer::ADD_SYMBOL, 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD actions are supported.' ); + if ( ! isset( $action[0] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Empty action.' ); + } + + if ( WP_MySQL_Lexer::DROP_SYMBOL === $action[0]->id ) { + $result = $this->execute_alter_table_drop_index( $table_name, $action ); + continue; + } + + $this->expect_token( $action, 0, WP_MySQL_Lexer::ADD_SYMBOL, 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD and DROP INDEX actions are supported.' ); $alter_item = array_slice( $action, 1 ); $result = $this->is_create_table_index_item( $alter_item ) @@ -1003,6 +1315,46 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen return $result ?? new WP_DuckDB_Result_Statement( array(), array(), 0 ); } + /** + * Execute ALTER TABLE ... DROP INDEX|KEY. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Action tokens starting at DROP. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_drop_index( string $table_name, array $tokens ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::DROP_SYMBOL, 'Expected DROP in ALTER TABLE action.' ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::PRIMARY_SYMBOL === $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP PRIMARY KEY requires a table rebuild.' ); + } + + if ( + ! isset( $tokens[ $index ] ) + || ( WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[ $index ]->id && WP_MySQL_Lexer::KEY_SYMBOL !== $tokens[ $index ]->id ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only DROP INDEX and DROP KEY are supported.' ); + } + ++$index; + + $index_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP INDEX options are not supported.' ); + } + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $index_name ): WP_DuckDB_Result_Statement { + $resolved_table_name = $this->resolve_required_lifecycle_table_name( $table_name, 'ALTER TABLE' ); + $this->drop_secondary_index( $resolved_table_name, $index_name ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + /** * Execute ALTER TABLE ... ADD INDEX. * @@ -1455,7 +1807,9 @@ private function execute_show_index( array $tokens ): WP_DuckDB_Result_Statement throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW INDEX statement in DuckDB driver. Use SHOW INDEX FROM table.' ); } - $table_name = $this->identifier_value( $tokens[3] ); + $table_name = $this->identifier_value( $tokens[3] ); + $resolved_table_name = $this->resolve_user_table_name( $table_name ); + $rows = null === $resolved_table_name ? array() : $this->index_rows_for_table( $resolved_table_name ); return new WP_DuckDB_Result_Statement( array( @@ -1475,7 +1829,7 @@ private function execute_show_index( array $tokens ): WP_DuckDB_Result_Statement 'Visible', 'Expression', ), - $this->index_rows_for_table( $table_name ) + $rows ); } @@ -3509,6 +3863,46 @@ private function auto_increment_metadata_for_table( string $table_name ): ?array ); } + /** + * Find recorded AUTO_INCREMENT sequence names for a table. + * + * @param string $table_name Table name. + * @return string[] Sequence names. + */ + private function auto_increment_sequences_for_table( string $table_name ): array { + $this->ensure_column_metadata_table(); + + $stmt = $this->execute_duckdb_query( + 'SELECT column_name FROM ' + . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . " AND extra = 'auto_increment' ORDER BY ordinal_position", + 'Failed to inspect DuckDB AUTO_INCREMENT metadata' + ); + + $sequence_names = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN ) as $column_name ) { + $sequence_names[] = $this->sequence_name( $table_name, (string) $column_name ); + } + + return $sequence_names; + } + + /** + * Drop AUTO_INCREMENT sequences that are no longer referenced by a table. + * + * @param string[] $sequence_names Sequence names. + */ + private function drop_auto_increment_sequences( array $sequence_names ): void { + foreach ( $sequence_names as $sequence_name ) { + $this->execute_duckdb_query( + 'DROP SEQUENCE IF EXISTS ' . $this->connection->quote_identifier( $sequence_name ), + 'Failed to drop DuckDB AUTO_INCREMENT sequence' + ); + } + } + /** * Parse the last explicit literal assigned to an AUTO_INCREMENT column. * @@ -3992,6 +4386,175 @@ private function append_column_metadata( string $table_name, array $column ): vo ); } + /** + * Drop a recorded secondary index and refresh MySQL-facing metadata. + * + * @param string $table_name Table name. + * @param string $mysql_index_name MySQL-facing index name. + */ + private function drop_secondary_index( string $table_name, string $mysql_index_name ): void { + if ( 0 === strcasecmp( $mysql_index_name, 'PRIMARY' ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP INDEX statement in DuckDB driver. Dropping PRIMARY requires a table rebuild.' ); + } + + $resolved_index_name = $this->resolve_secondary_index_name( $table_name, $mysql_index_name ); + if ( null === $resolved_index_name ) { + throw new WP_DuckDB_Driver_Exception( "Unknown index '{$mysql_index_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + + $this->drop_physical_secondary_index( $table_name, $resolved_index_name, false ); + $this->delete_index_metadata( $table_name, $resolved_index_name ); + $this->refresh_column_key_metadata( $table_name ); + } + + /** + * Resolve a secondary index name from recorded metadata. + * + * @param string $table_name Table name. + * @param string $mysql_index_name Requested index name. + * @return string|null Resolved index name. + */ + private function resolve_secondary_index_name( string $table_name, string $mysql_index_name ): ?string { + foreach ( $this->secondary_index_definitions_for_table( $table_name ) as $index_definition ) { + if ( 0 === strcasecmp( $index_definition['index_name'], $mysql_index_name ) ) { + return $index_definition['index_name']; + } + } + + return null; + } + + /** + * Drop a physical DuckDB secondary index by its MySQL-facing name. + * + * @param string $table_name Table name. + * @param string $mysql_index_name MySQL-facing index name. + * @param bool $if_exists Whether to use IF EXISTS. + */ + private function drop_physical_secondary_index( string $table_name, string $mysql_index_name, bool $if_exists ): void { + $this->execute_duckdb_query( + 'DROP INDEX ' + . ( $if_exists ? 'IF EXISTS ' : '' ) + . $this->connection->quote_identifier( $this->index_name( $table_name, $mysql_index_name ) ), + 'Failed to drop DuckDB index' + ); + } + + /** + * Delete one secondary index's metadata rows. + * + * @param string $table_name Table name. + * @param string $index_name Index name. + */ + private function delete_index_metadata( string $table_name, string $index_name ): void { + $this->ensure_index_metadata_table(); + + $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' AND index_name = ' + . $this->connection->quote( $index_name ), + 'Failed to delete DuckDB index metadata' + ); + } + + /** + * Delete all stored lifecycle metadata for a table. + * + * @param string $table_name Table name. + */ + private function delete_table_lifecycle_metadata( string $table_name ): void { + $this->ensure_index_metadata_table(); + $this->ensure_column_metadata_table(); + $this->ensure_table_metadata_table(); + + foreach ( + array( + self::INDEX_METADATA_TABLE => 'index', + self::COLUMN_METADATA_TABLE => 'column', + self::TABLE_METADATA_TABLE => 'table', + ) as $metadata_table => $label + ) { + $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( $metadata_table ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ), + 'Failed to delete DuckDB ' . $label . ' metadata' + ); + } + } + + /** + * Refresh stored COLUMN_KEY values after an index is dropped. + * + * @param string $table_name Table name. + */ + private function refresh_column_key_metadata( string $table_name ): void { + $metadata = $this->column_metadata_rows( $table_name ); + if ( count( $metadata ) === 0 ) { + return; + } + + foreach ( $metadata as &$column ) { + $column['column_key'] = ''; + } + unset( $column ); + + $primary_key = array_map( + function ( array $row ): string { + return (string) $row[4]; + }, + $this->primary_key_index_rows( $table_name ) + ); + + $this->record_column_metadata( + $table_name, + $this->apply_column_key_metadata( $metadata, $primary_key, $this->secondary_index_definitions_for_table( $table_name ) ) + ); + } + + /** + * Rebuild an empty table so AUTO_INCREMENT starts from 1 again. + * + * DuckDB cannot restart a sequence while a column default depends on it. + * + * @param string $table_name Table name. + */ + private function rebuild_empty_auto_increment_table( string $table_name ): void { + $create_sql = $this->mysql_create_table_statement( $table_name, $table_name ); + $sequence_names = $this->auto_increment_sequences_for_table( $table_name ); + + $this->execute_duckdb_query( + 'DROP TABLE ' . $this->connection->quote_identifier( $table_name ), + 'Failed to truncate DuckDB table' + ); + $this->drop_auto_increment_sequences( $sequence_names ); + $this->execute_create_table( $this->tokenize_and_validate( $create_sql ) ); + } + + /** + * Drop temporary information_schema compatibility snapshots. + */ + private function invalidate_information_schema_compatibility_tables(): void { + foreach ( + array( + self::INFO_SCHEMA_TABLES_TABLE, + self::INFO_SCHEMA_COLUMNS_TABLE, + self::INFO_SCHEMA_STATISTICS_TABLE, + self::INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE, + self::INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE, + ) as $table_name + ) { + $this->execute_duckdb_query( + 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $table_name ), + 'Failed to invalidate DuckDB information_schema compatibility table' + ); + } + } + /** * Read recorded MySQL column metadata. * @@ -5020,6 +5583,16 @@ private function resolve_user_table_name( string $table_name ): ?string { return null; } + /** + * Check whether a name targets a DuckDB driver internal table. + * + * @param string $table_name Table name. + * @return bool Whether the table is internal to the driver. + */ + private function is_duckdb_internal_table_name( string $table_name ): bool { + return 0 === strpos( $table_name, '__wp_duckdb_' ); + } + /** * Build metadata rows for tables created outside the MySQL-emulation DDL path. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index d3d75227d..2b6ee71ec 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -432,4 +432,145 @@ public function test_show_create_table_metadata_matches_sqlite(): void { $this->assertParityRows( 'SHOW CREATE TABLE plain' ); $this->assertParityRows( 'SHOW CREATE TABLE missing' ); } + + public function test_drop_table_lifecycle_matches_sqlite(): void { + $this->runParitySetup( + array( + $this->lifecycleTableSql( 'lifecycle_drop' ), + 'CREATE TABLE survivor (id INT, note VARCHAR(20))', + "INSERT INTO lifecycle_drop (name, payload) VALUES ('a', 'alpha'), ('b', 'bravo')", + 'DROP TABLE lifecycle_drop', + ) + ); + + $this->assertParityRows( 'SHOW TABLES' ); + $this->assertParityRows( 'SHOW CREATE TABLE lifecycle_drop' ); + $this->assertParityRows( + "SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'lifecycle_drop'" + ); + $this->assertParityRows( + "SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'wp' AND table_name = 'lifecycle_drop'" + ); + $this->assertParityRows( + "SELECT index_name + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'lifecycle_drop'" + ); + $this->assertParityRows( + "SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'lifecycle_drop'" + ); + + $this->runParitySetup( + array( + $this->lifecycleTableSql( 'lifecycle_drop' ), + "INSERT INTO lifecycle_drop (name, payload) VALUES ('fresh', 'value')", + ) + ); + $this->assertParityRows( 'SELECT id, name FROM lifecycle_drop ORDER BY id' ); + } + + public function test_truncate_lifecycle_matches_sqlite(): void { + $this->runParitySetup( + array( + $this->lifecycleTableSql( 'lifecycle_truncate' ), + "INSERT INTO lifecycle_truncate (name, payload) VALUES ('a', 'alpha'), ('b', 'bravo')", + 'DELETE FROM lifecycle_truncate WHERE name = \'b\'', + ) + ); + $this->assertParityRows( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'lifecycle_truncate'" + ); + + $this->runParitySetup( array( 'TRUNCATE TABLE wp.lifecycle_truncate' ) ); + $this->assertParityRows( 'SELECT COUNT(*) AS count FROM lifecycle_truncate' ); + $this->assertParityRows( 'SHOW COLUMNS FROM lifecycle_truncate' ); + $this->assertParityRowColumns( + 'SHOW INDEX FROM lifecycle_truncate', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRows( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'lifecycle_truncate'" + ); + $this->assertParityRowColumns( "SHOW TABLE STATUS LIKE 'lifecycle_truncate'", array( 'Name', 'Auto_increment' ) ); + $this->assertParityRows( 'SHOW CREATE TABLE lifecycle_truncate' ); + + $this->runParitySetup( array( "INSERT INTO lifecycle_truncate (name, payload) VALUES ('z', 'zulu')" ) ); + $this->assertParityRows( 'SELECT id, name FROM lifecycle_truncate ORDER BY id' ); + $this->assertParityRows( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'lifecycle_truncate'" + ); + } + + public function test_drop_index_lifecycle_matches_sqlite(): void { + $this->runParitySetup( + array( + $this->lifecycleTableSql( 'lifecycle_idx' ), + "INSERT INTO lifecycle_idx (name, payload) VALUES ('a', 'alpha')", + 'DROP INDEX payload_prefix ON lifecycle_idx', + ) + ); + + $this->assertParityRowColumns( + 'SHOW INDEX FROM lifecycle_idx', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRows( 'SHOW COLUMNS FROM lifecycle_idx' ); + $this->assertParityRows( + "SELECT index_name, column_name, sub_part + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'lifecycle_idx' + ORDER BY index_name, seq_in_index" + ); + $this->assertParityRows( + "SELECT constraint_name, constraint_type + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'lifecycle_idx' + ORDER BY constraint_name" + ); + $this->assertParityRows( 'SHOW CREATE TABLE lifecycle_idx' ); + + $this->runParitySetup( + array( + 'DROP INDEX name_unique ON wp.lifecycle_idx', + "INSERT INTO lifecycle_idx (name, payload) VALUES ('a', 'duplicate')", + ) + ); + + $this->assertParityRowColumns( + 'SHOW INDEX FROM lifecycle_idx', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRows( 'SHOW COLUMNS FROM lifecycle_idx' ); + $this->assertParityRows( + "SELECT constraint_name, constraint_type + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'lifecycle_idx' + ORDER BY constraint_name" + ); + $this->assertParityRows( 'SHOW CREATE TABLE lifecycle_idx' ); + $this->assertParityRows( 'SELECT name FROM lifecycle_idx ORDER BY id' ); + } + + private function lifecycleTableSql( string $table_name ): string { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return "CREATE TABLE {$table_name} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(191) NOT NULL DEFAULT '', + payload LONGTEXT, + PRIMARY KEY (id), + UNIQUE KEY name_unique (name), + KEY payload_prefix (payload(12)) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"; + } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 3194d5b99..96c205eb9 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1371,6 +1371,229 @@ public function test_show_create_table_reconstructs_mysql_shaped_ddl(): void { $this->assertSame( array(), $other_database_rows ); } + public function test_truncate_table_preserves_schema_and_resets_auto_increment(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( $this->lifecycleTableSql( 'lifecycle_t' ) ); + $driver->query( "INSERT INTO lifecycle_t (name, payload) VALUES ('a', 'alpha'), ('b', 'bravo')" ); + $driver->query( 'DELETE FROM lifecycle_t WHERE name = \'b\'' ); + + $this->assertSame( + array( array( 'AUTO_INCREMENT' => 3 ) ), + $driver->query( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'lifecycle_t'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $truncate = $driver->query( 'TRUNCATE TABLE wp.lifecycle_t' ); + $this->assertSame( 0, $truncate->rowCount() ); + $this->assertSame( + array( array( 'count' => 0 ) ), + $driver->query( 'SELECT COUNT(*) AS count FROM lifecycle_t' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( 'PRIMARY', 'name_unique', 'payload_prefix' ), + array_column( $driver->query( 'SHOW INDEX FROM lifecycle_t' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) + ); + $this->assertSame( + array( 'PRI', 'UNI', 'MUL' ), + array_column( $driver->query( 'SHOW COLUMNS FROM lifecycle_t' )->fetchAll( PDO::FETCH_ASSOC ), 'Key' ) + ); + $this->assertSame( + array( array( 'AUTO_INCREMENT' => 1 ) ), + $driver->query( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'lifecycle_t'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE lifecycle_t' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertStringNotContainsString( 'AUTO_INCREMENT=', $create_rows[0]['Create Table'] ); + + $driver->query( "INSERT INTO lifecycle_t (name, payload) VALUES ('z', 'zulu')" ); + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'z', + ), + ), + $driver->query( 'SELECT id, name FROM lifecycle_t ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_drop_index_updates_metadata_and_unique_enforcement(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( $this->lifecycleTableSql( 'lifecycle_idx' ) ); + $driver->query( "INSERT INTO lifecycle_idx (name, payload) VALUES ('a', 'alpha')" ); + + try { + $driver->query( "INSERT INTO lifecycle_idx (name, payload) VALUES ('a', 'duplicate')" ); + $this->fail( 'Duplicate insert should fail before dropping the unique index.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + + $drop_payload = $driver->query( 'DROP INDEX payload_prefix ON lifecycle_idx' ); + $this->assertSame( 0, $drop_payload->rowCount() ); + $this->assertSame( + array( 'PRIMARY', 'name_unique' ), + array_column( $driver->query( 'SHOW INDEX FROM lifecycle_idx' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) + ); + $this->assertSame( + array( + 'id' => 'PRI', + 'name' => 'UNI', + 'payload' => '', + ), + array_column( $driver->query( 'SHOW COLUMNS FROM lifecycle_idx' )->fetchAll( PDO::FETCH_ASSOC ), 'Key', 'Field' ) + ); + $this->assertSame( + array(), + $driver->query( + "SELECT INDEX_NAME + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'lifecycle_idx' AND index_name = 'payload_prefix'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $drop_unique = $driver->query( 'ALTER TABLE wp.lifecycle_idx DROP KEY name_unique' ); + $this->assertSame( 0, $drop_unique->rowCount() ); + $this->assertSame( + array( 'PRIMARY' ), + array_column( $driver->query( 'SHOW INDEX FROM lifecycle_idx' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) + ); + $this->assertSame( + array( + 'id' => 'PRI', + 'name' => '', + 'payload' => '', + ), + array_column( $driver->query( 'SHOW COLUMNS FROM lifecycle_idx' )->fetchAll( PDO::FETCH_ASSOC ), 'Key', 'Field' ) + ); + $this->assertSame( + array(), + $driver->query( + "SELECT CONSTRAINT_NAME + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'lifecycle_idx' AND constraint_name = 'name_unique'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( "INSERT INTO lifecycle_idx (name, payload) VALUES ('a', 'duplicate')" ); + $this->assertSame( + array( + array( 'name' => 'a' ), + array( 'name' => 'a' ), + ), + $driver->query( 'SELECT name FROM lifecycle_idx ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE lifecycle_idx' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertStringNotContainsString( 'payload_prefix', $create_rows[0]['Create Table'] ); + $this->assertStringNotContainsString( 'name_unique', $create_rows[0]['Create Table'] ); + } + + public function test_drop_table_removes_metadata_and_sequence_state(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( $this->lifecycleTableSql( 'lifecycle_drop' ) ); + $driver->query( 'CREATE TABLE survivor (id INT, note VARCHAR(20))' ); + $driver->query( "INSERT INTO lifecycle_drop (name, payload) VALUES ('a', 'alpha'), ('b', 'bravo')" ); + + $drop = $driver->query( 'DROP TABLE wp.lifecycle_drop' ); + $this->assertSame( 0, $drop->rowCount() ); + $this->assertSame( + array( array( 'Tables_in_wp' => 'survivor' ) ), + $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->query( 'SHOW CREATE TABLE lifecycle_drop' )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( array(), $driver->query( 'SHOW INDEX FROM lifecycle_drop' )->fetchAll( PDO::FETCH_ASSOC ) ); + + foreach ( array( 'tables', 'columns', 'statistics', 'table_constraints', 'key_column_usage' ) as $table ) { + $this->assertSame( + array(), + $driver->query( + "SELECT * + FROM information_schema.{$table} + WHERE table_schema = 'wp' AND table_name = 'lifecycle_drop'" + )->fetchAll( PDO::FETCH_ASSOC ), + 'DuckDB metadata was not cleared from information_schema.' . $table + ); + } + + $driver->query( $this->lifecycleTableSql( 'lifecycle_drop' ) ); + $driver->query( "INSERT INTO lifecycle_drop (name, payload) VALUES ('fresh', 'value')" ); + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'fresh', + ), + ), + $driver->query( 'SELECT id, name FROM lifecycle_drop' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_drop_table_multiple_if_exists_and_lifecycle_protections(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE first_table (id INT)' ); + $driver->query( 'CREATE TABLE second_table (id INT)' ); + $driver->query( 'CREATE TABLE protected_target (id INT, KEY id_idx(id))' ); + + $driver->query( 'DROP TABLE wp.first_table, second_table' ); + $this->assertSame( + array( array( 'Tables_in_wp' => 'protected_target' ) ), + $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( 0, $driver->query( 'DROP TABLE IF EXISTS missing_table' )->rowCount() ); + + foreach ( + array( + 'DROP TABLE information_schema.tables' => "Access denied for user 'duckdb'@'%' to database 'information_schema'", + 'TRUNCATE TABLE __wp_duckdb_column_metadata' => 'Internal DuckDB metadata tables cannot be modified', + 'DROP INDEX `PRIMARY` ON protected_target' => 'Dropping PRIMARY requires a table rebuild', + 'ALTER TABLE protected_target DROP PRIMARY KEY' => 'DROP PRIMARY KEY requires a table rebuild', + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected lifecycle protection to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + } + } + public function test_show_create_table_denies_information_schema_tables(): void { $this->requireDuckDBRuntime(); @@ -1425,6 +1648,17 @@ private function lastDuckDBQuery( WP_DuckDB_Driver $driver ): string { // phpcs: return $queries[ count( $queries ) - 1 ]; } + private function lifecycleTableSql( string $table_name ): string { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return "CREATE TABLE {$table_name} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(191) NOT NULL DEFAULT '', + payload LONGTEXT, + PRIMARY KEY (id), + UNIQUE KEY name_unique (name), + KEY payload_prefix (payload(12)) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"; + } + /** * WordPress-style schema statements that exercise core DDL shapes. * From 5f4d957a64e7fb7117fc6a1faca6cccb7af3d3d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 19:12:06 +0000 Subject: [PATCH 021/317] Add DuckDB multi-table DELETE parity --- .../src/duckdb/class-wp-duckdb-driver.php | 456 ++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 52 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 178 +++++++ 3 files changed, 686 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 5c06a99d2..e183c1966 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -665,6 +665,11 @@ private function execute_update( array $tokens ): WP_DuckDB_Result_Statement { * @return WP_DuckDB_Result_Statement */ private function execute_delete( array $tokens ): WP_DuckDB_Result_Statement { + $multi_table_delete = $this->parse_multi_table_delete_shape( $tokens ); + if ( null !== $multi_table_delete ) { + return $this->execute_multi_table_delete( $multi_table_delete ); + } + if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[1]->id ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Only DELETE FROM table is supported.' ); } @@ -690,6 +695,297 @@ private function execute_delete( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB DELETE' ); } + /** + * Parse supported multi-table DELETE shapes. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return array{targets:array,from_sql:string,where_tokens:array,temp_table:string}|null Parsed shape, or null for single-table DELETE. + */ + private function parse_multi_table_delete_shape( array $tokens ): ?array { + if ( ! isset( $tokens[1] ) ) { + return null; + } + + if ( + WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL === $tokens[1]->id + || WP_MySQL_Lexer::QUICK_SYMBOL === $tokens[1]->id + || WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[1]->id + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. DELETE modifiers are not supported.' ); + } + + $target_tokens = null; + $table_ref_start = null; + $first_clause_pos = null; + + if ( WP_MySQL_Lexer::FROM_SYMBOL === $tokens[1]->id ) { + $using_index = $this->find_top_level_token_index( $tokens, 2, WP_MySQL_Lexer::USING_SYMBOL ); + if ( null === $using_index ) { + return null; + } + $target_tokens = array_slice( $tokens, 2, $using_index - 2 ); + $table_ref_start = $using_index + 1; + } else { + $from_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::FROM_SYMBOL ); + if ( null === $from_index ) { + return null; + } + $target_tokens = array_slice( $tokens, 1, $from_index - 1 ); + $table_ref_start = $from_index + 1; + } + + $clauses = $this->dml_clause_indexes( $tokens, $table_ref_start ); + if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Multi-table DELETE with ORDER BY or LIMIT is not supported.' ); + } + $first_clause_pos = $clauses['where'] ?? count( $tokens ); + + $table_ref_tokens = array_slice( $tokens, $table_ref_start, $first_clause_pos - $table_ref_start ); + if ( count( $target_tokens ) === 0 || count( $table_ref_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Multi-table DELETE requires target aliases and table references.' ); + } + + $target_aliases = $this->parse_multi_delete_target_aliases( $target_tokens ); + $references = $this->parse_multi_delete_table_references( $table_ref_tokens ); + $targets = array(); + foreach ( $target_aliases as $offset => $target_alias ) { + $key = strtolower( $target_alias ); + if ( ! isset( $references['by_alias'][ $key ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Unknown DELETE target alias '{$target_alias}' in DuckDB driver." ); + } + $reference = $references['by_alias'][ $key ]; + $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'DELETE' ); + $targets[] = array( + 'alias' => $reference['alias'], + 'column' => '__target_' . $offset . '_rowid', + 'table_name' => $reference['table_name'], + ); + } + + $where_tokens = array(); + if ( null !== $clauses['where'] ) { + $where_tokens = array_slice( $tokens, $clauses['where'] + 1 ); + } + + return array( + 'targets' => $targets, + 'from_sql' => $references['sql'], + 'where_tokens' => $where_tokens, + 'temp_table' => '__wp_duckdb_dml_delete_' . substr( hash( 'sha256', (string) $this->last_mysql_query ), 0, 16 ), + ); + } + + /** + * Execute a parsed multi-table DELETE. + * + * @param array{targets:array,from_sql:string,where_tokens:array,temp_table:string} $shape Parsed shape. + * @return WP_DuckDB_Result_Statement + */ + private function execute_multi_table_delete( array $shape ): WP_DuckDB_Result_Statement { + return $this->execute_schema_lifecycle_change( + function () use ( $shape ): WP_DuckDB_Result_Statement { + $temp_table = $shape['temp_table']; + $this->execute_duckdb_query( + 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $temp_table ), + 'Failed to reset DuckDB multi-table DELETE targets' + ); + + $select_list = array(); + foreach ( $shape['targets'] as $target ) { + $select_list[] = $this->connection->quote_identifier( $target['alias'] ) + . '.rowid AS ' + . $this->connection->quote_identifier( $target['column'] ); + } + + $sql = 'CREATE TEMP TABLE ' + . $this->connection->quote_identifier( $temp_table ) + . ' AS SELECT DISTINCT ' + . implode( ', ', $select_list ) + . ' FROM ' + . $shape['from_sql']; + if ( count( $shape['where_tokens'] ) > 0 ) { + $sql .= ' WHERE ' . $this->translate_tokens_to_duckdb_sql( $shape['where_tokens'] ); + } + + $this->execute_duckdb_query( $sql, 'Failed to collect DuckDB multi-table DELETE targets' ); + + $affected_rows = 0; + foreach ( $shape['targets'] as $target ) { + $stmt = $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( $target['table_name'] ) + . ' AS ' + . $this->connection->quote_identifier( $target['alias'] ) + . ' WHERE rowid IN ( SELECT ' + . $this->connection->quote_identifier( $target['column'] ) + . ' FROM ' + . $this->connection->quote_identifier( $temp_table ) + . ' WHERE ' + . $this->connection->quote_identifier( $target['column'] ) + . ' IS NOT NULL )', + 'Failed to execute DuckDB multi-table DELETE' + ); + $affected_rows += $stmt->rowCount(); + } + + $this->execute_duckdb_query( + 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $temp_table ), + 'Failed to clean up DuckDB multi-table DELETE targets' + ); + + return new WP_DuckDB_Result_Statement( array(), array(), $affected_rows ); + } + ); + } + + /** + * Parse a multi-table DELETE target alias list. + * + * @param WP_Parser_Token[] $tokens Target alias tokens. + * @return string[] Target aliases. + */ + private function parse_multi_delete_target_aliases( array $tokens ): array { + $aliases = array(); + foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { + if ( 1 !== count( $item ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. DELETE target wildcards are not supported.' ); + } + + $alias = $this->identifier_value( $item[0] ); + $key = strtolower( $alias ); + if ( isset( $aliases[ $key ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Duplicate DELETE target alias '{$alias}' in DuckDB driver." ); + } + $aliases[ $key ] = $alias; + } + + return array_values( $aliases ); + } + + /** + * Parse comma-separated table references for a bounded multi-table DELETE. + * + * @param WP_Parser_Token[] $tokens Table reference tokens. + * @return array{sql:string,by_alias:array} SQL and references keyed by lowercase alias. + */ + private function parse_multi_delete_table_references( array $tokens ): array { + if ( $this->contains_top_level_join_token( $tokens ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Joined table references in multi-table DELETE are not supported yet.' ); + } + + $sql_items = array(); + $by_alias = array(); + foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { + $reference = $this->parse_multi_delete_table_reference( $item ); + $key = strtolower( $reference['alias'] ); + if ( isset( $by_alias[ $key ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Duplicate table alias '{$reference['alias']}' in DELETE statement." ); + } + + $by_alias[ $key ] = array( + 'alias' => $reference['alias'], + 'table_name' => $reference['table_name'], + ); + $sql_items[] = $this->connection->quote_identifier( $reference['table_name'] ) + . ' AS ' + . $this->connection->quote_identifier( $reference['alias'] ); + } + + return array( + 'sql' => implode( ', ', $sql_items ), + 'by_alias' => $by_alias, + ); + } + + /** + * Parse one base table reference for multi-table DELETE. + * + * @param WP_Parser_Token[] $tokens Table reference tokens. + * @return array{alias:string,table_name:string} + */ + private function parse_multi_delete_table_reference( array $tokens ): array { + $index = 0; + $database = null; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index ]->id ) { + $database = $table_name; + ++$index; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( 0 === strcasecmp( $database, 'information_schema' ) ) { + throw new WP_DuckDB_Driver_Exception( "Access denied for user 'duckdb'@'%' to database 'information_schema'" ); + } + + if ( 0 !== strcasecmp( $database, $this->database ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Only the current database is supported.' ); + } + } + + if ( $this->is_duckdb_internal_table_name( $table_name ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Internal DuckDB metadata tables cannot be modified.' ); + } + + $resolved_table_name = $this->resolve_user_table_name( $table_name ); + if ( null === $resolved_table_name ) { + throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in DELETE statement." ); + } + + $alias = $table_name; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $alias = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + } elseif ( isset( $tokens[ $index ] ) ) { + $alias = $this->identifier_value( $tokens[ $index ] ); + ++$index; + } + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Table reference options are not supported.' ); + } + + return array( + 'alias' => $alias, + 'table_name' => $resolved_table_name, + ); + } + + /** + * Check whether a token stream contains a top-level JOIN keyword. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether a top-level join is present. + */ + private function contains_top_level_join_token( array $tokens ): bool { + $join_tokens = array( + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::INNER_SYMBOL, + WP_MySQL_Lexer::LEFT_SYMBOL, + WP_MySQL_Lexer::RIGHT_SYMBOL, + WP_MySQL_Lexer::NATURAL_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ); + $depth = 0; + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id ) { + --$depth; + continue; + } + if ( 0 === $depth && in_array( $token->id, $join_tokens, true ) ) { + return true; + } + } + + return false; + } + /** * Execute a supported DROP statement. * @@ -1049,6 +1345,33 @@ private function skip_drop_index_options( array $tokens, int $index ): int { return $index; } + /** + * Find a token at top-level parenthesis depth. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $start First token to scan. + * @param int $token_id Token ID to find. + * @return int|null Token index, or null when absent. + */ + private function find_top_level_token_index( array $tokens, int $start, int $token_id ): ?int { + $depth = 0; + for ( $index = $start; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + continue; + } + if ( 0 === $depth && $token_id === $tokens[ $index ]->id ) { + return $index; + } + } + + return null; + } + /** * Find top-level WHERE/ORDER/LIMIT clauses in a DML statement. * @@ -3229,6 +3552,18 @@ private function translate_tokens_to_duckdb_sql( continue; } + $unix_timestamp_comparison = $this->translate_unix_timestamp_comparison( $tokens, $index ); + if ( null !== $unix_timestamp_comparison ) { + $pieces[] = $unix_timestamp_comparison; + continue; + } + + $like_escape_predicate = $this->translate_like_escape_predicate( $tokens, $index ); + if ( null !== $like_escape_predicate ) { + $pieces[] = $like_escape_predicate; + continue; + } + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { $identifier = null; if ( $rewrite_information_schema_tables ) { @@ -3708,6 +4043,127 @@ private function translate_token_to_duckdb_sql( WP_Parser_Token $token ): string return $token->get_bytes(); } + /** + * Translate MySQL's numeric column coercion around UNIX_TIMESTAMP(). + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index, advanced on match. + * @return string|null Translated comparison, or null when the pattern does not match. + */ + private function translate_unix_timestamp_comparison( array $tokens, int &$index ): ?string { + $left_tokens = array(); + $operator_index = $index + 1; + if ( + isset( $tokens[ $index + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + ) { + $left_tokens = array( $tokens[ $index ], $tokens[ $index + 1 ], $tokens[ $index + 2 ] ); + $operator_index = $index + 3; + } elseif ( isset( $tokens[ $index ] ) && ! $this->is_non_identifier_token( $tokens[ $index ] ) ) { + $left_tokens = array( $tokens[ $index ] ); + } + + if ( + count( $left_tokens ) === 0 + || ! isset( $tokens[ $operator_index + 3 ] ) + || ! in_array( + $tokens[ $operator_index ]->id, + array( + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_THAN_OPERATOR, + WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::EQUAL_OPERATOR, + ), + true + ) + || ! $this->is_empty_function_call( $tokens, $operator_index + 1, 'UNIX_TIMESTAMP' ) + ) { + return null; + } + + $left_sql = 1 === count( $left_tokens ) + ? $this->connection->quote_identifier( $this->identifier_value( $left_tokens[0] ) ) + : $this->connection->quote_identifier( $this->identifier_value( $left_tokens[0] ) ) + . '.' + . $this->connection->quote_identifier( $this->identifier_value( $left_tokens[2] ) ); + + $index = $operator_index + 3; + return 'TRY_CAST(' + . $left_sql + . ' AS BIGINT) ' + . $tokens[ $operator_index ]->get_bytes() + . ' CAST(epoch(current_timestamp) AS BIGINT)'; + } + + /** + * Translate MySQL's default backslash escape for simple LIKE predicates. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index, advanced on match. + * @return string|null Translated predicate, or null when the pattern does not match. + */ + private function translate_like_escape_predicate( array $tokens, int &$index ): ?string { + if ( ! isset( $tokens[ $index ] ) || $this->is_non_identifier_token( $tokens[ $index ] ) ) { + return null; + } + + $operator_index = $index + 1; + $left_tokens = array( $tokens[ $index ] ); + if ( + isset( $tokens[ $index + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && ! $this->is_non_identifier_token( $tokens[ $index + 2 ] ) + ) { + $left_tokens = array( $tokens[ $index ], $tokens[ $index + 2 ] ); + $operator_index = $index + 3; + } + + if ( ! isset( $tokens[ $operator_index ] ) ) { + return null; + } + + $is_not_like = false; + $pattern_index = $operator_index + 1; + if ( WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $operator_index ]->id ) { + $is_not_like = false; + } elseif ( + in_array( $tokens[ $operator_index ]->id, array( WP_MySQL_Lexer::NOT_SYMBOL, WP_MySQL_Lexer::NOT2_SYMBOL ), true ) + && isset( $tokens[ $operator_index + 1 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $operator_index + 1 ]->id + ) { + $is_not_like = true; + $pattern_index = $operator_index + 2; + } else { + return null; + } + + if ( + ! isset( $tokens[ $pattern_index ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $pattern_index ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $pattern_index ]->id + ) + || false === strpos( $tokens[ $pattern_index ]->get_value(), '\\' ) + || ( isset( $tokens[ $pattern_index + 1 ] ) && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern_index + 1 ]->id ) + ) { + return null; + } + + $left_sql = 1 === count( $left_tokens ) + ? $this->connection->quote_identifier( $this->identifier_value( $left_tokens[0] ) ) + : $this->connection->quote_identifier( $this->identifier_value( $left_tokens[0] ) ) + . '.' + . $this->connection->quote_identifier( $this->identifier_value( $left_tokens[1] ) ); + + $index = $pattern_index; + return $left_sql + . ( $is_not_like ? ' NOT LIKE ' : ' LIKE ' ) + . $this->connection->quote( $tokens[ $pattern_index ]->get_value() ) + . ' ESCAPE ' + . $this->connection->quote( '\\' ); + } + /** * Check if a token starts an empty function call by name. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 2b6ee71ec..01093b51e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -57,6 +57,58 @@ public function test_update_delete_alias_order_limit_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_multi_table_delete_transient_cleanup_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wp_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wp_options (option_name, option_value, autoload) VALUES + ('_transient_tag4', 'tag4', 'no'), + ('_transient_timeout_tag4', '1', 'no'), + ('_transient_tag5', 'tag5', 'no'), + ('_transient_timeout_tag5', '9999999999', 'no'), + ('_site_transient_tag1', 'tag1', 'no'), + ('_site_transient_timeout_tag1', '1', 'no'), + ('rss_1', 'rss', 'yes')", + ) + ); + + $this->assertParityRowCount( + "DELETE a, b FROM wp_options a, wp_options b + WHERE a.option_name LIKE '\_transient\_%' + AND a.option_name NOT LIKE '\_transient\_timeout_%' + AND b.option_name = CONCAT( '_transient_timeout_', SUBSTRING( a.option_name, 12 ) ) + AND b.option_value < UNIX_TIMESTAMP()" + ); + $this->assertParityRows( 'SELECT option_name FROM wp_options ORDER BY option_id' ); + } + + public function test_multi_table_delete_using_and_single_target_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20))', + 'CREATE TABLE t2 (id INT, note VARCHAR(20))', + "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')", + "INSERT INTO t2 VALUES (1, 'x'), (3, 'z'), (4, 'other')", + ) + ); + + $this->assertParityRowCount( 'DELETE FROM a, b USING t1 a, t2 b WHERE a.id = b.id AND a.id = 1' ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( 'DELETE a FROM t1 a, t2 b WHERE a.id = b.id' ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note FROM t2 ORDER BY id' ); + } + public function test_insert_set_match_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 96c205eb9..074053260 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -186,6 +186,184 @@ public function test_update_delete_alias_order_limit_are_rewritten(): void { ); } + public function test_escaped_like_predicates_use_mysql_backslash_semantics(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE options (option_name VARCHAR(100))' ); + $driver->query( + "INSERT INTO options VALUES + ('_transient_tag4'), + ('_transient_timeout_tag4'), + ('_site_transient_tag1'), + ('x_transient_tag4')" + ); + + $rows = $driver->query( + "SELECT option_name + FROM options + WHERE option_name LIKE '\_transient\_%' + AND option_name NOT LIKE '\_transient\_timeout_%' + ORDER BY option_name" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( array( array( 'option_name' => '_transient_tag4' ) ), $rows ); + $this->assertStringContainsString( " ESCAPE '\\'", $this->lastDuckDBQuery( $driver ) ); + } + + public function test_multi_table_delete_removes_expired_transient_alias_rows(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE wp_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wp_options (option_name, option_value, autoload) VALUES + ('_transient_tag4', 'tag4', 'no'), + ('_transient_timeout_tag4', '1', 'no'), + ('_transient_tag5', 'tag5', 'no'), + ('_transient_timeout_tag5', '9999999999', 'no'), + ('_site_transient_tag1', 'tag1', 'no'), + ('_site_transient_timeout_tag1', '1', 'no'), + ('rss_1', 'rss', 'yes')" + ); + + $delete = $driver->query( + "DELETE a, b FROM wp_options a, wp_options b + WHERE a.option_name LIKE '\_transient\_%' + AND a.option_name NOT LIKE '\_transient\_timeout_%' + AND b.option_name = CONCAT( '_transient_timeout_', SUBSTRING( a.option_name, 12 ) ) + AND b.option_value < UNIX_TIMESTAMP()" + ); + + $this->assertSame( 2, $delete->rowCount() ); + $this->assertSame( + array( + array( 'option_name' => '_transient_tag5' ), + array( 'option_name' => '_transient_timeout_tag5' ), + array( 'option_name' => '_site_transient_tag1' ), + array( 'option_name' => '_site_transient_timeout_tag1' ), + array( 'option_name' => 'rss_1' ), + ), + $driver->query( 'SELECT option_name FROM wp_options ORDER BY option_id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_multi_table_delete_using_form_and_single_target_are_rewritten(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, note VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'x'), (3, 'z'), (4, 'other')" ); + + $using_delete = $driver->query( 'DELETE FROM a, b USING t1 a, t2 b WHERE a.id = b.id AND a.id = 1' ); + $this->assertSame( 2, $using_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'b', + ), + array( + 'id' => 3, + 'note' => 'c', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 3, + 'note' => 'z', + ), + array( + 'id' => 4, + 'note' => 'other', + ), + ), + $driver->query( 'SELECT id, note FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $single_target_delete = $driver->query( 'DELETE a FROM t1 a, t2 b WHERE a.id = b.id' ); + $this->assertSame( 1, $single_target_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'b', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 3, + 'note' => 'z', + ), + array( + 'id' => 4, + 'note' => 'other', + ), + ), + $driver->query( 'SELECT id, note FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_multi_table_delete_rejects_unsupported_shapes(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT)' ); + $driver->query( 'CREATE TABLE t2 (id INT)' ); + $driver->query( 'CREATE TABLE has_rowid (rowid INT, id INT)' ); + + foreach ( + array( + array( + 'sql' => 'DELETE a FROM t1 a JOIN t2 b ON a.id = b.id', + 'message' => 'Joined table references in multi-table DELETE are not supported yet', + ), + array( + 'sql' => 'DELETE a.* FROM t1 a', + 'message' => 'DELETE target wildcards are not supported', + ), + array( + 'sql' => 'DELETE t FROM information_schema.tables t', + 'message' => "Access denied for user 'duckdb'@'%' to database 'information_schema'", + ), + array( + 'sql' => 'DELETE r FROM has_rowid r WHERE r.id = 1', + 'message' => 'ORDER BY/LIMIT rewrites require a table without a user-defined rowid column.', + ), + ) as $rejection + ) { + try { + $driver->query( $rejection['sql'] ); + $this->fail( 'Expected multi-table DELETE rejection for SQL: ' . $rejection['sql'] ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $rejection['message'], $e->getMessage() ); + } + } + } + public function test_ordered_limited_dml_rejects_user_defined_rowid_column(): void { $this->requireDuckDBRuntime(); From 8d68a3e0fbeb6e3db67f415923d73a460a38fdac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 19:27:29 +0000 Subject: [PATCH 022/317] Add DuckDB joined UPDATE parity --- .../src/duckdb/class-wp-duckdb-driver.php | 509 ++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 76 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 202 +++++++ 3 files changed, 787 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index e183c1966..540e829ae 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -624,6 +624,11 @@ private function execute_replace( array $tokens ): WP_DuckDB_Result_Statement { * @return WP_DuckDB_Result_Statement */ private function execute_update( array $tokens ): WP_DuckDB_Result_Statement { + $joined_update = $this->parse_joined_update_shape( $tokens ); + if ( null !== $joined_update ) { + return $this->execute_joined_update( $joined_update ); + } + $reference = $this->parse_single_table_dml_reference( $tokens, 1, 'UPDATE' ); $index = $reference['next_index']; @@ -658,6 +663,97 @@ private function execute_update( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB UPDATE' ); } + /** + * Parse supported joined UPDATE shapes. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return array{target:array{alias:string,table_name:string,requested_table_name:string},sources:array,join_predicates:array>,update_tokens:array,where_tokens:array}|null Parsed shape, or null for single-table UPDATE. + */ + private function parse_joined_update_shape( array $tokens ): ?array { + if ( ! isset( $tokens[1] ) ) { + return null; + } + + if ( + WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL === $tokens[1]->id + || WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[1]->id + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE modifiers are not supported.' ); + } + + $set_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::SET_SYMBOL ); + if ( null === $set_index ) { + return null; + } + + $table_tokens = array_slice( $tokens, 1, $set_index - 1 ); + if ( + ! $this->contains_top_level_token_id( $table_tokens, WP_MySQL_Lexer::COMMA_SYMBOL ) + && ! $this->contains_top_level_join_token( $table_tokens ) + ) { + return null; + } + + $references = $this->parse_joined_update_table_references( $table_tokens ); + if ( count( $references['sources'] ) === 0 ) { + return null; + } + + $clauses = $this->dml_clause_indexes( $tokens, $set_index + 1 ); + if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Joined UPDATE with ORDER BY or LIMIT is not supported.' ); + } + + $update_end = $clauses['where'] ?? count( $tokens ); + $update_tokens = array_slice( $tokens, $set_index + 1, $update_end - $set_index - 1 ); + if ( count( $update_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE list is required.' ); + } + + $where_tokens = array(); + if ( null !== $clauses['where'] ) { + $where_tokens = array_slice( $tokens, $clauses['where'] + 1 ); + } + + return array( + 'target' => $references['target'], + 'sources' => $references['sources'], + 'join_predicates' => $references['join_predicates'], + 'update_tokens' => $update_tokens, + 'where_tokens' => $where_tokens, + ); + } + + /** + * Execute a parsed joined UPDATE. + * + * @param array{target:array{alias:string,table_name:string,requested_table_name:string},sources:array,join_predicates:array>,update_tokens:array,where_tokens:array} $shape Parsed shape. + * @return WP_DuckDB_Result_Statement + */ + private function execute_joined_update( array $shape ): WP_DuckDB_Result_Statement { + $sql = 'UPDATE ' + . $this->connection->quote_identifier( $shape['target']['table_name'] ) + . ' AS ' + . $this->connection->quote_identifier( $shape['target']['alias'] ) + . ' SET ' + . $this->translate_joined_update_assignment_tokens_to_duckdb_sql( $shape['update_tokens'], $shape['target'], $shape['sources'] ) + . ' FROM ' + . implode( ', ', array_column( $shape['sources'], 'sql' ) ); + + $where_clauses = array(); + if ( count( $shape['where_tokens'] ) > 0 ) { + $where_clauses[] = $this->translate_tokens_to_duckdb_sql( $shape['where_tokens'] ); + } + foreach ( $shape['join_predicates'] as $predicate ) { + $where_clauses[] = $this->translate_tokens_to_duckdb_sql( $predicate ); + } + if ( count( $where_clauses ) > 0 ) { + $sql .= ' WHERE (' . implode( ') AND (', $where_clauses ) . ')'; + } + + return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB joined UPDATE' ); + } + /** * Execute a supported DELETE statement. * @@ -953,6 +1049,182 @@ private function parse_multi_delete_table_reference( array $tokens ): array { ); } + /** + * Parse joined UPDATE table references. + * + * @param WP_Parser_Token[] $tokens Table reference tokens. + * @return array{target:array{alias:string,table_name:string,requested_table_name:string},sources:array,join_predicates:array>} + */ + private function parse_joined_update_table_references( array $tokens ): array { + $items = $this->split_top_level_comma_items( $tokens ); + $target_item = array_shift( $items ); + $target_factor = $this->parse_joined_update_table_factor( $target_item, 0, false, true ); + $target = $target_factor['reference']; + $sources = array(); + $join_predicates = array(); + + if ( null === $target['table_name'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Derived tables cannot be updated.' ); + } + + $this->parse_joined_update_join_chain( $target_item, $target_factor['next_index'], $sources, $join_predicates ); + foreach ( $items as $item ) { + $source = $this->parse_joined_update_table_factor( $item, 0, true, false ); + $sources[] = $source['reference']; + $this->parse_joined_update_join_chain( $item, $source['next_index'], $sources, $join_predicates ); + } + + return array( + 'target' => array( + 'alias' => $target['alias'], + 'table_name' => $target['table_name'], + 'requested_table_name' => $target['requested_table_name'], + ), + 'sources' => $sources, + 'join_predicates' => $join_predicates, + ); + } + + /** + * Parse one joined UPDATE table factor. + * + * @param WP_Parser_Token[] $tokens Table reference tokens. + * @param int $index Current index. + * @param bool $allow_derived Whether derived tables are allowed. + * @param bool $is_target Whether this factor is the UPDATE target. + * @return array{reference:array{alias:string,sql:string,table_name:string|null,requested_table_name:string},next_index:int} + */ + private function parse_joined_update_table_factor( array $tokens, int $index, bool $allow_derived, bool $is_target ): array { + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Expected table reference.' ); + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + if ( ! $allow_derived ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Derived tables cannot be updated.' ); + } + + $close_index = $this->skip_balanced_parentheses( $tokens, $index ) - 1; + $inner = array_slice( $tokens, $index + 1, $close_index - $index - 1 ); + if ( ! isset( $inner[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $inner[0]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only derived SELECT sources are supported.' ); + } + if ( $this->contains_information_schema_reference( $inner ) ) { + throw new WP_DuckDB_Driver_Exception( "Access denied for user 'duckdb'@'%' to database 'information_schema'" ); + } + + $index = $close_index + 1; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + $alias = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + return array( + 'reference' => array( + 'alias' => $alias, + 'sql' => '( ' + . $this->translate_tokens_to_duckdb_sql( $this->strip_for_update_locking_clause( $inner ) ) + . ' ) AS ' + . $this->connection->quote_identifier( $alias ), + 'table_name' => null, + 'requested_table_name' => $alias, + ), + 'next_index' => $index, + ); + } + + $database = null; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index ]->id ) { + $database = $table_name; + ++$index; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( 0 === strcasecmp( $database, 'information_schema' ) ) { + throw new WP_DuckDB_Driver_Exception( "Access denied for user 'duckdb'@'%' to database 'information_schema'" ); + } + + if ( 0 !== strcasecmp( $database, $this->database ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only the current database is supported.' ); + } + } + + if ( $this->is_duckdb_internal_table_name( $table_name ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Internal DuckDB metadata tables cannot be modified.' ); + } + + $resolved_table_name = $this->resolve_user_table_name( $table_name ); + if ( null === $resolved_table_name ) { + throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in UPDATE statement." ); + } + + $alias = $table_name; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $alias = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + } elseif ( isset( $tokens[ $index ] ) && ! $this->is_joined_update_table_reference_boundary( $tokens[ $index ] ) ) { + $alias = $this->identifier_value( $tokens[ $index ] ); + ++$index; + } + + return array( + 'reference' => array( + 'alias' => $alias, + 'sql' => $this->connection->quote_identifier( $resolved_table_name ) + . ' AS ' + . $this->connection->quote_identifier( $alias ), + 'table_name' => $resolved_table_name, + 'requested_table_name' => $table_name, + ), + 'next_index' => $index, + ); + } + + /** + * Parse a joined UPDATE join chain. + * + * @param WP_Parser_Token[] $tokens Table reference tokens. + * @param int $index Current index. + * @param array $sources Source references. + * @param array $join_predicates Join predicate token lists. + */ + private function parse_joined_update_join_chain( array $tokens, int $index, array &$sources, array &$join_predicates ): void { + while ( $index < count( $tokens ) ) { + if ( WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::JOIN_SYMBOL, 'Expected JOIN after INNER.' ); + } elseif ( WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { + if ( $this->is_unsupported_joined_update_join_token( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only comma joins and INNER JOIN ... ON are supported.' ); + } + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Table reference options are not supported.' ); + } + + ++$index; + $source = $this->parse_joined_update_table_factor( $tokens, $index, true, false ); + $sources[] = $source['reference']; + $index = $source['next_index']; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::USING_SYMBOL === $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. JOIN ... USING is not supported.' ); + } + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ON_SYMBOL, 'Expected ON in joined UPDATE statement.' ); + ++$index; + $predicate_end = $this->find_next_joined_update_join_index( $tokens, $index ) ?? count( $tokens ); + if ( $predicate_end === $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. JOIN predicate is required.' ); + } + $join_predicates[] = array_slice( $tokens, $index, $predicate_end - $index ); + $index = $predicate_end; + } + } + /** * Check whether a token stream contains a top-level JOIN keyword. * @@ -986,6 +1258,109 @@ private function contains_top_level_join_token( array $tokens ): bool { return false; } + /** + * Check whether a token stream contains a token at top-level depth. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $token_id Token ID to find. + * @return bool Whether the token is present. + */ + private function contains_top_level_token_id( array $tokens, int $token_id ): bool { + return null !== $this->find_top_level_token_index( $tokens, 0, $token_id ); + } + + /** + * Check whether a token ends a joined UPDATE table factor. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token is a boundary. + */ + private function is_joined_update_table_reference_boundary( WP_Parser_Token $token ): bool { + return WP_MySQL_Lexer::ON_SYMBOL === $token->id + || WP_MySQL_Lexer::JOIN_SYMBOL === $token->id + || WP_MySQL_Lexer::INNER_SYMBOL === $token->id + || WP_MySQL_Lexer::LEFT_SYMBOL === $token->id + || WP_MySQL_Lexer::RIGHT_SYMBOL === $token->id + || WP_MySQL_Lexer::NATURAL_SYMBOL === $token->id + || WP_MySQL_Lexer::CROSS_SYMBOL === $token->id + || WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === $token->id + || WP_MySQL_Lexer::USING_SYMBOL === $token->id; + } + + /** + * Check whether a join token is unsupported in joined UPDATE. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether this is an unsupported join token. + */ + private function is_unsupported_joined_update_join_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::LEFT_SYMBOL, + WP_MySQL_Lexer::RIGHT_SYMBOL, + WP_MySQL_Lexer::NATURAL_SYMBOL, + WP_MySQL_Lexer::CROSS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::USING_SYMBOL, + ), + true + ); + } + + /** + * Find the next join operator in a joined UPDATE table item. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $start First token to scan. + * @return int|null Token index, or null when absent. + */ + private function find_next_joined_update_join_index( array $tokens, int $start ): ?int { + $depth = 0; + for ( $index = $start; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + continue; + } + if ( + 0 === $depth + && ( + WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $index ]->id + || WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $index ]->id + || $this->is_unsupported_joined_update_join_token( $tokens[ $index ] ) + ) + ) { + return $index; + } + } + + return null; + } + + /** + * Check whether a token stream references information_schema. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether information_schema is referenced. + */ + private function contains_information_schema_reference( array $tokens ): bool { + foreach ( $tokens as $index => $token ) { + if ( + isset( $tokens[ $index + 1 ] ) + && 0 === strcasecmp( $token->get_value(), 'information_schema' ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + ) { + return true; + } + } + + return false; + } + /** * Execute a supported DROP statement. * @@ -1463,6 +1838,105 @@ private function translate_update_assignment_tokens_to_duckdb_sql( array $tokens return implode( ', ', $items ); } + /** + * Translate joined UPDATE assignments and enforce a single writable target. + * + * @param WP_Parser_Token[] $tokens Update-list tokens. + * @param array{alias:string,table_name:string,requested_table_name:string} $target Target reference. + * @param array $sources Source references. + * @return string DuckDB update-list SQL. + */ + private function translate_joined_update_assignment_tokens_to_duckdb_sql( array $tokens, array $target, array $sources ): string { + $target_qualifiers = array_map( + 'strtolower', + array_unique( + array( + $target['alias'], + $target['requested_table_name'], + $target['table_name'], + ) + ) + ); + $source_aliases = array(); + foreach ( $sources as $source ) { + $source_aliases[ strtolower( $source['alias'] ) ] = $source; + if ( null !== $source['table_name'] ) { + $source_aliases[ strtolower( $source['table_name'] ) ] = $source; + } + } + + $items = array(); + foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { + $equals_index = $this->find_top_level_token_index( $item, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); + if ( null === $equals_index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE assignment is required.' ); + } + + $left_tokens = array_slice( $item, 0, $equals_index ); + $right_tokens = array_slice( $item, $equals_index + 1 ); + if ( count( $right_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE assignment value is required.' ); + } + + if ( 1 === count( $left_tokens ) ) { + $column = $this->identifier_value( $left_tokens[0] ); + if ( ! $this->table_has_column( $target['table_name'], $column ) ) { + throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target column '{$column}' in DuckDB driver." ); + } + foreach ( $sources as $source ) { + if ( null !== $source['table_name'] && $this->table_has_column( $source['table_name'], $column ) ) { + throw new WP_DuckDB_Driver_Exception( "Ambiguous unqualified UPDATE target column '{$column}' in DuckDB driver." ); + } + } + } elseif ( + 3 === count( $left_tokens ) + && WP_MySQL_Lexer::DOT_SYMBOL === $left_tokens[1]->id + ) { + $qualifier = strtolower( $this->identifier_value( $left_tokens[0] ) ); + $column = $this->identifier_value( $left_tokens[2] ); + if ( ! in_array( $qualifier, $target_qualifiers, true ) ) { + if ( isset( $source_aliases[ $qualifier ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE statement modifying multiple tables is not supported.' ); + } + throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target qualifier '{$this->identifier_value( $left_tokens[0] )}' in DuckDB driver." ); + } + if ( ! $this->table_has_column( $target['table_name'], $column ) ) { + throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target column '{$column}' in DuckDB driver." ); + } + } else { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only simple column assignments are supported.' ); + } + + $items[] = $this->connection->quote_identifier( $column ) + . ' = ' + . $this->translate_tokens_to_duckdb_sql( $right_tokens ); + } + + return implode( ', ', $items ); + } + + /** + * Check whether a table has a column. + * + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return bool Whether the column exists. + */ + private function table_has_column( string $table_name, string $column_name ): bool { + $metadata_rows = $this->column_metadata_rows( $table_name ); + if ( count( $metadata_rows ) === 0 ) { + $metadata_rows = $this->pragma_column_metadata_rows( $table_name ); + } + + foreach ( $metadata_rows as $metadata ) { + if ( 0 === strcasecmp( (string) $metadata['column_name'], $column_name ) ) { + return true; + } + } + + return false; + } + /** * Build a rowid subquery for ordered/limited UPDATE and DELETE statements. * @@ -3625,6 +4099,41 @@ private function translate_tokens_to_duckdb_sql( return $this->join_sql_pieces( $pieces ); } + /** + * Strip MySQL locking clauses that DuckDB does not support in derived SELECTs. + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @return WP_Parser_Token[] Tokens without a top-level FOR UPDATE clause. + */ + private function strip_for_update_locking_clause( array $tokens ): array { + $stripped = array(); + $depth = 0; + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + $stripped[] = $tokens[ $index ]; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + $stripped[] = $tokens[ $index ]; + continue; + } + if ( + 0 === $depth + && WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $index ]->id + && isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $index + 1 ]->id + ) { + ++$index; + continue; + } + $stripped[] = $tokens[ $index ]; + } + + return $stripped; + } + /** * Translate MySQL REPLACE ... VALUES to DuckDB INSERT OR REPLACE. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 01093b51e..7a2863ee5 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -57,6 +57,82 @@ public function test_update_delete_alias_order_limit_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_joined_update_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE posts (id INT, status VARCHAR(20), score INT)', + 'CREATE TABLE post_updates (post_id INT, new_status VARCHAR(20), bump INT, flag VARCHAR(20))', + "INSERT INTO posts VALUES (1, 'draft', 0), (2, 'draft', 0), (3, 'publish', 5)", + "INSERT INTO post_updates VALUES + (1, 'publish', 10, 'apply'), + (2, 'private', 20, 'skip'), + (3, 'archive', 30, 'apply')", + ) + ); + + $this->assertParityRowCount( + "UPDATE posts p + JOIN post_updates u ON u.post_id = p.id + SET p.status = u.new_status, p.score = p.score + u.bump + WHERE u.flag = 'apply'" + ); + $this->assertParityRows( 'SELECT id, status, score FROM posts ORDER BY id' ); + + $this->assertParityRowCount( + "UPDATE posts p, post_updates u + SET p.status = 'queued' + WHERE p.id = u.post_id AND u.flag = 'skip'" + ); + $this->assertParityRows( 'SELECT id, status, score FROM posts ORDER BY id' ); + } + + public function test_joined_update_derived_table_claim_query_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wp_actionscheduler_actions ( + action_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + status VARCHAR(20) NOT NULL, + scheduled_date_gmt DATETIME NULL, + priority TINYINT UNSIGNED NOT NULL DEFAULT '10', + attempts INT(11) NOT NULL DEFAULT '0', + claim_id BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + last_attempt_gmt DATETIME NULL, + last_attempt_local DATETIME NULL, + PRIMARY KEY (action_id) + )", + "INSERT INTO wp_actionscheduler_actions + (action_id, status, scheduled_date_gmt, priority, attempts, claim_id) + VALUES + (1, 'pending', '2025-09-03 12:00:00', 10, 0, 0), + (2, 'pending', '2025-09-03 12:10:00', 5, 0, 0)", + ) + ); + + $this->runParitySetup( + array( + "UPDATE wp_actionscheduler_actions t1 + JOIN ( + SELECT action_id + FROM wp_actionscheduler_actions + WHERE claim_id = 0 + AND scheduled_date_gmt <= '2025-09-03 12:23:55' + AND status = 'pending' + ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC + LIMIT 2 + FOR UPDATE + ) t2 ON t1.action_id = t2.action_id + SET claim_id = 37, + last_attempt_gmt = '2025-09-03 12:23:55', + last_attempt_local = '2025-09-03 12:23:55'", + ) + ); + $this->assertParityRows( + 'SELECT action_id, claim_id, last_attempt_gmt + FROM wp_actionscheduler_actions + ORDER BY action_id' + ); + } + public function test_multi_table_delete_transient_cleanup_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 074053260..e3056bc16 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -186,6 +186,208 @@ public function test_update_delete_alias_order_limit_are_rewritten(): void { ); } + public function test_joined_update_rewrites_join_and_comma_forms(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE posts (id INT, status VARCHAR(20), score INT)' ); + $driver->query( 'CREATE TABLE post_updates (post_id INT, new_status VARCHAR(20), bump INT, flag VARCHAR(20))' ); + $driver->query( "INSERT INTO posts VALUES (1, 'draft', 0), (2, 'draft', 0), (3, 'publish', 5)" ); + $driver->query( + "INSERT INTO post_updates VALUES + (1, 'publish', 10, 'apply'), + (2, 'private', 20, 'skip'), + (3, 'archive', 30, 'apply')" + ); + + $joined = $driver->query( + "UPDATE posts p + JOIN post_updates u ON u.post_id = p.id + SET p.status = u.new_status, p.score = p.score + u.bump + WHERE u.flag = 'apply'" + ); + $this->assertSame( 2, $joined->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'status' => 'publish', + 'score' => 10, + ), + array( + 'id' => 2, + 'status' => 'draft', + 'score' => 0, + ), + array( + 'id' => 3, + 'status' => 'archive', + 'score' => 35, + ), + ), + $driver->query( 'SELECT id, status, score FROM posts ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $comma = $driver->query( + "UPDATE posts p, post_updates u + SET p.status = 'queued' + WHERE p.id = u.post_id AND u.flag = 'skip'" + ); + $this->assertSame( 1, $comma->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'status' => 'publish', + 'score' => 10, + ), + array( + 'id' => 2, + 'status' => 'queued', + 'score' => 0, + ), + array( + 'id' => 3, + 'status' => 'archive', + 'score' => 35, + ), + ), + $driver->query( 'SELECT id, status, score FROM posts ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_joined_update_rewrites_derived_table_claim_query(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_actionscheduler_actions ( + action_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + status VARCHAR(20) NOT NULL, + scheduled_date_gmt DATETIME NULL, + priority TINYINT UNSIGNED NOT NULL DEFAULT '10', + attempts INT(11) NOT NULL DEFAULT '0', + claim_id BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + last_attempt_gmt DATETIME NULL, + last_attempt_local DATETIME NULL, + PRIMARY KEY (action_id) + )" + ); + $driver->query( + "INSERT INTO wp_actionscheduler_actions + (action_id, status, scheduled_date_gmt, priority, attempts, claim_id) + VALUES + (1, 'pending', '2025-09-03 12:00:00', 10, 0, 0), + (2, 'pending', '2025-09-03 12:10:00', 5, 0, 0), + (3, 'pending', '2025-09-03 12:20:00', 15, 0, 0), + (4, 'pending', '2025-09-03 12:00:00', 1, 0, 9)" + ); + + $claimed = $driver->query( + "UPDATE wp_actionscheduler_actions t1 + JOIN ( + SELECT action_id + FROM wp_actionscheduler_actions + WHERE claim_id = 0 + AND scheduled_date_gmt <= '2025-09-03 12:23:55' + AND status = 'pending' + ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC + LIMIT 2 + FOR UPDATE + ) t2 ON t1.action_id = t2.action_id + SET claim_id = 37, + last_attempt_gmt = '2025-09-03 12:23:55', + last_attempt_local = '2025-09-03 12:23:55'" + ); + + $this->assertSame( 2, $claimed->rowCount() ); + $this->assertSame( + array( + array( + 'action_id' => 1, + 'claim_id' => 37, + 'last_attempt_gmt' => '2025-09-03 12:23:55', + ), + array( + 'action_id' => 2, + 'claim_id' => 37, + 'last_attempt_gmt' => '2025-09-03 12:23:55', + ), + array( + 'action_id' => 3, + 'claim_id' => 0, + 'last_attempt_gmt' => null, + ), + array( + 'action_id' => 4, + 'claim_id' => 9, + 'last_attempt_gmt' => null, + ), + ), + $driver->query( + 'SELECT action_id, claim_id, last_attempt_gmt + FROM wp_actionscheduler_actions + ORDER BY action_id' + )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_joined_update_rejects_unsupported_shapes(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, note VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b')" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'x'), (3, 'z')" ); + + foreach ( + array( + array( + 'sql' => "UPDATE t1 a JOIN t2 b ON a.id = b.id SET a.note = 'target', b.note = 'source'", + 'message' => 'UPDATE statement modifying multiple tables is not supported', + ), + array( + 'sql' => "UPDATE t1 a, t2 b SET a.note = 'target', b.note = 'source' WHERE a.id = b.id", + 'message' => 'UPDATE statement modifying multiple tables is not supported', + ), + array( + 'sql' => "UPDATE t1 a LEFT JOIN t2 b ON a.id = b.id SET a.note = 'target'", + 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + ), + array( + 'sql' => "UPDATE t1 a JOIN t2 b ON a.id = b.id SET a.note = 'target' ORDER BY a.id LIMIT 1", + 'message' => 'Joined UPDATE with ORDER BY or LIMIT is not supported', + ), + array( + 'sql' => "UPDATE t1 a, information_schema.tables it SET a.note = 'target'", + 'message' => "Access denied for user 'duckdb'@'%' to database 'information_schema'", + ), + ) as $rejection + ) { + try { + $driver->query( $rejection['sql'] ); + $this->fail( 'Expected joined UPDATE rejection for SQL: ' . $rejection['sql'] ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $rejection['message'], $e->getMessage() ); + } + } + + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'a', + ), + array( + 'id' => 2, + 'note' => 'b', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_escaped_like_predicates_use_mysql_backslash_semantics(): void { $this->requireDuckDBRuntime(); From 22cf45e8a3a83166439152285409e46a92446a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 19:48:13 +0000 Subject: [PATCH 023/317] Add DuckDB temporary table parity --- .../src/duckdb/class-wp-duckdb-driver.php | 555 ++++++++++++------ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 170 +++++- .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 1 + 3 files changed, 548 insertions(+), 178 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 540e829ae..c65f962c1 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -22,6 +22,9 @@ class WP_DuckDB_Driver { const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; const TABLE_METADATA_TABLE = '__wp_duckdb_table_metadata'; + const TEMP_INDEX_METADATA_TABLE = '__wp_duckdb_temp_index_metadata'; + const TEMP_COLUMN_METADATA_TABLE = '__wp_duckdb_temp_column_metadata'; + const TEMP_TABLE_METADATA_TABLE = '__wp_duckdb_temp_table_metadata'; const INFO_SCHEMA_TABLES_TABLE = '__wp_duckdb_information_schema_tables'; const INFO_SCHEMA_COLUMNS_TABLE = '__wp_duckdb_information_schema_columns'; const INFO_SCHEMA_STATISTICS_TABLE = '__wp_duckdb_information_schema_statistics'; @@ -357,6 +360,14 @@ private function execute_create( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_create_table( $tokens ); } + if ( + isset( $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id + ) { + return $this->execute_create_table( $tokens ); + } + if ( $this->is_create_index_statement( $tokens ) ) { return $this->execute_create_index( $tokens ); } @@ -376,6 +387,13 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $index = 0; $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CREATE_SYMBOL, 'Expected CREATE.' ); ++$index; + + $temporary = false; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $index ]->id ) { + $temporary = true; + ++$index; + } + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::TABLE_SYMBOL, 'Only CREATE TABLE is supported by the DuckDB driver.' ); ++$index; @@ -417,7 +435,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme } if ( $this->is_create_table_index_item( $item ) ) { - $indexes[] = $this->translate_create_table_index( $table_name, $item ); + $indexes[] = $this->translate_create_table_index( $table_name, $item, $temporary ); continue; } @@ -425,7 +443,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE constraint in DuckDB driver: ' . $item[0]->get_bytes() . '.' ); } - list( $column_sql, $sequence_sql, $column_indexes, $column_metadata ) = $this->translate_create_table_column( $table_name, $item ); + list( $column_sql, $sequence_sql, $column_indexes, $column_metadata ) = $this->translate_create_table_column( $table_name, $item, true, false, $temporary ); $columns[] = $column_sql; $metadata[] = $column_metadata; if ( null !== $sequence_sql ) { @@ -444,7 +462,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $this->execute_duckdb_query( $sequence_sql, 'Failed to create DuckDB AUTO_INCREMENT sequence' ); } - $table_sql = 'CREATE TABLE '; + $table_sql = $temporary ? 'CREATE TEMPORARY TABLE ' : 'CREATE TABLE '; if ( $if_not_exists ) { $table_sql .= 'IF NOT EXISTS '; } @@ -458,8 +476,8 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); $this->record_index_metadata( $index_definition ); } - $this->record_column_metadata( $table_name, $this->apply_column_key_metadata( $metadata, $primary_key, $indexes ) ); - $this->record_table_metadata( $table_name, $table_metadata ); + $this->record_column_metadata( $table_name, $this->apply_column_key_metadata( $metadata, $primary_key, $indexes ), $temporary ); + $this->record_table_metadata( $table_name, $table_metadata, $temporary ); return $result; } @@ -500,12 +518,17 @@ private function execute_create_index( array $tokens ): WP_DuckDB_Result_Stateme $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + if ( null === $table_reference ) { + throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in CREATE INDEX statement." ); + } + $table_name = $table_reference['table_name']; $index = $this->skip_optional_index_type( $tokens, $index ); list( $columns, $column_metadata, $index ) = $this->translate_index_column_list( $tokens, $index ); $this->assert_supported_index_options( $tokens, $index ); - $index_definition = $this->build_secondary_index_definition( $table_name, $mysql_index_name, $unique, $columns, $column_metadata ); + $index_definition = $this->build_secondary_index_definition( $table_name, $mysql_index_name, $unique, $columns, $column_metadata, $table_reference['temporary'] ); $result = $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); $this->record_index_metadata( $index_definition ); @@ -649,7 +672,7 @@ private function execute_update( array $tokens ): WP_DuckDB_Result_Statement { . $this->translate_update_assignment_tokens_to_duckdb_sql( $update_tokens, $reference ); if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { - $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'UPDATE' ); + $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'UPDATE', $reference['temporary'] ); $sql .= ' WHERE rowid IN ( ' . $this->dml_rowid_subquery_sql( $tokens, $clauses, $reference ) . ' )'; @@ -774,7 +797,7 @@ private function execute_delete( array $tokens ): WP_DuckDB_Result_Statement { $clauses = $this->dml_clause_indexes( $tokens, $reference['next_index'] ); if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { - $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'DELETE' ); + $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'DELETE', $reference['temporary'] ); $sql = 'DELETE FROM ' . $this->connection->quote_identifier( $reference['table_name'] ) . ' WHERE rowid IN ( ' . $this->dml_rowid_subquery_sql( $tokens, $clauses, $reference ) @@ -850,11 +873,12 @@ private function parse_multi_table_delete_shape( array $tokens ): ?array { throw new WP_DuckDB_Driver_Exception( "Unknown DELETE target alias '{$target_alias}' in DuckDB driver." ); } $reference = $references['by_alias'][ $key ]; - $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'DELETE' ); + $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'DELETE', $reference['temporary'] ); $targets[] = array( 'alias' => $reference['alias'], 'column' => '__target_' . $offset . '_rowid', 'table_name' => $reference['table_name'], + 'temporary' => $reference['temporary'], ); } @@ -981,6 +1005,7 @@ private function parse_multi_delete_table_references( array $tokens ): array { $by_alias[ $key ] = array( 'alias' => $reference['alias'], 'table_name' => $reference['table_name'], + 'temporary' => $reference['temporary'], ); $sql_items[] = $this->connection->quote_identifier( $reference['table_name'] ) . ' AS ' @@ -997,7 +1022,7 @@ private function parse_multi_delete_table_references( array $tokens ): array { * Parse one base table reference for multi-table DELETE. * * @param WP_Parser_Token[] $tokens Table reference tokens. - * @return array{alias:string,table_name:string} + * @return array{alias:string,table_name:string,temporary:bool} */ private function parse_multi_delete_table_reference( array $tokens ): array { $index = 0; @@ -1024,8 +1049,8 @@ private function parse_multi_delete_table_reference( array $tokens ): array { throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Internal DuckDB metadata tables cannot be modified.' ); } - $resolved_table_name = $this->resolve_user_table_name( $table_name ); - if ( null === $resolved_table_name ) { + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + if ( null === $table_reference ) { throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in DELETE statement." ); } @@ -1045,7 +1070,8 @@ private function parse_multi_delete_table_reference( array $tokens ): array { return array( 'alias' => $alias, - 'table_name' => $resolved_table_name, + 'table_name' => $table_reference['table_name'], + 'temporary' => $table_reference['temporary'], ); } @@ -1078,6 +1104,7 @@ private function parse_joined_update_table_references( array $tokens ): array { 'target' => array( 'alias' => $target['alias'], 'table_name' => $target['table_name'], + 'temporary' => $target['temporary'], 'requested_table_name' => $target['requested_table_name'], ), 'sources' => $sources, @@ -1128,6 +1155,7 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo . ' ) AS ' . $this->connection->quote_identifier( $alias ), 'table_name' => null, + 'temporary' => false, 'requested_table_name' => $alias, ), 'next_index' => $index, @@ -1157,8 +1185,8 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Internal DuckDB metadata tables cannot be modified.' ); } - $resolved_table_name = $this->resolve_user_table_name( $table_name ); - if ( null === $resolved_table_name ) { + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + if ( null === $table_reference ) { throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in UPDATE statement." ); } @@ -1175,10 +1203,11 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo return array( 'reference' => array( 'alias' => $alias, - 'sql' => $this->connection->quote_identifier( $resolved_table_name ) + 'sql' => $this->connection->quote_identifier( $table_reference['table_name'] ) . ' AS ' . $this->connection->quote_identifier( $alias ), - 'table_name' => $resolved_table_name, + 'table_name' => $table_reference['table_name'], + 'temporary' => $table_reference['temporary'], 'requested_table_name' => $table_name, ), 'next_index' => $index, @@ -1368,8 +1397,12 @@ private function contains_information_schema_reference( array $tokens ): bool { * @return WP_DuckDB_Result_Statement */ private function execute_drop( array $tokens ): WP_DuckDB_Result_Statement { - if ( isset( $tokens[1] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP TABLE statement in DuckDB driver. Temporary tables are not supported.' ); + if ( + isset( $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id + ) { + return $this->execute_drop_table( $tokens, true ); } if ( isset( $tokens[1] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[1]->id ) { @@ -1396,10 +1429,14 @@ private function execute_drop( array $tokens ): WP_DuckDB_Result_Statement { * @param WP_Parser_Token[] $tokens MySQL tokens. * @return WP_DuckDB_Result_Statement */ - private function execute_drop_table( array $tokens ): WP_DuckDB_Result_Statement { + private function execute_drop_table( array $tokens, bool $temporary_only = false ): WP_DuckDB_Result_Statement { $index = 0; $this->expect_token( $tokens, $index, WP_MySQL_Lexer::DROP_SYMBOL, 'Expected DROP.' ); ++$index; + if ( $temporary_only ) { + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::TEMPORARY_SYMBOL, 'Expected TEMPORARY in DROP TEMPORARY TABLE statement.' ); + ++$index; + } $this->expect_token( $tokens, $index, WP_MySQL_Lexer::TABLE_SYMBOL, 'Expected TABLE in DROP TABLE statement.' ); ++$index; @@ -1434,22 +1471,26 @@ private function execute_drop_table( array $tokens ): WP_DuckDB_Result_Statement } return $this->execute_schema_lifecycle_change( - function () use ( $targets, $if_exists ): WP_DuckDB_Result_Statement { + function () use ( $targets, $if_exists, $temporary_only ): WP_DuckDB_Result_Statement { foreach ( $targets as $requested_table_name ) { - $table_name = $this->resolve_user_table_name( $requested_table_name ); - if ( null === $table_name ) { + $table_reference = $temporary_only + ? $this->resolve_temporary_user_table_reference( $requested_table_name ) + : $this->resolve_visible_user_table_reference( $requested_table_name ); + if ( null === $table_reference ) { if ( $if_exists ) { continue; } throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$requested_table_name}' in DROP TABLE statement." ); } + $table_name = $table_reference['table_name']; + $temporary = $table_reference['temporary']; - $sequence_names = $this->auto_increment_sequences_for_table( $table_name ); + $sequence_names = $this->auto_increment_sequences_for_table( $table_name, $temporary ); $index_names = array_map( function ( array $index_definition ): string { return $index_definition['index_name']; }, - $this->secondary_index_definitions_for_table( $table_name ) + $this->secondary_index_definitions_for_table( $table_name, $temporary ) ); $this->execute_duckdb_query( @@ -1458,10 +1499,10 @@ function ( array $index_definition ): string { ); foreach ( $index_names as $index_name ) { - $this->drop_physical_secondary_index( $table_name, $index_name, true ); + $this->drop_physical_secondary_index( $table_name, $index_name, true, $temporary ); } $this->drop_auto_increment_sequences( $sequence_names ); - $this->delete_table_lifecycle_metadata( $table_name ); + $this->delete_table_lifecycle_metadata( $table_name, $temporary ); } $this->invalidate_information_schema_compatibility_tables(); @@ -1491,14 +1532,16 @@ private function execute_truncate_table( array $tokens ): WP_DuckDB_Result_State return $this->execute_schema_lifecycle_change( function () use ( $reference ): WP_DuckDB_Result_Statement { - $table_name = $this->resolve_required_lifecycle_table_name( $reference['requested_table_name'], 'TRUNCATE' ); - if ( null === $this->auto_increment_metadata_for_table( $table_name ) ) { + $table_reference = $this->resolve_required_lifecycle_table( $reference['requested_table_name'], 'TRUNCATE' ); + $table_name = $table_reference['table_name']; + $temporary = $table_reference['temporary']; + if ( null === $this->auto_increment_metadata_for_table( $table_name, $temporary ) ) { $this->execute_duckdb_query( 'DELETE FROM ' . $this->connection->quote_identifier( $table_name ), 'Failed to truncate DuckDB table' ); } else { - $this->rebuild_empty_auto_increment_table( $table_name ); + $this->rebuild_empty_auto_increment_table( $table_name, $temporary ); } $this->invalidate_information_schema_compatibility_tables(); @@ -1541,8 +1584,8 @@ private function execute_drop_index( array $tokens ): WP_DuckDB_Result_Statement return $this->execute_schema_lifecycle_change( function () use ( $reference, $index_name ): WP_DuckDB_Result_Statement { - $table_name = $this->resolve_required_lifecycle_table_name( $reference['requested_table_name'], 'DROP INDEX' ); - $this->drop_secondary_index( $table_name, $index_name ); + $table_reference = $this->resolve_required_lifecycle_table( $reference['requested_table_name'], 'DROP INDEX' ); + $this->drop_secondary_index( $table_reference['table_name'], $index_name, $table_reference['temporary'] ); $this->invalidate_information_schema_compatibility_tables(); return $this->empty_ddl_result(); } @@ -1587,8 +1630,11 @@ private function parse_single_table_dml_reference( array $tokens, int $index, st ++$index; } + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + return array( - 'table_name' => $this->resolve_user_table_name( $table_name ) ?? $table_name, + 'table_name' => null === $table_reference ? $table_name : $table_reference['table_name'], + 'temporary' => null !== $table_reference && $table_reference['temporary'], 'requested_table_name' => $table_name, 'alias' => $alias, 'next_index' => $index, @@ -1598,7 +1644,7 @@ private function parse_single_table_dml_reference( array $tokens, int $index, st /** * Build a DuckDB SQL table reference for a DML target. * - * @param array{table_name:string,requested_table_name:string,alias:string|null,next_index:int} $reference Parsed table reference. + * @param array{table_name:string,temporary?:bool,requested_table_name:string,alias:string|null,next_index:int} $reference Parsed table reference. * @return string DuckDB table reference. */ private function dml_table_reference_sql( array $reference ): string { @@ -1656,12 +1702,24 @@ private function parse_schema_lifecycle_table_reference( array $tokens, int $ind * @return string Resolved table name. */ private function resolve_required_lifecycle_table_name( string $requested_table_name, string $statement ): string { - $table_name = $this->resolve_user_table_name( $requested_table_name ); - if ( null === $table_name ) { + $table_reference = $this->resolve_required_lifecycle_table( $requested_table_name, $statement ); + return $table_reference['table_name']; + } + + /** + * Resolve a lifecycle table target or throw a stable missing-table error. + * + * @param string $requested_table_name Requested table name. + * @param string $statement Statement name. + * @return array{table_name:string,temporary:bool} Resolved table reference. + */ + private function resolve_required_lifecycle_table( string $requested_table_name, string $statement ): array { + $table_reference = $this->resolve_visible_user_table_reference( $requested_table_name ); + if ( null === $table_reference ) { throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$requested_table_name}' in {$statement} statement." ); } - return $table_name; + return $table_reference; } /** @@ -1880,11 +1938,11 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array if ( 1 === count( $left_tokens ) ) { $column = $this->identifier_value( $left_tokens[0] ); - if ( ! $this->table_has_column( $target['table_name'], $column ) ) { + if ( ! $this->table_has_column( $target['table_name'], $column, $target['temporary'] ) ) { throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target column '{$column}' in DuckDB driver." ); } foreach ( $sources as $source ) { - if ( null !== $source['table_name'] && $this->table_has_column( $source['table_name'], $column ) ) { + if ( null !== $source['table_name'] && $this->table_has_column( $source['table_name'], $column, $source['temporary'] ) ) { throw new WP_DuckDB_Driver_Exception( "Ambiguous unqualified UPDATE target column '{$column}' in DuckDB driver." ); } } @@ -1900,7 +1958,7 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array } throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target qualifier '{$this->identifier_value( $left_tokens[0] )}' in DuckDB driver." ); } - if ( ! $this->table_has_column( $target['table_name'], $column ) ) { + if ( ! $this->table_has_column( $target['table_name'], $column, $target['temporary'] ) ) { throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target column '{$column}' in DuckDB driver." ); } } else { @@ -1922,8 +1980,8 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array * @param string $column_name Column name. * @return bool Whether the column exists. */ - private function table_has_column( string $table_name, string $column_name ): bool { - $metadata_rows = $this->column_metadata_rows( $table_name ); + private function table_has_column( string $table_name, string $column_name, bool $temporary = false ): bool { + $metadata_rows = $this->column_metadata_rows( $table_name, $temporary ); if ( count( $metadata_rows ) === 0 ) { $metadata_rows = $this->pragma_column_metadata_rows( $table_name ); } @@ -1975,8 +2033,8 @@ private function dml_rowid_subquery_sql( array $tokens, array $clauses, array $r * @param string $table_name Table name. * @param string $statement Statement name. */ - private function assert_dml_rowid_rewrite_supported( string $table_name, string $statement ): void { - $metadata_rows = $this->column_metadata_rows( $table_name ); + private function assert_dml_rowid_rewrite_supported( string $table_name, string $statement, bool $temporary = false ): void { + $metadata_rows = $this->column_metadata_rows( $table_name, $temporary ); if ( count( $metadata_rows ) === 0 ) { $metadata_rows = $this->pragma_column_metadata_rows( $table_name ); } @@ -2081,9 +2139,11 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen $this->expect_token( $tokens, $index, WP_MySQL_Lexer::TABLE_SYMBOL, 'Only ALTER TABLE is supported by the DuckDB driver.' ); ++$index; - $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'ALTER TABLE' ); - $table_name = $this->resolve_user_table_name( $reference['requested_table_name'] ) ?? $reference['requested_table_name']; - $index = $reference['next_index']; + $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'ALTER TABLE' ); + $table_reference = $this->resolve_required_lifecycle_table( $reference['requested_table_name'], 'ALTER TABLE' ); + $table_name = $table_reference['table_name']; + $temporary = $table_reference['temporary']; + $index = $reference['next_index']; $actions = $this->split_top_level_comma_items( array_slice( $tokens, $index ) ); if ( count( $actions ) === 0 ) { @@ -2097,7 +2157,7 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen } if ( WP_MySQL_Lexer::DROP_SYMBOL === $action[0]->id ) { - $result = $this->execute_alter_table_drop_index( $table_name, $action ); + $result = $this->execute_alter_table_drop_index( $table_name, $action, $temporary ); continue; } @@ -2105,8 +2165,8 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen $alter_item = array_slice( $action, 1 ); $result = $this->is_create_table_index_item( $alter_item ) - ? $this->execute_alter_table_add_index( $table_name, $alter_item ) - : $this->execute_alter_table_add_column( $table_name, $alter_item ); + ? $this->execute_alter_table_add_index( $table_name, $alter_item, $temporary ) + : $this->execute_alter_table_add_column( $table_name, $alter_item, $temporary ); } return $result ?? new WP_DuckDB_Result_Statement( array(), array(), 0 ); @@ -2119,7 +2179,7 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen * @param WP_Parser_Token[] $tokens Action tokens starting at DROP. * @return WP_DuckDB_Result_Statement */ - private function execute_alter_table_drop_index( string $table_name, array $tokens ): WP_DuckDB_Result_Statement { + private function execute_alter_table_drop_index( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { $index = 0; $this->expect_token( $tokens, $index, WP_MySQL_Lexer::DROP_SYMBOL, 'Expected DROP in ALTER TABLE action.' ); ++$index; @@ -2143,9 +2203,8 @@ private function execute_alter_table_drop_index( string $table_name, array $toke } return $this->execute_schema_lifecycle_change( - function () use ( $table_name, $index_name ): WP_DuckDB_Result_Statement { - $resolved_table_name = $this->resolve_required_lifecycle_table_name( $table_name, 'ALTER TABLE' ); - $this->drop_secondary_index( $resolved_table_name, $index_name ); + function () use ( $table_name, $index_name, $temporary ): WP_DuckDB_Result_Statement { + $this->drop_secondary_index( $table_name, $index_name, $temporary ); $this->invalidate_information_schema_compatibility_tables(); return $this->empty_ddl_result(); } @@ -2159,8 +2218,8 @@ function () use ( $table_name, $index_name ): WP_DuckDB_Result_Statement { * @param WP_Parser_Token[] $tokens Index definition tokens after ADD. * @return WP_DuckDB_Result_Statement */ - private function execute_alter_table_add_index( string $table_name, array $tokens ): WP_DuckDB_Result_Statement { - $index_definition = $this->translate_create_table_index( $table_name, $tokens ); + private function execute_alter_table_add_index( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $index_definition = $this->translate_create_table_index( $table_name, $tokens, $temporary ); $result = $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); $this->record_index_metadata( $index_definition ); @@ -2174,7 +2233,7 @@ private function execute_alter_table_add_index( string $table_name, array $token * @param WP_Parser_Token[] $tokens Tokens after ADD. * @return WP_DuckDB_Result_Statement */ - private function execute_alter_table_add_column( string $table_name, array $tokens ): WP_DuckDB_Result_Statement { + private function execute_alter_table_add_column( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { if ( isset( $tokens[0] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[0]->id ) { $tokens = array_slice( $tokens, 1 ); } @@ -2191,7 +2250,7 @@ private function execute_alter_table_add_column( string $table_name, array $toke $tokens = $items[0]; } - list( $column_sql, $sequence_sql, $indexes, $metadata ) = $this->translate_create_table_column( $table_name, $tokens, false, true ); + list( $column_sql, $sequence_sql, $indexes, $metadata ) = $this->translate_create_table_column( $table_name, $tokens, false, true, $temporary ); if ( 'PRI' === $metadata['column_key'] ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN PRIMARY KEY is not supported.' ); } @@ -2232,7 +2291,8 @@ function () use ( $table_name, $metadata ): void { . ' SET NOT NULL', 'Failed to apply DuckDB NOT NULL column constraint' ); - } + }, + $temporary ); } @@ -2240,7 +2300,7 @@ function () use ( $table_name, $metadata ): void { $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); $this->record_index_metadata( $index_definition ); } - $this->append_column_metadata( $table_name, $metadata ); + $this->append_column_metadata( $table_name, $metadata, $temporary ); return $result; } @@ -2332,8 +2392,8 @@ private function execute_show_create_table( array $tokens ): WP_DuckDB_Result_St } } - $resolved_table_name = $this->resolve_user_table_name( $table_name ); - if ( null === $resolved_table_name ) { + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + if ( null === $table_reference ) { return new WP_DuckDB_Result_Statement( array( 'Table', 'Create Table' ), array() @@ -2345,7 +2405,7 @@ private function execute_show_create_table( array $tokens ): WP_DuckDB_Result_St array( array( $table_name, - $this->mysql_create_table_statement( $resolved_table_name, $table_name ), + $this->mysql_create_table_statement( $table_reference['table_name'], $table_name, $table_reference['temporary'] ), ), ) ); @@ -2358,11 +2418,11 @@ private function execute_show_create_table( array $tokens ): WP_DuckDB_Result_St * @param string $requested_table_name Requested MySQL table name. * @return string MySQL CREATE TABLE statement. */ - private function mysql_create_table_statement( string $table_name, string $requested_table_name ): string { - $metadata_by_table = $this->table_metadata_by_table(); + private function mysql_create_table_statement( string $table_name, string $requested_table_name, bool $temporary = false ): string { + $metadata_by_table = $this->table_metadata_by_table( $temporary ); $table_metadata = $metadata_by_table[ $table_name ] ?? $this->fallback_table_metadata( $table_name ); - $table_info = $this->information_schema_table_row( $table_name, $table_metadata ); - $column_rows = $this->show_create_table_column_rows( $table_name ); + $table_info = $this->information_schema_table_row( $table_name, $table_metadata, $temporary ); + $column_rows = $this->show_create_table_column_rows( $table_name, $temporary ); $rows = array(); $has_auto_increment = false; @@ -2370,11 +2430,11 @@ private function mysql_create_table_statement( string $table_name, string $reque $rows[] = $this->format_show_create_table_column( $column, $has_auto_increment ); } - foreach ( $this->show_create_table_index_groups( $table_name ) as $index_group ) { + foreach ( $this->show_create_table_index_groups( $table_name, $temporary ) as $index_group ) { $rows[] = $this->format_show_create_table_index( $index_group ); } - $sql = 'CREATE TABLE ' . $this->quote_mysql_identifier( $requested_table_name ) . " (\n"; + $sql = 'CREATE ' . ( $temporary ? 'TEMPORARY ' : '' ) . 'TABLE ' . $this->quote_mysql_identifier( $requested_table_name ) . " (\n"; $sql .= implode( ",\n", $rows ); $sql .= "\n)"; $sql .= ' ENGINE=' . (string) $table_info['ENGINE']; @@ -2537,8 +2597,13 @@ private function execute_show_columns( array $tokens ): WP_DuckDB_Result_Stateme throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW COLUMNS statement in DuckDB driver. Only optional LIKE is supported.' ); } + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + if ( null === $table_reference ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB table does not exist: ' . $table_name . '.' ); + } + if ( ! $full ) { - $rows = $this->describe_column_rows( $table_name ); + $rows = $this->describe_column_rows( $table_reference['table_name'], $table_reference['temporary'] ); if ( null !== $like_pattern ) { $rows = $this->filter_column_rows_by_like( $rows, $like_pattern ); } @@ -2549,7 +2614,7 @@ private function execute_show_columns( array $tokens ): WP_DuckDB_Result_Stateme ); } - $full_rows = $this->full_describe_column_rows( $table_name ); + $full_rows = $this->full_describe_column_rows( $table_reference['table_name'], $table_reference['temporary'] ); if ( null !== $like_pattern ) { $full_rows = $this->filter_column_rows_by_like( $full_rows, $like_pattern ); } @@ -2604,9 +2669,9 @@ private function execute_show_index( array $tokens ): WP_DuckDB_Result_Statement throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW INDEX statement in DuckDB driver. Use SHOW INDEX FROM table.' ); } - $table_name = $this->identifier_value( $tokens[3] ); - $resolved_table_name = $this->resolve_user_table_name( $table_name ); - $rows = null === $resolved_table_name ? array() : $this->index_rows_for_table( $resolved_table_name ); + $table_name = $this->identifier_value( $tokens[3] ); + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + $rows = null === $table_reference ? array() : $this->index_rows_for_table( $table_reference['table_name'], $table_reference['temporary'] ); return new WP_DuckDB_Result_Statement( array( @@ -2636,10 +2701,10 @@ private function execute_show_index( array $tokens ): WP_DuckDB_Result_Statement * @param string $table_name Table name. * @return array> */ - private function index_rows_for_table( string $table_name ): array { + private function index_rows_for_table( string $table_name, bool $temporary = false ): array { return array_merge( $this->primary_key_index_rows( $table_name ), - $this->secondary_index_rows( $table_name ) + $this->secondary_index_rows( $table_name, $temporary ) ); } @@ -2649,8 +2714,8 @@ private function index_rows_for_table( string $table_name ): array { * @param string $table_name Table name. * @return array> */ - private function show_create_table_column_rows( string $table_name ): array { - $metadata_rows = $this->column_metadata_rows( $table_name ); + private function show_create_table_column_rows( string $table_name, bool $temporary = false ): array { + $metadata_rows = $this->column_metadata_rows( $table_name, $temporary ); if ( count( $metadata_rows ) === 0 ) { $metadata_rows = $this->pragma_column_metadata_rows( $table_name ); } @@ -2732,10 +2797,10 @@ private function format_show_create_table_default( array $column ): string { * @param string $table_name Table name. * @return array}> */ - private function show_create_table_index_groups( string $table_name ): array { + private function show_create_table_index_groups( string $table_name, bool $temporary = false ): array { $groups = array(); - foreach ( $this->index_rows_for_table( $table_name ) as $row ) { + foreach ( $this->index_rows_for_table( $table_name, $temporary ) as $row ) { $index_name = (string) $row[2]; if ( ! isset( $groups[ $index_name ] ) ) { $groups[ $index_name ] = array( @@ -2843,12 +2908,12 @@ private function primary_key_index_rows( string $table_name ): array { * @param string $table_name Table name. * @return array> */ - private function secondary_index_rows( string $table_name ): array { - $this->ensure_index_metadata_table(); + private function secondary_index_rows( string $table_name, bool $temporary = false ): array { + $this->ensure_index_metadata_table( $temporary ); $stmt = $this->execute_duckdb_query( 'SELECT index_name, non_unique, seq_in_index, column_name, sub_part FROM ' - . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) . ' ORDER BY index_name, seq_in_index', @@ -2914,18 +2979,33 @@ private function execute_describe( array $tokens ): WP_DuckDB_Result_Statement { return new WP_DuckDB_Result_Statement( array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), - $this->describe_column_rows( $this->identifier_value( $tokens[1] ) ) + $this->describe_column_rows_for_request( $this->identifier_value( $tokens[1] ) ) ); } + /** + * Build DESCRIBE rows for a requested table name, resolving temporary tables first. + * + * @param string $table_name Requested table name. + * @return array> + */ + private function describe_column_rows_for_request( string $table_name ): array { + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + if ( null === $table_reference ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB table does not exist: ' . $table_name . '.' ); + } + + return $this->describe_column_rows( $table_reference['table_name'], $table_reference['temporary'] ); + } + /** * Build MySQL DESCRIBE/SHOW COLUMNS rows from DuckDB table metadata. * * @param string $table_name Table name. * @return array> */ - private function describe_column_rows( string $table_name ): array { - $metadata_rows = $this->column_metadata_rows( $table_name ); + private function describe_column_rows( string $table_name, bool $temporary = false ): array { + $metadata_rows = $this->column_metadata_rows( $table_name, $temporary ); if ( count( $metadata_rows ) > 0 ) { return array_map( function ( array $row ): array { @@ -2977,8 +3057,8 @@ function ( array $row ): array { * @param string $table_name Table name. * @return array> */ - private function full_describe_column_rows( string $table_name ): array { - $metadata_rows = $this->column_metadata_rows( $table_name ); + private function full_describe_column_rows( string $table_name, bool $temporary = false ): array { + $metadata_rows = $this->column_metadata_rows( $table_name, $temporary ); if ( count( $metadata_rows ) > 0 ) { return array_map( function ( array $row ): array { @@ -2999,7 +3079,7 @@ function ( array $row ): array { } $full_rows = array(); - foreach ( $this->describe_column_rows( $table_name ) as $row ) { + foreach ( $this->describe_column_rows( $table_name, $temporary ) as $row ) { $full_rows[] = array( $row[0], $row[1], @@ -3173,7 +3253,7 @@ private function split_top_level_comma_items( array $tokens ): array { * @param bool $allow_position_options Whether to accept FIRST/AFTER position hints. * @return array{0:string,1:string|null,2:array}>,3:array} */ - private function translate_create_table_column( string $table_name, array $tokens, bool $include_inline_constraints = true, bool $allow_position_options = false ): array { + private function translate_create_table_column( string $table_name, array $tokens, bool $include_inline_constraints = true, bool $allow_position_options = false, bool $temporary = false ): array { $index = 0; $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; @@ -3280,8 +3360,8 @@ private function translate_create_table_column( string $table_name, array $token $sequence = null; if ( $auto_increment ) { - $sequence_name = $this->sequence_name( $table_name, $column_name ); - $sequence = 'CREATE SEQUENCE IF NOT EXISTS ' . $this->connection->quote_identifier( $sequence_name ) . ' START 1'; + $sequence_name = $this->sequence_name( $table_name, $column_name, $temporary ); + $sequence = 'CREATE ' . ( $temporary ? 'TEMP ' : '' ) . 'SEQUENCE IF NOT EXISTS ' . $this->connection->quote_identifier( $sequence_name ) . ' START 1'; $column_sql .= ' DEFAULT nextval(' . $this->connection->quote( $sequence_name ) . ')'; } elseif ( null !== $default_sql ) { $column_sql .= ' DEFAULT ' . $default_sql; @@ -3306,7 +3386,8 @@ private function translate_create_table_column( string $table_name, array $token 'name' => $column_name, 'sub_part' => null, ), - ) + ), + $temporary ); } @@ -3610,7 +3691,7 @@ private function is_create_index_statement( array $tokens ): bool { * @param WP_Parser_Token[] $tokens Index definition tokens. * @return array{sql:string,table_name:string,index_name:string,unique:bool,columns:array} */ - private function translate_create_table_index( string $table_name, array $tokens ): array { + private function translate_create_table_index( string $table_name, array $tokens, bool $temporary = false ): array { $index = 0; $unique = false; @@ -3650,7 +3731,7 @@ private function translate_create_table_index( string $table_name, array $tokens $mysql_index_name = 'unnamed_' . substr( hash( 'sha256', serialize( $column_metadata ) ), 0, 8 ); } - return $this->build_secondary_index_definition( $table_name, $mysql_index_name, $unique, $columns, $column_metadata ); + return $this->build_secondary_index_definition( $table_name, $mysql_index_name, $unique, $columns, $column_metadata, $temporary ); } /** @@ -3661,14 +3742,14 @@ private function translate_create_table_index( string $table_name, array $tokens * @param bool $unique Whether the index is unique. * @param string[] $columns DuckDB column SQL fragments. * @param array $column_metadata MySQL column metadata. - * @return array{sql:string,table_name:string,index_name:string,unique:bool,columns:array} + * @return array{sql:string,table_name:string,index_name:string,unique:bool,temporary:bool,columns:array} */ - private function build_secondary_index_definition( string $table_name, string $mysql_index_name, bool $unique, array $columns, array $column_metadata ): array { + private function build_secondary_index_definition( string $table_name, string $mysql_index_name, bool $unique, array $columns, array $column_metadata, bool $temporary = false ): array { return array( 'sql' => 'CREATE ' . ( $unique ? 'UNIQUE ' : '' ) . 'INDEX IF NOT EXISTS ' - . $this->connection->quote_identifier( $this->index_name( $table_name, $mysql_index_name ) ) + . $this->connection->quote_identifier( $this->index_name( $table_name, $mysql_index_name, $temporary ) ) . ' ON ' . $this->connection->quote_identifier( $table_name ) . ' (' @@ -3677,6 +3758,7 @@ private function build_secondary_index_definition( string $table_name, string $m 'table_name' => $table_name, 'index_name' => $mysql_index_name, 'unique' => $unique, + 'temporary' => $temporary, 'columns' => $column_metadata, ); } @@ -4777,7 +4859,8 @@ private function should_omit_space_before_open_parenthesis( ?string $previous ): * @return WP_DuckDB_Result_Statement */ private function execute_auto_increment_write( string $table_name, string $sql, string $context, array $tokens = array(), ?int $table_index = null ): WP_DuckDB_Result_Statement { - $metadata = $this->auto_increment_metadata_for_table( $table_name ); + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + $metadata = null === $table_reference ? null : $this->auto_increment_metadata_for_table( $table_reference['table_name'], $table_reference['temporary'] ); $sequence_name = null === $metadata ? null : $metadata['sequence_name']; $explicit_insert_id = null === $metadata || null === $table_index ? null @@ -4803,11 +4886,11 @@ private function execute_auto_increment_write( string $table_name, string $sql, * @param string $table_name Table name. * @return array{column_name:string,sequence_name:string}|null Metadata, or null when no AUTO_INCREMENT column is known. */ - private function auto_increment_metadata_for_table( string $table_name ): ?array { + private function auto_increment_metadata_for_table( string $table_name, bool $temporary = false ): ?array { try { $stmt = $this->connection->query( 'SELECT column_name FROM ' - . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) . " AND extra = 'auto_increment' ORDER BY ordinal_position LIMIT 1" @@ -4824,7 +4907,7 @@ private function auto_increment_metadata_for_table( string $table_name ): ?array $column_name = (string) $column_name; return array( 'column_name' => $column_name, - 'sequence_name' => $this->sequence_name( $table_name, $column_name ), + 'sequence_name' => $this->sequence_name( $table_name, $column_name, $temporary ), ); } @@ -4834,12 +4917,12 @@ private function auto_increment_metadata_for_table( string $table_name ): ?array * @param string $table_name Table name. * @return string[] Sequence names. */ - private function auto_increment_sequences_for_table( string $table_name ): array { - $this->ensure_column_metadata_table(); + private function auto_increment_sequences_for_table( string $table_name, bool $temporary = false ): array { + $this->ensure_column_metadata_table( $temporary ); $stmt = $this->execute_duckdb_query( 'SELECT column_name FROM ' - . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) . " AND extra = 'auto_increment' ORDER BY ordinal_position", @@ -4848,7 +4931,7 @@ private function auto_increment_sequences_for_table( string $table_name ): array $sequence_names = array(); foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN ) as $column_name ) { - $sequence_names[] = $this->sequence_name( $table_name, (string) $column_name ); + $sequence_names[] = $this->sequence_name( $table_name, (string) $column_name, $temporary ); } return $sequence_names; @@ -5056,10 +5139,12 @@ private function execute_duckdb_query( string $sql, string $context ): WP_DuckDB /** * Ensure the internal index metadata table exists. */ - private function ensure_index_metadata_table(): void { + private function ensure_index_metadata_table( bool $temporary = false ): void { $this->execute_duckdb_query( - 'CREATE TABLE IF NOT EXISTS ' - . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + 'CREATE ' + . ( $temporary ? 'TEMP ' : '' ) + . 'TABLE IF NOT EXISTS ' + . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) . ' (table_name VARCHAR, index_name VARCHAR, non_unique INTEGER, seq_in_index INTEGER, column_name VARCHAR, sub_part INTEGER)', 'Failed to initialize DuckDB index metadata' ); @@ -5068,10 +5153,12 @@ private function ensure_index_metadata_table(): void { /** * Ensure the internal column metadata table exists. */ - private function ensure_column_metadata_table(): void { + private function ensure_column_metadata_table( bool $temporary = false ): void { $this->execute_duckdb_query( - 'CREATE TABLE IF NOT EXISTS ' - . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + 'CREATE ' + . ( $temporary ? 'TEMP ' : '' ) + . 'TABLE IF NOT EXISTS ' + . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' (table_name VARCHAR, ordinal_position INTEGER, column_name VARCHAR, column_type VARCHAR, is_nullable VARCHAR, column_key VARCHAR, column_default VARCHAR, extra VARCHAR, collation_name VARCHAR, comment VARCHAR)', 'Failed to initialize DuckDB column metadata' ); @@ -5080,29 +5167,62 @@ private function ensure_column_metadata_table(): void { /** * Ensure the internal table metadata table exists. */ - private function ensure_table_metadata_table(): void { + private function ensure_table_metadata_table( bool $temporary = false ): void { $this->execute_duckdb_query( - 'CREATE TABLE IF NOT EXISTS ' - . $this->connection->quote_identifier( self::TABLE_METADATA_TABLE ) + 'CREATE ' + . ( $temporary ? 'TEMP ' : '' ) + . 'TABLE IF NOT EXISTS ' + . $this->connection->quote_identifier( $this->table_metadata_table_name( $temporary ) ) . ' (table_name VARCHAR, engine VARCHAR, row_format VARCHAR, table_collation VARCHAR, table_comment VARCHAR, create_options VARCHAR, create_time VARCHAR)', 'Failed to initialize DuckDB table metadata' ); } + /** + * Return the metadata table that stores secondary index rows. + * + * @param bool $temporary Whether to use session-local temporary metadata. + * @return string Metadata table name. + */ + private function index_metadata_table_name( bool $temporary ): string { + return $temporary ? self::TEMP_INDEX_METADATA_TABLE : self::INDEX_METADATA_TABLE; + } + + /** + * Return the metadata table that stores column rows. + * + * @param bool $temporary Whether to use session-local temporary metadata. + * @return string Metadata table name. + */ + private function column_metadata_table_name( bool $temporary ): string { + return $temporary ? self::TEMP_COLUMN_METADATA_TABLE : self::COLUMN_METADATA_TABLE; + } + + /** + * Return the metadata table that stores table rows. + * + * @param bool $temporary Whether to use session-local temporary metadata. + * @return string Metadata table name. + */ + private function table_metadata_table_name( bool $temporary ): string { + return $temporary ? self::TEMP_TABLE_METADATA_TABLE : self::TABLE_METADATA_TABLE; + } + /** * Record MySQL index metadata for SHOW INDEX. * - * @param array{table_name:string,index_name:string,unique:bool,columns:array} $index_definition Index definition. + * @param array{table_name:string,index_name:string,unique:bool,temporary?:bool,columns:array} $index_definition Index definition. */ private function record_index_metadata( array $index_definition ): void { - $this->ensure_index_metadata_table(); + $temporary = isset( $index_definition['temporary'] ) && (bool) $index_definition['temporary']; + $this->ensure_index_metadata_table( $temporary ); $table_name = $index_definition['table_name']; $index_name = $index_definition['index_name']; $this->execute_duckdb_query( 'DELETE FROM ' - . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) . ' AND index_name = ' @@ -5113,7 +5233,7 @@ private function record_index_metadata( array $index_definition ): void { foreach ( $index_definition['columns'] as $offset => $column ) { $this->execute_duckdb_query( 'INSERT INTO ' - . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) . ' (table_name, index_name, non_unique, seq_in_index, column_name, sub_part) VALUES (' . $this->connection->quote( $table_name ) . ', ' @@ -5138,11 +5258,11 @@ private function record_index_metadata( array $index_definition ): void { * @param string $table_name Table name. * @param callable $callback Schema change callback. */ - private function execute_with_secondary_indexes_rebuilt( string $table_name, callable $callback ): void { - $index_definitions = $this->secondary_index_definitions_for_table( $table_name ); + private function execute_with_secondary_indexes_rebuilt( string $table_name, callable $callback, bool $temporary = false ): void { + $index_definitions = $this->secondary_index_definitions_for_table( $table_name, $temporary ); foreach ( $index_definitions as $index_definition ) { $this->execute_duckdb_query( - 'DROP INDEX IF EXISTS ' . $this->connection->quote_identifier( $this->index_name( $table_name, $index_definition['index_name'] ) ), + 'DROP INDEX IF EXISTS ' . $this->connection->quote_identifier( $this->index_name( $table_name, $index_definition['index_name'], $temporary ) ), 'Failed to drop DuckDB index before schema change' ); } @@ -5170,12 +5290,12 @@ private function execute_with_secondary_indexes_rebuilt( string $table_name, cal * @param string $table_name Table name. * @return array}> */ - private function secondary_index_definitions_for_table( string $table_name ): array { - $this->ensure_index_metadata_table(); + private function secondary_index_definitions_for_table( string $table_name, bool $temporary = false ): array { + $this->ensure_index_metadata_table( $temporary ); $stmt = $this->execute_duckdb_query( 'SELECT index_name, non_unique, seq_in_index, column_name, sub_part FROM ' - . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) . ' ORDER BY index_name, seq_in_index', @@ -5209,7 +5329,8 @@ function ( array $column ): string { }, $definition['columns'] ), - $definition['columns'] + $definition['columns'], + $temporary ); } @@ -5222,12 +5343,12 @@ function ( array $column ): string { * @param string $table_name Table name. * @param array> $metadata Column metadata. */ - private function record_column_metadata( string $table_name, array $metadata ): void { - $this->ensure_column_metadata_table(); + private function record_column_metadata( string $table_name, array $metadata, bool $temporary = false ): void { + $this->ensure_column_metadata_table( $temporary ); $this->execute_duckdb_query( 'DELETE FROM ' - . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ), 'Failed to reset DuckDB column metadata' @@ -5236,7 +5357,7 @@ private function record_column_metadata( string $table_name, array $metadata ): foreach ( $metadata as $offset => $column ) { $this->execute_duckdb_query( 'INSERT INTO ' - . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' (table_name, ordinal_position, column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment) VALUES (' . $this->connection->quote( $table_name ) . ', ' @@ -5269,12 +5390,12 @@ private function record_column_metadata( string $table_name, array $metadata ): * @param string $table_name Table name. * @param array $metadata Table metadata. */ - private function record_table_metadata( string $table_name, array $metadata ): void { - $this->ensure_table_metadata_table(); + private function record_table_metadata( string $table_name, array $metadata, bool $temporary = false ): void { + $this->ensure_table_metadata_table( $temporary ); $this->execute_duckdb_query( 'DELETE FROM ' - . $this->connection->quote_identifier( self::TABLE_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->table_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ), 'Failed to reset DuckDB table metadata' @@ -5282,7 +5403,7 @@ private function record_table_metadata( string $table_name, array $metadata ): v $this->execute_duckdb_query( 'INSERT INTO ' - . $this->connection->quote_identifier( self::TABLE_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->table_metadata_table_name( $temporary ) ) . ' (table_name, engine, row_format, table_collation, table_comment, create_options, create_time) VALUES (' . $this->connection->quote( $table_name ) . ', ' @@ -5308,12 +5429,12 @@ private function record_table_metadata( string $table_name, array $metadata ): v * @param string $table_name Table name. * @param array $column Column metadata. */ - private function append_column_metadata( string $table_name, array $column ): void { - $this->ensure_column_metadata_table(); + private function append_column_metadata( string $table_name, array $column, bool $temporary = false ): void { + $this->ensure_column_metadata_table( $temporary ); $stmt = $this->execute_duckdb_query( 'SELECT COUNT(*) AS column_count, COALESCE(MAX(ordinal_position), 0) AS max_ordinal FROM ' - . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ), 'Failed to inspect DuckDB column metadata' @@ -5325,7 +5446,7 @@ private function append_column_metadata( string $table_name, array $column ): vo $this->execute_duckdb_query( 'INSERT INTO ' - . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' (table_name, ordinal_position, column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment) VALUES (' . $this->connection->quote( $table_name ) . ', ' @@ -5357,19 +5478,19 @@ private function append_column_metadata( string $table_name, array $column ): vo * @param string $table_name Table name. * @param string $mysql_index_name MySQL-facing index name. */ - private function drop_secondary_index( string $table_name, string $mysql_index_name ): void { + private function drop_secondary_index( string $table_name, string $mysql_index_name, bool $temporary = false ): void { if ( 0 === strcasecmp( $mysql_index_name, 'PRIMARY' ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP INDEX statement in DuckDB driver. Dropping PRIMARY requires a table rebuild.' ); } - $resolved_index_name = $this->resolve_secondary_index_name( $table_name, $mysql_index_name ); + $resolved_index_name = $this->resolve_secondary_index_name( $table_name, $mysql_index_name, $temporary ); if ( null === $resolved_index_name ) { throw new WP_DuckDB_Driver_Exception( "Unknown index '{$mysql_index_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); } - $this->drop_physical_secondary_index( $table_name, $resolved_index_name, false ); - $this->delete_index_metadata( $table_name, $resolved_index_name ); - $this->refresh_column_key_metadata( $table_name ); + $this->drop_physical_secondary_index( $table_name, $resolved_index_name, false, $temporary ); + $this->delete_index_metadata( $table_name, $resolved_index_name, $temporary ); + $this->refresh_column_key_metadata( $table_name, $temporary ); } /** @@ -5379,8 +5500,8 @@ private function drop_secondary_index( string $table_name, string $mysql_index_n * @param string $mysql_index_name Requested index name. * @return string|null Resolved index name. */ - private function resolve_secondary_index_name( string $table_name, string $mysql_index_name ): ?string { - foreach ( $this->secondary_index_definitions_for_table( $table_name ) as $index_definition ) { + private function resolve_secondary_index_name( string $table_name, string $mysql_index_name, bool $temporary = false ): ?string { + foreach ( $this->secondary_index_definitions_for_table( $table_name, $temporary ) as $index_definition ) { if ( 0 === strcasecmp( $index_definition['index_name'], $mysql_index_name ) ) { return $index_definition['index_name']; } @@ -5396,11 +5517,11 @@ private function resolve_secondary_index_name( string $table_name, string $mysql * @param string $mysql_index_name MySQL-facing index name. * @param bool $if_exists Whether to use IF EXISTS. */ - private function drop_physical_secondary_index( string $table_name, string $mysql_index_name, bool $if_exists ): void { + private function drop_physical_secondary_index( string $table_name, string $mysql_index_name, bool $if_exists, bool $temporary = false ): void { $this->execute_duckdb_query( 'DROP INDEX ' . ( $if_exists ? 'IF EXISTS ' : '' ) - . $this->connection->quote_identifier( $this->index_name( $table_name, $mysql_index_name ) ), + . $this->connection->quote_identifier( $this->index_name( $table_name, $mysql_index_name, $temporary ) ), 'Failed to drop DuckDB index' ); } @@ -5411,12 +5532,12 @@ private function drop_physical_secondary_index( string $table_name, string $mysq * @param string $table_name Table name. * @param string $index_name Index name. */ - private function delete_index_metadata( string $table_name, string $index_name ): void { - $this->ensure_index_metadata_table(); + private function delete_index_metadata( string $table_name, string $index_name, bool $temporary = false ): void { + $this->ensure_index_metadata_table( $temporary ); $this->execute_duckdb_query( 'DELETE FROM ' - . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) . ' AND index_name = ' @@ -5430,16 +5551,16 @@ private function delete_index_metadata( string $table_name, string $index_name ) * * @param string $table_name Table name. */ - private function delete_table_lifecycle_metadata( string $table_name ): void { - $this->ensure_index_metadata_table(); - $this->ensure_column_metadata_table(); - $this->ensure_table_metadata_table(); + private function delete_table_lifecycle_metadata( string $table_name, bool $temporary = false ): void { + $this->ensure_index_metadata_table( $temporary ); + $this->ensure_column_metadata_table( $temporary ); + $this->ensure_table_metadata_table( $temporary ); foreach ( array( - self::INDEX_METADATA_TABLE => 'index', - self::COLUMN_METADATA_TABLE => 'column', - self::TABLE_METADATA_TABLE => 'table', + $this->index_metadata_table_name( $temporary ) => 'index', + $this->column_metadata_table_name( $temporary ) => 'column', + $this->table_metadata_table_name( $temporary ) => 'table', ) as $metadata_table => $label ) { $this->execute_duckdb_query( @@ -5457,8 +5578,8 @@ private function delete_table_lifecycle_metadata( string $table_name ): void { * * @param string $table_name Table name. */ - private function refresh_column_key_metadata( string $table_name ): void { - $metadata = $this->column_metadata_rows( $table_name ); + private function refresh_column_key_metadata( string $table_name, bool $temporary = false ): void { + $metadata = $this->column_metadata_rows( $table_name, $temporary ); if ( count( $metadata ) === 0 ) { return; } @@ -5477,7 +5598,8 @@ function ( array $row ): string { $this->record_column_metadata( $table_name, - $this->apply_column_key_metadata( $metadata, $primary_key, $this->secondary_index_definitions_for_table( $table_name ) ) + $this->apply_column_key_metadata( $metadata, $primary_key, $this->secondary_index_definitions_for_table( $table_name, $temporary ) ), + $temporary ); } @@ -5488,9 +5610,9 @@ function ( array $row ): string { * * @param string $table_name Table name. */ - private function rebuild_empty_auto_increment_table( string $table_name ): void { - $create_sql = $this->mysql_create_table_statement( $table_name, $table_name ); - $sequence_names = $this->auto_increment_sequences_for_table( $table_name ); + private function rebuild_empty_auto_increment_table( string $table_name, bool $temporary = false ): void { + $create_sql = $this->mysql_create_table_statement( $table_name, $table_name, $temporary ); + $sequence_names = $this->auto_increment_sequences_for_table( $table_name, $temporary ); $this->execute_duckdb_query( 'DROP TABLE ' . $this->connection->quote_identifier( $table_name ), @@ -5526,12 +5648,12 @@ private function invalidate_information_schema_compatibility_tables(): void { * @param string $table_name Table name. * @return array> */ - private function column_metadata_rows( string $table_name ): array { - $this->ensure_column_metadata_table(); + private function column_metadata_rows( string $table_name, bool $temporary = false ): array { + $this->ensure_column_metadata_table( $temporary ); $stmt = $this->execute_duckdb_query( 'SELECT ordinal_position, column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment FROM ' - . $this->connection->quote_identifier( self::COLUMN_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) . ' ORDER BY ordinal_position', @@ -5801,7 +5923,7 @@ private function information_schema_table_rows(): array { * @param array $metadata Table metadata. * @return array */ - private function information_schema_table_row( string $table_name, array $metadata ): array { + private function information_schema_table_row( string $table_name, array $metadata, bool $temporary = false ): array { return array( 'TABLE_CATALOG' => 'def', 'TABLE_SCHEMA' => $this->database, @@ -5816,7 +5938,7 @@ private function information_schema_table_row( string $table_name, array $metada 'MAX_DATA_LENGTH' => 0, 'INDEX_LENGTH' => 0, 'DATA_FREE' => 0, - 'AUTO_INCREMENT' => $this->table_auto_increment_value( $table_name ), + 'AUTO_INCREMENT' => $this->table_auto_increment_value( $table_name, $temporary ), 'CREATE_TIME' => $metadata['create_time'], 'UPDATE_TIME' => null, 'CHECK_TIME' => null, @@ -5832,12 +5954,12 @@ private function information_schema_table_row( string $table_name, array $metada * * @return array> Metadata keyed by table name. */ - private function table_metadata_by_table(): array { - $this->ensure_table_metadata_table(); + private function table_metadata_by_table( bool $temporary = false ): array { + $this->ensure_table_metadata_table( $temporary ); $stmt = $this->execute_duckdb_query( 'SELECT table_name, engine, row_format, table_collation, table_comment, create_options, create_time FROM ' - . $this->connection->quote_identifier( self::TABLE_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->table_metadata_table_name( $temporary ) ) . ' ORDER BY table_name', 'Failed to inspect DuckDB table metadata' ); @@ -5874,8 +5996,8 @@ private function fallback_table_metadata( string $table_name ): array { * @param string $table_name Table name. * @return int|null Next generated value, or null when there is no auto-increment column. */ - private function table_auto_increment_value( string $table_name ): ?int { - $metadata = $this->auto_increment_metadata_for_table( $table_name ); + private function table_auto_increment_value( string $table_name, bool $temporary = false ): ?int { + $metadata = $this->auto_increment_metadata_for_table( $table_name, $temporary ); if ( null === $metadata ) { return null; } @@ -6532,6 +6654,74 @@ private function user_table_names(): array { ); } + /** + * List non-internal DuckDB temporary tables visible in this session. + * + * @return string[] Table names. + */ + private function temporary_user_table_names(): array { + $stmt = $this->execute_duckdb_query( + "SELECT table_name FROM information_schema.tables WHERE table_type = 'LOCAL TEMPORARY'" + . ' AND table_name NOT LIKE ' + . $this->connection->quote( '\_\_wp\_duckdb\_%' ) + . " ESCAPE '\\'" + . ' ORDER BY table_name', + 'Failed to inspect DuckDB temporary tables' + ); + + return array_map( + 'strval', + $stmt->fetchAll( PDO::FETCH_COLUMN ) + ); + } + + /** + * Resolve a requested table name to the visible DuckDB table, preferring temporary tables. + * + * @param string $table_name Requested table name. + * @return array{table_name:string,temporary:bool}|null Resolved table reference, or null when no table matches. + */ + private function resolve_visible_user_table_reference( string $table_name ): ?array { + foreach ( $this->temporary_user_table_names() as $candidate ) { + if ( 0 === strcasecmp( $candidate, $table_name ) ) { + return array( + 'table_name' => $candidate, + 'temporary' => true, + ); + } + } + + foreach ( $this->user_table_names() as $candidate ) { + if ( 0 === strcasecmp( $candidate, $table_name ) ) { + return array( + 'table_name' => $candidate, + 'temporary' => false, + ); + } + } + + return null; + } + + /** + * Resolve a requested table name to a temporary table in the current session. + * + * @param string $table_name Requested table name. + * @return array{table_name:string,temporary:bool}|null Resolved table reference, or null when no temp table matches. + */ + private function resolve_temporary_user_table_reference( string $table_name ): ?array { + foreach ( $this->temporary_user_table_names() as $candidate ) { + if ( 0 === strcasecmp( $candidate, $table_name ) ) { + return array( + 'table_name' => $candidate, + 'temporary' => true, + ); + } + } + + return null; + } + /** * Resolve a requested MySQL table name to a visible DuckDB table name. * @@ -6952,8 +7142,8 @@ private function is_integer_duckdb_type( string $duck_type ): bool { * @param string $column_name Column name. * @return string Sequence name. */ - private function sequence_name( string $table_name, string $column_name ): string { - return self::SEQUENCE_PREFIX . substr( hash( 'sha256', $table_name . "\0" . $column_name ), 0, 16 ); + private function sequence_name( string $table_name, string $column_name, bool $temporary = false ): string { + return self::SEQUENCE_PREFIX . substr( hash( 'sha256', $this->table_namespace_key( $table_name, $temporary ) . "\0" . $column_name ), 0, 16 ); } /** @@ -6963,8 +7153,19 @@ private function sequence_name( string $table_name, string $column_name ): strin * @param string $mysql_index_name MySQL index name. * @return string DuckDB index name. */ - private function index_name( string $table_name, string $mysql_index_name ): string { - return self::INDEX_PREFIX . substr( hash( 'sha256', $table_name ), 0, 8 ) . '_' . $mysql_index_name; + private function index_name( string $table_name, string $mysql_index_name, bool $temporary = false ): string { + return self::INDEX_PREFIX . substr( hash( 'sha256', $this->table_namespace_key( $table_name, $temporary ) ), 0, 8 ) . '_' . $mysql_index_name; + } + + /** + * Build a stable namespace key for physical helper objects. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the table is temporary. + * @return string Namespace key. + */ + private function table_namespace_key( string $table_name, bool $temporary ): string { + return ( $temporary ? 'temporary' : 'persistent' ) . "\0" . $table_name; } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index e3056bc16..cbdb5d1c2 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -991,7 +991,14 @@ public function test_create_index_statement_updates_show_index_metadata(): void $result = $driver->query( 'CREATE INDEX post_name ON wp_posts (post_name(191))' ); $this->assertSame( 0, $result->rowCount() ); - $this->assertStringStartsWith( 'CREATE INDEX IF NOT EXISTS "wp_duckdb_idx_', $driver->get_last_duckdb_queries()[0] ); + $this->assertNotEmpty( + array_filter( + $driver->get_last_duckdb_queries(), + function ( string $sql ): bool { + return 0 === strpos( $sql, 'CREATE INDEX IF NOT EXISTS "wp_duckdb_idx_' ); + } + ) + ); $indexes = $driver->query( 'SHOW INDEX FROM wp_posts' )->fetchAll( PDO::FETCH_ASSOC ); $this->assertSame( array( 'PRIMARY', 'post_name' ), array_column( $indexes, 'Key_name' ) ); @@ -1751,6 +1758,167 @@ public function test_show_create_table_reconstructs_mysql_shaped_ddl(): void { $this->assertSame( array(), $other_database_rows ); } + public function test_temporary_table_lifecycle_uses_session_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $create = $driver->query( + "CREATE TEMPORARY TABLE temp_items ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL DEFAULT '', + KEY name_key (name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + $this->assertSame( 0, $create->rowCount() ); + + $driver->query( "INSERT INTO temp_items (name) VALUES ('first'), ('second')" ); + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'first', + ), + array( + 'id' => 2, + 'name' => 'second', + ), + ), + $driver->query( 'SELECT id, name FROM temp_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( array(), $driver->query( "SHOW TABLE STATUS LIKE 'temp_items'" )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( + array( 'PRIMARY', 'name_key' ), + array_column( $driver->query( 'SHOW INDEX FROM temp_items' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) + ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE temp_items' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertStringStartsWith( 'CREATE TEMPORARY TABLE `temp_items`', $create_rows[0]['Create Table'] ); + $this->assertStringContainsString( 'AUTO_INCREMENT=3', $create_rows[0]['Create Table'] ); + + $drop = $driver->query( 'DROP TEMPORARY TABLE temp_items' ); + $this->assertSame( 0, $drop->rowCount() ); + $this->assertSame( array(), $driver->query( 'SHOW CREATE TABLE temp_items' )->fetchAll( PDO::FETCH_ASSOC ) ); + } + + public function test_temporary_table_takes_precedence_over_persistent_table(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE t (a INT, INDEX ia(a))' ); + $driver->query( 'INSERT INTO t VALUES (1)' ); + $driver->query( 'CREATE TEMPORARY TABLE t (b INT, INDEX ib(b))' ); + $driver->query( 'INSERT INTO t VALUES (2)' ); + + $this->assertSame( + array( array( 'b' => 2 ) ), + $driver->query( 'SELECT * FROM t' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( 'b' ), + array_column( $driver->query( 'SHOW COLUMNS FROM t' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( 'b' ), + array_column( $driver->query( 'DESCRIBE t' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( 'ib' ), + array_column( $driver->query( 'SHOW INDEX FROM t' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) + ); + $this->assertStringStartsWith( + 'CREATE TEMPORARY TABLE `t`', + $driver->query( 'SHOW CREATE TABLE t' )->fetch( PDO::FETCH_ASSOC )['Create Table'] + ); + + $driver->query( 'ALTER TABLE t ADD COLUMN c INT' ); + $this->assertSame( + array( 'b', 'c' ), + array_column( $driver->query( 'SHOW COLUMNS FROM t' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( array( 'COLUMN_NAME' => 'a' ) ), + $driver->query( + "SELECT COLUMN_NAME + FROM information_schema.columns + WHERE table_name = 't' + ORDER BY ORDINAL_POSITION" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( 't' ), + array_column( $driver->query( "SHOW TABLE STATUS LIKE 't'" )->fetchAll( PDO::FETCH_ASSOC ), 'Name' ) + ); + + $driver->query( 'DROP TABLE t' ); + $this->assertSame( + array( 'a' ), + array_column( $driver->query( 'SHOW COLUMNS FROM t' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( array( 'a' => 1 ) ), + $driver->query( 'SELECT * FROM t' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_temporary_tables_are_connection_scoped(): void { + $this->requireDuckDBRuntime(); + + $path = tempnam( sys_get_temp_dir(), 'wp-duckdb-temp-scope-' ); + if ( false === $path ) { + $this->fail( 'Failed to allocate a temporary DuckDB path.' ); + } + unlink( $path ); + + try { + $first = new WP_DuckDB_Driver( + array( + 'path' => $path, + 'database' => 'wp', + ) + ); + $first->query( 'CREATE TABLE persistent_items (id INT)' ); + $first->query( 'CREATE TEMPORARY TABLE session_items (id INT)' ); + + $second = new WP_DuckDB_Driver( + array( + 'path' => $path, + 'database' => 'wp', + ) + ); + $this->assertSame( + array( + array( + 'Field' => 'id', + 'Type' => 'int', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + ), + $second->query( 'SHOW COLUMNS FROM persistent_items' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $this->expectExceptionMessage( 'DuckDB table does not exist: session_items.' ); + $second->query( 'SHOW COLUMNS FROM session_items' ); + } finally { + @unlink( $path ); + } + } + public function test_truncate_table_preserves_schema_and_resets_auto_increment(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index 55363940a..d549782e8 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -116,6 +116,7 @@ public function test_duckdb_wpdb_query_updates_raw_query_state(): void { $result['client_queries'], function ( string $sql ): bool { return false === strpos( $sql, '__wp_duckdb_' ) + && false === strpos( $sql, 'information_schema.tables' ) && false === strpos( $sql, 'currval(' ); } ) From 5bbf4964f60ee0c639766d54da8e675a0bc26122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 20:09:53 +0000 Subject: [PATCH 024/317] Add DuckDB ALTER TABLE DROP COLUMN parity --- .../src/duckdb/class-wp-duckdb-driver.php | 321 +++++++++++++++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 257 ++++++++++++++ 2 files changed, 568 insertions(+), 10 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index c65f962c1..d2a3181f2 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -2147,7 +2147,7 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen $actions = $this->split_top_level_comma_items( array_slice( $tokens, $index ) ); if ( count( $actions ) === 0 ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD COLUMN, ADD INDEX, and DROP INDEX are supported.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD COLUMN, ADD INDEX, DROP COLUMN, and DROP INDEX are supported.' ); } $result = null; @@ -2157,11 +2157,13 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen } if ( WP_MySQL_Lexer::DROP_SYMBOL === $action[0]->id ) { - $result = $this->execute_alter_table_drop_index( $table_name, $action, $temporary ); + $result = $this->is_alter_table_drop_index_action( $action ) + ? $this->execute_alter_table_drop_index( $table_name, $action, $temporary ) + : $this->execute_alter_table_drop_column( $table_name, $action, $temporary ); continue; } - $this->expect_token( $action, 0, WP_MySQL_Lexer::ADD_SYMBOL, 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD and DROP INDEX actions are supported.' ); + $this->expect_token( $action, 0, WP_MySQL_Lexer::ADD_SYMBOL, 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD and DROP actions are supported.' ); $alter_item = array_slice( $action, 1 ); $result = $this->is_create_table_index_item( $alter_item ) @@ -2211,6 +2213,280 @@ function () use ( $table_name, $index_name, $temporary ): WP_DuckDB_Result_State ); } + /** + * Check whether an ALTER TABLE DROP action targets an index/key. + * + * @param WP_Parser_Token[] $tokens Action tokens starting at DROP. + * @return bool Whether this is a DROP INDEX/KEY/PRIMARY action. + */ + private function is_alter_table_drop_index_action( array $tokens ): bool { + if ( ! isset( $tokens[1] ) ) { + return false; + } + + return in_array( + $tokens[1]->id, + array( + WP_MySQL_Lexer::INDEX_SYMBOL, + WP_MySQL_Lexer::KEY_SYMBOL, + WP_MySQL_Lexer::PRIMARY_SYMBOL, + ), + true + ); + } + + /** + * Execute ALTER TABLE ... DROP [COLUMN]. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Action tokens starting at DROP. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_drop_column( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::DROP_SYMBOL, 'Expected DROP in ALTER TABLE action.' ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + + $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN options are not supported.' ); + } + + $resolved_column_name = $this->assert_alter_table_drop_column_supported( $table_name, $column_name, $temporary ); + $index_definitions = $this->secondary_index_definitions_for_table( $table_name, $temporary ); + $rebuilt_indexes = $this->secondary_index_definitions_after_column_drop( $table_name, $resolved_column_name, $index_definitions, $temporary ); + + $callback = function () use ( $table_name, $resolved_column_name, $index_definitions, $rebuilt_indexes, $temporary ): WP_DuckDB_Result_Statement { + return $this->execute_alter_table_drop_column_change( $table_name, $resolved_column_name, $index_definitions, $rebuilt_indexes, $temporary ); + }; + + if ( count( $index_definitions ) > 0 ) { + if ( $this->connection->inTransaction() ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN on indexed columns cannot run inside an active DuckDB transaction.' ); + } + + return $callback(); + } + + return $this->execute_schema_lifecycle_change( + $callback + ); + } + + /** + * Apply an ALTER TABLE ... DROP COLUMN after validation. + * + * @param string $table_name Table name. + * @param string $column_name Resolved column name. + * @param array}> $dropped_indexes Existing indexes dropped before the schema change. + * @param array}> $rebuilt_indexes Rebuilt surviving indexes. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_drop_column_change( string $table_name, string $column_name, array $dropped_indexes, array $rebuilt_indexes, bool $temporary = false ): WP_DuckDB_Result_Statement { + foreach ( $dropped_indexes as $index_definition ) { + $this->drop_physical_secondary_index( $table_name, $index_definition['index_name'], true, $temporary ); + $this->delete_index_metadata( $table_name, $index_definition['index_name'], $temporary ); + } + + try { + $result = $this->execute_duckdb_query( + 'ALTER TABLE ' + . $this->connection->quote_identifier( $table_name ) + . ' DROP COLUMN ' + . $this->connection->quote_identifier( $column_name ), + 'Failed to drop DuckDB column' + ); + } catch ( Throwable $e ) { + $this->restore_secondary_index_definitions( $dropped_indexes ); + $this->refresh_column_key_metadata( $table_name, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + throw $e; + } + + $this->delete_column_metadata( $table_name, $column_name, $temporary ); + + foreach ( $rebuilt_indexes as $index_definition ) { + $this->execute_duckdb_query( $index_definition['sql'], 'Failed to recreate DuckDB index after dropping column' ); + $this->record_index_metadata( $index_definition ); + } + + $this->refresh_column_key_metadata( $table_name, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + + return $result; + } + + /** + * Best-effort restore of secondary indexes after a failed DROP COLUMN. + * + * @param array}> $index_definitions Index definitions. + */ + private function restore_secondary_index_definitions( array $index_definitions ): void { + foreach ( $index_definitions as $index_definition ) { + try { + $this->execute_duckdb_query( $index_definition['sql'], 'Failed to restore DuckDB index after failed DROP COLUMN' ); + $this->record_index_metadata( $index_definition ); + } catch ( Throwable $restore_exception ) { + unset( $restore_exception ); + } + } + } + + /** + * Resolve and validate a supported ALTER TABLE ... DROP COLUMN target. + * + * @param string $table_name Table name. + * @param string $column_name Requested column name. + * @param bool $temporary Whether the target is a temporary table. + * @return string Resolved column name using stored table casing. + */ + private function assert_alter_table_drop_column_supported( string $table_name, string $column_name, bool $temporary = false ): string { + $metadata = $this->table_column_metadata_rows( $table_name, $temporary ); + if ( count( $metadata ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in ALTER TABLE statement." ); + } + + $resolved_column_name = null; + foreach ( $metadata as $column ) { + if ( 0 === strcasecmp( (string) $column['column_name'], $column_name ) ) { + $resolved_column_name = (string) $column['column_name']; + break; + } + } + + if ( null === $resolved_column_name ) { + throw new WP_DuckDB_Driver_Exception( "Unknown column '{$column_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + + if ( 1 === count( $metadata ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN cannot remove the last column.' ); + } + + foreach ( $this->primary_key_index_rows( $table_name ) as $index_row ) { + if ( 0 === strcasecmp( (string) $index_row[4], $resolved_column_name ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN on a primary key column requires a table rebuild.' ); + } + } + + $auto_increment = $this->auto_increment_metadata_for_table( $table_name, $temporary ); + if ( null !== $auto_increment && 0 === strcasecmp( $auto_increment['column_name'], $resolved_column_name ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN on an AUTO_INCREMENT column requires a table rebuild.' ); + } + + return $resolved_column_name; + } + + /** + * Read stored column metadata, falling back to DuckDB pragma metadata. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + * @return array> + */ + private function table_column_metadata_rows( string $table_name, bool $temporary = false ): array { + $metadata = $this->column_metadata_rows( $table_name, $temporary ); + if ( count( $metadata ) === 0 ) { + $metadata = $this->pragma_column_metadata_rows( $table_name ); + } + + return $metadata; + } + + /** + * Check whether a secondary index definition contains a column. + * + * @param array{columns:array} $index_definition Index definition. + * @param string $column_name Column name. + * @return bool Whether the index references the column. + */ + private function index_definition_contains_column( array $index_definition, string $column_name ): bool { + foreach ( $index_definition['columns'] as $column ) { + if ( 0 === strcasecmp( $column['name'], $column_name ) ) { + return true; + } + } + + return false; + } + + /** + * Build secondary indexes that survive after dropping one column. + * + * @param string $table_name Table name. + * @param string $column_name Dropped column name. + * @param array}> $index_definitions Current index definitions. + * @param bool $temporary Whether the target is a temporary table. + * @return array}> + */ + private function secondary_index_definitions_after_column_drop( string $table_name, string $column_name, array $index_definitions, bool $temporary = false ): array { + $rebuilt_indexes = array(); + + foreach ( $index_definitions as $index_definition ) { + if ( ! $this->index_definition_contains_column( $index_definition, $column_name ) ) { + $rebuilt_indexes[] = $index_definition; + continue; + } + + $column_metadata = array_values( + array_filter( + $index_definition['columns'], + function ( array $column ) use ( $column_name ): bool { + return 0 !== strcasecmp( $column['name'], $column_name ); + } + ) + ); + + if ( count( $column_metadata ) === 0 ) { + continue; + } + + $rebuilt_indexes[] = $this->build_secondary_index_definition( + $table_name, + $index_definition['index_name'], + $index_definition['unique'], + array_map( + function ( array $column ): string { + return $this->connection->quote_identifier( $column['name'] ); + }, + $column_metadata + ), + $column_metadata, + $temporary + ); + } + + return $rebuilt_indexes; + } + + /** + * Delete stored metadata for a dropped column. + * + * @param string $table_name Table name. + * @param string $column_name Column name. + * @param bool $temporary Whether the target is a temporary table. + */ + private function delete_column_metadata( string $table_name, string $column_name, bool $temporary = false ): void { + $this->ensure_column_metadata_table( $temporary ); + + $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' AND column_name = ' + . $this->connection->quote( $column_name ), + 'Failed to delete DuckDB column metadata' + ); + } + /** * Execute ALTER TABLE ... ADD INDEX. * @@ -3569,6 +3845,7 @@ private function apply_column_key_metadata( array $metadata, array $primary_key, continue; } + $has_non_unique_index = false; foreach ( $indexes as $index_definition ) { if ( count( $index_definition['columns'] ) === 0 ) { continue; @@ -3576,8 +3853,17 @@ private function apply_column_key_metadata( array $metadata, array $primary_key, if ( strtolower( $index_definition['columns'][0]['name'] ) !== $column_name ) { continue; } - $column['column_key'] = $index_definition['unique'] ? 'UNI' : 'MUL'; - break; + + if ( $index_definition['unique'] ) { + $column['column_key'] = 'UNI'; + continue 2; + } + + $has_non_unique_index = true; + } + + if ( $has_non_unique_index ) { + $column['column_key'] = 'MUL'; } } unset( $column ); @@ -5574,7 +5860,7 @@ private function delete_table_lifecycle_metadata( string $table_name, bool $temp } /** - * Refresh stored COLUMN_KEY values after an index is dropped. + * Refresh stored COLUMN_KEY values after index metadata changes. * * @param string $table_name Table name. */ @@ -5596,11 +5882,26 @@ function ( array $row ): string { $this->primary_key_index_rows( $table_name ) ); - $this->record_column_metadata( - $table_name, - $this->apply_column_key_metadata( $metadata, $primary_key, $this->secondary_index_definitions_for_table( $table_name, $temporary ) ), - $temporary + $metadata_table = $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ); + $metadata = $this->apply_column_key_metadata( + $metadata, + $primary_key, + $this->secondary_index_definitions_for_table( $table_name, $temporary ) ); + + foreach ( $metadata as $column ) { + $this->execute_duckdb_query( + 'UPDATE ' + . $metadata_table + . ' SET column_key = ' + . $this->connection->quote( $column['column_key'] ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' AND column_name = ' + . $this->connection->quote( $column['column_name'] ), + 'Failed to refresh DuckDB column metadata' + ); + } } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index cbdb5d1c2..481b114d1 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -2058,6 +2058,263 @@ public function test_drop_index_updates_metadata_and_unique_enforcement(): void $this->assertStringNotContainsString( 'name_unique', $create_rows[0]['Create Table'] ); } + public function test_alter_table_drop_column_updates_data_and_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE drop_col_meta ( + id INT, + keep_col VARCHAR(20) DEFAULT 'keep', + drop_col INT DEFAULT 0, + tail_col VARCHAR(20), + KEY keep_idx (keep_col), + KEY drop_idx (drop_col), + KEY keep_drop_tail (keep_col, drop_col, tail_col) + )" + ); + $driver->query( "INSERT INTO drop_col_meta (id, keep_col, drop_col, tail_col) VALUES (1, 'a', 9, 'z')" ); + + $drop = $driver->query( 'ALTER TABLE drop_col_meta DROP COLUMN drop_col' ); + $this->assertSame( 0, $drop->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'keep_col' => 'a', + 'tail_col' => 'z', + ), + ), + $driver->query( 'SELECT * FROM drop_col_meta' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( 'id', 'keep_col', 'tail_col' ), + array_column( $driver->query( 'SHOW COLUMNS FROM drop_col_meta' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( 'id', 'keep_col', 'tail_col' ), + array_column( $driver->query( 'DESCRIBE drop_col_meta' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( + array( + 'COLUMN_NAME' => 'id', + 'ORDINAL_POSITION' => 1, + ), + array( + 'COLUMN_NAME' => 'keep_col', + 'ORDINAL_POSITION' => 2, + ), + array( + 'COLUMN_NAME' => 'tail_col', + 'ORDINAL_POSITION' => 4, + ), + ), + $driver->query( + "SELECT COLUMN_NAME, ORDINAL_POSITION + FROM information_schema.columns + WHERE table_schema = 'wp' AND table_name = 'drop_col_meta' + ORDER BY ORDINAL_POSITION" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE drop_col_meta' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertStringNotContainsString( '`drop_col`', $create_rows[0]['Create Table'] ); + $this->assertStringNotContainsString( 'KEY `drop_idx`', $create_rows[0]['Create Table'] ); + $this->assertStringContainsString( 'KEY `keep_drop_tail` (`keep_col`, `tail_col`)', $create_rows[0]['Create Table'] ); + } + + public function test_alter_table_drop_column_prunes_secondary_indexes(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE drop_col_idx ( + a INT, + b INT, + c INT, + UNIQUE KEY unique_ab (a, b), + KEY compound (a, b, c), + KEY only_b (b), + KEY c_idx (c) + )' + ); + $driver->query( 'INSERT INTO drop_col_idx (a, b, c) VALUES (1, 10, 100), (2, 20, 200)' ); + + $driver->query( 'ALTER TABLE drop_col_idx DROP b' ); + + $index_rows = array_map( + function ( array $row ): array { + return array( + 'Key_name' => $row['Key_name'], + 'Seq_in_index' => $row['Seq_in_index'], + 'Column_name' => $row['Column_name'], + 'Non_unique' => $row['Non_unique'], + ); + }, + $driver->query( 'SHOW INDEX FROM drop_col_idx' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'Key_name' => 'c_idx', + 'Seq_in_index' => 1, + 'Column_name' => 'c', + 'Non_unique' => 1, + ), + array( + 'Key_name' => 'compound', + 'Seq_in_index' => 1, + 'Column_name' => 'a', + 'Non_unique' => 1, + ), + array( + 'Key_name' => 'compound', + 'Seq_in_index' => 2, + 'Column_name' => 'c', + 'Non_unique' => 1, + ), + array( + 'Key_name' => 'unique_ab', + 'Seq_in_index' => 1, + 'Column_name' => 'a', + 'Non_unique' => 0, + ), + ), + $index_rows + ); + $this->assertSame( + array( + 'a' => 'UNI', + 'c' => 'MUL', + ), + array_column( $driver->query( 'SHOW COLUMNS FROM drop_col_idx' )->fetchAll( PDO::FETCH_ASSOC ), 'Key', 'Field' ) + ); + $this->assertSame( + array(), + $driver->query( + "SELECT index_name + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'drop_col_idx' AND index_name = 'only_b'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_alter_table_drop_multiple_and_mixed_columns(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE drop_col_multi ( + a INT, + b INT, + c INT, + marker VARCHAR(20) + )' + ); + $driver->query( "INSERT INTO drop_col_multi (a, b, c, marker) VALUES (1, 2, 3, 'row')" ); + + $driver->query( 'ALTER TABLE drop_col_multi DROP a, DROP COLUMN b' ); + $this->assertSame( + array( 'c', 'marker' ), + array_column( $driver->query( 'SHOW COLUMNS FROM drop_col_multi' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + + $driver->query( 'ALTER TABLE drop_col_multi ADD d INT DEFAULT 9, DROP c' ); + $this->assertSame( + array( 'marker', 'd' ), + array_column( $driver->query( 'SHOW COLUMNS FROM drop_col_multi' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( + array( + 'marker' => 'row', + 'd' => 9, + ), + ), + $driver->query( 'SELECT marker, d FROM drop_col_multi' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_alter_table_drop_column_targets_temporary_table(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE shadow_drop (a INT, b INT)' ); + $driver->query( 'CREATE TEMPORARY TABLE shadow_drop (a INT, b INT, c INT)' ); + $driver->query( 'INSERT INTO shadow_drop (a, b, c) VALUES (3, 4, 5)' ); + + $driver->query( 'ALTER TABLE shadow_drop DROP COLUMN b' ); + $this->assertSame( + array( 'a', 'c' ), + array_column( $driver->query( 'SHOW COLUMNS FROM shadow_drop' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( + array( + 'a' => 3, + 'c' => 5, + ), + ), + $driver->query( 'SELECT * FROM shadow_drop' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'DROP TEMPORARY TABLE shadow_drop' ); + $this->assertSame( + array( 'a', 'b' ), + array_column( $driver->query( 'SHOW COLUMNS FROM shadow_drop' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + } + + public function test_alter_table_drop_column_rejects_protected_columns(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE drop_col_pk (id INT NOT NULL, keep_col INT, PRIMARY KEY (id))' ); + $driver->query( 'CREATE TABLE drop_col_auto (id BIGINT NOT NULL AUTO_INCREMENT, keep_col INT, KEY id_idx (id))' ); + $driver->query( 'CREATE TABLE drop_col_last (only_col INT)' ); + + foreach ( + array( + 'ALTER TABLE drop_col_pk DROP COLUMN id' => 'primary key column requires a table rebuild', + 'ALTER TABLE drop_col_auto DROP COLUMN id' => 'AUTO_INCREMENT column requires a table rebuild', + 'ALTER TABLE drop_col_last DROP COLUMN only_col' => 'DROP COLUMN cannot remove the last column', + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected DROP COLUMN protection to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + } + } + public function test_drop_table_removes_metadata_and_sequence_state(): void { $this->requireDuckDBRuntime(); From 05f3d04b283638bb51bf30a90d51a4b0f9969a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 20:16:41 +0000 Subject: [PATCH 025/317] Fix DuckDB plugin dispatcher test isolation --- .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index d549782e8..484132d7f 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -11,6 +11,18 @@ class WP_DuckDB_Plugin_Dispatcher_Tests extends PHPUnit\Framework\TestCase { */ private $temp_dirs = array(); + public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); + + foreach ( array( 'WP_DUCKDB_AUTOLOAD', 'DUCKDB_PHP_AUTOLOAD' ) as $environment_key ) { + $autoload = getenv( $environment_key ); + if ( is_string( $autoload ) && '' !== $autoload && file_exists( $autoload ) ) { + require_once $autoload; + return; + } + } + } + protected function tearDown(): void { foreach ( array_reverse( $this->temp_dirs ) as $temp_dir ) { $this->remove_temp_dir( $temp_dir ); @@ -480,7 +492,14 @@ private function get_dispatcher_include_code( string $include_file ): string { } private function get_wordpress_stub_code(): string { - $temp_dir = $this->create_temp_dir(); + $temp_dir = $this->create_temp_dir(); + $duckdb_autoload = getenv( 'DUCKDB_PHP_AUTOLOAD' ); + if ( ! is_string( $duckdb_autoload ) || '' === $duckdb_autoload || ! file_exists( $duckdb_autoload ) ) { + $duckdb_autoload = getenv( 'WP_DUCKDB_AUTOLOAD' ); + } + $duckdb_autoload_constant = is_string( $duckdb_autoload ) && '' !== $duckdb_autoload && file_exists( $duckdb_autoload ) + ? "define( 'DUCKDB_PHP_AUTOLOAD', " . var_export( $duckdb_autoload, true ) . " );\n" + : ''; return "create_temp_file( $code ); $output = array(); $exit_code = 0; - $command = escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( $script_file ) . ' 2>&1'; + $ffi_enable = ini_get( 'ffi.enable' ); + $php_args = is_string( $ffi_enable ) && '' !== $ffi_enable + ? ' -d ' . escapeshellarg( 'ffi.enable=' . $ffi_enable ) + : ''; + $command = escapeshellarg( PHP_BINARY ) . $php_args . ' ' . escapeshellarg( $script_file ) . ' 2>&1'; try { exec( $command, $output, $exit_code ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec From f2925a305c17b492b2906ea62b3c7289340a57fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 20:35:13 +0000 Subject: [PATCH 026/317] Add DuckDB ALTER CHANGE MODIFY parity --- .../src/duckdb/class-wp-duckdb-driver.php | 499 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 26 + .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 273 ++++++++++ 3 files changed, 797 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index d2a3181f2..c2bed1711 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -2163,7 +2163,17 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen continue; } - $this->expect_token( $action, 0, WP_MySQL_Lexer::ADD_SYMBOL, 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD and DROP actions are supported.' ); + if ( WP_MySQL_Lexer::CHANGE_SYMBOL === $action[0]->id ) { + $result = $this->execute_alter_table_change_column( $table_name, $action, $temporary ); + continue; + } + + if ( WP_MySQL_Lexer::MODIFY_SYMBOL === $action[0]->id ) { + $result = $this->execute_alter_table_modify_column( $table_name, $action, $temporary ); + continue; + } + + $this->expect_token( $action, 0, WP_MySQL_Lexer::ADD_SYMBOL, 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD, DROP, CHANGE, and MODIFY actions are supported.' ); $alter_item = array_slice( $action, 1 ); $result = $this->is_create_table_index_item( $alter_item ) @@ -2466,6 +2476,354 @@ function ( array $column ): string { return $rebuilt_indexes; } + /** + * Apply an ALTER TABLE ... CHANGE/MODIFY COLUMN after validation. + * + * @param string $table_name Table name. + * @param string $current_column_name Resolved current column name. + * @param array $metadata New column metadata. + * @param array $current_column Current column metadata. + * @param array> $metadata_rows Current table metadata rows. + * @param bool $has_stored_metadata Whether metadata rows came from the driver metadata table. + * @param array}> $dropped_indexes Current indexes dropped before the schema change. + * @param array}> $rebuilt_indexes Rebuilt surviving indexes. + * @param bool $rename_column Whether the column is renamed. + * @param bool $type_change Whether the physical DuckDB type changes. + * @param bool $default_change Whether the physical default changes. + * @param bool $nullability_change Whether the physical nullability changes. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_change_modify_column_change( string $table_name, string $current_column_name, array $metadata, array $current_column, array $metadata_rows, bool $has_stored_metadata, array $dropped_indexes, array $rebuilt_indexes, bool $rename_column, bool $type_change, bool $default_change, bool $nullability_change, bool $temporary = false ): WP_DuckDB_Result_Statement { + if ( $rename_column ) { + foreach ( $dropped_indexes as $index_definition ) { + $this->drop_physical_secondary_index( $table_name, $index_definition['index_name'], true, $temporary ); + $this->delete_index_metadata( $table_name, $index_definition['index_name'], $temporary ); + } + } + + $active_column_name = $current_column_name; + $dropped_default_for_type = false; + try { + if ( $rename_column ) { + $active_column_name = (string) $metadata['column_name']; + $this->execute_duckdb_query( + 'ALTER TABLE ' + . $this->connection->quote_identifier( $table_name ) + . ' RENAME COLUMN ' + . $this->connection->quote_identifier( $current_column_name ) + . ' TO ' + . $this->connection->quote_identifier( $active_column_name ), + 'Failed to rename DuckDB column' + ); + } + + if ( $type_change ) { + if ( null !== $current_column['column_default'] ) { + $this->execute_duckdb_query( + 'ALTER TABLE ' + . $this->connection->quote_identifier( $table_name ) + . ' ALTER COLUMN ' + . $this->connection->quote_identifier( $active_column_name ) + . ' DROP DEFAULT', + 'Failed to drop DuckDB column default before type change' + ); + $dropped_default_for_type = true; + } + + $this->execute_duckdb_query( + 'ALTER TABLE ' + . $this->connection->quote_identifier( $table_name ) + . ' ALTER COLUMN ' + . $this->connection->quote_identifier( $active_column_name ) + . ' SET DATA TYPE ' + . $metadata['_duckdb_type'], + 'Failed to change DuckDB column type' + ); + } + + if ( $default_change || ( $type_change && null !== $metadata['_default_sql'] ) ) { + if ( null === $metadata['_default_sql'] ) { + if ( ! $dropped_default_for_type ) { + $this->execute_duckdb_query( + 'ALTER TABLE ' + . $this->connection->quote_identifier( $table_name ) + . ' ALTER COLUMN ' + . $this->connection->quote_identifier( $active_column_name ) + . ' DROP DEFAULT', + 'Failed to drop DuckDB column default' + ); + } + } else { + $this->execute_duckdb_query( + 'ALTER TABLE ' + . $this->connection->quote_identifier( $table_name ) + . ' ALTER COLUMN ' + . $this->connection->quote_identifier( $active_column_name ) + . ' SET DEFAULT ' + . $metadata['_default_sql'], + 'Failed to set DuckDB column default' + ); + } + } + + if ( $nullability_change ) { + $this->execute_duckdb_query( + 'ALTER TABLE ' + . $this->connection->quote_identifier( $table_name ) + . ' ALTER COLUMN ' + . $this->connection->quote_identifier( $active_column_name ) + . ( 'NO' === $metadata['is_nullable'] ? ' SET NOT NULL' : ' DROP NOT NULL' ), + 'Failed to change DuckDB column nullability' + ); + } + } catch ( Throwable $e ) { + if ( $rename_column ) { + $this->restore_secondary_index_definitions( $dropped_indexes ); + $this->refresh_column_key_metadata( $table_name, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + } + throw $e; + } + + $this->replace_changed_column_metadata( $table_name, $current_column_name, $metadata, $metadata_rows, $has_stored_metadata, $temporary ); + + if ( $rename_column ) { + foreach ( $rebuilt_indexes as $index_definition ) { + $this->execute_duckdb_query( $index_definition['sql'], 'Failed to recreate DuckDB index after changing column' ); + $this->record_index_metadata( $index_definition ); + } + } + + $this->refresh_column_key_metadata( $table_name, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + + return $this->empty_ddl_result(); + } + + /** + * Resolve current column metadata for CHANGE/MODIFY. + * + * @param string $table_name Table name. + * @param string $column_name Requested column name. + * @param array> $metadata_rows Current table metadata rows. + * @return array Current column metadata. + */ + private function resolve_alter_table_change_column_metadata( string $table_name, string $column_name, array $metadata_rows ): array { + if ( count( $metadata_rows ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in ALTER TABLE statement." ); + } + + foreach ( $metadata_rows as $column ) { + if ( 0 === strcasecmp( (string) $column['column_name'], $column_name ) ) { + return $column; + } + } + + throw new WP_DuckDB_Driver_Exception( "Unknown column '{$column_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + + /** + * Validate a supported ALTER TABLE ... CHANGE/MODIFY target. + * + * @param string $table_name Table name. + * @param array $current_column Current column metadata. + * @param array> $metadata_rows Current table metadata rows. + * @param string $new_column_name Requested new column name. + * @param bool $temporary Whether the target is a temporary table. + */ + private function assert_alter_table_change_column_supported( string $table_name, array $current_column, array $metadata_rows, string $new_column_name, bool $temporary = false ): void { + $current_column_name = (string) $current_column['column_name']; + foreach ( $metadata_rows as $column ) { + if ( + 0 !== strcasecmp( (string) $column['column_name'], $current_column_name ) + && 0 === strcasecmp( (string) $column['column_name'], $new_column_name ) + ) { + throw new WP_DuckDB_Driver_Exception( "Duplicate column name '{$new_column_name}' in ALTER TABLE statement." ); + } + } + + foreach ( $this->primary_key_index_rows( $table_name ) as $index_row ) { + if ( 0 === strcasecmp( (string) $index_row[4], $current_column_name ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY on a primary key column requires a table rebuild.' ); + } + } + + $auto_increment = $this->auto_increment_metadata_for_table( $table_name, $temporary ); + if ( + ( null !== $auto_increment && 0 === strcasecmp( $auto_increment['column_name'], $current_column_name ) ) + || 'auto_increment' === $current_column['extra'] + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY on an AUTO_INCREMENT column requires a table rebuild.' ); + } + } + + /** + * Read one physical DuckDB pragma column row. + * + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return array Physical column info. + */ + private function physical_column_info_row( string $table_name, string $column_name ): array { + $stmt = $this->execute_duckdb_query( + 'SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ') ORDER BY cid', + 'Failed to inspect DuckDB table columns' + ); + + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + if ( 0 === strcasecmp( (string) $row['name'], $column_name ) ) { + return $row; + } + } + + throw new WP_DuckDB_Driver_Exception( "Unknown column '{$column_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + + /** + * Check whether a CHANGE/MODIFY definition changes the stored default. + * + * @param array $current_column Current column metadata. + * @param array $metadata New column metadata. + * @return bool Whether the default changes. + */ + private function column_default_changed( array $current_column, array $metadata ): bool { + $current_default = $current_column['column_default']; + $new_default = $metadata['column_default']; + + if ( null === $current_default || null === $new_default ) { + return $current_default !== $new_default; + } + + return (string) $current_default !== (string) $new_default; + } + + /** + * Normalize DuckDB type strings for physical type comparisons. + * + * @param string $type DuckDB type. + * @return string Canonical type. + */ + private function canonical_duckdb_type( string $type ): string { + $type = strtoupper( trim( preg_replace( '/\s+/', ' ', $type ) ) ); + + $aliases = array( + 'INT' => 'INTEGER', + 'STRING' => 'VARCHAR', + 'CHAR' => 'VARCHAR', + 'BOOLEAN' => 'BOOLEAN', + 'BOOL' => 'BOOLEAN', + ); + + return $aliases[ $type ] ?? $type; + } + + /** + * Build secondary indexes after renaming one column. + * + * @param string $table_name Table name. + * @param string $old_column_name Old column name. + * @param string $new_column_name New column name. + * @param array}> $index_definitions Current index definitions. + * @param bool $temporary Whether the target is a temporary table. + * @return array}> + */ + private function secondary_index_definitions_after_column_rename( string $table_name, string $old_column_name, string $new_column_name, array $index_definitions, bool $temporary = false ): array { + $rebuilt_indexes = array(); + + foreach ( $index_definitions as $index_definition ) { + $column_metadata = array_map( + function ( array $column ) use ( $old_column_name, $new_column_name ): array { + if ( 0 === strcasecmp( $column['name'], $old_column_name ) ) { + $column['name'] = $new_column_name; + } + return $column; + }, + $index_definition['columns'] + ); + + $rebuilt_indexes[] = $this->build_secondary_index_definition( + $table_name, + $index_definition['index_name'], + $index_definition['unique'], + array_map( + function ( array $column ): string { + return $this->connection->quote_identifier( $column['name'] ); + }, + $column_metadata + ), + $column_metadata, + $temporary + ); + } + + return $rebuilt_indexes; + } + + /** + * Replace one stored column metadata row while preserving ordinal positions. + * + * @param string $table_name Table name. + * @param string $old_column_name Old column name. + * @param array $metadata New column metadata. + * @param array> $metadata_rows Current table metadata rows. + * @param bool $has_stored_metadata Whether metadata rows came from the driver metadata table. + * @param bool $temporary Whether the target is a temporary table. + */ + private function replace_changed_column_metadata( string $table_name, string $old_column_name, array $metadata, array $metadata_rows, bool $has_stored_metadata, bool $temporary = false ): void { + if ( ! $has_stored_metadata ) { + foreach ( $metadata_rows as &$column ) { + if ( 0 === strcasecmp( (string) $column['column_name'], $old_column_name ) ) { + $column = array_merge( + $column, + array( + 'column_name' => $metadata['column_name'], + 'column_type' => $metadata['column_type'], + 'is_nullable' => $metadata['is_nullable'], + 'column_key' => $metadata['column_key'], + 'column_default' => $metadata['column_default'], + 'extra' => $metadata['extra'], + 'collation_name' => $metadata['collation_name'], + 'comment' => $metadata['comment'], + ) + ); + break; + } + } + unset( $column ); + + $this->record_column_metadata( $table_name, $metadata_rows, $temporary ); + return; + } + + $this->ensure_column_metadata_table( $temporary ); + $this->execute_duckdb_query( + 'UPDATE ' + . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) + . ' SET column_name = ' + . $this->connection->quote( $metadata['column_name'] ) + . ', column_type = ' + . $this->connection->quote( $metadata['column_type'] ) + . ', is_nullable = ' + . $this->connection->quote( $metadata['is_nullable'] ) + . ', column_key = ' + . $this->connection->quote( $metadata['column_key'] ) + . ', column_default = ' + . $this->connection->quote( $metadata['column_default'] ) + . ', extra = ' + . $this->connection->quote( $metadata['extra'] ) + . ', collation_name = ' + . $this->connection->quote( $metadata['collation_name'] ) + . ', comment = ' + . $this->connection->quote( $metadata['comment'] ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' AND column_name = ' + . $this->connection->quote( $old_column_name ), + 'Failed to update DuckDB column metadata' + ); + } + /** * Delete stored metadata for a dropped column. * @@ -2581,6 +2939,143 @@ function () use ( $table_name, $metadata ): void { return $result; } + /** + * Execute ALTER TABLE ... CHANGE [COLUMN]. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Action tokens starting at CHANGE. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_change_column( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CHANGE_SYMBOL, 'Expected CHANGE in ALTER TABLE action.' ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + + $old_column_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + $new_column_token = $tokens[ $index ] ?? null; + $this->identifier_value( $new_column_token ); + ++$index; + + $definition_tokens = array_merge( array( $new_column_token ), array_slice( $tokens, $index ) ); + if ( count( $definition_tokens ) < 2 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE COLUMN requires a full column definition.' ); + } + + return $this->execute_alter_table_change_or_modify_column( $table_name, $old_column_name, $definition_tokens, $temporary ); + } + + /** + * Execute ALTER TABLE ... MODIFY [COLUMN]. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Action tokens starting at MODIFY. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_modify_column( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::MODIFY_SYMBOL, 'Expected MODIFY in ALTER TABLE action.' ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + + $column_token = $tokens[ $index ] ?? null; + $column_name = $this->identifier_value( $column_token ); + ++$index; + + $definition_tokens = array_merge( array( $column_token ), array_slice( $tokens, $index ) ); + if ( count( $definition_tokens ) < 2 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. MODIFY COLUMN requires a full column definition.' ); + } + + return $this->execute_alter_table_change_or_modify_column( $table_name, $column_name, $definition_tokens, $temporary ); + } + + /** + * Execute the common CHANGE/MODIFY column path. + * + * @param string $table_name Table name. + * @param string $old_column_name Existing column name. + * @param WP_Parser_Token[] $definition_tokens New column definition tokens. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_change_or_modify_column( string $table_name, string $old_column_name, array $definition_tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + list( , $sequence_sql, $inline_indexes, $metadata ) = $this->translate_create_table_column( $table_name, $definition_tokens, false, true, $temporary ); + if ( null !== $sequence_sql || 'auto_increment' === $metadata['extra'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY AUTO_INCREMENT requires a table rebuild.' ); + } + if ( 'PRI' === $metadata['column_key'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY inline PRIMARY KEY is not supported.' ); + } + if ( 'UNI' === $metadata['column_key'] || count( $inline_indexes ) > 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY inline UNIQUE is not supported.' ); + } + + $stored_metadata_rows = $this->column_metadata_rows( $table_name, $temporary ); + $metadata_rows = count( $stored_metadata_rows ) > 0 ? $stored_metadata_rows : $this->pragma_column_metadata_rows( $table_name ); + $current_column = $this->resolve_alter_table_change_column_metadata( $table_name, $old_column_name, $metadata_rows ); + $current_column_name = (string) $current_column['column_name']; + $new_column_name = (string) $metadata['column_name']; + + $this->assert_alter_table_change_column_supported( $table_name, $current_column, $metadata_rows, $new_column_name, $temporary ); + + $physical_column = $this->physical_column_info_row( $table_name, $current_column_name ); + $rename_column = 0 !== strcasecmp( $current_column_name, $new_column_name ); + $type_change = $this->canonical_duckdb_type( (string) $physical_column['type'] ) !== $this->canonical_duckdb_type( (string) $metadata['_duckdb_type'] ); + $default_change = $this->column_default_changed( $current_column, $metadata ); + $nullability_change = (string) $current_column['is_nullable'] !== (string) $metadata['is_nullable']; + $index_definitions = $this->secondary_index_definitions_for_table( $table_name, $temporary ); + $rebuilt_indexes = $rename_column + ? $this->secondary_index_definitions_after_column_rename( $table_name, $current_column_name, $new_column_name, $index_definitions, $temporary ) + : $index_definitions; + $has_physical_changes = $rename_column || $type_change || $default_change || $nullability_change; + + if ( count( $index_definitions ) > 0 && $has_physical_changes && ! $rename_column ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY on indexed columns that changes type, default, or nullability requires a table rebuild.' ); + } + if ( count( $index_definitions ) > 0 && $rename_column && ( $type_change || $default_change || $nullability_change ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE COLUMN rename on indexed columns cannot also change type, default, or nullability without a table rebuild.' ); + } + + $callback = function () use ( $table_name, $current_column_name, $metadata, $current_column, $metadata_rows, $stored_metadata_rows, $index_definitions, $rebuilt_indexes, $rename_column, $type_change, $default_change, $nullability_change, $temporary ): WP_DuckDB_Result_Statement { + return $this->execute_alter_table_change_modify_column_change( + $table_name, + $current_column_name, + $metadata, + $current_column, + $metadata_rows, + count( $stored_metadata_rows ) > 0, + $index_definitions, + $rebuilt_indexes, + $rename_column, + $type_change, + $default_change, + $nullability_change, + $temporary + ); + }; + + if ( count( $index_definitions ) > 0 && $rename_column ) { + if ( $this->connection->inTransaction() ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE COLUMN on indexed columns cannot run inside an active DuckDB transaction.' ); + } + + return $callback(); + } + + return $this->execute_schema_lifecycle_change( $callback ); + } + /** * Execute supported SHOW statements. * @@ -3676,6 +4171,8 @@ private function translate_create_table_column( string $table_name, array $token 'extra' => $auto_increment ? 'auto_increment' : '', 'collation_name' => $this->mysql_column_collation_from_tokens( $tokens, $type_token ), 'comment' => $this->mysql_column_comment_from_tokens( $tokens ), + '_duckdb_type' => $duck_type, + '_default_sql' => $auto_increment ? null : $default_sql, ); return array( $column_sql, $sequence, $indexes, $metadata ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 7a2863ee5..d0b867672 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -324,6 +324,32 @@ public function test_alter_table_add_column_metadata_matches_sqlite(): void { $this->assertParityRows( 'SHOW COLUMNS FROM metadata' ); } + public function test_alter_table_change_modify_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE metadata ( + option_name VARCHAR(255) DEFAULT '7', + option_value LONGTEXT, + autoload VARCHAR(10) DEFAULT 'no' + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO metadata (option_name, option_value, autoload) VALUES ('7', 'payload', 'no')", + 'ALTER TABLE metadata CHANGE COLUMN option_name option_name SMALLINT NOT NULL DEFAULT 14', + "ALTER TABLE metadata MODIFY COLUMN autoload VARCHAR(20) NOT NULL DEFAULT 'yes'", + 'ALTER TABLE metadata CHANGE COLUMN option_value option_payload LONGTEXT NULL', + 'INSERT INTO metadata (option_payload) VALUES (\'defaulted\')', + ) + ); + + $this->assertParityRows( 'SELECT option_name, option_payload, autoload FROM metadata ORDER BY option_payload' ); + $this->assertParityRows( 'SHOW COLUMNS FROM metadata' ); + $this->assertParityRows( + "SELECT COLUMN_NAME, ORDINAL_POSITION, COLUMN_DEFAULT, IS_NULLABLE, COLUMN_TYPE, COLUMN_KEY, EXTRA + FROM information_schema.columns + WHERE table_schema = 'wp' AND table_name = 'metadata' + ORDER BY ordinal_position" + ); + } + public function test_information_schema_columns_metadata_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 481b114d1..845fa637f 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -2286,6 +2286,279 @@ public function test_alter_table_drop_column_targets_temporary_table(): void { ); } + public function test_alter_table_change_column_renames_with_type_default_and_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE change_col_meta ( + option_name VARCHAR(255), + option_value LONGTEXT + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci' + ); + $driver->query( "INSERT INTO change_col_meta (option_name, option_value) VALUES ('siteurl', 'https://example.test')" ); + + $change = $driver->query( "ALTER TABLE change_col_meta CHANGE COLUMN option_name option_key VARCHAR(191) NOT NULL DEFAULT '' COMMENT 'Option key'" ); + + $this->assertSame( 0, $change->rowCount() ); + $this->assertSame( + array( + array( + 'option_key' => 'siteurl', + 'option_value' => 'https://example.test', + ), + ), + $driver->query( 'SELECT option_key, option_value FROM change_col_meta' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( 'option_key', 'option_value' ), + array_column( $driver->query( 'SHOW COLUMNS FROM change_col_meta' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + + $key_column = $driver->query( "SHOW FULL COLUMNS FROM change_col_meta LIKE 'option_key'" )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( 'varchar(191)', $key_column['Type'] ); + $this->assertSame( 'NO', $key_column['Null'] ); + $this->assertSame( '', $key_column['Default'] ); + $this->assertSame( 'Option key', $key_column['Comment'] ); + + $this->assertSame( + array( + array( + 'COLUMN_NAME' => 'option_key', + 'ORDINAL_POSITION' => 1, + 'COLUMN_DEFAULT' => '', + 'IS_NULLABLE' => 'NO', + 'COLUMN_TYPE' => 'varchar(191)', + 'COLUMN_COMMENT' => 'Option key', + ), + ), + $driver->query( + "SELECT COLUMN_NAME, ORDINAL_POSITION, COLUMN_DEFAULT, IS_NULLABLE, COLUMN_TYPE, COLUMN_COMMENT + FROM information_schema.columns + WHERE table_schema = 'wp' AND table_name = 'change_col_meta' AND column_name = 'option_key'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( "INSERT INTO change_col_meta (option_value) VALUES ('defaulted')" ); + $this->assertSame( + array( + array( 'option_key' => '' ), + ), + $driver->query( "SELECT option_key FROM change_col_meta WHERE option_value = 'defaulted'" )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_alter_table_change_same_name_and_modify_refresh_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE change_modify_meta ( + option_name VARCHAR(255) DEFAULT '7', + autoload VARCHAR(10) DEFAULT 'no' + )" + ); + $driver->query( "INSERT INTO change_modify_meta (option_name, autoload) VALUES ('7', 'no')" ); + + $driver->query( 'ALTER TABLE change_modify_meta CHANGE COLUMN option_name option_name SMALLINT NOT NULL DEFAULT 14' ); + $driver->query( "ALTER TABLE change_modify_meta MODIFY COLUMN autoload VARCHAR(20) NOT NULL DEFAULT 'yes' COMMENT 'Load flag'" ); + $driver->query( 'INSERT INTO change_modify_meta (option_name, autoload) VALUES (DEFAULT, DEFAULT)' ); + + $this->assertSame( + array( + array( + 'option_name' => 7, + 'autoload' => 'no', + ), + array( + 'option_name' => 14, + 'autoload' => 'yes', + ), + ), + $driver->query( 'SELECT option_name, autoload FROM change_modify_meta ORDER BY option_name' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $columns = array_column( $driver->query( 'SHOW FULL COLUMNS FROM change_modify_meta' )->fetchAll( PDO::FETCH_ASSOC ), null, 'Field' ); + $this->assertSame( 'smallint', $columns['option_name']['Type'] ); + $this->assertSame( 'NO', $columns['option_name']['Null'] ); + $this->assertSame( '14', $columns['option_name']['Default'] ); + $this->assertSame( 'varchar(20)', $columns['autoload']['Type'] ); + $this->assertSame( 'NO', $columns['autoload']['Null'] ); + $this->assertSame( 'yes', $columns['autoload']['Default'] ); + $this->assertSame( 'Load flag', $columns['autoload']['Comment'] ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE change_modify_meta' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertStringContainsString( "`option_name` smallint NOT NULL DEFAULT '14'", $create_rows[0]['Create Table'] ); + $this->assertStringContainsString( "`autoload` varchar(20) NOT NULL DEFAULT 'yes' COMMENT 'Load flag'", $create_rows[0]['Create Table'] ); + } + + public function test_alter_table_change_column_rebuilds_indexes_for_renamed_column(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE change_col_idx ( + name VARCHAR(50) NOT NULL DEFAULT 'mark', + lastname VARCHAR(50), + payload INT, + UNIQUE KEY name (name), + KEY composite (name, lastname) + )" + ); + $driver->query( "INSERT INTO change_col_idx (name, lastname, payload) VALUES ('ada', 'lovelace', 1)" ); + + $driver->query( "ALTER TABLE change_col_idx CHANGE name firstname VARCHAR(50) NOT NULL DEFAULT 'mark'" ); + + $this->assertSame( + array( + array( + 'firstname' => 'ada', + 'lastname' => 'lovelace', + 'payload' => 1, + ), + ), + $driver->query( 'SELECT firstname, lastname, payload FROM change_col_idx' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $index_rows = array_map( + function ( array $row ): array { + return array( + 'Key_name' => $row['Key_name'], + 'Seq_in_index' => $row['Seq_in_index'], + 'Column_name' => $row['Column_name'], + 'Non_unique' => $row['Non_unique'], + ); + }, + $driver->query( 'SHOW INDEX FROM change_col_idx' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'Key_name' => 'composite', + 'Seq_in_index' => 1, + 'Column_name' => 'firstname', + 'Non_unique' => 1, + ), + array( + 'Key_name' => 'composite', + 'Seq_in_index' => 2, + 'Column_name' => 'lastname', + 'Non_unique' => 1, + ), + array( + 'Key_name' => 'name', + 'Seq_in_index' => 1, + 'Column_name' => 'firstname', + 'Non_unique' => 0, + ), + ), + $index_rows + ); + $this->assertSame( + array( + 'firstname' => 'UNI', + 'lastname' => '', + 'payload' => '', + ), + array_column( $driver->query( 'SHOW COLUMNS FROM change_col_idx' )->fetchAll( PDO::FETCH_ASSOC ), 'Key', 'Field' ) + ); + $this->assertSame( + array(), + $driver->query( + "SELECT index_name + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'change_col_idx' AND column_name = 'name'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + try { + $driver->query( "INSERT INTO change_col_idx (firstname, lastname, payload) VALUES ('ada', 'duplicate', 2)" ); + $this->fail( 'Duplicate firstname should fail after rebuilding the renamed unique index.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + } + + public function test_alter_table_change_column_targets_temporary_shadow_table(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE shadow_change (name VARCHAR(20), payload INT)' ); + $driver->query( 'CREATE TEMPORARY TABLE shadow_change (name VARCHAR(20), payload INT)' ); + $driver->query( "INSERT INTO shadow_change (name, payload) VALUES ('temp', 3)" ); + + $driver->query( "ALTER TABLE shadow_change CHANGE COLUMN name temp_name VARCHAR(30) DEFAULT ''" ); + + $this->assertSame( + array( 'temp_name', 'payload' ), + array_column( $driver->query( 'SHOW COLUMNS FROM shadow_change' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( + array( + 'temp_name' => 'temp', + 'payload' => 3, + ), + ), + $driver->query( 'SELECT * FROM shadow_change' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'DROP TEMPORARY TABLE shadow_change' ); + $this->assertSame( + array( 'name', 'payload' ), + array_column( $driver->query( 'SHOW COLUMNS FROM shadow_change' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + } + + public function test_alter_table_change_modify_rejects_protected_definitions(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE change_pk (id INT NOT NULL, note VARCHAR(20), PRIMARY KEY (id))' ); + $driver->query( 'CREATE TABLE change_auto (id BIGINT NOT NULL AUTO_INCREMENT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE change_inline_unique (name VARCHAR(20))' ); + + foreach ( + array( + 'ALTER TABLE change_pk CHANGE id item_id INT NOT NULL' => 'primary key column requires a table rebuild', + 'ALTER TABLE change_auto MODIFY id BIGINT NOT NULL' => 'AUTO_INCREMENT column requires a table rebuild', + 'ALTER TABLE change_inline_unique MODIFY name VARCHAR(20) UNIQUE' => 'inline UNIQUE is not supported', + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected CHANGE/MODIFY protection to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + } + } + public function test_alter_table_drop_column_rejects_protected_columns(): void { $this->requireDuckDBRuntime(); From 351a27bc50af06c4156c076dca8be114776d0dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 20:50:30 +0000 Subject: [PATCH 027/317] Add DuckDB AUTO_INCREMENT seed parity --- .../src/duckdb/class-wp-duckdb-driver.php | 241 ++++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 102 ++++++++ 2 files changed, 322 insertions(+), 21 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index c2bed1711..52de288d0 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -416,12 +416,13 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme list( $items, $index ) = $this->collect_parenthesized_items( $tokens, $index ); $table_metadata = $this->parse_create_table_options( array_slice( $tokens, $index ) ); - $columns = array(); - $constraints = array(); - $indexes = array(); - $sequences = array(); - $metadata = array(); - $primary_key = array(); + $columns = array(); + $constraints = array(); + $indexes = array(); + $sequences = array(); + $metadata = array(); + $primary_key = array(); + $auto_increment_seed = $table_metadata['auto_increment']; foreach ( $items as $item ) { if ( count( $item ) === 0 ) { @@ -443,11 +444,14 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE constraint in DuckDB driver: ' . $item[0]->get_bytes() . '.' ); } - list( $column_sql, $sequence_sql, $column_indexes, $column_metadata ) = $this->translate_create_table_column( $table_name, $item, true, false, $temporary ); + list( $column_sql, $sequence_sql, $column_indexes, $column_metadata ) = $this->translate_create_table_column( $table_name, $item, true, false, $temporary, $auto_increment_seed ); $columns[] = $column_sql; $metadata[] = $column_metadata; if ( null !== $sequence_sql ) { - $sequences[] = $sequence_sql; + $sequences[] = array( + 'sql' => $sequence_sql, + 'column_name' => $column_metadata['column_name'], + ); } foreach ( $column_indexes as $index_definition ) { $indexes[] = $index_definition; @@ -458,8 +462,14 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme throw new WP_DuckDB_Driver_Exception( 'CREATE TABLE requires at least one column.' ); } - foreach ( $sequences as $sequence_sql ) { - $this->execute_duckdb_query( $sequence_sql, 'Failed to create DuckDB AUTO_INCREMENT sequence' ); + foreach ( $sequences as $sequence ) { + $this->execute_duckdb_query( $sequence['sql'], 'Failed to create DuckDB AUTO_INCREMENT sequence' ); + if ( null !== $auto_increment_seed && $auto_increment_seed > 1 ) { + $this->prime_auto_increment_sequence( + $this->sequence_name( $table_name, (string) $sequence['column_name'], $temporary ), + $auto_increment_seed + ); + } } $table_sql = $temporary ? 'CREATE TEMPORARY TABLE ' : 'CREATE TABLE '; @@ -2173,7 +2183,12 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen continue; } - $this->expect_token( $action, 0, WP_MySQL_Lexer::ADD_SYMBOL, 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD, DROP, CHANGE, and MODIFY actions are supported.' ); + if ( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL === $action[0]->id ) { + $result = $this->execute_alter_table_set_auto_increment( $table_name, $action, $temporary ); + continue; + } + + $this->expect_token( $action, 0, WP_MySQL_Lexer::ADD_SYMBOL, 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD, DROP, CHANGE, MODIFY, and AUTO_INCREMENT actions are supported.' ); $alter_item = array_slice( $action, 1 ); $result = $this->is_create_table_index_item( $alter_item ) @@ -2184,6 +2199,33 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen return $result ?? new WP_DuckDB_Result_Statement( array(), array(), 0 ); } + /** + * Execute ALTER TABLE ... AUTO_INCREMENT = N. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_set_auto_increment( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $requested_next = $this->parse_auto_increment_option_value( $tokens, 1, 'ALTER TABLE' ); + $metadata = $this->auto_increment_metadata_for_table( $table_name, $temporary ); + if ( null === $metadata ) { + return $this->empty_ddl_result(); + } + + $max_existing = $this->max_auto_increment_column_value( $table_name, $metadata['column_name'], $temporary ); + $next_value = max( $requested_next, $max_existing + 1, 1 ); + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $next_value, $temporary ): WP_DuckDB_Result_Statement { + $this->rebuild_auto_increment_table_with_next_value( $table_name, $next_value, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + /** * Execute ALTER TABLE ... DROP INDEX|KEY. * @@ -3185,14 +3227,23 @@ private function execute_show_create_table( array $tokens ): WP_DuckDB_Result_St /** * Build a MySQL-shaped SHOW CREATE TABLE statement from DuckDB metadata. * - * @param string $table_name Resolved physical table name. - * @param string $requested_table_name Requested MySQL table name. + * @param string $table_name Resolved physical table name. + * @param string $requested_table_name Requested MySQL table name. + * @param bool $temporary Whether the table is temporary. + * @param int|null $auto_increment_override AUTO_INCREMENT value override. * @return string MySQL CREATE TABLE statement. */ - private function mysql_create_table_statement( string $table_name, string $requested_table_name, bool $temporary = false ): string { + private function mysql_create_table_statement( string $table_name, string $requested_table_name, bool $temporary = false, ?int $auto_increment_override = null ): string { $metadata_by_table = $this->table_metadata_by_table( $temporary ); $table_metadata = $metadata_by_table[ $table_name ] ?? $this->fallback_table_metadata( $table_name ); - $table_info = $this->information_schema_table_row( $table_name, $table_metadata, $temporary ); + $table_info = null === $auto_increment_override + ? $this->information_schema_table_row( $table_name, $table_metadata, $temporary ) + : array( + 'ENGINE' => $table_metadata['engine'], + 'AUTO_INCREMENT' => $auto_increment_override, + 'TABLE_COLLATION' => $table_metadata['table_collation'], + 'TABLE_COMMENT' => $table_metadata['table_comment'], + ); $column_rows = $this->show_create_table_column_rows( $table_name, $temporary ); $rows = array(); $has_auto_increment = false; @@ -3210,7 +3261,7 @@ private function mysql_create_table_statement( string $table_name, string $reque $sql .= "\n)"; $sql .= ' ENGINE=' . (string) $table_info['ENGINE']; - $auto_increment = $table_info['AUTO_INCREMENT']; + $auto_increment = null === $auto_increment_override ? $table_info['AUTO_INCREMENT'] : $auto_increment_override; if ( $has_auto_increment && null !== $auto_increment && (int) $auto_increment > 1 ) { $sql .= ' AUTO_INCREMENT=' . (int) $auto_increment; } @@ -4022,9 +4073,11 @@ private function split_top_level_comma_items( array $tokens ): array { * @param WP_Parser_Token[] $tokens Column definition tokens. * @param bool $include_inline_constraints Whether to include inline NOT NULL/PRIMARY KEY SQL. * @param bool $allow_position_options Whether to accept FIRST/AFTER position hints. + * @param bool $temporary Whether the target is a temporary table. + * @param int|null $auto_increment_seed Optional AUTO_INCREMENT table option. * @return array{0:string,1:string|null,2:array}>,3:array} */ - private function translate_create_table_column( string $table_name, array $tokens, bool $include_inline_constraints = true, bool $allow_position_options = false, bool $temporary = false ): array { + private function translate_create_table_column( string $table_name, array $tokens, bool $include_inline_constraints = true, bool $allow_position_options = false, bool $temporary = false, ?int $auto_increment_seed = null ): array { $index = 0; $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; @@ -4132,7 +4185,12 @@ private function translate_create_table_column( string $table_name, array $token if ( $auto_increment ) { $sequence_name = $this->sequence_name( $table_name, $column_name, $temporary ); - $sequence = 'CREATE ' . ( $temporary ? 'TEMP ' : '' ) . 'SEQUENCE IF NOT EXISTS ' . $this->connection->quote_identifier( $sequence_name ) . ' START 1'; + $sequence = 'CREATE ' + . ( $temporary ? 'TEMP ' : '' ) + . 'SEQUENCE IF NOT EXISTS ' + . $this->connection->quote_identifier( $sequence_name ) + . ' START ' + . ( null !== $auto_increment_seed && $auto_increment_seed > 1 ? $auto_increment_seed - 1 : 1 ); $column_sql .= ' DEFAULT nextval(' . $this->connection->quote( $sequence_name ) . ')'; } elseif ( null !== $default_sql ) { $column_sql .= ' DEFAULT ' . $default_sql; @@ -4675,12 +4733,13 @@ private function assert_supported_index_options( array $tokens, int $index ): vo * Parse supported CREATE TABLE tail options into MySQL-facing metadata. * * @param WP_Parser_Token[] $tokens Tail tokens after the column list. - * @return array{engine:string,row_format:string,table_collation:string,table_comment:string,create_options:string} + * @return array{engine:string,row_format:string,table_collation:string,table_comment:string,create_options:string,auto_increment:int|null} */ private function parse_create_table_options( array $tokens ): array { $engine = 'InnoDB'; $table_collation = 'utf8mb4_0900_ai_ci'; $table_comment = ''; + $auto_increment = null; $index = 0; while ( $index < count( $tokens ) ) { $token = $tokens[ $index ]; @@ -4709,13 +4768,18 @@ private function parse_create_table_options( array $tokens ): array { if ( WP_MySQL_Lexer::CHARSET_SYMBOL === $token->id - || WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL === $token->id || WP_MySQL_Lexer::ROW_FORMAT_SYMBOL === $token->id ) { $index = $this->skip_option_value( $tokens, $index + 1 ); continue; } + if ( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL === $token->id ) { + $auto_increment = $this->parse_auto_increment_option_value( $tokens, $index + 1, 'CREATE TABLE', true ); + $index = $this->skip_option_value( $tokens, $index + 1 ); + continue; + } + if ( WP_MySQL_Lexer::CHAR_SYMBOL === $token->id || WP_MySQL_Lexer::CHARACTER_SYMBOL === $token->id ) { if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $index + 1 ]->id ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE option in DuckDB driver: ' . $token->get_bytes() . '.' ); @@ -4732,9 +4796,40 @@ private function parse_create_table_options( array $tokens ): array { 'table_collation' => $table_collation, 'table_comment' => $table_comment, 'create_options' => '', + 'auto_increment' => $auto_increment, ); } + /** + * Parse an AUTO_INCREMENT table option value. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Index at optional equals or value. + * @param string $statement Statement name for errors. + * @param bool $allow_trailing Whether unrelated trailing option tokens are allowed. + * @return int Requested next AUTO_INCREMENT value. + */ + private function parse_auto_increment_option_value( array $tokens, int $index, string $statement, bool $allow_trailing = false ): int { + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $index ]->id ) { + ++$index; + } + + if ( ! isset( $tokens[ $index ] ) || ! $this->is_number_token( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( $statement . ' AUTO_INCREMENT requires a numeric value in the DuckDB driver.' ); + } + + $value = (int) $tokens[ $index ]->get_bytes(); + if ( $value < 1 ) { + throw new WP_DuckDB_Driver_Exception( $statement . ' AUTO_INCREMENT requires a positive value in the DuckDB driver.' ); + } + + if ( ! $allow_trailing && isset( $tokens[ $index + 1 ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' AUTO_INCREMENT option in DuckDB driver.' ); + } + + return $value; + } + /** * Normalize a MySQL storage engine value for information_schema.tables. * @@ -5902,6 +5997,25 @@ private function sequence_currval( string $sequence_name ): ?int { return false === $value || null === $value ? null : (int) $value; } + /** + * Prime a sequence so the next generated value matches MySQL AUTO_INCREMENT metadata. + * + * DuckDB exposes currval only after nextval has been called in the session. + * + * @param string $sequence_name Sequence name. + * @param int $next_value Desired next generated value. + */ + private function prime_auto_increment_sequence( string $sequence_name, int $next_value ): void { + if ( $next_value <= 1 ) { + return; + } + + $this->execute_duckdb_query( + 'SELECT nextval(' . $this->connection->quote( $sequence_name ) . ')', + 'Failed to initialize DuckDB AUTO_INCREMENT sequence' + ); + } + /** * Execute DuckDB SQL and preserve inspectable SQL output. * @@ -6409,7 +6523,7 @@ function ( array $row ): string { * @param string $table_name Table name. */ private function rebuild_empty_auto_increment_table( string $table_name, bool $temporary = false ): void { - $create_sql = $this->mysql_create_table_statement( $table_name, $table_name, $temporary ); + $create_sql = $this->mysql_create_table_statement( $table_name, $table_name, $temporary, 1 ); $sequence_names = $this->auto_increment_sequences_for_table( $table_name, $temporary ); $this->execute_duckdb_query( @@ -6420,6 +6534,70 @@ private function rebuild_empty_auto_increment_table( string $table_name, bool $t $this->execute_create_table( $this->tokenize_and_validate( $create_sql ) ); } + /** + * Rebuild a table so its AUTO_INCREMENT sequence has a specific next value. + * + * @param string $table_name Table name. + * @param int $next_value Desired next generated value. + * @param bool $temporary Whether the target is a temporary table. + */ + private function rebuild_auto_increment_table_with_next_value( string $table_name, int $next_value, bool $temporary = false ): void { + $create_sql = $this->mysql_create_table_statement( $table_name, $table_name, $temporary, $next_value ); + $sequence_names = $this->auto_increment_sequences_for_table( $table_name, $temporary ); + $metadata_rows = $this->table_column_metadata_rows( $table_name, $temporary ); + $column_names = array_map( + function ( array $column ): string { + return (string) $column['column_name']; + }, + $metadata_rows + ); + $quoted_columns = implode( + ', ', + array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ); + }, + $column_names + ) + ); + $backup_table = '__wp_duckdb_auto_increment_backup_' . substr( hash( 'sha256', $table_name . "\0" . microtime( true ) . "\0" . mt_rand() ), 0, 16 ); + + $this->execute_duckdb_query( + 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $backup_table ), + 'Failed to reset DuckDB AUTO_INCREMENT rebuild backup table' + ); + $this->execute_duckdb_query( + 'CREATE TEMP TABLE ' + . $this->connection->quote_identifier( $backup_table ) + . ' AS SELECT ' + . $quoted_columns + . ' FROM ' + . $this->connection->quote_identifier( $table_name ), + 'Failed to back up DuckDB table for AUTO_INCREMENT rebuild' + ); + $this->execute_duckdb_query( + 'DROP TABLE ' . $this->connection->quote_identifier( $table_name ), + 'Failed to rebuild DuckDB AUTO_INCREMENT table' + ); + $this->drop_auto_increment_sequences( $sequence_names ); + $this->execute_create_table( $this->tokenize_and_validate( $create_sql ) ); + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( $table_name ) + . ' (' + . $quoted_columns + . ') SELECT ' + . $quoted_columns + . ' FROM ' + . $this->connection->quote_identifier( $backup_table ), + 'Failed to restore DuckDB table rows after AUTO_INCREMENT rebuild' + ); + $this->execute_duckdb_query( + 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $backup_table ), + 'Failed to drop DuckDB AUTO_INCREMENT rebuild backup table' + ); + } + /** * Drop temporary information_schema compatibility snapshots. */ @@ -6804,6 +6982,27 @@ private function table_auto_increment_value( string $table_name, bool $temporary return null === $current ? 1 : $current + 1; } + /** + * Read the current maximum value in an AUTO_INCREMENT column. + * + * @param string $table_name Table name. + * @param string $column_name AUTO_INCREMENT column name. + * @param bool $temporary Whether the target is a temporary table. + * @return int Maximum existing value, or 0 for an empty table. + */ + private function max_auto_increment_column_value( string $table_name, string $column_name, bool $temporary = false ): int { + $stmt = $this->execute_duckdb_query( + 'SELECT MAX(' + . $this->connection->quote_identifier( $column_name ) + . ') AS max_value FROM ' + . $this->connection->quote_identifier( $table_name ), + 'Failed to inspect DuckDB AUTO_INCREMENT column' + ); + + $value = $stmt->fetchColumn(); + return false === $value || null === $value ? 0 : (int) $value; + } + /** * Return the canonical tables column name for a token value. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index d0b867672..d933e0795 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -667,6 +667,108 @@ public function test_truncate_lifecycle_matches_sqlite(): void { ); } + public function test_create_table_auto_increment_seed_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE seeded_ai ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(20) + ) AUTO_INCREMENT=100', + 'CREATE TABLE seeded_plain (id INT, name VARCHAR(20)) AUTO_INCREMENT=500', + ) + ); + + $this->assertParityRowColumns( "SHOW TABLE STATUS LIKE 'seeded_ai'", array( 'Name', 'Auto_increment' ) ); + $this->runParitySetup( array( "INSERT INTO seeded_ai (name) VALUES ('a')" ) ); + $this->assertParityRows( 'SELECT id, name FROM seeded_ai ORDER BY id' ); + $this->assertParityRowColumns( "SHOW TABLE STATUS LIKE 'seeded_ai'", array( 'Name', 'Auto_increment' ) ); + $this->assertParityRows( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'seeded_ai'" + ); + $this->assertParityRows( 'SHOW CREATE TABLE seeded_ai' ); + $this->assertParityRowColumns( "SHOW TABLE STATUS LIKE 'seeded_plain'", array( 'Name', 'Auto_increment' ) ); + $this->assertParityRows( 'SHOW CREATE TABLE seeded_plain' ); + } + + public function test_alter_table_auto_increment_seed_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE alter_ai ( + id INT AUTO_INCREMENT PRIMARY KEY, + name TEXT + )', + 'ALTER TABLE alter_ai AUTO_INCREMENT = 50', + ) + ); + + $this->assertParityRowColumns( "SHOW TABLE STATUS LIKE 'alter_ai'", array( 'Name', 'Auto_increment' ) ); + $this->runParitySetup( array( "INSERT INTO alter_ai (name) VALUES ('first')" ) ); + $this->assertParityRows( 'SELECT id, name FROM alter_ai ORDER BY id' ); + $this->assertParityRowColumns( "SHOW TABLE STATUS LIKE 'alter_ai'", array( 'Name', 'Auto_increment' ) ); + + $this->runParitySetup( array( 'ALTER TABLE alter_ai AUTO_INCREMENT = 200' ) ); + $this->assertParityRowColumns( "SHOW TABLE STATUS LIKE 'alter_ai'", array( 'Name', 'Auto_increment' ) ); + $this->assertParityRows( 'SHOW CREATE TABLE alter_ai' ); + + $this->runParitySetup( array( 'ALTER TABLE alter_ai AUTO_INCREMENT = 1' ) ); + $this->assertParityRowColumns( "SHOW TABLE STATUS LIKE 'alter_ai'", array( 'Name', 'Auto_increment' ) ); + $this->runParitySetup( array( "INSERT INTO alter_ai (name) VALUES ('second')" ) ); + $this->assertParityRows( 'SELECT id, name FROM alter_ai ORDER BY id' ); + + $this->runParitySetup( + array( + 'CREATE TABLE alter_plain (id INT, name TEXT) AUTO_INCREMENT=500', + 'ALTER TABLE alter_plain AUTO_INCREMENT = 900', + ) + ); + $this->assertParityRowColumns( "SHOW TABLE STATUS LIKE 'alter_plain'", array( 'Name', 'Auto_increment' ) ); + $this->assertParityRows( 'SHOW CREATE TABLE alter_plain' ); + } + + public function test_temporary_table_auto_increment_seed_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE shadow_ai ( + id INT AUTO_INCREMENT PRIMARY KEY, + name TEXT + )', + "INSERT INTO shadow_ai (name) VALUES ('p1'), ('p2')", + 'CREATE TEMPORARY TABLE shadow_ai ( + id INT AUTO_INCREMENT PRIMARY KEY, + name TEXT + ) AUTO_INCREMENT=500', + ) + ); + + $this->assertParityRowColumns( "SHOW TABLE STATUS LIKE 'shadow_ai'", array( 'Name', 'Auto_increment' ) ); + $this->assertParityRows( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'shadow_ai'" + ); + + $this->runParitySetup( array( "INSERT INTO shadow_ai (name) VALUES ('temp-a')" ) ); + $this->assertParityRows( 'SELECT id, name FROM shadow_ai ORDER BY id' ); + $this->runParitySetup( + array( + 'ALTER TABLE shadow_ai AUTO_INCREMENT = 1000', + "INSERT INTO shadow_ai (name) VALUES ('temp-b')", + ) + ); + $this->assertParityRows( 'SELECT id, name FROM shadow_ai ORDER BY id' ); + $this->assertParityRowColumns( "SHOW TABLE STATUS LIKE 'shadow_ai'", array( 'Name', 'Auto_increment' ) ); + + $this->runParitySetup( + array( + 'DROP TEMPORARY TABLE shadow_ai', + "INSERT INTO shadow_ai (name) VALUES ('p3')", + ) + ); + $this->assertParityRows( 'SELECT id, name FROM shadow_ai ORDER BY id' ); + } + public function test_drop_index_lifecycle_matches_sqlite(): void { $this->runParitySetup( array( From bce83cbd6b53a1aa6f2bb9b92311ece354e26306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 20:58:39 +0000 Subject: [PATCH 028/317] Add DuckDB SQL transaction parity --- .../src/duckdb/class-wp-duckdb-driver.php | 98 +++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 42 ++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 29 ++++++ 3 files changed, 169 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 52de288d0..630ffe580 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -158,6 +158,14 @@ public function query( string $query ): WP_DuckDB_Result_Statement { } switch ( $tokens[0]->id ) { + case WP_MySQL_Lexer::BEGIN_SYMBOL: + return $this->execute_begin_transaction_statement( $tokens ); + case WP_MySQL_Lexer::START_SYMBOL: + return $this->execute_start_transaction_statement( $tokens ); + case WP_MySQL_Lexer::COMMIT_SYMBOL: + return $this->execute_commit_statement( $tokens ); + case WP_MySQL_Lexer::ROLLBACK_SYMBOL: + return $this->execute_rollback_statement( $tokens ); case WP_MySQL_Lexer::SELECT_SYMBOL: return $this->execute_select( $tokens ); case WP_MySQL_Lexer::CREATE_SYMBOL: @@ -1768,6 +1776,96 @@ private function empty_ddl_result(): WP_DuckDB_Result_Statement { return new WP_DuckDB_Result_Statement( array(), array(), 0 ); } + /** + * Execute BEGIN [WORK]. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_begin_transaction_statement( array $tokens ): WP_DuckDB_Result_Statement { + $this->assert_optional_work_only( $tokens, 'BEGIN' ); + $this->begin_user_transaction(); + return $this->empty_ddl_result(); + } + + /** + * Execute START TRANSACTION. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_start_transaction_statement( array $tokens ): WP_DuckDB_Result_Statement { + if ( 2 !== count( $tokens ) || WP_MySQL_Lexer::TRANSACTION_SYMBOL !== $tokens[1]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported START statement in DuckDB driver. Only START TRANSACTION is supported.' ); + } + + $this->begin_user_transaction(); + return $this->empty_ddl_result(); + } + + /** + * Execute COMMIT [WORK]. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_commit_statement( array $tokens ): WP_DuckDB_Result_Statement { + $this->assert_optional_work_only( $tokens, 'COMMIT' ); + if ( $this->connection->inTransaction() ) { + $this->last_duckdb_queries[] = 'COMMIT'; + $this->connection->commit(); + } + return $this->empty_ddl_result(); + } + + /** + * Execute ROLLBACK [WORK]. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_rollback_statement( array $tokens ): WP_DuckDB_Result_Statement { + $this->assert_optional_work_only( $tokens, 'ROLLBACK' ); + if ( $this->connection->inTransaction() ) { + $this->last_duckdb_queries[] = 'ROLLBACK'; + $this->connection->rollback(); + } + return $this->empty_ddl_result(); + } + + /** + * Begin a MySQL-style user transaction. + * + * MySQL implicitly commits the active transaction before starting another. + */ + private function begin_user_transaction(): void { + if ( $this->connection->inTransaction() ) { + $this->last_duckdb_queries[] = 'COMMIT'; + $this->connection->commit(); + } + + $this->last_duckdb_queries[] = 'BEGIN TRANSACTION'; + $this->connection->beginTransaction(); + } + + /** + * Assert that a transaction statement has no trailing tokens except WORK. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param string $statement Statement name. + */ + private function assert_optional_work_only( array $tokens, string $statement ): void { + if ( 1 === count( $tokens ) ) { + return; + } + + if ( 2 === count( $tokens ) && WP_MySQL_Lexer::WORK_SYMBOL === $tokens[1]->id ) { + return; + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only ' . $statement . ' [WORK] is supported.' ); + } + /** * Skip supported DROP INDEX options. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index d933e0795..271aac4e3 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -33,6 +33,48 @@ public function test_create_insert_select_update_delete_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_transaction_sql_matches_sqlite(): void { + $this->runParitySetup( array( 'CREATE TABLE tx_items (id INT)' ) ); + + $this->runParitySetup( + array( + 'BEGIN', + 'INSERT INTO tx_items (id) VALUES (1)', + ) + ); + $this->assertParityRows( 'SELECT id FROM tx_items ORDER BY id' ); + $this->runParitySetup( array( 'ROLLBACK' ) ); + $this->assertParityRows( 'SELECT id FROM tx_items ORDER BY id' ); + + $this->runParitySetup( + array( + 'START TRANSACTION', + 'INSERT INTO tx_items (id) VALUES (2)', + 'COMMIT', + ) + ); + $this->assertParityRows( 'SELECT id FROM tx_items ORDER BY id' ); + + $this->runParitySetup( + array( + 'BEGIN WORK', + 'INSERT INTO tx_items (id) VALUES (3)', + 'BEGIN', + 'INSERT INTO tx_items (id) VALUES (4)', + 'ROLLBACK WORK', + ) + ); + $this->assertParityRows( 'SELECT id FROM tx_items ORDER BY id' ); + + $this->runParitySetup( + array( + 'ROLLBACK', + 'COMMIT WORK', + ) + ); + $this->assertParityRows( 'SELECT id FROM tx_items ORDER BY id' ); + } + public function test_update_delete_alias_order_limit_match_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 845fa637f..87435d582 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -120,6 +120,35 @@ function ( string $sql ): bool { $this->assertSame( 'anonymous', $describe[1]['Default'] ); } + public function test_sql_transaction_statements_update_connection_state_and_query_log(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE tx_state (id INT)' ); + + $this->assertFalse( $driver->get_connection()->inTransaction() ); + $driver->query( 'BEGIN' ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( array( 'BEGIN TRANSACTION' ), $driver->get_last_duckdb_queries() ); + + $driver->query( 'INSERT INTO tx_state (id) VALUES (1)' ); + $driver->query( 'BEGIN' ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( array( 'COMMIT', 'BEGIN TRANSACTION' ), $driver->get_last_duckdb_queries() ); + + $driver->query( 'INSERT INTO tx_state (id) VALUES (2)' ); + $driver->query( 'ROLLBACK' ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + $this->assertSame( array( 'ROLLBACK' ), $driver->get_last_duckdb_queries() ); + $this->assertSame( + array( array( 'id' => 1 ) ), + $driver->query( 'SELECT id FROM tx_state ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'COMMIT' ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + public function test_update_delete_alias_order_limit_are_rewritten(): void { $this->requireDuckDBRuntime(); From 71fbabb2a8f783f46d1e4f26ca02461335b568f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 21:19:55 +0000 Subject: [PATCH 029/317] Add DuckDB case-insensitive unique parity --- .../src/duckdb/class-wp-duckdb-driver.php | 389 +++++++++++++++++- .../WP_DuckDB_Differential_TestCase.php | 48 +++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 62 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 108 +++++ 4 files changed, 594 insertions(+), 13 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 630ffe580..c005d61f1 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -452,7 +452,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE constraint in DuckDB driver: ' . $item[0]->get_bytes() . '.' ); } - list( $column_sql, $sequence_sql, $column_indexes, $column_metadata ) = $this->translate_create_table_column( $table_name, $item, true, false, $temporary, $auto_increment_seed ); + list( $column_sql, $sequence_sql, $column_indexes, $column_metadata ) = $this->translate_create_table_column( $table_name, $item, true, false, $temporary, $auto_increment_seed, $table_metadata['table_collation'] ); $columns[] = $column_sql; $metadata[] = $column_metadata; if ( null !== $sequence_sql ) { @@ -614,11 +614,15 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { ); } + if ( ! $ignore ) { + $this->assert_insert_values_do_not_conflict_with_case_insensitive_unique_keys( $tokens, $index ); + } + return $this->execute_auto_increment_write( $this->identifier_value( $tokens[ $index ] ?? null ), $ignore - ? $this->translate_insert_ignore_tokens_to_duckdb_sql( $tokens, $index ) - : $this->translate_tokens_to_duckdb_sql( $tokens ), + ? $this->translate_insert_ignore_tokens_to_duckdb_sql( $tokens, $index ) + : $this->translate_tokens_to_duckdb_sql( $tokens ), 'Failed to execute DuckDB INSERT', $tokens, $index @@ -649,6 +653,11 @@ private function execute_replace( array $tokens ): WP_DuckDB_Result_Statement { $this->assert_values_write_statement( $tokens, $index, 'REPLACE' ); + $manual_replace = $this->execute_replace_values_with_manual_conflict_handling( $tokens, $index ); + if ( null !== $manual_replace ) { + return $manual_replace; + } + return $this->execute_auto_increment_write( $this->identifier_value( $tokens[ $index ] ?? null ), $this->translate_replace_tokens_to_duckdb_sql( $tokens ), @@ -3024,7 +3033,7 @@ private function execute_alter_table_add_column( string $table_name, array $toke $tokens = $items[0]; } - list( $column_sql, $sequence_sql, $indexes, $metadata ) = $this->translate_create_table_column( $table_name, $tokens, false, true, $temporary ); + list( $column_sql, $sequence_sql, $indexes, $metadata ) = $this->translate_create_table_column( $table_name, $tokens, false, true, $temporary, null, $this->table_default_collation( $table_name, $temporary ) ); if ( 'PRI' === $metadata['column_key'] ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN PRIMARY KEY is not supported.' ); } @@ -3150,7 +3159,7 @@ private function execute_alter_table_modify_column( string $table_name, array $t * @return WP_DuckDB_Result_Statement */ private function execute_alter_table_change_or_modify_column( string $table_name, string $old_column_name, array $definition_tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { - list( , $sequence_sql, $inline_indexes, $metadata ) = $this->translate_create_table_column( $table_name, $definition_tokens, false, true, $temporary ); + list( , $sequence_sql, $inline_indexes, $metadata ) = $this->translate_create_table_column( $table_name, $definition_tokens, false, true, $temporary, null, $this->table_default_collation( $table_name, $temporary ) ); if ( null !== $sequence_sql || 'auto_increment' === $metadata['extra'] ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY AUTO_INCREMENT requires a table rebuild.' ); } @@ -4173,9 +4182,10 @@ private function split_top_level_comma_items( array $tokens ): array { * @param bool $allow_position_options Whether to accept FIRST/AFTER position hints. * @param bool $temporary Whether the target is a temporary table. * @param int|null $auto_increment_seed Optional AUTO_INCREMENT table option. + * @param string|null $default_collation_name Effective table collation for text columns without an explicit collation. * @return array{0:string,1:string|null,2:array}>,3:array} */ - private function translate_create_table_column( string $table_name, array $tokens, bool $include_inline_constraints = true, bool $allow_position_options = false, bool $temporary = false, ?int $auto_increment_seed = null ): array { + private function translate_create_table_column( string $table_name, array $tokens, bool $include_inline_constraints = true, bool $allow_position_options = false, bool $temporary = false, ?int $auto_increment_seed = null, ?string $default_collation_name = null ): array { $index = 0; $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; @@ -4278,8 +4288,14 @@ private function translate_create_table_column( string $table_name, array $token $duck_type = 'BIGINT'; } - $column_sql = $this->connection->quote_identifier( $column_name ) . ' ' . $duck_type; - $sequence = null; + $metadata_collation_name = $this->mysql_column_collation_from_tokens( $tokens, $type_token ); + $physical_collation_name = $this->mysql_column_collation_from_tokens( $tokens, $type_token, $default_collation_name ); + $column_sql = $this->connection->quote_identifier( $column_name ) . ' ' . $duck_type; + $sequence = null; + + if ( $this->mysql_column_uses_case_insensitive_collation( $type_token, $physical_collation_name ) ) { + $column_sql .= ' COLLATE NOCASE'; + } if ( $auto_increment ) { $sequence_name = $this->sequence_name( $table_name, $column_name, $temporary ); @@ -4325,7 +4341,7 @@ private function translate_create_table_column( string $table_name, array $token 'column_key' => $primary_key ? 'PRI' : ( $unique_key ? 'UNI' : '' ), 'column_default' => $auto_increment || null === $default_sql ? null : $this->normalize_describe_default( $default_sql ), 'extra' => $auto_increment ? 'auto_increment' : '', - 'collation_name' => $this->mysql_column_collation_from_tokens( $tokens, $type_token ), + 'collation_name' => $metadata_collation_name, 'comment' => $this->mysql_column_comment_from_tokens( $tokens ), '_duckdb_type' => $duck_type, '_default_sql' => $auto_increment ? null : $default_sql, @@ -4420,14 +4436,45 @@ private function mysql_column_type_from_tokens( array $tokens, int $type_index ) * @param WP_Parser_Token $type_token Type token. * @return string|null Collation name. */ - private function mysql_column_collation_from_tokens( array $tokens, WP_Parser_Token $type_token ): ?string { + private function mysql_column_collation_from_tokens( array $tokens, WP_Parser_Token $type_token, ?string $default_collation_name = null ): ?string { for ( $index = 0; $index < count( $tokens ); ++$index ) { if ( WP_MySQL_Lexer::COLLATE_SYMBOL === $tokens[ $index ]->id ) { return $this->option_value( $tokens, $index + 1 ); } } - return $this->mysql_type_has_collation( $type_token ) ? 'utf8mb4_0900_ai_ci' : null; + if ( ! $this->mysql_type_has_collation( $type_token ) ) { + return null; + } + + return null !== $default_collation_name && '' !== $default_collation_name ? $default_collation_name : 'utf8mb4_0900_ai_ci'; + } + + /** + * Check whether a MySQL column should use DuckDB's case-insensitive collation. + * + * @param WP_Parser_Token $type_token Type token. + * @param string|null $collation_name MySQL collation name. + * @return bool Whether the column should use COLLATE NOCASE. + */ + private function mysql_column_uses_case_insensitive_collation( WP_Parser_Token $type_token, ?string $collation_name ): bool { + return $this->mysql_type_has_collation( $type_token ) && $this->mysql_collation_is_case_insensitive( $collation_name ); + } + + /** + * Check whether a MySQL collation is case-insensitive. + * + * @param string|null $collation_name MySQL collation name. + * @return bool Whether the collation is case-insensitive. + */ + private function mysql_collation_is_case_insensitive( ?string $collation_name ): bool { + if ( null === $collation_name || '' === $collation_name ) { + return false; + } + + $collation_name = strtolower( trim( $collation_name ) ); + + return strlen( $collation_name ) > 3 && '_ci' === substr( $collation_name, -3 ); } /** @@ -5207,6 +5254,21 @@ private function translate_replace_tokens_to_duckdb_sql( array $tokens ): string return 'INSERT OR REPLACE INTO ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $index ) ); } + /** + * Translate MySQL REPLACE ... VALUES to a plain DuckDB INSERT after manual conflict deletion. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return string DuckDB SQL. + */ + private function translate_replace_tokens_to_duckdb_insert_sql( array $tokens ): string { + $index = 1; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + + return 'INSERT INTO ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $index ) ); + } + /** * Translate MySQL INSERT IGNORE ... VALUES to DuckDB INSERT OR IGNORE. * @@ -5401,6 +5463,252 @@ private function parse_on_duplicate_insert_shape( array $tokens, int $table_inde ); } + /** + * Assert plain INSERT ... VALUES does not bypass a case-insensitive unique key. + * + * DuckDB's plain INSERT can allow case-only duplicates for a unique index on a + * COLLATE NOCASE column, even though INSERT OR IGNORE and ON CONFLICT honor it. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + */ + private function assert_insert_values_do_not_conflict_with_case_insensitive_unique_keys( array $tokens, int $table_index ): void { + $insert_shape = $this->parse_insert_values_shape( $tokens, $table_index ); + $case_insensitive_columns = $this->case_insensitive_column_names( $insert_shape['table_name'] ); + if ( count( $case_insensitive_columns ) === 0 ) { + return; + } + + foreach ( $insert_shape['rows'] as $values_by_column ) { + foreach ( $this->unique_key_column_sets( $insert_shape['table_name'] ) as $column_set ) { + $has_all_values = true; + $has_case_insensitive_key = false; + foreach ( $column_set as $column_name ) { + $column_key = strtolower( $column_name ); + if ( ! array_key_exists( $column_key, $values_by_column ) ) { + $has_all_values = false; + break; + } + if ( isset( $case_insensitive_columns[ $column_key ] ) ) { + $has_case_insensitive_key = true; + } + } + + if ( ! $has_all_values || ! $has_case_insensitive_key ) { + continue; + } + + if ( $this->insert_values_conflict_with_target( $insert_shape['table_name'], $column_set, $values_by_column ) ) { + throw new WP_DuckDB_Driver_Exception( + 'Failed to execute DuckDB INSERT: UNIQUE constraint failed: ' + . $insert_shape['table_name'] + . '.' + . implode( ', ', $column_set ) + ); + } + } + } + } + + /** + * Parse a supported INSERT ... VALUES shape into per-row value SQL. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @return array{table_name:string,rows:array>} + */ + private function parse_insert_values_shape( array $tokens, int $table_index ): array { + $table_name = $this->identifier_value( $tokens[ $table_index ] ?? null ); + $index = $table_index + 1; + $columns = array(); + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + list( $column_items, $index ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); + foreach ( $column_items as $column_tokens ) { + if ( 1 !== count( $column_tokens ) ) { + return array( + 'table_name' => $table_name, + 'rows' => array(), + ); + } + $columns[] = $this->identifier_value( $column_tokens[0] ); + } + } else { + foreach ( $this->table_column_metadata_rows( $table_name ) as $row ) { + if ( ! array_key_exists( 'column_name', $row ) ) { + return array( + 'table_name' => $table_name, + 'rows' => array(), + ); + } + $columns[] = (string) $row['column_name']; + } + } + + if ( count( $columns ) === 0 || ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $index ]->id ) { + return array( + 'table_name' => $table_name, + 'rows' => array(), + ); + } + ++$index; + + $rows = array(); + while ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + list( $value_items, $index ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); + if ( count( $columns ) !== count( $value_items ) ) { + return array( + 'table_name' => $table_name, + 'rows' => array(), + ); + } + + $values_by_column = array(); + foreach ( $columns as $offset => $column_name ) { + $values_by_column[ strtolower( $column_name ) ] = $this->translate_tokens_to_duckdb_sql( $value_items[ $offset ] ); + } + $rows[] = $values_by_column; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + continue; + } + break; + } + + return array( + 'table_name' => $table_name, + 'rows' => $rows, + ); + } + + /** + * Execute REPLACE ... VALUES when DuckDB's native INSERT OR REPLACE is insufficient. + * + * DuckDB requires a conflict target for INSERT OR REPLACE when multiple unique + * constraints exist. MySQL REPLACE instead deletes every row that conflicts + * with any supplied unique key, then inserts the incoming row. Case-insensitive + * MySQL unique keys also need manual matching because DuckDB plain INSERT can + * bypass those conflicts. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @return WP_DuckDB_Result_Statement|null Statement when emulated, null when native DuckDB can be used. + */ + private function execute_replace_values_with_manual_conflict_handling( array $tokens, int $table_index ): ?WP_DuckDB_Result_Statement { + $replace_shape = $this->parse_insert_values_shape( $tokens, $table_index ); + $case_insensitive_columns = $this->case_insensitive_column_names( $replace_shape['table_name'] ); + if ( count( $replace_shape['rows'] ) === 0 ) { + return null; + } + + $unique_sets = $this->unique_key_column_sets( $replace_shape['table_name'] ); + if ( count( $unique_sets ) < 2 && ! $this->has_case_insensitive_unique_key( $unique_sets, $case_insensitive_columns ) ) { + return null; + } + + $started_transaction = false; + if ( ! $this->connection->inTransaction() ) { + $this->connection->beginTransaction(); + $started_transaction = true; + } + + try { + foreach ( $replace_shape['rows'] as $values_by_column ) { + $delete_predicates = array(); + foreach ( $unique_sets as $column_set ) { + $predicate = $this->unique_key_conflict_predicate( $column_set, $values_by_column, $case_insensitive_columns ); + if ( null !== $predicate ) { + $delete_predicates[] = '(' . $predicate . ')'; + } + } + + if ( count( $delete_predicates ) > 0 ) { + $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( $replace_shape['table_name'] ) + . ' WHERE ' + . implode( ' OR ', $delete_predicates ), + 'Failed to delete DuckDB REPLACE conflicts' + ); + } + } + + $result = $this->execute_auto_increment_write( + $replace_shape['table_name'], + $this->translate_replace_tokens_to_duckdb_insert_sql( $tokens ), + 'Failed to execute DuckDB REPLACE', + $tokens, + $table_index + ); + + if ( $started_transaction ) { + $this->connection->commit(); + } + + return $result; + } catch ( Throwable $e ) { + if ( $started_transaction && $this->connection->inTransaction() ) { + $this->connection->rollback(); + } + throw $e; + } + } + + /** + * Check whether any unique key includes a case-insensitive column. + * + * @param array $unique_sets Unique key column sets. + * @param array $case_insensitive_columns Lowercase column-name map. + * @return bool Whether a case-insensitive unique key exists. + */ + private function has_case_insensitive_unique_key( array $unique_sets, array $case_insensitive_columns ): bool { + foreach ( $unique_sets as $column_set ) { + foreach ( $column_set as $column_name ) { + if ( isset( $case_insensitive_columns[ strtolower( $column_name ) ] ) ) { + return true; + } + } + } + + return false; + } + + /** + * Build a predicate that matches an incoming row against a supplied unique key. + * + * @param string[] $column_set Unique key columns. + * @param array $values_by_column Inserted values keyed by lowercase column name. + * @param array $case_insensitive_columns Lowercase case-insensitive column-name map. + * @return string|null SQL predicate, or null when the incoming row does not supply every key column. + */ + private function unique_key_conflict_predicate( array $column_set, array $values_by_column, array $case_insensitive_columns ): ?string { + $where = array(); + foreach ( $column_set as $column_name ) { + $column_key = strtolower( $column_name ); + if ( ! array_key_exists( $column_key, $values_by_column ) ) { + return null; + } + + $value_sql = $values_by_column[ $column_key ]; + if ( isset( $case_insensitive_columns[ $column_key ] ) ) { + $where[] = 'lower(' + . $this->connection->quote_identifier( $column_name ) + . ') IS NOT DISTINCT FROM lower(CAST((' + . $value_sql + . ') AS VARCHAR))'; + continue; + } + + $where[] = $this->connection->quote_identifier( $column_name ) + . ' IS NOT DISTINCT FROM (' + . $value_sql + . ')'; + } + + return implode( ' AND ', $where ); + } + /** * Select the conflict target that MySQL would hit for a single inserted row. * @@ -5499,11 +5807,22 @@ function ( array $row ): string { * @return bool Whether an existing row matches the target values. */ private function insert_values_conflict_with_target( string $table_name, array $column_set, array $values_by_column ): bool { - $where = array(); + $where = array(); + $case_insensitive_columns = $this->case_insensitive_column_names( $table_name ); foreach ( $column_set as $column_name ) { + $value_sql = $values_by_column[ strtolower( $column_name ) ]; + if ( isset( $case_insensitive_columns[ strtolower( $column_name ) ] ) ) { + $where[] = 'lower(' + . $this->connection->quote_identifier( $column_name ) + . ') IS NOT DISTINCT FROM lower(CAST((' + . $value_sql + . ') AS VARCHAR))'; + continue; + } + $where[] = $this->connection->quote_identifier( $column_name ) . ' IS NOT DISTINCT FROM (' - . $values_by_column[ strtolower( $column_name ) ] + . $value_sql . ')'; } @@ -5519,6 +5838,31 @@ private function insert_values_conflict_with_target( string $table_name, array $ return false !== $stmt->fetch( PDO::FETCH_NUM ); } + /** + * Read case-insensitive MySQL-facing text columns for a table. + * + * @param string $table_name Table name. + * @return array Lowercase column-name map. + */ + private function case_insensitive_column_names( string $table_name ): array { + $columns = array(); + $table_collation = $this->table_default_collation( $table_name ); + foreach ( $this->column_metadata_rows( $table_name ) as $row ) { + if ( ! array_key_exists( 'column_name', $row ) || ! array_key_exists( 'collation_name', $row ) ) { + continue; + } + $collation_name = null === $row['collation_name'] ? null : (string) $row['collation_name']; + if ( 'utf8mb4_0900_ai_ci' === strtolower( (string) $collation_name ) && ! $this->mysql_collation_is_case_insensitive( $table_collation ) ) { + continue; + } + if ( $this->mysql_collation_is_case_insensitive( $collation_name ) ) { + $columns[ strtolower( (string) $row['column_name'] ) ] = true; + } + } + + return $columns; + } + /** * Translate an ODKU update list, rewriting MySQL VALUES(col) references. * @@ -7040,12 +7384,31 @@ private function table_metadata_by_table( bool $temporary = false ): array { $metadata = array(); foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + if ( ! array_key_exists( 'table_name', $row ) ) { + continue; + } $metadata[ (string) $row['table_name'] ] = $row; } return $metadata; } + /** + * Read the effective default table collation for new text columns. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + * @return string MySQL collation name. + */ + private function table_default_collation( string $table_name, bool $temporary = false ): string { + $metadata = $this->table_metadata_by_table( $temporary ); + if ( isset( $metadata[ $table_name ]['table_collation'] ) && '' !== $metadata[ $table_name ]['table_collation'] ) { + return (string) $metadata[ $table_name ]['table_collation']; + } + + return 'utf8mb4_0900_ai_ci'; + } + /** * Fallback table metadata for tables created outside the MySQL-emulation path. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php index 91ebd511f..80a8bce6d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php @@ -79,6 +79,22 @@ protected function assertParityRowCount( string $sql ): void { // phpcs:ignore W ); } + /** + * Run SQL on both engines and assert both fail with a matching message. + * + * @param string $sql SQL query. + * @param string $needle Expected message fragment. + */ + protected function assertParityErrorContains( string $sql, string $needle ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $sqlite_message = $this->query_sqlite_error_message( $sql ); + $duckdb_message = $this->query_duckdb_error_message( $sql ); + + $this->assertIsString( $sqlite_message, 'SQLite query should have failed for SQL: ' . $sql ); + $this->assertIsString( $duckdb_message, 'DuckDB query should have failed for SQL: ' . $sql ); + $this->assertStringContainsString( $needle, $sqlite_message, 'SQLite error mismatch for SQL: ' . $sql ); + $this->assertStringContainsString( $needle, $duckdb_message, 'DuckDB error mismatch for SQL: ' . $sql ); + } + /** * Execute setup SQL on both engines. * @@ -133,6 +149,38 @@ private function query_duckdb_row_count( string $sql ): int { return $this->duckdb_driver->query( $sql )->rowCount(); } + /** + * Run a query on SQLite and return the error message. + * + * @param string $sql SQL query. + * @return string|null Error message, or null when the query succeeds. + */ + private function query_sqlite_error_message( string $sql ): ?string { + try { + $this->sqlite_driver->query( $sql, PDO::FETCH_ASSOC ); + } catch ( Throwable $e ) { + return $e->getMessage(); + } + + return null; + } + + /** + * Run a query on DuckDB and return the error message. + * + * @param string $sql SQL query. + * @return string|null Error message, or null when the query succeeds. + */ + private function query_duckdb_error_message( string $sql ): ?string { + try { + $this->duckdb_driver->query( $sql ); + } catch ( Throwable $e ) { + return $e->getMessage(); + } + + return null; + } + /** * Normalize rows across SQLite and DuckDB scalar fetch differences. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 271aac4e3..be3615b42 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -313,6 +313,68 @@ public function test_on_duplicate_key_update_values_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_case_insensitive_unique_conflicts_match_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE ci_items ( + id INTEGER PRIMARY KEY, + name VARCHAR(20) NOT NULL DEFAULT '', + payload VARCHAR(20), + UNIQUE KEY name (name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO ci_items (id, name, payload) VALUES (1, 'first', 'a')", + ) + ); + + $this->assertParityErrorContains( + "INSERT INTO ci_items (id, name, payload) VALUES (2, 'FIRST', 'duplicate')", + 'UNIQUE constraint failed' + ); + $this->assertParityRows( 'SELECT id, name, payload FROM ci_items ORDER BY id' ); + + $this->assertParityRowCount( "INSERT IGNORE INTO ci_items (id, name, payload) VALUES (2, 'FIRST', 'ignored')" ); + $this->assertParityRows( 'SELECT id, name, payload FROM ci_items ORDER BY id' ); + + $this->assertParityRowCount( + "INSERT INTO ci_items (id, name, payload) VALUES (2, 'FIRST', 'updated') + ON DUPLICATE KEY UPDATE name = VALUES(name), payload = VALUES(payload)" + ); + $this->assertParityRows( 'SELECT id, name, payload FROM ci_items ORDER BY id' ); + + $this->assertParityRowCount( "REPLACE INTO ci_items (id, name, payload) VALUES (2, 'first', 'replaced')" ); + $this->assertParityRows( 'SELECT id, name, payload FROM ci_items ORDER BY id' ); + } + + public function test_case_insensitive_composite_unique_conflicts_match_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE site_options ( + id INTEGER PRIMARY KEY, + site_id INTEGER NOT NULL DEFAULT 0, + option_name VARCHAR(20) NOT NULL DEFAULT '', + payload VARCHAR(20), + UNIQUE KEY site_option (site_id, option_name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO site_options (id, site_id, option_name, payload) VALUES (1, 1, 'first', 'a')", + "INSERT INTO site_options (id, site_id, option_name, payload) VALUES (2, 2, 'FIRST', 'other-site')", + ) + ); + + $this->assertParityErrorContains( + "INSERT INTO site_options (id, site_id, option_name, payload) VALUES (3, 1, 'FIRST', 'duplicate')", + 'UNIQUE constraint failed' + ); + + $this->assertParityRowCount( + "INSERT INTO site_options (id, site_id, option_name, payload) VALUES (3, 1, 'FIRST', 'updated') + ON DUPLICATE KEY UPDATE option_name = VALUES(option_name), payload = VALUES(payload)" + ); + $this->assertParityRows( 'SELECT id, site_id, option_name, payload FROM site_options ORDER BY id' ); + + $this->assertParityRowCount( "REPLACE INTO site_options (id, site_id, option_name, payload) VALUES (3, 1, 'first', 'replaced')" ); + $this->assertParityRows( 'SELECT id, site_id, option_name, payload FROM site_options ORDER BY id' ); + } + public function test_create_table_if_not_exists_with_secondary_index_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 87435d582..ce2a78e41 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -962,6 +962,114 @@ public function test_insert_on_duplicate_key_update_values_is_emulated(): void { ); } + public function test_case_insensitive_unique_conflicts_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE ci_items ( + id INTEGER PRIMARY KEY, + name VARCHAR(20) NOT NULL DEFAULT '', + payload VARCHAR(20), + UNIQUE KEY name (name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $this->assertStringContainsString( 'COLLATE NOCASE', $driver->get_last_duckdb_queries()[0] ); + + $driver->query( "INSERT INTO ci_items (id, name, payload) VALUES (1, 'first', 'a')" ); + + try { + $driver->query( "INSERT INTO ci_items (id, name, payload) VALUES (2, 'FIRST', 'duplicate')" ); + $this->fail( 'Expected case-insensitive duplicate INSERT to fail.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'UNIQUE constraint failed', $e->getMessage() ); + } + + $ignored = $driver->query( "INSERT IGNORE INTO ci_items (id, name, payload) VALUES (2, 'FIRST', 'ignored')" ); + $this->assertSame( 0, $ignored->rowCount() ); + + $updated = $driver->query( + "INSERT INTO ci_items (id, name, payload) VALUES (2, 'FIRST', 'updated') + ON DUPLICATE KEY UPDATE name = VALUES(name), payload = VALUES(payload)" + ); + $this->assertSame( 1, $updated->rowCount() ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'FIRST', + 'payload' => 'updated', + ), + ), + $driver->query( 'SELECT id, name, payload FROM ci_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $replaced = $driver->query( "REPLACE INTO ci_items (id, name, payload) VALUES (2, 'first', 'replaced')" ); + $this->assertSame( 1, $replaced->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'name' => 'first', + 'payload' => 'replaced', + ), + ), + $driver->query( 'SELECT id, name, payload FROM ci_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_binary_collation_unique_keys_remain_case_sensitive(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE bin_items ( + id INTEGER PRIMARY KEY, + name VARCHAR(20), + payload VARCHAR(20), + UNIQUE KEY name (name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin' + ); + + $driver->query( "INSERT INTO bin_items (id, name, payload) VALUES (1, 'first', 'a')" ); + $driver->query( "INSERT INTO bin_items (id, name, payload) VALUES (2, 'FIRST', 'b')" ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'first', + 'payload' => 'a', + ), + array( + 'id' => 2, + 'name' => 'FIRST', + 'payload' => 'b', + ), + ), + $driver->query( 'SELECT id, name, payload FROM bin_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $replaced = $driver->query( "REPLACE INTO bin_items (id, name, payload) VALUES (3, 'first', 'replaced')" ); + $this->assertSame( 1, $replaced->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'name' => 'FIRST', + 'payload' => 'b', + ), + array( + 'id' => 3, + 'name' => 'first', + 'payload' => 'replaced', + ), + ), + $driver->query( 'SELECT id, name, payload FROM bin_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_wordpress_style_schema_can_be_created(): void { $this->requireDuckDBRuntime(); From 1b881d07123b817d50ed4e3422c3b3bf989d5535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 21:37:12 +0000 Subject: [PATCH 030/317] Add DuckDB lock table parity --- .../src/duckdb/class-wp-duckdb-driver.php | 115 ++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 46 ++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 131 ++++++++++++++++++ 3 files changed, 291 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index c005d61f1..dbc4c9f7e 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -100,6 +100,13 @@ class WP_DuckDB_Driver { */ private $last_insert_id = 0; + /** + * Whether a MySQL LOCK TABLES statement opened the current transaction. + * + * @var bool + */ + private $table_lock_active = false; + /** * MySQL-compatible server version string. * @@ -166,6 +173,10 @@ public function query( string $query ): WP_DuckDB_Result_Statement { return $this->execute_commit_statement( $tokens ); case WP_MySQL_Lexer::ROLLBACK_SYMBOL: return $this->execute_rollback_statement( $tokens ); + case WP_MySQL_Lexer::LOCK_SYMBOL: + return $this->execute_lock_tables_statement( $tokens ); + case WP_MySQL_Lexer::UNLOCK_SYMBOL: + return $this->execute_unlock_tables_statement( $tokens ); case WP_MySQL_Lexer::SELECT_SYMBOL: return $this->execute_select( $tokens ); case WP_MySQL_Lexer::CREATE_SYMBOL: @@ -1785,6 +1796,105 @@ private function empty_ddl_result(): WP_DuckDB_Result_Statement { return new WP_DuckDB_Result_Statement( array(), array(), 0 ); } + /** + * Execute LOCK TABLE[S] ... READ|WRITE. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_lock_tables_statement( array $tokens ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::LOCK_SYMBOL, 'Expected LOCK.' ); + ++$index; + if ( + ! isset( $tokens[ $index ] ) + || ( WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $index ]->id && WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[ $index ]->id ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported LOCK statement in DuckDB driver. Only LOCK TABLES ... READ|WRITE is supported.' ); + } + ++$index; + + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'LOCK TABLES requires at least one table name.' ); + } + + $requested_table_names = array(); + while ( $index < count( $tokens ) ) { + $reference = $this->parse_lock_table_reference( $tokens, $index ); + $requested_table_names[] = $reference['requested_table_name']; + $index = $reference['next_index']; + + if ( $index >= count( $tokens ) ) { + break; + } + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::COMMA_SYMBOL, 'Expected comma between LOCK TABLES table references.' ); + ++$index; + if ( $index >= count( $tokens ) ) { + throw new WP_DuckDB_Driver_Exception( 'Expected table name after comma in LOCK TABLES statement.' ); + } + } + + foreach ( $requested_table_names as $requested_table_name ) { + if ( null === $this->resolve_visible_user_table_reference( $requested_table_name ) ) { + throw new WP_DuckDB_Driver_Exception( "Table '{$this->database}.{$requested_table_name}' doesn't exist" ); + } + } + + $this->begin_user_transaction(); + $this->table_lock_active = true; + + return $this->empty_ddl_result(); + } + + /** + * Parse one LOCK TABLES table reference and lock type. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index of the table reference. + * @return array{requested_table_name:string,next_index:int} + */ + private function parse_lock_table_reference( array $tokens, int $index ): array { + $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'LOCK TABLES' ); + $index = $reference['next_index']; + + if ( + ! isset( $tokens[ $index ] ) + || ( WP_MySQL_Lexer::READ_SYMBOL !== $tokens[ $index ]->id && WP_MySQL_Lexer::WRITE_SYMBOL !== $tokens[ $index ]->id ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported LOCK TABLES statement in DuckDB driver. Each table must specify READ or WRITE.' ); + } + ++$index; + + return array( + 'requested_table_name' => $reference['requested_table_name'], + 'next_index' => $index, + ); + } + + /** + * Execute UNLOCK TABLE[S]. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_unlock_tables_statement( array $tokens ): WP_DuckDB_Result_Statement { + if ( + 2 !== count( $tokens ) + || WP_MySQL_Lexer::UNLOCK_SYMBOL !== $tokens[0]->id + || ( WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id && WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[1]->id ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UNLOCK statement in DuckDB driver. Only UNLOCK TABLES is supported.' ); + } + + if ( $this->table_lock_active && $this->connection->inTransaction() ) { + $this->last_duckdb_queries[] = 'COMMIT'; + $this->connection->commit(); + } + $this->table_lock_active = false; + + return $this->empty_ddl_result(); + } + /** * Execute BEGIN [WORK]. * @@ -1824,6 +1934,7 @@ private function execute_commit_statement( array $tokens ): WP_DuckDB_Result_Sta $this->last_duckdb_queries[] = 'COMMIT'; $this->connection->commit(); } + $this->table_lock_active = false; return $this->empty_ddl_result(); } @@ -1839,6 +1950,7 @@ private function execute_rollback_statement( array $tokens ): WP_DuckDB_Result_S $this->last_duckdb_queries[] = 'ROLLBACK'; $this->connection->rollback(); } + $this->table_lock_active = false; return $this->empty_ddl_result(); } @@ -1851,6 +1963,7 @@ private function begin_user_transaction(): void { if ( $this->connection->inTransaction() ) { $this->last_duckdb_queries[] = 'COMMIT'; $this->connection->commit(); + $this->table_lock_active = false; } $this->last_duckdb_queries[] = 'BEGIN TRANSACTION'; @@ -8203,7 +8316,7 @@ private function resolve_user_table_name( string $table_name ): ?string { * @return bool Whether the table is internal to the driver. */ private function is_duckdb_internal_table_name( string $table_name ): bool { - return 0 === strpos( $table_name, '__wp_duckdb_' ); + return 0 === stripos( $table_name, '__wp_duckdb_' ); } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index be3615b42..f854a09f7 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -75,6 +75,52 @@ public function test_transaction_sql_matches_sqlite(): void { $this->assertParityRows( 'SELECT id FROM tx_items ORDER BY id' ); } + public function test_lock_unlock_sql_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE lock_items (id INT)', + 'CREATE TEMPORARY TABLE lock_temp (id INT)', + ) + ); + + $this->assertParityRowCount( 'UNLOCK TABLES' ); + $this->assertParityRowCount( 'LOCK TABLES lock_items READ' ); + $this->assertParityRowCount( 'UNLOCK TABLES' ); + $this->assertParityRowCount( 'LOCK TABLES wp.lock_items WRITE' ); + $this->assertParityRowCount( 'UNLOCK TABLES' ); + $this->assertParityRowCount( 'LOCK TABLE lock_items READ' ); + $this->assertParityRowCount( 'UNLOCK TABLES' ); + $this->assertParityRowCount( 'LOCK TABLES lock_temp READ, lock_items WRITE' ); + $this->assertParityRowCount( 'UNLOCK TABLES' ); + + $this->runParitySetup( + array( + 'BEGIN', + 'INSERT INTO lock_items (id) VALUES (1)', + 'LOCK TABLES lock_items WRITE', + 'INSERT INTO lock_items (id) VALUES (2)', + 'UNLOCK TABLES', + 'ROLLBACK', + ) + ); + $this->assertParityRows( 'SELECT id FROM lock_items ORDER BY id' ); + + $this->runParitySetup( + array( + 'LOCK TABLES lock_items WRITE', + 'BEGIN', + 'INSERT INTO lock_items (id) VALUES (3)', + 'COMMIT', + 'UNLOCK TABLES', + ) + ); + $this->assertParityRows( 'SELECT id FROM lock_items ORDER BY id' ); + + $this->assertParityErrorContains( 'LOCK TABLES missing_lock_item READ', "Table 'wp.missing_lock_item' doesn't exist" ); + $this->assertParityErrorContains( 'LOCK TABLES lock_items READ, missing_lock_item WRITE', "Table 'wp.missing_lock_item' doesn't exist" ); + $this->assertParityErrorContains( 'LOCK TABLES information_schema.tables READ', "to database 'information_schema'" ); + } + public function test_update_delete_alias_order_limit_match_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index ce2a78e41..9bfa64e18 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -149,6 +149,137 @@ public function test_sql_transaction_statements_update_connection_state_and_quer $this->assertSame( array(), $driver->get_last_duckdb_queries() ); } + public function test_lock_unlock_table_statements_update_transaction_state(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE lock_items (id INT)' ); + $driver->query( 'CREATE TEMPORARY TABLE lock_temp (id INT)' ); + + $unlock = $driver->query( 'UNLOCK TABLES' ); + $this->assertSame( 0, $unlock->rowCount() ); + $this->assertSame( 0, $unlock->columnCount() ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $lock = $driver->query( 'LOCK TABLES lock_items READ' ); + $this->assertSame( 0, $lock->rowCount() ); + $this->assertSame( 0, $lock->columnCount() ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( 'BEGIN TRANSACTION', $this->lastDuckDBQuery( $driver ) ); + + $unlock = $driver->query( 'UNLOCK TABLES' ); + $this->assertSame( 0, $unlock->rowCount() ); + $this->assertSame( 0, $unlock->columnCount() ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + $this->assertSame( array( 'COMMIT' ), $driver->get_last_duckdb_queries() ); + + $driver->query( 'LOCK TABLES wp.lock_items WRITE' ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( 'BEGIN TRANSACTION', $this->lastDuckDBQuery( $driver ) ); + $driver->query( 'UNLOCK TABLE' ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + + $driver->query( 'LOCK TABLE lock_items READ' ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( 'BEGIN TRANSACTION', $this->lastDuckDBQuery( $driver ) ); + $driver->query( 'UNLOCK TABLES' ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + + $driver->query( 'LOCK TABLES lock_temp READ, lock_items WRITE' ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( 'BEGIN TRANSACTION', $this->lastDuckDBQuery( $driver ) ); + $driver->query( 'UNLOCK TABLES' ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + + $driver->query( 'BEGIN' ); + $driver->query( 'INSERT INTO lock_items (id) VALUES (1)' ); + $driver->query( 'LOCK TABLES lock_items WRITE' ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( array( 'COMMIT', 'BEGIN TRANSACTION' ), array_slice( $driver->get_last_duckdb_queries(), -2 ) ); + $driver->query( 'INSERT INTO lock_items (id) VALUES (2)' ); + $driver->query( 'UNLOCK TABLES' ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + $driver->query( 'ROLLBACK' ); + $this->assertSame( + array( + array( 'id' => 1 ), + array( 'id' => 2 ), + ), + $driver->query( 'SELECT id FROM lock_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'LOCK TABLES lock_items WRITE' ); + $driver->query( 'BEGIN' ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( array( 'COMMIT', 'BEGIN TRANSACTION' ), $driver->get_last_duckdb_queries() ); + $driver->query( 'COMMIT' ); + $driver->query( 'UNLOCK TABLES' ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + + $driver->query( 'LOCK TABLES lock_items WRITE' ); + $driver->query( 'COMMIT' ); + $driver->query( 'BEGIN' ); + $driver->query( 'INSERT INTO lock_items (id) VALUES (4)' ); + $driver->query( 'UNLOCK TABLES' ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $driver->query( 'ROLLBACK' ); + $this->assertSame( + array( + array( 'id' => 1 ), + array( 'id' => 2 ), + ), + $driver->query( 'SELECT id FROM lock_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_lock_table_validation_matches_mysql_shaped_errors(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE lock_one (id INT)' ); + $driver->query( 'CREATE TABLE lock_three (id INT)' ); + $driver->query( 'BEGIN' ); + $driver->query( 'INSERT INTO lock_one (id) VALUES (1)' ); + + try { + $driver->query( 'LOCK TABLES lock_one READ, missing_table READ, lock_three WRITE' ); + $this->fail( 'Expected LOCK TABLES to reject a missing table.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "Table 'wp.missing_table' doesn't exist", $e->getMessage() ); + } + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $driver->query( 'ROLLBACK' ); + $this->assertSame( array(), $driver->query( 'SELECT id FROM lock_one' )->fetchAll( PDO::FETCH_ASSOC ) ); + + foreach ( + array( + 'LOCK TABLES information_schema.tables READ' => "Access denied for user 'duckdb'@'%' to database 'information_schema'", + 'LOCK TABLES __wp_duckdb_column_metadata READ' => 'Internal DuckDB metadata tables cannot be modified', + 'LOCK TABLES __WP_DUCKDB_COLUMN_METADATA READ' => 'Internal DuckDB metadata tables cannot be modified', + 'LOCK TABLES other_database.lock_one READ' => 'Only the current database is supported', + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected LOCK TABLES to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + $this->assertFalse( $driver->get_connection()->inTransaction() ); + } + } + public function test_update_delete_alias_order_limit_are_rewritten(): void { $this->requireDuckDBRuntime(); From bd47d1e27e5854e2e235489d90e1c0477b3ffdd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 21:52:28 +0000 Subject: [PATCH 031/317] Add DuckDB session variable parity --- .../src/duckdb/class-wp-duckdb-driver.php | 413 ++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 36 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 199 +++++++++ 3 files changed, 648 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index dbc4c9f7e..337986e98 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -31,6 +31,11 @@ class WP_DuckDB_Driver { const INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE = '__wp_duckdb_information_schema_table_constraints'; const INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE = '__wp_duckdb_information_schema_key_column_usage'; + const SUPPORTED_SESSION_SYSTEM_VARIABLES = array( + 'autocommit' => true, + 'big_tables' => true, + ); + const DATA_TYPE_MAP = array( WP_MySQL_Lexer::BOOL_SYMBOL => 'BOOLEAN', WP_MySQL_Lexer::BOOLEAN_SYMBOL => 'BOOLEAN', @@ -100,6 +105,13 @@ class WP_DuckDB_Driver { */ private $last_insert_id = 0; + /** + * MySQL session system variables emulated by this driver. + * + * @var array + */ + private $session_system_variables = array(); + /** * Whether a MySQL LOCK TABLES statement opened the current transaction. * @@ -177,6 +189,8 @@ public function query( string $query ): WP_DuckDB_Result_Statement { return $this->execute_lock_tables_statement( $tokens ); case WP_MySQL_Lexer::UNLOCK_SYMBOL: return $this->execute_unlock_tables_statement( $tokens ); + case WP_MySQL_Lexer::SET_SYMBOL: + return $this->execute_set_statement( $tokens ); case WP_MySQL_Lexer::SELECT_SYMBOL: return $this->execute_select( $tokens ); case WP_MySQL_Lexer::CREATE_SYMBOL: @@ -241,6 +255,18 @@ public function get_insert_id(): int { return $this->last_insert_id; } + /** + * Get an emulated MySQL session system variable value. + * + * @param string $name Variable name. + * @return int|string|null Stored value, or null when supported but unset. + */ + private function get_session_system_variable( string $name ) { + $normalized_name = $this->normalize_supported_session_system_variable_name( $name ); + + return $this->session_system_variables[ $normalized_name ] ?? null; + } + /** * Tokenize and parse a single MySQL statement. * @@ -334,6 +360,11 @@ private function without_eof( array $tokens ): array { * @return WP_DuckDB_Result_Statement */ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { + $session_variable_select = $this->execute_session_system_variable_select( $tokens ); + if ( null !== $session_variable_select ) { + return $session_variable_select; + } + $rewrite_information_schema_tables = $this->uses_information_schema_tables( $tokens ); $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); $rewrite_information_schema_statistics = $this->uses_information_schema_statistics( $tokens ); @@ -368,6 +399,136 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { ); } + /** + * Execute a simple SELECT list of supported session system variables. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement|null Statement when emulated, null for generic SELECT handling. + */ + private function execute_session_system_variable_select( array $tokens ): ?WP_DuckDB_Result_Statement { + $variables = $this->parse_session_system_variable_select( $tokens ); + if ( null === $variables ) { + return null; + } + + $columns = array(); + $row = array(); + foreach ( $variables as $variable ) { + $columns[] = $variable['alias']; + $row[] = $this->get_session_system_variable( $variable['name'] ); + } + + return new WP_DuckDB_Result_Statement( $columns, array( $row ), 0 ); + } + + /** + * Parse a simple SELECT list of supported session system variables. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return array|null Variables, or null for generic SELECT handling. + */ + private function parse_session_system_variable_select( array $tokens ): ?array { + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $select_list_end = count( $tokens ); + if ( + $select_list_end >= 4 + && WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $select_list_end - 2 ]->id + && WP_MySQL_Lexer::DUAL_SYMBOL === $tokens[ $select_list_end - 1 ]->id + ) { + $select_list_end -= 2; + } + + $select_list = array_slice( $tokens, 1, $select_list_end - 1 ); + if ( count( $select_list ) === 0 ) { + return null; + } + + $variables = array(); + foreach ( $this->split_top_level_comma_items( $select_list ) as $item ) { + $variable = $this->parse_session_system_variable_reference( $item ); + if ( null === $variable ) { + return null; + } + $variables[] = $variable; + } + + return $variables; + } + + /** + * Parse one supported @@session_variable reference. + * + * @param WP_Parser_Token[] $tokens Reference tokens. + * @return array{name:string,alias:string}|null Variable, or null when the item is not supported by this slice. + */ + private function parse_session_system_variable_reference( array $tokens ): ?array { + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[0]->id ) { + return null; + } + + if ( 2 === count( $tokens ) ) { + $name = $this->session_system_variable_name( $tokens[1] ); + } elseif ( + 4 === count( $tokens ) + && WP_MySQL_Lexer::SESSION_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[2]->id + ) { + $name = $this->session_system_variable_name( $tokens[3] ); + } else { + return null; + } + + if ( null === $name || ! $this->is_supported_session_system_variable( $name ) ) { + return null; + } + + return array( + 'name' => $name, + 'alias' => $this->concatenate_token_bytes( $tokens ), + ); + } + + /** + * Normalize a supported session system variable token. + * + * @param WP_Parser_Token $token Variable-name token. + * @return string|null Lowercase variable name, or null when not an identifier. + */ + private function session_system_variable_name( WP_Parser_Token $token ): ?string { + if ( $this->is_non_identifier_token( $token ) ) { + return null; + } + + return strtolower( $token->get_value() ); + } + + /** + * Check whether this bounded slice supports a session system variable. + * + * @param string $name Lowercase variable name. + * @return bool Whether the variable is supported. + */ + private function is_supported_session_system_variable( string $name ): bool { + return isset( self::SUPPORTED_SESSION_SYSTEM_VARIABLES[ $name ] ); + } + + /** + * Concatenate original token bytes without whitespace. + * + * @param WP_Parser_Token[] $tokens Tokens. + * @return string Concatenated token bytes. + */ + private function concatenate_token_bytes( array $tokens ): string { + $bytes = ''; + foreach ( $tokens as $token ) { + $bytes .= $token->get_bytes(); + } + return $bytes; + } + /** * Execute a supported CREATE statement. * @@ -1796,6 +1957,258 @@ private function empty_ddl_result(): WP_DuckDB_Result_Statement { return new WP_DuckDB_Result_Statement( array(), array(), 0 ); } + /** + * Execute bounded MySQL SET session-variable assignments. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_set_statement( array $tokens ): WP_DuckDB_Result_Statement { + $this->expect_token( $tokens, 0, WP_MySQL_Lexer::SET_SYMBOL, 'Expected SET.' ); + + $assignments = $this->split_top_level_comma_items( array_slice( $tokens, 1 ) ); + if ( count( $assignments ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'SET statement requires at least one assignment.' ); + } + + // Match the current SQLite driver's scoped comma-list behavior: only + // the leading SESSION assignment is applied. + if ( + count( $assignments ) > 1 + && isset( $assignments[0][0] ) + && WP_MySQL_Lexer::SESSION_SYMBOL === $assignments[0][0]->id + ) { + $assignments = array( $assignments[0] ); + } + + $default_scope = WP_MySQL_Lexer::SESSION_SYMBOL; + foreach ( $assignments as $assignment ) { + $default_scope = $this->execute_set_session_system_variable_assignment( $assignment, $default_scope ); + } + + return $this->empty_ddl_result(); + } + + /** + * Execute one SET assignment for an emulated session system variable. + * + * @param WP_Parser_Token[] $tokens Assignment tokens. + * @param int $default_scope Active default scope token ID. + * @return int Updated default scope token ID. + */ + private function execute_set_session_system_variable_assignment( array $tokens, int $default_scope ): int { + $index = 0; + $scope = $default_scope; + + if ( isset( $tokens[ $index ] ) && $this->is_set_statement_type_token( $tokens[ $index ] ) ) { + $scope = $tokens[ $index ]->id; + $default_scope = $scope; + ++$index; + } + + $target = $this->parse_set_session_system_variable_target( $tokens, $index, $scope ); + $name = $this->normalize_supported_session_system_variable_name( $target['name'] ); + $scope = $target['scope']; + $index = $target['next_index']; + + $this->assert_supported_set_session_scope( $scope ); + $this->expect_token( + $tokens, + $index, + WP_MySQL_Lexer::EQUAL_OPERATOR, + 'Unsupported SET statement in DuckDB driver. Expected "=" for session variable assignment.' + ); + ++$index; + + $value_tokens = array_slice( $tokens, $index ); + if ( 1 !== count( $value_tokens ) ) { + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + $this->session_system_variables[ $name ] = $this->normalize_set_session_system_variable_value( $name, $value_tokens[0] ); + + return $default_scope; + } + + /** + * Parse the target side of one SET assignment. + * + * @param WP_Parser_Token[] $tokens Assignment tokens. + * @param int $index Current token index. + * @param int $default_scope Active default scope token ID. + * @return array{name:string,scope:int,next_index:int} + */ + private function parse_set_session_system_variable_target( array $tokens, int $index, int $default_scope ): array { + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SET statement in DuckDB driver. Expected session variable name.' ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[ $index ]->id ) { + return array( + 'name' => $this->identifier_value( $tokens[ $index ] ), + 'scope' => $default_scope, + 'next_index' => $index + 1, + ); + } + + ++$index; + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SET statement in DuckDB driver. Expected session variable name after @@.' ); + } + + $scope = WP_MySQL_Lexer::SESSION_SYMBOL; + if ( + $this->is_set_statement_type_token( $tokens[ $index ] ) + && isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + ) { + $scope = $tokens[ $index ]->id; + $index += 2; + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SET statement in DuckDB driver. Expected session variable name after @@scope.' ); + } + } + + return array( + 'name' => $this->identifier_value( $tokens[ $index ] ), + 'scope' => $scope, + 'next_index' => $index + 1, + ); + } + + /** + * Normalize an emulated session system variable value. + * + * @param string $name Normalized variable name. + * @param WP_Parser_Token $token Value token. + * @return int|string Normalized stored value. + */ + private function normalize_set_session_system_variable_value( string $name, WP_Parser_Token $token ) { + $value = $token->get_value(); + $lower = strtolower( $value ); + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + if ( 'on' === $lower ) { + return 1; + } + if ( 'off' === $lower ) { + return 0; + } + + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + if ( 'on' === $lower || 'true' === $lower ) { + return 1; + } + if ( 'off' === $lower || 'false' === $lower ) { + return 0; + } + if ( 'default' === $lower ) { + return 'DEFAULT'; + } + if ( WP_MySQL_Lexer::INT_NUMBER === $token->id && ( '0' === $value || '1' === $value ) ) { + return (int) $value; + } + + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + /** + * Build an unsupported SET value exception. + * + * @param string $name Normalized variable name. + * @return WP_DuckDB_Driver_Exception Exception. + */ + private function new_unsupported_set_session_system_variable_value_exception( string $name ): WP_DuckDB_Driver_Exception { + return new WP_DuckDB_Driver_Exception( + 'Unsupported SET value for ' + . $name + . ' in DuckDB driver. Only ON, OFF, TRUE, FALSE, 1, 0, and DEFAULT are supported.' + ); + } + + /** + * Normalize and validate a supported emulated session system variable name. + * + * @param string $name Variable name. + * @return string Normalized variable name. + */ + private function normalize_supported_session_system_variable_name( string $name ): string { + $normalized = strtolower( $name ); + if ( ! isset( self::SUPPORTED_SESSION_SYSTEM_VARIABLES[ $normalized ] ) ) { + throw new WP_DuckDB_Driver_Exception( + 'Unsupported SET session variable in DuckDB driver: ' + . $name + . '. Only autocommit and big_tables are supported.' + ); + } + + return $normalized; + } + + /** + * Check whether a token is a SET statement scope/type token. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether this token is a SET scope/type. + */ + private function is_set_statement_type_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::SESSION_SYMBOL, + WP_MySQL_Lexer::LOCAL_SYMBOL, + WP_MySQL_Lexer::GLOBAL_SYMBOL, + WP_MySQL_Lexer::PERSIST_SYMBOL, + WP_MySQL_Lexer::PERSIST_ONLY_SYMBOL, + ), + true + ); + } + + /** + * Assert that a SET assignment uses a supported session scope. + * + * @param int $scope SET statement scope token ID. + */ + private function assert_supported_set_session_scope( int $scope ): void { + if ( WP_MySQL_Lexer::SESSION_SYMBOL === $scope ) { + return; + } + + throw new WP_DuckDB_Driver_Exception( + "Unsupported SET statement type: '{$this->set_statement_type_name( $scope )}' in DuckDB driver." + ); + } + + /** + * Get a readable SET statement scope/type name. + * + * @param int $scope SET statement scope token ID. + * @return string Scope/type name. + */ + private function set_statement_type_name( int $scope ): string { + switch ( $scope ) { + case WP_MySQL_Lexer::SESSION_SYMBOL: + return 'SESSION'; + case WP_MySQL_Lexer::LOCAL_SYMBOL: + return 'LOCAL'; + case WP_MySQL_Lexer::GLOBAL_SYMBOL: + return 'GLOBAL'; + case WP_MySQL_Lexer::PERSIST_SYMBOL: + return 'PERSIST'; + case WP_MySQL_Lexer::PERSIST_ONLY_SYMBOL: + return 'PERSIST_ONLY'; + } + + return 'UNKNOWN'; + } + /** * Execute LOCK TABLE[S] ... READ|WRITE. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index f854a09f7..ab848a9a7 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -75,6 +75,42 @@ public function test_transaction_sql_matches_sqlite(): void { $this->assertParityRows( 'SELECT id FROM tx_items ORDER BY id' ); } + public function test_session_variable_sql_matches_sqlite(): void { + $this->assertParityRows( 'SELECT @@autocommit, @@session.autocommit, @@big_tables, @@SESSION.big_tables' ); + + $this->assertParityRowCount( 'SET SESSION autocommit = 1, big_tables = 0' ); + $this->assertParityRows( 'SELECT @@autocommit, @@session.autocommit, @@big_tables, @@session.big_tables' ); + + foreach ( + array( + 'SET autocommit = ON, big_tables = OFF', + 'SET autocommit = on, big_tables = off', + "SET autocommit = 'ON', big_tables = 'OFF'", + "SET autocommit = 'on', big_tables = 'off'", + 'SET autocommit = TRUE, big_tables = FALSE', + 'SET autocommit = true, big_tables = false', + 'SET autocommit = 1, big_tables = 0', + ) as $sql + ) { + $this->assertParityRowCount( $sql ); + $this->assertParityRows( 'SELECT @@autocommit, @@big_tables' ); + } + + $this->assertParityRowCount( 'SET autocommit = OFF' ); + $this->assertParityRows( 'SELECT @@autocommit' ); + $this->assertParityRowCount( 'SET big_tables = ON' ); + $this->assertParityRows( 'SELECT @@big_tables' ); + + $this->assertParityRowCount( 'SET SESSION autocommit = 0' ); + $this->assertParityRowCount( 'SET @@session.big_tables = 1' ); + $this->assertParityRows( + 'SELECT @@autocommit, @@SESSION.autocommit, @@big_tables, @@session.big_tables' + ); + + $this->assertParityRowCount( 'SET autocommit = DEFAULT, big_tables = DEFAULT' ); + $this->assertParityRows( 'SELECT @@autocommit, @@big_tables' ); + } + public function test_lock_unlock_sql_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 9bfa64e18..0441fbe54 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -149,6 +149,196 @@ public function test_sql_transaction_statements_update_connection_state_and_quer $this->assertSame( array(), $driver->get_last_duckdb_queries() ); } + public function test_session_boolean_variables_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + foreach ( + array( + 'SET autocommit = ON, big_tables = OFF', + 'SET autocommit = on, big_tables = off', + "SET autocommit = 'ON', big_tables = 'OFF'", + "SET autocommit = 'on', big_tables = 'off'", + 'SET autocommit = TRUE, big_tables = FALSE', + 'SET autocommit = true, big_tables = false', + 'SET autocommit = 1, big_tables = 0', + ) as $sql + ) { + $set = $driver->query( $sql ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $read = $driver->query( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( 2, $read->columnCount() ); + $this->assertSame( array( 'name' => '@@autocommit' ), $read->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => '@@big_tables' ), $read->getColumnMeta( 1 ) ); + $this->assertSame( + array( + '@@autocommit' => 1, + '@@big_tables' => 0, + ), + $read->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + + $set = $driver->query( 'SET autocommit = OFF' ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( + array( '@@autocommit' => 0 ), + $driver->query( 'SELECT @@autocommit' )->fetch( PDO::FETCH_ASSOC ) + ); + + $set = $driver->query( 'SET big_tables = ON' ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( + array( '@@big_tables' => 1 ), + $driver->query( 'SELECT @@big_tables' )->fetch( PDO::FETCH_ASSOC ) + ); + } + + public function test_session_variable_scoped_forms_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + $read = $driver->query( 'SELECT @@session.autocommit, @@SESSION.big_tables' ); + $this->assertSame( + array( + '@@session.autocommit' => null, + '@@SESSION.big_tables' => null, + ), + $read->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array( 'name' => '@@session.autocommit' ), $read->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => '@@SESSION.big_tables' ), $read->getColumnMeta( 1 ) ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + foreach ( + array( + 'SET SESSION autocommit = 0', + 'SET @@session.big_tables = 1', + ) as $sql + ) { + $set = $driver->query( $sql ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + + $this->assertSame( + array( + '@@autocommit' => 0, + '@@SESSION.autocommit' => 0, + '@@big_tables' => 1, + '@@session.big_tables' => 1, + ), + $driver->query( + 'SELECT @@autocommit, @@SESSION.autocommit, @@big_tables, @@session.big_tables' + )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + + public function test_session_variable_scoped_comma_list_matches_sqlite_current_behavior(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + $set = $driver->query( 'SET SESSION autocommit = 1, big_tables = 0' ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $read = $driver->query( 'SELECT @@autocommit, @@session.autocommit, @@big_tables, @@session.big_tables' ); + $this->assertSame( + array( + '@@autocommit' => 1, + '@@session.autocommit' => 1, + '@@big_tables' => null, + '@@session.big_tables' => null, + ), + $read->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + + public function test_session_variable_default_matches_sqlite_current_behavior(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + foreach ( + array( + 'SET autocommit = DEFAULT', + 'SET @@session.big_tables = DEFAULT', + ) as $sql + ) { + $set = $driver->query( $sql ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + + $this->assertSame( + array( + '@@autocommit' => 'DEFAULT', + '@@session.big_tables' => 'DEFAULT', + ), + $driver->query( 'SELECT @@autocommit, @@session.big_tables' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + + public function test_session_variable_unsupported_set_forms_are_rejected(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + foreach ( + array( + 'SET autocommit = 2', + 'SET autocommit = -1', + 'SET autocommit = NULL', + 'SET autocommit = YES', + 'SET autocommit = (SELECT 1)', + 'SET autocommit = @saved', + 'SET GLOBAL autocommit = 1', + 'SET LOCAL autocommit = 1', + 'SET PERSIST autocommit = 1', + 'SET PERSIST_ONLY autocommit = 1', + 'SET @@GLOBAL.autocommit = 1', + 'SET @@LOCAL.autocommit = 1', + ) as $sql + ) { + $this->assertDriverQueryRejected( $driver, $sql ); + } + } + + public function test_session_variable_unsupported_select_shapes_are_rejected(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE real_table (id INT)' ); + + foreach ( + array( + 'SELECT @@GLOBAL.autocommit', + 'SELECT @@LOCAL.autocommit', + 'SELECT @@autocommit AS ac', + 'SELECT @@autocommit + 0', + 'SELECT COALESCE(@@autocommit, 1)', + 'SELECT @@autocommit FROM real_table', + ) as $sql + ) { + $this->assertDriverQueryRejected( $driver, $sql ); + } + } + public function test_lock_unlock_table_statements_update_transaction_state(): void { $this->requireDuckDBRuntime(); @@ -2989,6 +3179,15 @@ public function test_unsupported_alter_table_add_not_null_without_default_on_non $driver->query( 'ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL' ); } + private function assertDriverQueryRejected( WP_DuckDB_Driver $driver, string $sql ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + try { + $driver->query( $sql ); + $this->fail( 'Expected DuckDB driver rejection for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertNotSame( '', $e->getMessage() ); + } + } + private function lastDuckDBQuery( WP_DuckDB_Driver $driver ): string { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid $queries = $driver->get_last_duckdb_queries(); return $queries[ count( $queries ) - 1 ]; From c468e69a47c8b1a61b1ee639b190b253fa1a6790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 22:10:36 +0000 Subject: [PATCH 032/317] Add DuckDB SQL mode bootstrap parity --- .../src/duckdb/class-wp-duckdb-driver.php | 143 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 29 ++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 105 +++++++++++++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 103 +++++++++++++ .../wp-includes/duckdb/class-wp-duckdb-db.php | 82 ++++++++++ 5 files changed, 454 insertions(+), 8 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 337986e98..29597b00d 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -34,6 +34,12 @@ class WP_DuckDB_Driver { const SUPPORTED_SESSION_SYSTEM_VARIABLES = array( 'autocommit' => true, 'big_tables' => true, + 'sql_mode' => true, + ); + + const READ_ONLY_SYSTEM_VARIABLES = array( + 'version' => true, + 'version_comment' => true, ); const DATA_TYPE_MAP = array( @@ -105,6 +111,22 @@ class WP_DuckDB_Driver { */ private $last_insert_id = 0; + /** + * The currently active MySQL SQL modes. + * + * The default value reflects the default SQL modes for MySQL 8.0. + * + * @var string[] + */ + private $active_sql_modes = array( + 'ERROR_FOR_DIVISION_BY_ZERO', + 'NO_ENGINE_SUBSTITUTION', + 'NO_ZERO_DATE', + 'NO_ZERO_IN_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + ); + /** * MySQL session system variables emulated by this driver. * @@ -262,6 +284,20 @@ public function get_insert_id(): int { * @return int|string|null Stored value, or null when supported but unset. */ private function get_session_system_variable( string $name ) { + $normalized_name = strtolower( $name ); + + if ( 'sql_mode' === $normalized_name ) { + return implode( ',', $this->active_sql_modes ); + } + + if ( 'version' === $normalized_name ) { + return $this->format_mysql_system_variable_version(); + } + + if ( 'version_comment' === $normalized_name ) { + return 'MySQL Community Server - GPL'; + } + $normalized_name = $this->normalize_supported_session_system_variable_name( $name ); return $this->session_system_variables[ $normalized_name ] ?? null; @@ -469,6 +505,7 @@ private function parse_session_system_variable_reference( array $tokens ): ?arra return null; } + $session_scoped = false; if ( 2 === count( $tokens ) ) { $name = $this->session_system_variable_name( $tokens[1] ); } elseif ( @@ -476,12 +513,13 @@ private function parse_session_system_variable_reference( array $tokens ): ?arra && WP_MySQL_Lexer::SESSION_SYMBOL === $tokens[1]->id && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[2]->id ) { - $name = $this->session_system_variable_name( $tokens[3] ); + $name = $this->session_system_variable_name( $tokens[3] ); + $session_scoped = true; } else { return null; } - if ( null === $name || ! $this->is_supported_session_system_variable( $name ) ) { + if ( null === $name || ! $this->is_supported_session_system_variable_reference( $name, $session_scoped ) ) { return null; } @@ -506,13 +544,18 @@ private function session_system_variable_name( WP_Parser_Token $token ): ?string } /** - * Check whether this bounded slice supports a session system variable. + * Check whether this bounded slice supports a system-variable reference. * - * @param string $name Lowercase variable name. + * @param string $name Lowercase variable name. + * @param bool $session_scoped Whether the reference uses @@SESSION. * @return bool Whether the variable is supported. */ - private function is_supported_session_system_variable( string $name ): bool { - return isset( self::SUPPORTED_SESSION_SYSTEM_VARIABLES[ $name ] ); + private function is_supported_session_system_variable_reference( string $name, bool $session_scoped ): bool { + if ( isset( self::SUPPORTED_SESSION_SYSTEM_VARIABLES[ $name ] ) ) { + return true; + } + + return ! $session_scoped && isset( self::READ_ONLY_SYSTEM_VARIABLES[ $name ] ); } /** @@ -1983,12 +2026,42 @@ private function execute_set_statement( array $tokens ): WP_DuckDB_Result_Statem $default_scope = WP_MySQL_Lexer::SESSION_SYMBOL; foreach ( $assignments as $assignment ) { + if ( $this->is_set_charset_bootstrap_assignment( $assignment ) ) { + continue; + } + $default_scope = $this->execute_set_session_system_variable_assignment( $assignment, $default_scope ); } return $this->empty_ddl_result(); } + /** + * Check whether a SET statement is a charset bootstrap no-op. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return bool Whether the assignment is SET NAMES, SET CHARSET, or SET CHARACTER SET. + */ + private function is_set_charset_bootstrap_assignment( array $tokens ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::NAMES_SYMBOL === $tokens[0]->id || WP_MySQL_Lexer::CHARSET_SYMBOL === $tokens[0]->id ) { + return true; + } + + if ( + ( WP_MySQL_Lexer::CHARACTER_SYMBOL === $tokens[0]->id || WP_MySQL_Lexer::CHAR_SYMBOL === $tokens[0]->id ) + && isset( $tokens[1] ) + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[1]->id + ) { + return true; + } + + return false; + } + /** * Execute one SET assignment for an emulated session system variable. * @@ -2025,7 +2098,12 @@ private function execute_set_session_system_variable_assignment( array $tokens, throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); } - $this->session_system_variables[ $name ] = $this->normalize_set_session_system_variable_value( $name, $value_tokens[0] ); + $value = $this->normalize_set_session_system_variable_value( $name, $value_tokens[0] ); + if ( 'sql_mode' === $name ) { + $this->active_sql_modes = '' === $value ? array() : explode( ',', (string) $value ); + } else { + $this->session_system_variables[ $name ] = $value; + } return $default_scope; } @@ -2084,6 +2162,10 @@ private function parse_set_session_system_variable_target( array $tokens, int $i * @return int|string Normalized stored value. */ private function normalize_set_session_system_variable_value( string $name, WP_Parser_Token $token ) { + if ( 'sql_mode' === $name ) { + return $this->normalize_set_sql_mode_value( $token ); + } + $value = $token->get_value(); $lower = strtolower( $value ); @@ -2118,6 +2200,32 @@ private function normalize_set_session_system_variable_value( string $name, WP_P throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); } + /** + * Normalize a SET sql_mode value for driver-local readback. + * + * @param WP_Parser_Token $token Value token. + * @return string Normalized SQL mode string. + */ + private function normalize_set_sql_mode_value( WP_Parser_Token $token ): string { + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + throw $this->new_unsupported_set_session_system_variable_value_exception( 'sql_mode' ); + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return strtoupper( $token->get_value() ); + } + + if ( 'default' === strtolower( $token->get_value() ) ) { + return 'DEFAULT'; + } + + if ( ! $this->is_non_identifier_token( $token ) ) { + return strtoupper( $token->get_value() ); + } + + throw $this->new_unsupported_set_session_system_variable_value_exception( 'sql_mode' ); + } + /** * Build an unsupported SET value exception. * @@ -2125,6 +2233,12 @@ private function normalize_set_session_system_variable_value( string $name, WP_P * @return WP_DuckDB_Driver_Exception Exception. */ private function new_unsupported_set_session_system_variable_value_exception( string $name ): WP_DuckDB_Driver_Exception { + if ( 'sql_mode' === $name ) { + return new WP_DuckDB_Driver_Exception( + 'Unsupported SET value for sql_mode in DuckDB driver. Only string literals, bare mode names, and DEFAULT are supported.' + ); + } + return new WP_DuckDB_Driver_Exception( 'Unsupported SET value for ' . $name @@ -2144,7 +2258,7 @@ private function normalize_supported_session_system_variable_name( string $name throw new WP_DuckDB_Driver_Exception( 'Unsupported SET session variable in DuckDB driver: ' . $name - . '. Only autocommit and big_tables are supported.' + . '. Only autocommit, big_tables, and sql_mode are supported.' ); } @@ -9188,6 +9302,19 @@ private function format_mysql_version(): string { return sprintf( '%d.%d.%d-DuckDB', $major, $minor, $patch ); } + /** + * Format the emulated MySQL version for @@version readback. + * + * @return string + */ + private function format_mysql_system_variable_version(): string { + $major = (int) floor( $this->mysql_version / 10000 ); + $minor = (int) floor( ( $this->mysql_version % 10000 ) / 100 ); + $patch = $this->mysql_version % 100; + + return sprintf( '%d.%d.%d', $major, $minor, $patch ); + } + /** * Build an unsupported statement exception. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index ab848a9a7..c48a5f811 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -111,6 +111,35 @@ public function test_session_variable_sql_matches_sqlite(): void { $this->assertParityRows( 'SELECT @@autocommit, @@big_tables' ); } + public function test_sql_mode_bootstrap_sql_matches_sqlite(): void { + $this->assertParityRowCount( 'SET NAMES utf8mb4' ); + $this->assertParityRowCount( 'SET CHARSET utf8mb4' ); + $this->assertParityRowCount( 'SET CHARACTER SET utf8mb4' ); + + $this->assertParityRows( 'SELECT @@SESSION.sql_mode, @@sql_mode' ); + + $this->assertParityRowCount( 'SET NAMES utf8mb4, autocommit = 0' ); + $this->assertParityRows( 'SELECT @@autocommit' ); + + $this->assertParityRowCount( "SET CHARACTER SET utf8mb4, sql_mode = 'NO_ZERO_DATE'" ); + $this->assertParityRows( 'SELECT @@SESSION.sql_mode, @@sql_mode' ); + + $this->assertParityRowCount( "SET SESSION sql_mode = ''" ); + $this->assertParityRows( 'SELECT @@SESSION.sql_mode, @@sql_mode' ); + + $this->assertParityRowCount( "SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION'" ); + $this->assertParityRows( 'SELECT @@SESSION.sql_mode, @@sql_mode' ); + + $this->assertParityRowCount( + "SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'" + ); + $this->assertParityRows( 'SELECT @@SESSION.sql_mode, @@sql_mode' ); + } + + public function test_builtin_system_variables_match_sqlite(): void { + $this->assertParityRows( 'SELECT @@version, @@version_comment' ); + } + public function test_lock_unlock_sql_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 0441fbe54..05d2635fe 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -294,6 +294,111 @@ public function test_session_variable_default_matches_sqlite_current_behavior(): $this->assertSame( array(), $driver->get_last_duckdb_queries() ); } + public function test_sql_mode_bootstrap_statements_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + foreach ( + array( + 'SET NAMES utf8mb4', + 'SET CHARSET utf8mb4', + 'SET CHARACTER SET utf8mb4', + ) as $sql + ) { + $set = $driver->query( $sql ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + + $default_sql_mode = 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,' + . 'NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES'; + $read = $driver->query( 'SELECT @@SESSION.sql_mode, @@sql_mode' ); + $this->assertSame( array( 'name' => '@@SESSION.sql_mode' ), $read->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => '@@sql_mode' ), $read->getColumnMeta( 1 ) ); + $this->assertSame( + array( + '@@SESSION.sql_mode' => $default_sql_mode, + '@@sql_mode' => $default_sql_mode, + ), + $read->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $set = $driver->query( 'SET NAMES utf8mb4, autocommit = 0' ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + $this->assertSame( + array( '@@autocommit' => 0 ), + $driver->query( 'SELECT @@autocommit' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $set = $driver->query( "SET CHARACTER SET utf8mb4, sql_mode = 'NO_ZERO_DATE'" ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + $this->assertSame( + array( + '@@SESSION.sql_mode' => 'NO_ZERO_DATE', + '@@sql_mode' => 'NO_ZERO_DATE', + ), + $driver->query( 'SELECT @@SESSION.sql_mode, @@sql_mode' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + foreach ( + array( + array( + 'sql' => "SET SESSION sql_mode = ''", + 'expected' => '', + ), + array( + 'sql' => "SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION'", + 'expected' => 'NO_ENGINE_SUBSTITUTION', + ), + array( + 'sql' => "SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'", + 'expected' => 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION', + ), + ) as $case + ) { + $set = $driver->query( $case['sql'] ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $this->assertSame( + array( + '@@SESSION.sql_mode' => $case['expected'], + '@@sql_mode' => $case['expected'], + ), + $driver->query( 'SELECT @@SESSION.sql_mode, @@sql_mode' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + } + + public function test_builtin_system_variables_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $read = $driver->query( 'SELECT @@version, @@version_comment' ); + + $this->assertSame( array( 'name' => '@@version' ), $read->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => '@@version_comment' ), $read->getColumnMeta( 1 ) ); + $this->assertSame( + array( + '@@version' => '8.0.38', + '@@version_comment' => 'MySQL Community Server - GPL', + ), + $read->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + public function test_session_variable_unsupported_set_forms_are_rejected(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index 484132d7f..c429650f8 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -172,6 +172,35 @@ public function test_duckdb_wpdb_flush_clears_statement_metadata(): void { $this->assertSame( array(), $result['after_flush_column_names'] ); } + public function test_duckdb_wpdb_db_connect_sets_filtered_sql_mode(): void { + $result = $this->run_sql_mode_boot_state_script( false ); + + $this->assertTrue( $result['connected'] ); + $this->assertTrue( $result['ready'] ); + $this->assertSame( '', $result['last_error'] ); + $this->assertSame( + array( + 'SELECT @@SESSION.sql_mode', + "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'", + ), + $result['driver_queries'] + ); + } + + public function test_duckdb_wpdb_db_connect_reports_sql_mode_boot_failure(): void { + $result = $this->run_sql_mode_boot_state_script( true ); + + $this->assertFalse( $result['connected'] ); + $this->assertFalse( $result['ready'] ); + $this->assertSame( 'Broken SQL mode read.', $result['last_error'] ); + $this->assertSame( + array( + 'SELECT @@SESSION.sql_mode', + ), + $result['driver_queries'] + ); + } + private function run_dispatcher_script( string $engine_expression ): array { $plugin_dir = $this->get_plugin_dir(); $code = $this->get_wordpress_stub_code(); @@ -209,6 +238,66 @@ private function run_dropin_copy_script( string $copy_file, string $prepend_code } } + private function run_sql_mode_boot_state_script( bool $fail_read ): array { + $plugin_dir = $this->get_plugin_dir(); + $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; + $code = $this->get_wordpress_stub_code(); + $code .= "\nrequire_once " . var_export( $driver_load, true ) . ";\n"; + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= '$fail_read = ' . ( $fail_read ? 'true' : 'false' ) . ";\n"; + $code .= <<<'PHP' + +class WP_DuckDB_Plugin_SQL_Mode_Test_Driver extends WP_DuckDB_Driver { + public $queries = array(); + private $fail_read; + + public function __construct( $fail_read ) { + $this->fail_read = $fail_read; + } + + public function query( string $sql ): WP_DuckDB_Result_Statement { + $this->queries[] = $sql; + + if ( 'SELECT @@SESSION.sql_mode' === $sql ) { + if ( $this->fail_read ) { + throw new RuntimeException( 'Broken SQL mode read.' ); + } + + return new WP_DuckDB_Result_Statement( + array( '@@SESSION.sql_mode' ), + array( + array( 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION,ANSI' ), + ), + 0 + ); + } + + if ( "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'" === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } +} + +$driver = new WP_DuckDB_Plugin_SQL_Mode_Test_Driver( $fail_read ); +$GLOBALS['@duckdb_driver'] = $driver; +$db = new WP_DuckDB_DB( 'wordpress_test' ); +$connected = $db->db_connect( false ); + +echo json_encode( + array( + 'connected' => $connected, + 'ready' => $db->ready, + 'last_error' => $db->last_error, + 'driver_queries' => $driver->queries, + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + private function run_raw_query_state_script(): array { $plugin_dir = $this->get_plugin_dir(); $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; @@ -331,6 +420,20 @@ class WP_DuckDB_Plugin_Insert_Id_Test_Driver extends WP_DuckDB_Driver { public function __construct() {} public function query( string $sql ): WP_DuckDB_Result_Statement { + if ( 'SELECT @@SESSION.sql_mode' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( '@@SESSION.sql_mode' ), + array( + array( 'NO_ENGINE_SUBSTITUTION' ), + ), + 0 + ); + } + + if ( "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'" === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + if ( false !== strpos( $sql, 'BROKEN' ) ) { throw new RuntimeException( 'Synthetic insert failure.' ); } diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index d64bca022..58f81071f 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -61,6 +61,52 @@ public function get_col_charset( $table, $column ) { return 'utf8mb4'; } + /** + * Changes the current SQL mode, and ensures its WordPress compatibility. + * + * @param array $modes Optional. A list of SQL modes to set. Default empty array. + */ + public function set_sql_mode( $modes = array() ) { + if ( ! $this->dbh instanceof WP_DuckDB_Driver ) { + return; + } + + if ( empty( $modes ) ) { + $result = $this->dbh->query( 'SELECT @@SESSION.sql_mode' ); + $row = $result->fetch( PDO::FETCH_OBJ ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + + if ( ! $row || ! isset( $row->{'@@SESSION.sql_mode'} ) ) { + throw new RuntimeException( 'DuckDB SQL mode bootstrap did not return @@SESSION.sql_mode.' ); + } + + $modes_str = $row->{'@@SESSION.sql_mode'}; + if ( empty( $modes_str ) ) { + return; + } + $modes = explode( ',', $modes_str ); + } + + $modes = array_change_key_case( $modes, CASE_UPPER ); + + /** + * Filters the list of incompatible SQL modes to exclude. + * + * @since 3.9.0 + * + * @param array $incompatible_modes An array of incompatible modes. + */ + $incompatible_modes = $this->get_incompatible_sql_modes(); + + foreach ( $modes as $i => $mode ) { + if ( in_array( $mode, $incompatible_modes, true ) ) { + unset( $modes[ $i ] ); + } + } + $modes_str = implode( ',', $modes ); + + $this->dbh->query( "SET SESSION sql_mode='" . str_replace( "'", "''", $modes_str ) . "'" ); + } + /** * Close the database connection. * @@ -157,6 +203,13 @@ public function db_connect( $allow_bail = true ) { } $this->ready = true; + try { + $this->set_sql_mode(); + } catch ( Throwable $e ) { + $this->last_error = $e->getMessage(); + $this->ready = false; + return false; + } return true; } @@ -319,6 +372,35 @@ public function db_server_info() { } } + /** + * Get the WordPress-incompatible SQL modes for filtering. + * + * @return array + */ + private function get_incompatible_sql_modes() { + $incompatible_modes = property_exists( $this, 'incompatible_modes' ) + ? $this->incompatible_modes + : $this->get_default_incompatible_sql_modes(); + + return (array) apply_filters( 'incompatible_sql_modes', $incompatible_modes ); + } + + /** + * Get the WordPress wpdb default incompatible SQL modes. + * + * @return array + */ + private function get_default_incompatible_sql_modes() { + return array( + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + ); + } + /** * Ensure database directory exists and is protected. * From 7902425551ca55abdbbdfb2eed08561cdc157fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 22:30:38 +0000 Subject: [PATCH 033/317] Add DuckDB SELECT helper parity --- .../src/duckdb/class-wp-duckdb-driver.php | 592 ++++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 163 +++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 276 ++++++++ 3 files changed, 967 insertions(+), 64 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 29597b00d..1fbc3f457 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -111,6 +111,18 @@ class WP_DuckDB_Driver { */ private $last_insert_id = 0; + /** + * Data for emulating MySQL FOUND_ROWS(). + * + * SQL_CALC_FOUND_ROWS stores an eager integer count without the SELECT LIMIT. + * Other SELECT statements store the translated DuckDB query and count it + * lazily when FOUND_ROWS() is requested, matching the existing SQLite driver + * behavior for the bounded compatibility slice. + * + * @var int|string + */ + private $found_rows = 0; + /** * The currently active MySQL SQL modes. * @@ -193,52 +205,72 @@ public function query( string $query ): WP_DuckDB_Result_Statement { $this->last_duckdb_queries = array(); $this->last_insert_id = 0; - $tokens = $this->tokenize_and_validate( $query ); - if ( count( $tokens ) === 0 ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported DuckDB MySQL-emulation statement: empty query.' ); - } - - switch ( $tokens[0]->id ) { - case WP_MySQL_Lexer::BEGIN_SYMBOL: - return $this->execute_begin_transaction_statement( $tokens ); - case WP_MySQL_Lexer::START_SYMBOL: - return $this->execute_start_transaction_statement( $tokens ); - case WP_MySQL_Lexer::COMMIT_SYMBOL: - return $this->execute_commit_statement( $tokens ); - case WP_MySQL_Lexer::ROLLBACK_SYMBOL: - return $this->execute_rollback_statement( $tokens ); - case WP_MySQL_Lexer::LOCK_SYMBOL: - return $this->execute_lock_tables_statement( $tokens ); - case WP_MySQL_Lexer::UNLOCK_SYMBOL: - return $this->execute_unlock_tables_statement( $tokens ); - case WP_MySQL_Lexer::SET_SYMBOL: - return $this->execute_set_statement( $tokens ); - case WP_MySQL_Lexer::SELECT_SYMBOL: - return $this->execute_select( $tokens ); - case WP_MySQL_Lexer::CREATE_SYMBOL: - return $this->execute_create( $tokens ); - case WP_MySQL_Lexer::INSERT_SYMBOL: - return $this->execute_insert( $tokens ); - case WP_MySQL_Lexer::REPLACE_SYMBOL: - return $this->execute_replace( $tokens ); - case WP_MySQL_Lexer::UPDATE_SYMBOL: - return $this->execute_update( $tokens ); - case WP_MySQL_Lexer::DELETE_SYMBOL: - return $this->execute_delete( $tokens ); - case WP_MySQL_Lexer::DROP_SYMBOL: - return $this->execute_drop( $tokens ); - case WP_MySQL_Lexer::TRUNCATE_SYMBOL: - return $this->execute_truncate_table( $tokens ); - case WP_MySQL_Lexer::ALTER_SYMBOL: - return $this->execute_alter_table( $tokens ); - case WP_MySQL_Lexer::SHOW_SYMBOL: - return $this->execute_show( $tokens ); - case WP_MySQL_Lexer::DESCRIBE_SYMBOL: - case WP_MySQL_Lexer::DESC_SYMBOL: - return $this->execute_describe( $tokens ); - } - - throw $this->new_unsupported_statement_exception( $tokens[0] ); + try { + $tokens = $this->tokenize_and_validate( $query ); + if ( count( $tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DuckDB MySQL-emulation statement: empty query.' ); + } + + switch ( $tokens[0]->id ) { + case WP_MySQL_Lexer::BEGIN_SYMBOL: + $this->found_rows = 0; + return $this->execute_begin_transaction_statement( $tokens ); + case WP_MySQL_Lexer::START_SYMBOL: + $this->found_rows = 0; + return $this->execute_start_transaction_statement( $tokens ); + case WP_MySQL_Lexer::COMMIT_SYMBOL: + $this->found_rows = 0; + return $this->execute_commit_statement( $tokens ); + case WP_MySQL_Lexer::ROLLBACK_SYMBOL: + $this->found_rows = 0; + return $this->execute_rollback_statement( $tokens ); + case WP_MySQL_Lexer::LOCK_SYMBOL: + $this->found_rows = 0; + return $this->execute_lock_tables_statement( $tokens ); + case WP_MySQL_Lexer::UNLOCK_SYMBOL: + $this->found_rows = 0; + return $this->execute_unlock_tables_statement( $tokens ); + case WP_MySQL_Lexer::SET_SYMBOL: + $this->found_rows = 0; + return $this->execute_set_statement( $tokens ); + case WP_MySQL_Lexer::SELECT_SYMBOL: + return $this->execute_select( $tokens ); + case WP_MySQL_Lexer::CREATE_SYMBOL: + $this->found_rows = 0; + return $this->execute_create( $tokens ); + case WP_MySQL_Lexer::INSERT_SYMBOL: + $this->found_rows = 0; + return $this->execute_insert( $tokens ); + case WP_MySQL_Lexer::REPLACE_SYMBOL: + $this->found_rows = 0; + return $this->execute_replace( $tokens ); + case WP_MySQL_Lexer::UPDATE_SYMBOL: + $this->found_rows = 0; + return $this->execute_update( $tokens ); + case WP_MySQL_Lexer::DELETE_SYMBOL: + $this->found_rows = 0; + return $this->execute_delete( $tokens ); + case WP_MySQL_Lexer::DROP_SYMBOL: + $this->found_rows = 0; + return $this->execute_drop( $tokens ); + case WP_MySQL_Lexer::TRUNCATE_SYMBOL: + $this->found_rows = 0; + return $this->execute_truncate_table( $tokens ); + case WP_MySQL_Lexer::ALTER_SYMBOL: + $this->found_rows = 0; + return $this->execute_alter_table( $tokens ); + case WP_MySQL_Lexer::SHOW_SYMBOL: + return $this->record_found_rows_from_result( $this->execute_show( $tokens ) ); + case WP_MySQL_Lexer::DESCRIBE_SYMBOL: + case WP_MySQL_Lexer::DESC_SYMBOL: + return $this->record_found_rows_from_result( $this->execute_describe( $tokens ) ); + } + + throw $this->new_unsupported_statement_exception( $tokens[0] ); + } catch ( Throwable $e ) { + $this->found_rows = 0; + throw $e; + } } /** @@ -396,11 +428,21 @@ private function without_eof( array $tokens ): array { * @return WP_DuckDB_Result_Statement */ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { + $found_rows_alias = $this->parse_found_rows_select( $tokens ); + if ( null !== $found_rows_alias ) { + $found_rows = $this->evaluate_found_rows(); + $this->found_rows = 1; + return new WP_DuckDB_Result_Statement( array( $found_rows_alias ), array( array( $found_rows ) ), 0 ); + } + $session_variable_select = $this->execute_session_system_variable_select( $tokens ); if ( null !== $session_variable_select ) { - return $session_variable_select; + return $this->record_found_rows_from_result( $session_variable_select ); } + $has_sql_calc_found_rows = $this->has_top_level_sql_calc_found_rows( $tokens ); + $tokens = $this->normalize_select_helper_tokens( $tokens ); + $rewrite_information_schema_tables = $this->uses_information_schema_tables( $tokens ); $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); $rewrite_information_schema_statistics = $this->uses_information_schema_statistics( $tokens ); @@ -422,17 +464,146 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $this->refresh_information_schema_key_column_usage_table(); } - return $this->execute_duckdb_query( - $this->translate_tokens_to_duckdb_sql( - $tokens, - $rewrite_information_schema_tables, - $rewrite_information_schema_columns, - $rewrite_information_schema_statistics, - $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage - ), - 'Unsupported DuckDB MySQL-emulation SELECT statement' + $sql = $this->translate_tokens_to_duckdb_sql( + $tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage ); + + if ( $has_sql_calc_found_rows ) { + try { + $this->found_rows = $this->count_select_rows( + $this->strip_top_level_limit_clause( $tokens ), + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage + ); + return $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); + } catch ( Throwable $e ) { + $this->found_rows = 0; + throw $e; + } + } + + $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); + $this->found_rows = $sql; + return $result; + } + + /** + * Parse SELECT FOUND_ROWS() with an optional alias and optional FROM DUAL. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return string|null Result column name, or null for generic SELECT handling. + */ + private function parse_found_rows_select( array $tokens ): ?string { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $select_list_end = count( $tokens ); + if ( + $select_list_end >= 4 + && WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $select_list_end - 2 ]->id + && WP_MySQL_Lexer::DUAL_SYMBOL === $tokens[ $select_list_end - 1 ]->id + ) { + $select_list_end -= 2; + } + + $select_list = array_slice( $tokens, 1, $select_list_end - 1 ); + if ( + ! $this->is_empty_function_call( $select_list, 0, 'FOUND_ROWS' ) + || $this->contains_top_level_token_id( $select_list, WP_MySQL_Lexer::COMMA_SYMBOL ) + ) { + return null; + } + + $index = 3; + $alias = 'FOUND_ROWS()'; + if ( isset( $select_list[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $select_list[ $index ]->id ) { + ++$index; + $alias = $this->identifier_value( $select_list[ $index ] ?? null ); + ++$index; + } elseif ( isset( $select_list[ $index ] ) ) { + $alias = $this->identifier_value( $select_list[ $index ] ); + ++$index; + } + + return count( $select_list ) === $index ? $alias : null; + } + + /** + * Evaluate the current FOUND_ROWS() state. + * + * @return int FOUND_ROWS() value. + */ + private function evaluate_found_rows(): int { + if ( is_int( $this->found_rows ) ) { + return $this->found_rows; + } + + return (int) $this->execute_duckdb_query( + 'SELECT COUNT(*) AS cnt FROM (' . $this->found_rows . ') AS __wp_duckdb_found_rows', + 'Failed to evaluate DuckDB FOUND_ROWS()' + )->fetchColumn(); + } + + /** + * Count rows returned by a SELECT token stream. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return int Row count. + */ + private function count_select_rows( + array $tokens, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage + ): int { + $sql = $this->translate_tokens_to_duckdb_sql( + $tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage + ); + + return (int) $this->execute_duckdb_query( + 'SELECT COUNT(*) AS cnt FROM (' . $sql . ') AS __wp_duckdb_found_rows', + 'Failed to count DuckDB SQL_CALC_FOUND_ROWS rows' + )->fetchColumn(); + } + + /** + * Materialize a result statement so FOUND_ROWS() can report its row count. + * + * @param WP_DuckDB_Result_Statement $statement Statement to record. + * @return WP_DuckDB_Result_Statement Rewound statement with the same rows. + */ + private function record_found_rows_from_result( WP_DuckDB_Result_Statement $statement ): WP_DuckDB_Result_Statement { + if ( 0 === $statement->columnCount() ) { + $this->found_rows = 0; + return $statement; + } + + $columns = array(); + for ( $index = 0; $index < $statement->columnCount(); ++$index ) { + $meta = $statement->getColumnMeta( $index ); + $columns[] = is_array( $meta ) && isset( $meta['name'] ) ? (string) $meta['name'] : (string) $index; + } + + $rows = $statement->fetchAll( PDO::FETCH_NUM ); + $this->found_rows = count( $rows ); + + return new WP_DuckDB_Result_Statement( $columns, $rows, $statement->rowCount() ); } /** @@ -5728,6 +5899,26 @@ private function translate_tokens_to_duckdb_sql( continue; } + $limit_clause = $this->translate_limit_offset_count_clause( $tokens, $index ); + if ( null !== $limit_clause ) { + $pieces[] = $limit_clause; + continue; + } + + $field_function = $this->translate_field_function_call( + $tokens, + $index, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage + ); + if ( null !== $field_function ) { + $pieces[] = $field_function; + continue; + } + if ( WP_MySQL_Lexer::NOT_SYMBOL === $token->id && isset( $tokens[ $index + 1 ], $tokens[ $index + 2 ] ) @@ -5845,12 +6036,175 @@ private function translate_tokens_to_duckdb_sql( } /** - * Strip MySQL locking clauses that DuckDB does not support in derived SELECTs. + * Translate MySQL LIMIT offset, row_count syntax to DuckDB LIMIT/OFFSET. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current token index, advanced on match. + * @return string|null DuckDB SQL, or null when the current token does not start this LIMIT shape. + */ + private function translate_limit_offset_count_clause( array $tokens, int &$index ): ?string { + if ( + ! isset( $tokens[ $index + 3 ] ) + || WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $index ]->id + || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $index + 2 ]->id + ) { + return null; + } + + $offset = $this->translate_token_to_duckdb_sql( $tokens[ $index + 1 ] ); + $row_count = $this->translate_token_to_duckdb_sql( $tokens[ $index + 3 ] ); + $index += 3; + + return 'LIMIT ' . $row_count . ' OFFSET ' . $offset; + } + + /** + * Normalize SELECT helper syntax that DuckDB does not understand directly. + * + * @param WP_Parser_Token[] $tokens MySQL SELECT tokens. + * @return WP_Parser_Token[] Normalized tokens. + */ + private function normalize_select_helper_tokens( array $tokens ): array { + $tokens = $this->strip_top_level_sql_calc_found_rows( $tokens ); + $tokens = $this->strip_select_index_hints( $tokens ); + return $this->strip_select_locking_clauses( $tokens ); + } + + /** + * Check whether a SELECT has a top-level SQL_CALC_FOUND_ROWS option. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return bool Whether SQL_CALC_FOUND_ROWS is present. + */ + private function has_top_level_sql_calc_found_rows( array $tokens ): bool { + return null !== $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL ); + } + + /** + * Strip a top-level SQL_CALC_FOUND_ROWS SELECT option. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_Parser_Token[] Tokens without SQL_CALC_FOUND_ROWS. + */ + private function strip_top_level_sql_calc_found_rows( array $tokens ): array { + $stripped = array(); + $depth = 0; + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + ++$depth; + $stripped[] = $token; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id ) { + --$depth; + $stripped[] = $token; + continue; + } + if ( 0 === $depth && WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $token->id ) { + continue; + } + $stripped[] = $token; + } + + return $stripped; + } + + /** + * Strip a top-level LIMIT clause for SQL_CALC_FOUND_ROWS counting. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_Parser_Token[] Tokens without the top-level LIMIT clause. + */ + private function strip_top_level_limit_clause( array $tokens ): array { + $limit_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::LIMIT_SYMBOL ); + if ( null === $limit_index ) { + return $tokens; + } + + return array_slice( $tokens, 0, $limit_index ); + } + + /** + * Strip MySQL index hints, which are optimizer directives only. + * + * @param WP_Parser_Token[] $tokens MySQL SELECT tokens. + * @return WP_Parser_Token[] Tokens without index hints. + */ + private function strip_select_index_hints( array $tokens ): array { + $stripped = array(); + for ( $index = 0; $index < count( $tokens ); ++$index ) { + $next_index = $this->skip_select_index_hint( $tokens, $index ); + if ( null !== $next_index ) { + $index = $next_index - 1; + continue; + } + $stripped[] = $tokens[ $index ]; + } + + return $stripped; + } + + /** + * Skip one MySQL index hint when the current token starts one. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current token index. + * @return int|null Index after the hint, or null when no hint starts here. + */ + private function skip_select_index_hint( array $tokens, int $index ): ?int { + if ( + ! isset( $tokens[ $index + 2 ] ) + || ! in_array( + $tokens[ $index ]->id, + array( + WP_MySQL_Lexer::USE_SYMBOL, + WP_MySQL_Lexer::FORCE_SYMBOL, + WP_MySQL_Lexer::IGNORE_SYMBOL, + ), + true + ) + || ! in_array( + $tokens[ $index + 1 ]->id, + array( + WP_MySQL_Lexer::INDEX_SYMBOL, + WP_MySQL_Lexer::KEY_SYMBOL, + ), + true + ) + ) { + return null; + } + + $index += 2; + if ( + isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $index ]->id + && WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $index + 1 ]->id + ) { + $index += 2; + } elseif ( + isset( $tokens[ $index + 2 ] ) + && WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $index ]->id + && ( WP_MySQL_Lexer::ORDER_SYMBOL === $tokens[ $index + 1 ]->id || WP_MySQL_Lexer::GROUP_SYMBOL === $tokens[ $index + 1 ]->id ) + && WP_MySQL_Lexer::BY_SYMBOL === $tokens[ $index + 2 ]->id + ) { + $index += 3; + } + + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index ]->id ) { + return null; + } + + return $this->skip_balanced_parentheses( $tokens, $index ); + } + + /** + * Strip MySQL locking clauses that DuckDB does not support. * * @param WP_Parser_Token[] $tokens SELECT tokens. - * @return WP_Parser_Token[] Tokens without a top-level FOR UPDATE clause. + * @return WP_Parser_Token[] Tokens without locking clauses. */ - private function strip_for_update_locking_clause( array $tokens ): array { + private function strip_select_locking_clauses( array $tokens ): array { $stripped = array(); $depth = 0; for ( $index = 0; $index < count( $tokens ); ++$index ) { @@ -5865,12 +6219,21 @@ private function strip_for_update_locking_clause( array $tokens ): array { continue; } if ( - 0 === $depth - && WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $index ]->id + WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $index ]->id && isset( $tokens[ $index + 1 ] ) - && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $index + 1 ]->id + && ( WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $index + 1 ]->id || WP_MySQL_Lexer::SHARE_SYMBOL === $tokens[ $index + 1 ]->id ) ) { - ++$index; + $index = $this->skip_select_locking_clause( $tokens, $index, $depth ) - 1; + continue; + } + if ( + WP_MySQL_Lexer::LOCK_SYMBOL === $tokens[ $index ]->id + && isset( $tokens[ $index + 3 ] ) + && WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $index + 1 ]->id + && WP_MySQL_Lexer::SHARE_SYMBOL === $tokens[ $index + 2 ]->id + && WP_MySQL_Lexer::MODE_SYMBOL === $tokens[ $index + 3 ]->id + ) { + $index += 3; continue; } $stripped[] = $tokens[ $index ]; @@ -5879,6 +6242,107 @@ private function strip_for_update_locking_clause( array $tokens ): array { return $stripped; } + /** + * Skip a SELECT locking clause. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index at FOR. + * @param int $depth Parenthesis depth of the locking clause. + * @return int Index after the locking clause. + */ + private function skip_select_locking_clause( array $tokens, int $index, int $depth ): int { + $index += 2; + $scan_depth = $depth; + while ( $index < count( $tokens ) ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$scan_depth; + ++$index; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + if ( $scan_depth === $depth ) { + return $index; + } + --$scan_depth; + ++$index; + continue; + } + ++$index; + } + + return $index; + } + + /** + * Back-compat wrapper for older joined-update code paths. + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @return WP_Parser_Token[] Tokens without locking clauses. + */ + private function strip_for_update_locking_clause( array $tokens ): array { + return $this->strip_select_locking_clauses( $tokens ); + } + + /** + * Translate MySQL FIELD(expr, value...) to a CASE expression. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current token index, advanced on match. + * @return string|null DuckDB SQL, or null when the token does not start FIELD(). + */ + private function translate_field_function_call( + array $tokens, + int &$index, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage + ): ?string { + if ( + ! isset( $tokens[ $index + 1 ] ) + || $this->is_non_identifier_token( $tokens[ $index ] ) + || 0 !== strcasecmp( $tokens[ $index ]->get_value(), 'FIELD' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + return null; + } + + list( $items, $next_index ) = $this->collect_parenthesized_items( $tokens, $index + 2 ); + if ( count( $items ) < 2 ) { + $index = $next_index - 1; + return '0'; + } + + $needle = $this->translate_tokens_to_duckdb_sql( + $items[0], + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage + ); + + $needle_comparison = 'lower(CAST((' . $needle . ') AS VARCHAR))'; + $cases = array( 'CASE' ); + for ( $item_index = 1; $item_index < count( $items ); ++$item_index ) { + $value_sql = $this->translate_tokens_to_duckdb_sql( + $items[ $item_index ], + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage + ); + $value_comparison = 'lower(CAST((' . $value_sql . ') AS VARCHAR))'; + $cases[] = 'WHEN ' . $needle_comparison . ' = ' . $value_comparison . ' THEN ' . $item_index; + } + $cases[] = 'ELSE 0 END'; + + $index = $next_index - 1; + return implode( ' ', $cases ); + } + /** * Translate MySQL REPLACE ... VALUES to DuckDB INSERT OR REPLACE. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index c48a5f811..b0241b353 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -210,6 +210,169 @@ public function test_update_delete_alias_order_limit_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_sql_calc_found_rows_and_found_rows_match_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wp_found_rows_users ( + ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + user_login VARCHAR(60) NOT NULL DEFAULT '', + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wp_found_rows_users (user_login) VALUES + ('ada'), + ('grace'), + ('katherine')", + ) + ); + + $this->assertParityRows( + 'SELECT SQL_CALC_FOUND_ROWS ID, user_login FROM wp_found_rows_users ORDER BY ID LIMIT 2' + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + } + + public function test_found_rows_state_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_found_rows_state (id INT, label VARCHAR(20))', + "INSERT INTO wp_found_rows_state VALUES (1, 'one'), (2, 'two'), (3, 'three')", + ) + ); + + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + $this->assertParityRows( 'SELECT id, label FROM wp_found_rows_state ORDER BY id' ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRowCount( "UPDATE wp_found_rows_state SET label = 'updated' WHERE id = 1" ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( 'SELECT id, label FROM wp_found_rows_state ORDER BY id' ); + $this->runParitySetup( array( 'CREATE TABLE wp_found_rows_state_extra (id INT)' ) ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( 'SHOW TABLES' ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( 'DESCRIBE wp_found_rows_state' ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + } + + public function test_failed_selects_reset_found_rows_state_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_found_rows_reset (id INT)', + 'INSERT INTO wp_found_rows_reset VALUES (1), (2), (3)', + ) + ); + + $this->assertParityRows( 'SELECT id FROM wp_found_rows_reset ORDER BY id' ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( 'SELECT id FROM wp_found_rows_reset ORDER BY id' ); + $this->assertParityErrorContains( 'SELECT * FROM missing_found_rows_reset', 'missing_found_rows_reset' ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( 'SELECT id FROM wp_found_rows_reset ORDER BY id' ); + $this->assertParityErrorContains( + 'SELECT SQL_CALC_FOUND_ROWS * FROM missing_found_rows_reset LIMIT 1', + 'missing_found_rows_reset' + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + } + + public function test_select_index_hints_match_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wp_hint_posts ( + ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + post_title VARCHAR(200) NOT NULL DEFAULT '', + PRIMARY KEY (ID), + KEY post_status (post_status) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wp_hint_posts (post_status, post_title) VALUES + ('publish', 'first'), + ('draft', 'second'), + ('publish', 'third')", + ) + ); + + $this->assertParityRows( + "SELECT post_title + FROM wp_hint_posts FORCE INDEX (PRIMARY, post_status) + WHERE post_status = 'publish' + ORDER BY ID" + ); + $this->assertParityRows( + 'SELECT post_status, COUNT(*) AS total + FROM wp_hint_posts USE KEY FOR GROUP BY (post_status) + GROUP BY post_status + ORDER BY post_status' + ); + $this->assertParityRows( + 'SELECT post_title + FROM wp_hint_posts IGNORE INDEX FOR ORDER BY (post_status) + ORDER BY ID DESC + LIMIT 1' + ); + } + + public function test_row_locking_clauses_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_lock_items (name VARCHAR(255), value VARCHAR(255))', + "INSERT INTO wp_lock_items (name, value) VALUES ('test_lock', '123')", + ) + ); + + foreach ( + array( + "SELECT value FROM wp_lock_items WHERE name = 'test_lock' FOR UPDATE", + "SELECT value FROM wp_lock_items WHERE name = 'test_lock' FOR SHARE", + "SELECT value FROM wp_lock_items WHERE name = 'test_lock' LOCK IN SHARE MODE", + "SELECT value FROM wp_lock_items WHERE name = 'test_lock' FOR UPDATE SKIP LOCKED", + "SELECT value FROM wp_lock_items WHERE name = 'test_lock' FOR UPDATE NOWAIT", + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_order_by_field_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wp_field_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value VARCHAR(191) NOT NULL DEFAULT '', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wp_field_options (option_name, option_value) VALUES + ('User 0000019', 'second'), + ('User 0000020', 'third'), + ('User 0000018', 'first')", + ) + ); + + $this->assertParityRows( + "SELECT FIELD(option_name, 'User 0000018', 'User 0000019', 'User 0000020') AS sorting_order + FROM wp_field_options + ORDER BY FIELD(option_name, 'User 0000018', 'User 0000019', 'User 0000020')" + ); + $this->assertParityRows( + "SELECT option_value + FROM wp_field_options + ORDER BY FIELD(option_name, 'User 0000018', 'User 0000019', 'User 0000020')" + ); + $this->assertParityRows( + "SELECT FIELD('b', 'A', 'B') AS case_match, + FIELD(NULL, 'A', 'B') AS null_match, + FIELD('z', 'A', 'B') AS no_match" + ); + } + public function test_joined_update_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 05d2635fe..2c6a861e6 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -868,6 +868,282 @@ public function test_escaped_like_predicates_use_mysql_backslash_semantics(): vo $this->assertStringContainsString( " ESCAPE '\\'", $this->lastDuckDBQuery( $driver ) ); } + public function test_sql_calc_found_rows_and_found_rows_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_found_rows_users ( + ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + user_login VARCHAR(60) NOT NULL DEFAULT '', + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wp_found_rows_users (user_login) VALUES + ('ada'), + ('grace'), + ('katherine')" + ); + + $rows = $driver->query( + 'SELECT SQL_CALC_FOUND_ROWS ID, user_login FROM wp_found_rows_users ORDER BY ID LIMIT 2' + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + array( + 'ID' => 1, + 'user_login' => 'ada', + ), + array( + 'ID' => 2, + 'user_login' => 'grace', + ), + ), + $rows + ); + $this->assertSame( + array( array( 'found_rows' => 3 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_found_rows_state_tracks_selects_and_result_counts(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE wp_found_rows_state (id INT, label VARCHAR(20))' ); + $this->assertSame( + array( array( 'found_rows' => 0 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( "INSERT INTO wp_found_rows_state VALUES (1, 'one'), (2, 'two'), (3, 'three')" ); + $this->assertSame( + array( array( 'found_rows' => 0 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'SELECT id, label FROM wp_found_rows_state ORDER BY id' ); + $this->assertSame( + array( array( 'found_rows' => 3 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'found_rows' => 1 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( "UPDATE wp_found_rows_state SET label = 'updated' WHERE id = 1" ); + $this->assertSame( + array( array( 'found_rows' => 0 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'SELECT id, label FROM wp_found_rows_state ORDER BY id' ); + $driver->query( 'CREATE TABLE wp_found_rows_state_extra (id INT)' ); + $this->assertSame( + array( array( 'found_rows' => 0 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'SHOW TABLES' ); + $this->assertSame( + array( array( 'found_rows' => 2 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'DESCRIBE wp_found_rows_state' ); + $this->assertSame( + array( array( 'found_rows' => 2 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_failed_selects_reset_found_rows_state(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE wp_found_rows_reset (id INT)' ); + $driver->query( 'INSERT INTO wp_found_rows_reset VALUES (1), (2), (3)' ); + + $driver->query( 'SELECT id FROM wp_found_rows_reset ORDER BY id' ); + $this->assertSame( + array( array( 'found_rows' => 3 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'SELECT id FROM wp_found_rows_reset ORDER BY id' ); + try { + $driver->query( 'SELECT * FROM missing_found_rows_reset' ); + $this->fail( 'Missing table SELECT should have failed.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'missing_found_rows_reset', $e->getMessage() ); + } + $this->assertSame( + array( array( 'found_rows' => 0 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'SELECT id FROM wp_found_rows_reset ORDER BY id' ); + try { + $driver->query( 'SELECT SQL_CALC_FOUND_ROWS * FROM missing_found_rows_reset LIMIT 1' ); + $this->fail( 'Missing table SQL_CALC_FOUND_ROWS SELECT should have failed.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'missing_found_rows_reset', $e->getMessage() ); + } + $this->assertSame( + array( array( 'found_rows' => 0 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_select_index_hints_are_ignored(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_hint_posts ( + ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + post_title VARCHAR(200) NOT NULL DEFAULT '', + PRIMARY KEY (ID), + KEY post_status (post_status) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wp_hint_posts (post_status, post_title) VALUES + ('publish', 'first'), + ('draft', 'second'), + ('publish', 'third')" + ); + + $this->assertSame( + array( + array( 'post_title' => 'first' ), + array( 'post_title' => 'third' ), + ), + $driver->query( + "SELECT post_title + FROM wp_hint_posts FORCE INDEX (PRIMARY, post_status) + WHERE post_status = 'publish' + ORDER BY ID" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'post_status' => 'draft', + 'total' => 1, + ), + array( + 'post_status' => 'publish', + 'total' => 2, + ), + ), + $driver->query( + 'SELECT post_status, COUNT(*) AS total + FROM wp_hint_posts USE KEY FOR GROUP BY (post_status) + GROUP BY post_status + ORDER BY post_status' + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'post_title' => 'third' ) ), + $driver->query( + 'SELECT post_title + FROM wp_hint_posts IGNORE INDEX FOR ORDER BY (post_status) + ORDER BY ID DESC + LIMIT 1' + )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_row_locking_clauses_are_ignored_for_selects(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE wp_lock_items (name VARCHAR(255), value VARCHAR(255))' ); + $driver->query( "INSERT INTO wp_lock_items (name, value) VALUES ('test_lock', '123')" ); + + foreach ( + array( + "SELECT value FROM wp_lock_items WHERE name = 'test_lock' FOR UPDATE", + "SELECT value FROM wp_lock_items WHERE name = 'test_lock' FOR SHARE", + "SELECT value FROM wp_lock_items WHERE name = 'test_lock' LOCK IN SHARE MODE", + "SELECT value FROM wp_lock_items WHERE name = 'test_lock' FOR UPDATE SKIP LOCKED", + "SELECT value FROM wp_lock_items WHERE name = 'test_lock' FOR UPDATE NOWAIT", + ) as $sql + ) { + $this->assertSame( + array( array( 'value' => '123' ) ), + $driver->query( $sql )->fetchAll( PDO::FETCH_ASSOC ), + 'Row-locking clause changed SELECT results for SQL: ' . $sql + ); + } + } + + public function test_order_by_field_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_field_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value VARCHAR(191) NOT NULL DEFAULT '', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wp_field_options (option_name, option_value) VALUES + ('User 0000019', 'second'), + ('User 0000020', 'third'), + ('User 0000018', 'first')" + ); + + $this->assertSame( + array( + array( 'sorting_order' => 1 ), + array( 'sorting_order' => 2 ), + array( 'sorting_order' => 3 ), + ), + $driver->query( + "SELECT FIELD(option_name, 'User 0000018', 'User 0000019', 'User 0000020') AS sorting_order + FROM wp_field_options + ORDER BY FIELD(option_name, 'User 0000018', 'User 0000019', 'User 0000020')" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( 'option_value' => 'first' ), + array( 'option_value' => 'second' ), + array( 'option_value' => 'third' ), + ), + $driver->query( + "SELECT option_value + FROM wp_field_options + ORDER BY FIELD(option_name, 'User 0000018', 'User 0000019', 'User 0000020')" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'case_match' => 2, + 'null_match' => 0, + 'no_match' => 0, + ), + ), + $driver->query( + "SELECT FIELD('b', 'A', 'B') AS case_match, + FIELD(NULL, 'A', 'B') AS null_match, + FIELD('z', 'A', 'B') AS no_match" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_multi_table_delete_removes_expired_transient_alias_rows(): void { $this->requireDuckDBRuntime(); From 8a4e6c0cc4d3627986347c6f0403ebacc8a58a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 22:37:37 +0000 Subject: [PATCH 034/317] Document DuckDB savepoint limitation --- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 2c6a861e6..8ed5dc265 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -149,6 +149,40 @@ public function test_sql_transaction_statements_update_connection_state_and_quer $this->assertSame( array(), $driver->get_last_duckdb_queries() ); } + public function test_savepoint_sql_is_rejected_without_mutating_active_transaction(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE tx_savepoint_state (id INT)' ); + + $driver->query( 'BEGIN' ); + $driver->query( 'INSERT INTO tx_savepoint_state (id) VALUES (1)' ); + + foreach ( + array( + 'SAVEPOINT sp1', + 'ROLLBACK TO sp1', + 'ROLLBACK TO SAVEPOINT sp1', + 'RELEASE SAVEPOINT sp1', + ) as $sql + ) { + $this->assertDriverQueryRejected( $driver, $sql ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( + array( array( 'id' => 1 ) ), + $driver->query( 'SELECT id FROM tx_savepoint_state ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + $driver->query( 'ROLLBACK' ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + $this->assertSame( + array(), + $driver->query( 'SELECT id FROM tx_savepoint_state ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_session_boolean_variables_are_emulated(): void { $this->requireDuckDBRuntime(); From e95b3749fcdaaf222c95bc573c9c377f7967138b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 22:49:13 +0000 Subject: [PATCH 035/317] Add DuckDB CHECK constraint parity --- .../src/duckdb/class-wp-duckdb-driver.php | 296 ++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 30 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 84 +++++ 3 files changed, 410 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 1fbc3f457..a5a5c72c8 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -22,9 +22,11 @@ class WP_DuckDB_Driver { const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; const TABLE_METADATA_TABLE = '__wp_duckdb_table_metadata'; + const CHECK_METADATA_TABLE = '__wp_duckdb_check_metadata'; const TEMP_INDEX_METADATA_TABLE = '__wp_duckdb_temp_index_metadata'; const TEMP_COLUMN_METADATA_TABLE = '__wp_duckdb_temp_column_metadata'; const TEMP_TABLE_METADATA_TABLE = '__wp_duckdb_temp_table_metadata'; + const TEMP_CHECK_METADATA_TABLE = '__wp_duckdb_temp_check_metadata'; const INFO_SCHEMA_TABLES_TABLE = '__wp_duckdb_information_schema_tables'; const INFO_SCHEMA_COLUMNS_TABLE = '__wp_duckdb_information_schema_columns'; const INFO_SCHEMA_STATISTICS_TABLE = '__wp_duckdb_information_schema_statistics'; @@ -812,6 +814,8 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $columns = array(); $constraints = array(); + $check_constraints = array(); + $check_names = array(); $indexes = array(); $sequences = array(); $metadata = array(); @@ -829,6 +833,13 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme continue; } + if ( $this->is_create_table_check_constraint( $item ) ) { + $check_constraint = $this->translate_table_check_constraint( $table_name, $item, $check_names ); + $constraints[] = $check_constraint['sql']; + $check_constraints[] = $check_constraint['metadata']; + continue; + } + if ( $this->is_create_table_index_item( $item ) ) { $indexes[] = $this->translate_create_table_index( $table_name, $item, $temporary ); continue; @@ -882,6 +893,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme } $this->record_column_metadata( $table_name, $this->apply_column_key_metadata( $metadata, $primary_key, $indexes ), $temporary ); $this->record_table_metadata( $table_name, $table_metadata, $temporary ); + $this->record_check_metadata( $table_name, $check_constraints, $temporary ); return $result; } @@ -4174,6 +4186,10 @@ private function mysql_create_table_statement( string $table_name, string $reque $rows[] = $this->format_show_create_table_index( $index_group ); } + foreach ( $this->check_constraint_metadata_rows( $table_name, $temporary ) as $check_constraint ) { + $rows[] = $this->format_show_create_table_check_constraint( $check_constraint ); + } + $sql = 'CREATE ' . ( $temporary ? 'TEMPORARY ' : '' ) . 'TABLE ' . $this->quote_mysql_identifier( $requested_table_name ) . " (\n"; $sql .= implode( ",\n", $rows ); $sql .= "\n)"; @@ -4199,6 +4215,20 @@ private function mysql_create_table_statement( string $table_name, string $reque return $sql; } + /** + * Format one SHOW CREATE TABLE CHECK constraint. + * + * @param array $check_constraint CHECK metadata row. + * @return string MySQL CHECK constraint definition. + */ + private function format_show_create_table_check_constraint( array $check_constraint ): string { + $sql = ' CONSTRAINT '; + $sql .= $this->quote_mysql_identifier( (string) $check_constraint['constraint_name'] ); + $sql .= ' CHECK (' . (string) $check_constraint['check_clause'] . ')'; + + return $sql; + } + /** * Execute SHOW TABLE STATUS. * @@ -4381,6 +4411,8 @@ private function execute_show_tables(): WP_DuckDB_Result_Statement { . ' AND table_name <> ' . $this->connection->quote( self::TABLE_METADATA_TABLE ) . ' AND table_name <> ' + . $this->connection->quote( self::CHECK_METADATA_TABLE ) + . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_TABLES_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) @@ -5382,6 +5414,153 @@ private function apply_column_key_metadata( array $metadata, array $primary_key, return $metadata; } + /** + * Check whether a CREATE TABLE item is a table-level CHECK constraint. + * + * @param WP_Parser_Token[] $tokens Item tokens. + * @return bool + */ + private function is_create_table_check_constraint( array $tokens ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[0]->id ) { + return true; + } + + if ( WP_MySQL_Lexer::CONSTRAINT_SYMBOL !== $tokens[0]->id || ! isset( $tokens[1] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[1]->id ) { + return true; + } + + return isset( $tokens[2] ) && WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[2]->id; + } + + /** + * Translate a table-level CHECK constraint. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Constraint tokens. + * @param array $check_names Existing MySQL-facing CHECK names, keyed lowercase. + * @return array{sql:string,metadata:array{constraint_name:string,check_clause:string,enforced:string}} + */ + private function translate_table_check_constraint( string $table_name, array $tokens, array &$check_names ): array { + $index = 0; + $constraint_name = null; + + if ( WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::CHECK_SYMBOL !== $tokens[ $index ]->id ) { + $constraint_name = $this->identifier_value( $tokens[ $index ] ); + ++$index; + } + } + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CHECK_SYMBOL, 'Expected CHECK constraint.' ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Expected CHECK expression.' ); + + $expression_end = $this->skip_balanced_parentheses( $tokens, $index ); + $expression_tokens = array_slice( $tokens, $index + 1, $expression_end - $index - 2 ); + if ( count( $expression_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'CHECK constraint requires an expression in the DuckDB driver.' ); + } + + $index = $expression_end; + if ( isset( $tokens[ $index ] ) ) { + if ( + WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $index ]->id + && isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::ENFORCED_SYMBOL === $tokens[ $index + 1 ]->id + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE CHECK constraint in DuckDB driver: NOT ENFORCED is not supported.' ); + } + + if ( WP_MySQL_Lexer::ENFORCED_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + } + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE CHECK constraint in DuckDB driver.' ); + } + + if ( null === $constraint_name ) { + $constraint_name = $this->generate_check_constraint_name( $table_name, $check_names ); + } + $this->register_check_constraint_name( $constraint_name, $check_names ); + + $duckdb_expression = $this->translate_tokens_to_duckdb_sql( $expression_tokens ); + $mysql_expression = $this->mysql_check_clause_from_tokens( $expression_tokens ); + + return array( + 'sql' => 'CONSTRAINT ' + . $this->connection->quote_identifier( $constraint_name ) + . ' CHECK (' + . $duckdb_expression + . ')', + 'metadata' => array( + 'constraint_name' => $constraint_name, + 'check_clause' => $mysql_expression, + 'enforced' => 'YES', + ), + ); + } + + /** + * Generate a MySQL-compatible name for an unnamed CHECK constraint. + * + * @param string $table_name Table name. + * @param array $check_names Existing CHECK names, keyed lowercase. + * @return string Generated constraint name. + */ + private function generate_check_constraint_name( string $table_name, array $check_names ): string { + $prefix = $table_name . '_chk_'; + $index = 1; + + while ( isset( $check_names[ strtolower( $prefix . $index ) ] ) ) { + ++$index; + } + + return $prefix . $index; + } + + /** + * Register a CHECK constraint name and reject duplicates. + * + * @param string $constraint_name Constraint name. + * @param array $check_names Existing CHECK names, keyed lowercase. + */ + private function register_check_constraint_name( string $constraint_name, array &$check_names ): void { + $key = strtolower( $constraint_name ); + if ( isset( $check_names[ $key ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Duplicate CHECK constraint name in DuckDB driver: ' . $constraint_name . '.' ); + } + + $check_names[ $key ] = true; + } + + /** + * Format CHECK expression tokens for MySQL-facing metadata. + * + * @param WP_Parser_Token[] $tokens Expression tokens. + * @return string MySQL-facing CHECK clause. + */ + private function mysql_check_clause_from_tokens( array $tokens ): string { + return $this->join_sql_pieces( + array_map( + function ( WP_Parser_Token $token ): string { + return $token->get_bytes(); + }, + $tokens + ) + ); + } + /** * Translate a table-level PRIMARY KEY constraint. * @@ -7621,6 +7800,20 @@ private function ensure_table_metadata_table( bool $temporary = false ): void { ); } + /** + * Ensure the internal CHECK constraint metadata table exists. + */ + private function ensure_check_metadata_table( bool $temporary = false ): void { + $this->execute_duckdb_query( + 'CREATE ' + . ( $temporary ? 'TEMP ' : '' ) + . 'TABLE IF NOT EXISTS ' + . $this->connection->quote_identifier( $this->check_metadata_table_name( $temporary ) ) + . ' (table_name VARCHAR, constraint_name VARCHAR, check_clause VARCHAR, enforced VARCHAR)', + 'Failed to initialize DuckDB CHECK constraint metadata' + ); + } + /** * Return the metadata table that stores secondary index rows. * @@ -7651,6 +7844,16 @@ private function table_metadata_table_name( bool $temporary ): string { return $temporary ? self::TEMP_TABLE_METADATA_TABLE : self::TABLE_METADATA_TABLE; } + /** + * Return the metadata table that stores CHECK constraint rows. + * + * @param bool $temporary Whether to use session-local temporary metadata. + * @return string Metadata table name. + */ + private function check_metadata_table_name( bool $temporary ): string { + return $temporary ? self::TEMP_CHECK_METADATA_TABLE : self::CHECK_METADATA_TABLE; + } + /** * Record MySQL index metadata for SHOW INDEX. * @@ -7866,6 +8069,64 @@ private function record_table_metadata( string $table_name, array $metadata, boo ); } + /** + * Record MySQL CHECK constraint metadata for SHOW CREATE TABLE. + * + * @param string $table_name Table name. + * @param array> $metadata CHECK metadata rows. + * @param bool $temporary Whether the target is a temporary table. + */ + private function record_check_metadata( string $table_name, array $metadata, bool $temporary = false ): void { + $this->ensure_check_metadata_table( $temporary ); + + $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( $this->check_metadata_table_name( $temporary ) ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ), + 'Failed to reset DuckDB CHECK constraint metadata' + ); + + foreach ( $metadata as $constraint ) { + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( $this->check_metadata_table_name( $temporary ) ) + . ' (table_name, constraint_name, check_clause, enforced) VALUES (' + . $this->connection->quote( $table_name ) + . ', ' + . $this->connection->quote( $constraint['constraint_name'] ) + . ', ' + . $this->connection->quote( $constraint['check_clause'] ) + . ', ' + . $this->connection->quote( $constraint['enforced'] ) + . ')', + 'Failed to store DuckDB CHECK constraint metadata' + ); + } + } + + /** + * Read recorded MySQL CHECK constraint metadata. + * + * @param string $table_name Table name. + * @param bool $temporary Whether to use session-local temporary metadata. + * @return array> + */ + private function check_constraint_metadata_rows( string $table_name, bool $temporary = false ): array { + $this->ensure_check_metadata_table( $temporary ); + + $stmt = $this->execute_duckdb_query( + 'SELECT constraint_name, check_clause, enforced FROM ' + . $this->connection->quote_identifier( $this->check_metadata_table_name( $temporary ) ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' ORDER BY constraint_name', + 'Failed to inspect DuckDB CHECK constraint metadata' + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + /** * Append one MySQL column metadata row when full table metadata is already recorded. * @@ -7998,12 +8259,14 @@ private function delete_table_lifecycle_metadata( string $table_name, bool $temp $this->ensure_index_metadata_table( $temporary ); $this->ensure_column_metadata_table( $temporary ); $this->ensure_table_metadata_table( $temporary ); + $this->ensure_check_metadata_table( $temporary ); foreach ( array( $this->index_metadata_table_name( $temporary ) => 'index', $this->column_metadata_table_name( $temporary ) => 'column', $this->table_metadata_table_name( $temporary ) => 'table', + $this->check_metadata_table_name( $temporary ) => 'CHECK constraint', ) as $metadata_table => $label ) { $this->execute_duckdb_query( @@ -8927,6 +9190,16 @@ private function information_schema_table_constraints_rows(): array { $rows[] = $this->information_schema_table_constraints_row( $constraint ); } + foreach ( $this->information_schema_check_constraint_rows() as $constraint ) { + $key = $constraint['table_name'] . "\0" . $constraint['constraint_type'] . "\0" . $constraint['constraint_name']; + if ( isset( $seen[ $key ] ) ) { + continue; + } + + $seen[ $key ] = true; + $rows[] = $this->information_schema_table_constraints_row( $constraint ); + } + return $rows; } @@ -9103,6 +9376,27 @@ private function information_schema_key_constraint_rows(): array { return $rows; } + /** + * Build normalized CHECK constraint rows. + * + * @return array + */ + private function information_schema_check_constraint_rows(): array { + $rows = array(); + + foreach ( $this->user_table_names() as $table_name ) { + foreach ( $this->check_constraint_metadata_rows( $table_name ) as $check_constraint ) { + $rows[] = array( + 'table_name' => $table_name, + 'constraint_name' => (string) $check_constraint['constraint_name'], + 'constraint_type' => 'CHECK', + ); + } + } + + return $rows; + } + /** * Refresh a temporary MySQL-shaped information_schema compatibility table. * @@ -9197,6 +9491,8 @@ private function user_table_names(): array { . ' AND table_name <> ' . $this->connection->quote( self::TABLE_METADATA_TABLE ) . ' AND table_name <> ' + . $this->connection->quote( self::CHECK_METADATA_TABLE ) + . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_TABLES_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index b0241b353..cad3ed8bf 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -859,6 +859,36 @@ public function test_information_schema_constraint_metadata_matches_sqlite(): vo ); } + public function test_table_level_check_constraints_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE check_metadata ( + id INT, + amount INT, + CONSTRAINT amount_positive CHECK (amount > 0), + CHECK (id IS NULL OR id >= 0) + )', + ) + ); + + $this->assertParityRowCount( 'INSERT INTO check_metadata (id, amount) VALUES (1, 10)' ); + $this->assertParityErrorContains( + 'INSERT INTO check_metadata (id, amount) VALUES (2, -1)', + 'CHECK constraint failed' + ); + $this->assertParityErrorContains( + 'INSERT INTO check_metadata (id, amount) VALUES (-1, 1)', + 'CHECK constraint failed' + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'check_metadata' + ORDER BY constraint_name" + ); + $this->assertParityRows( 'SHOW CREATE TABLE check_metadata' ); + } + public function test_information_schema_tables_metadata_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 8ed5dc265..10505689b 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -2367,6 +2367,80 @@ public function test_information_schema_constraints_expose_mysql_shaped_metadata $this->assertSame( array(), $internal_usage ); } + public function test_create_table_check_constraints_use_native_enforcement_and_mysql_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( + 'CREATE TABLE checks ( + id INT, + amount INT, + CONSTRAINT amount_positive CHECK (amount > 0), + CHECK (id IS NULL OR id >= 0) + )' + ); + + $this->assertSame( 2, $driver->query( 'INSERT INTO checks (id, amount) VALUES (1, 10), (NULL, 2)' )->rowCount() ); + + try { + $driver->query( 'INSERT INTO checks (id, amount) VALUES (2, -1)' ); + $this->fail( 'Expected CHECK constraint enforcement to reject a negative amount.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + try { + $driver->query( 'INSERT INTO checks (id, amount) VALUES (-1, 1)' ); + $this->fail( 'Expected CHECK constraint enforcement to reject a negative id.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + $constraints = $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'checks' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'amount_positive', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'YES', + ), + array( + 'CONSTRAINT_NAME' => 'checks_chk_1', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'YES', + ), + ), + $constraints + ); + + $create = $driver->query( 'SHOW CREATE TABLE checks' )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `checks` (', + ' `id` int DEFAULT NULL,', + ' `amount` int DEFAULT NULL,', + ' CONSTRAINT `amount_positive` CHECK (amount > 0),', + ' CONSTRAINT `checks_chk_1` CHECK (id IS NULL OR id >= 0)', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $create['Create Table'] + ); + } + public function test_information_schema_tables_exposes_mysql_shaped_table_metadata(): void { $this->requireDuckDBRuntime(); @@ -3571,6 +3645,16 @@ public function test_unsupported_alter_table_add_column_constraints_throw_driver $driver->query( 'ALTER TABLE users ADD COLUMN id INT PRIMARY KEY' ); } + public function test_unsupported_create_table_check_constraint_not_enforced_throws_driver_exception(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $this->expectExceptionMessage( 'Unsupported CREATE TABLE CHECK constraint in DuckDB driver: NOT ENFORCED is not supported.' ); + $driver->query( 'CREATE TABLE checks (id INT, CONSTRAINT positive CHECK (id > 0) NOT ENFORCED)' ); + } + public function test_unsupported_alter_table_add_auto_increment_throws_driver_exception(): void { $this->requireDuckDBRuntime(); From 9e978e3423cd1bdc9c57a3243be201e6efebe9e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 23:03:21 +0000 Subject: [PATCH 036/317] Add DuckDB simple foreign key parity --- .../src/duckdb/class-wp-duckdb-driver.php | 508 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 39 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 149 +++++ 3 files changed, 688 insertions(+), 8 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index a5a5c72c8..a3d10c640 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -23,10 +23,12 @@ class WP_DuckDB_Driver { const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; const TABLE_METADATA_TABLE = '__wp_duckdb_table_metadata'; const CHECK_METADATA_TABLE = '__wp_duckdb_check_metadata'; + const FOREIGN_KEY_METADATA_TABLE = '__wp_duckdb_foreign_key_metadata'; const TEMP_INDEX_METADATA_TABLE = '__wp_duckdb_temp_index_metadata'; const TEMP_COLUMN_METADATA_TABLE = '__wp_duckdb_temp_column_metadata'; const TEMP_TABLE_METADATA_TABLE = '__wp_duckdb_temp_table_metadata'; const TEMP_CHECK_METADATA_TABLE = '__wp_duckdb_temp_check_metadata'; + const TEMP_FOREIGN_KEY_METADATA_TABLE = '__wp_duckdb_temp_foreign_key_metadata'; const INFO_SCHEMA_TABLES_TABLE = '__wp_duckdb_information_schema_tables'; const INFO_SCHEMA_COLUMNS_TABLE = '__wp_duckdb_information_schema_columns'; const INFO_SCHEMA_STATISTICS_TABLE = '__wp_duckdb_information_schema_statistics'; @@ -816,6 +818,8 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $constraints = array(); $check_constraints = array(); $check_names = array(); + $foreign_keys = array(); + $foreign_key_names = array(); $indexes = array(); $sequences = array(); $metadata = array(); @@ -840,6 +844,13 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme continue; } + if ( $this->is_create_table_foreign_key_constraint( $item ) ) { + $foreign_key = $this->translate_table_foreign_key_constraint( $table_name, $item, $foreign_key_names ); + $constraints[] = $foreign_key['sql']; + $foreign_keys[] = $foreign_key['metadata']; + continue; + } + if ( $this->is_create_table_index_item( $item ) ) { $indexes[] = $this->translate_create_table_index( $table_name, $item, $temporary ); continue; @@ -894,6 +905,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $this->record_column_metadata( $table_name, $this->apply_column_key_metadata( $metadata, $primary_key, $indexes ), $temporary ); $this->record_table_metadata( $table_name, $table_metadata, $temporary ); $this->record_check_metadata( $table_name, $check_constraints, $temporary ); + $this->record_foreign_key_metadata( $table_name, $foreign_keys, $temporary ); return $result; } @@ -4186,6 +4198,10 @@ private function mysql_create_table_statement( string $table_name, string $reque $rows[] = $this->format_show_create_table_index( $index_group ); } + foreach ( $this->show_create_table_foreign_key_groups( $table_name, $temporary ) as $foreign_key ) { + $rows[] = $this->format_show_create_table_foreign_key_constraint( $foreign_key ); + } + foreach ( $this->check_constraint_metadata_rows( $table_name, $temporary ) as $check_constraint ) { $rows[] = $this->format_show_create_table_check_constraint( $check_constraint ); } @@ -4215,6 +4231,73 @@ private function mysql_create_table_statement( string $table_name, string $reque return $sql; } + /** + * Build grouped FOREIGN KEY metadata for SHOW CREATE TABLE. + * + * @param string $table_name Table name. + * @param bool $temporary Whether to use session-local temporary metadata. + * @return array + */ + private function show_create_table_foreign_key_groups( string $table_name, bool $temporary = false ): array { + $groups = array(); + + foreach ( $this->foreign_key_metadata_rows( $table_name, $temporary ) as $row ) { + $constraint_name = (string) $row['constraint_name']; + if ( ! isset( $groups[ $constraint_name ] ) ) { + $groups[ $constraint_name ] = array( + 'constraint_name' => $constraint_name, + 'columns' => array(), + 'referenced_table_name' => (string) $row['referenced_table_name'], + 'referenced_columns' => array(), + 'update_rule' => (string) $row['update_rule'], + 'delete_rule' => (string) $row['delete_rule'], + ); + } + + $groups[ $constraint_name ]['columns'][] = (string) $row['column_name']; + $groups[ $constraint_name ]['referenced_columns'][] = (string) $row['referenced_column_name']; + } + + return array_values( $groups ); + } + + /** + * Format one SHOW CREATE TABLE FOREIGN KEY constraint. + * + * @param array $foreign_key FOREIGN KEY metadata group. + * @return string MySQL FOREIGN KEY constraint definition. + */ + private function format_show_create_table_foreign_key_constraint( array $foreign_key ): string { + $columns = array_map( + function ( string $column_name ): string { + return $this->quote_mysql_identifier( $column_name ); + }, + $foreign_key['columns'] + ); + + $referenced_columns = array_map( + function ( string $column_name ): string { + return $this->quote_mysql_identifier( $column_name ); + }, + $foreign_key['referenced_columns'] + ); + + $sql = ' CONSTRAINT '; + $sql .= $this->quote_mysql_identifier( (string) $foreign_key['constraint_name'] ); + $sql .= ' FOREIGN KEY (' . implode( ', ', $columns ) . ')'; + $sql .= ' REFERENCES ' . $this->quote_mysql_identifier( (string) $foreign_key['referenced_table_name'] ); + $sql .= ' (' . implode( ', ', $referenced_columns ) . ')'; + + if ( 'NO ACTION' !== $foreign_key['delete_rule'] ) { + $sql .= ' ON DELETE ' . (string) $foreign_key['delete_rule']; + } + if ( 'NO ACTION' !== $foreign_key['update_rule'] ) { + $sql .= ' ON UPDATE ' . (string) $foreign_key['update_rule']; + } + + return $sql; + } + /** * Format one SHOW CREATE TABLE CHECK constraint. * @@ -4413,6 +4496,8 @@ private function execute_show_tables(): WP_DuckDB_Result_Statement { . ' AND table_name <> ' . $this->connection->quote( self::CHECK_METADATA_TABLE ) . ' AND table_name <> ' + . $this->connection->quote( self::FOREIGN_KEY_METADATA_TABLE ) + . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_TABLES_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) @@ -5114,6 +5199,8 @@ private function translate_create_table_column( string $table_name, array $token } $index = $this->skip_option_value( $tokens, $index + 2 ); break; + case WP_MySQL_Lexer::REFERENCES_SYMBOL: + throw new WP_DuckDB_Driver_Exception( 'Unsupported inline REFERENCES constraint in DuckDB driver. Use a table-level FOREIGN KEY constraint.' ); default: throw new WP_DuckDB_Driver_Exception( 'Unsupported column attribute in DuckDB driver: ' . $token->get_bytes() . '.' ); } @@ -5561,6 +5648,275 @@ function ( WP_Parser_Token $token ): string { ); } + /** + * Check whether a CREATE TABLE item is a table-level FOREIGN KEY constraint. + * + * @param WP_Parser_Token[] $tokens Item tokens. + * @return bool + */ + private function is_create_table_foreign_key_constraint( array $tokens ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[0]->id ) { + return true; + } + + if ( WP_MySQL_Lexer::CONSTRAINT_SYMBOL !== $tokens[0]->id || ! isset( $tokens[1] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[1]->id ) { + return true; + } + + return isset( $tokens[2] ) && WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[2]->id; + } + + /** + * Translate a table-level FOREIGN KEY constraint. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Constraint tokens. + * @param array $foreign_key_names Existing FOREIGN KEY names, keyed lowercase. + * @return array{sql:string,metadata:array{constraint_name:string,columns:string[],referenced_table_name:string,referenced_columns:string[],update_rule:string,delete_rule:string}} + */ + private function translate_table_foreign_key_constraint( string $table_name, array $tokens, array &$foreign_key_names ): array { + $index = 0; + $constraint_name = null; + + if ( WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::FOREIGN_SYMBOL !== $tokens[ $index ]->id ) { + $constraint_name = $this->identifier_value( $tokens[ $index ] ); + ++$index; + } + } + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::FOREIGN_SYMBOL, 'Expected FOREIGN KEY constraint.' ); + $this->expect_token( $tokens, $index + 1, WP_MySQL_Lexer::KEY_SYMBOL, 'Expected FOREIGN KEY constraint.' ); + $index += 2; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index ]->id ) { + $this->identifier_value( $tokens[ $index ] ); + ++$index; + } + + list( $columns, $index ) = $this->parse_foreign_key_column_list( $tokens, $index, 'FOREIGN KEY column list' ); + if ( 1 !== count( $columns ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE FOREIGN KEY constraint in DuckDB driver. Only single-column foreign keys are supported.' ); + } + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::REFERENCES_SYMBOL, 'Expected REFERENCES in FOREIGN KEY constraint.' ); + ++$index; + + $referenced_table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE FOREIGN KEY constraint in DuckDB driver. Schema-qualified references are not supported.' ); + } + + list( $referenced_columns, $index ) = $this->parse_foreign_key_column_list( $tokens, $index, 'FOREIGN KEY referenced column list' ); + if ( 1 !== count( $referenced_columns ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE FOREIGN KEY constraint in DuckDB driver. Only single-column foreign keys are supported.' ); + } + + list( $update_rule, $delete_rule, $index ) = $this->parse_foreign_key_actions( $tokens, $index ); + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE FOREIGN KEY constraint in DuckDB driver.' ); + } + + if ( null === $constraint_name ) { + $constraint_name = $this->generate_foreign_key_constraint_name( $table_name, $foreign_key_names ); + } + $this->register_foreign_key_constraint_name( $constraint_name, $foreign_key_names ); + + return array( + 'sql' => 'CONSTRAINT ' + . $this->connection->quote_identifier( $constraint_name ) + . ' FOREIGN KEY (' + . $this->connection->quote_identifier( $columns[0] ) + . ') REFERENCES ' + . $this->connection->quote_identifier( $referenced_table_name ) + . ' (' + . $this->connection->quote_identifier( $referenced_columns[0] ) + . ')', + 'metadata' => array( + 'constraint_name' => $constraint_name, + 'columns' => $columns, + 'referenced_table_name' => $referenced_table_name, + 'referenced_columns' => $referenced_columns, + 'update_rule' => $update_rule, + 'delete_rule' => $delete_rule, + ), + ); + } + + /** + * Parse a FOREIGN KEY column list. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Index at opening parenthesis. + * @param string $label User-facing list label for errors. + * @return array{0:string[],1:int} + */ + private function parse_foreign_key_column_list( array $tokens, int $index, string $label ): array { + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Expected ' . $label . '.' ); + ++$index; + + $columns = array(); + while ( $index < count( $tokens ) ) { + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + break; + } + + $columns[] = $this->identifier_value( $tokens[ $index ] ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + continue; + } + + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $label . ' in DuckDB driver.' ); + } + } + + if ( count( $columns ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( $label . ' requires at least one column in the DuckDB driver.' ); + } + + return array( $columns, $index ); + } + + /** + * Parse optional FOREIGN KEY ON UPDATE/DELETE actions. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index. + * @return array{0:string,1:string,2:int} + */ + private function parse_foreign_key_actions( array $tokens, int $index ): array { + $update_rule = 'NO ACTION'; + $delete_rule = 'NO ACTION'; + $seen = array(); + + while ( $index < count( $tokens ) ) { + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ON_SYMBOL, 'Expected ON in FOREIGN KEY action.' ); + if ( ! isset( $tokens[ $index + 1 ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Expected FOREIGN KEY action target in DuckDB driver.' ); + } + + if ( WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $index + 1 ]->id ) { + $target = 'UPDATE'; + } elseif ( WP_MySQL_Lexer::DELETE_SYMBOL === $tokens[ $index + 1 ]->id ) { + $target = 'DELETE'; + } else { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE FOREIGN KEY action in DuckDB driver: ON ' . $tokens[ $index + 1 ]->get_bytes() . '.' ); + } + + if ( isset( $seen[ $target ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Duplicate ON ' . $target . ' action in DuckDB FOREIGN KEY constraint.' ); + } + $seen[ $target ] = true; + + list( $rule, $index ) = $this->parse_foreign_key_action( $tokens, $index + 2, $target ); + if ( 'UPDATE' === $target ) { + $update_rule = $rule; + } else { + $delete_rule = $rule; + } + } + + return array( $update_rule, $delete_rule, $index ); + } + + /** + * Parse one FOREIGN KEY action. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Index at action. + * @param string $target UPDATE or DELETE. + * @return array{0:string,1:int} + */ + private function parse_foreign_key_action( array $tokens, int $index, string $target ): array { + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Expected FOREIGN KEY action after ON ' . $target . ' in DuckDB driver.' ); + } + + if ( WP_MySQL_Lexer::NO_SYMBOL === $tokens[ $index ]->id ) { + if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::ACTION_SYMBOL !== $tokens[ $index + 1 ]->id ) { + throw $this->unsupported_foreign_key_action_exception( $tokens, $index, $target ); + } + return array( 'NO ACTION', $index + 2 ); + } + + if ( WP_MySQL_Lexer::RESTRICT_SYMBOL === $tokens[ $index ]->id ) { + return array( 'RESTRICT', $index + 1 ); + } + + throw $this->unsupported_foreign_key_action_exception( $tokens, $index, $target ); + } + + /** + * Build an unsupported FOREIGN KEY action exception. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Index at action. + * @param string $target UPDATE or DELETE. + * @return WP_DuckDB_Driver_Exception + */ + private function unsupported_foreign_key_action_exception( array $tokens, int $index, string $target ): WP_DuckDB_Driver_Exception { + $action = isset( $tokens[ $index ] ) ? $tokens[ $index ]->get_bytes() : ''; + if ( + isset( $tokens[ $index + 1 ] ) + && ( + WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $index ]->id + || WP_MySQL_Lexer::NO_SYMBOL === $tokens[ $index ]->id + ) + ) { + $action .= ' ' . $tokens[ $index + 1 ]->get_bytes(); + } + + return new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE FOREIGN KEY action in DuckDB driver: ON ' . $target . ' ' . trim( $action ) . ' is not supported.' ); + } + + /** + * Generate a MySQL-compatible name for an unnamed FOREIGN KEY constraint. + * + * @param string $table_name Table name. + * @param array $foreign_key_names Existing FOREIGN KEY names, keyed lowercase. + * @return string Generated constraint name. + */ + private function generate_foreign_key_constraint_name( string $table_name, array $foreign_key_names ): string { + $prefix = $table_name . '_ibfk_'; + $index = 1; + + while ( isset( $foreign_key_names[ strtolower( $prefix . $index ) ] ) ) { + ++$index; + } + + return $prefix . $index; + } + + /** + * Register a FOREIGN KEY constraint name and reject duplicates. + * + * @param string $constraint_name Constraint name. + * @param array $foreign_key_names Existing FOREIGN KEY names, keyed lowercase. + */ + private function register_foreign_key_constraint_name( string $constraint_name, array &$foreign_key_names ): void { + $key = strtolower( $constraint_name ); + if ( isset( $foreign_key_names[ $key ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Duplicate FOREIGN KEY constraint name in DuckDB driver: ' . $constraint_name . '.' ); + } + + $foreign_key_names[ $key ] = true; + } + /** * Translate a table-level PRIMARY KEY constraint. * @@ -7814,6 +8170,20 @@ private function ensure_check_metadata_table( bool $temporary = false ): void { ); } + /** + * Ensure the internal FOREIGN KEY metadata table exists. + */ + private function ensure_foreign_key_metadata_table( bool $temporary = false ): void { + $this->execute_duckdb_query( + 'CREATE ' + . ( $temporary ? 'TEMP ' : '' ) + . 'TABLE IF NOT EXISTS ' + . $this->connection->quote_identifier( $this->foreign_key_metadata_table_name( $temporary ) ) + . ' (table_name VARCHAR, constraint_name VARCHAR, ordinal_position INTEGER, column_name VARCHAR, referenced_table_name VARCHAR, referenced_column_name VARCHAR, update_rule VARCHAR, delete_rule VARCHAR)', + 'Failed to initialize DuckDB FOREIGN KEY metadata' + ); + } + /** * Return the metadata table that stores secondary index rows. * @@ -7854,6 +8224,16 @@ private function check_metadata_table_name( bool $temporary ): string { return $temporary ? self::TEMP_CHECK_METADATA_TABLE : self::CHECK_METADATA_TABLE; } + /** + * Return the metadata table that stores FOREIGN KEY constraint rows. + * + * @param bool $temporary Whether to use session-local temporary metadata. + * @return string Metadata table name. + */ + private function foreign_key_metadata_table_name( bool $temporary ): string { + return $temporary ? self::TEMP_FOREIGN_KEY_METADATA_TABLE : self::FOREIGN_KEY_METADATA_TABLE; + } + /** * Record MySQL index metadata for SHOW INDEX. * @@ -8105,6 +8485,52 @@ private function record_check_metadata( string $table_name, array $metadata, boo } } + /** + * Record MySQL FOREIGN KEY constraint metadata. + * + * @param string $table_name Table name. + * @param array> $metadata FOREIGN KEY metadata rows. + * @param bool $temporary Whether the target is a temporary table. + */ + private function record_foreign_key_metadata( string $table_name, array $metadata, bool $temporary = false ): void { + $this->ensure_foreign_key_metadata_table( $temporary ); + + $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( $this->foreign_key_metadata_table_name( $temporary ) ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ), + 'Failed to reset DuckDB FOREIGN KEY metadata' + ); + + foreach ( $metadata as $constraint ) { + foreach ( $constraint['columns'] as $offset => $column_name ) { + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( $this->foreign_key_metadata_table_name( $temporary ) ) + . ' (table_name, constraint_name, ordinal_position, column_name, referenced_table_name, referenced_column_name, update_rule, delete_rule) VALUES (' + . $this->connection->quote( $table_name ) + . ', ' + . $this->connection->quote( $constraint['constraint_name'] ) + . ', ' + . ( $offset + 1 ) + . ', ' + . $this->connection->quote( $column_name ) + . ', ' + . $this->connection->quote( $constraint['referenced_table_name'] ) + . ', ' + . $this->connection->quote( $constraint['referenced_columns'][ $offset ] ) + . ', ' + . $this->connection->quote( $constraint['update_rule'] ) + . ', ' + . $this->connection->quote( $constraint['delete_rule'] ) + . ')', + 'Failed to store DuckDB FOREIGN KEY metadata' + ); + } + } + } + /** * Read recorded MySQL CHECK constraint metadata. * @@ -8127,6 +8553,28 @@ private function check_constraint_metadata_rows( string $table_name, bool $tempo return $stmt->fetchAll( PDO::FETCH_ASSOC ); } + /** + * Read recorded MySQL FOREIGN KEY constraint metadata. + * + * @param string $table_name Table name. + * @param bool $temporary Whether to use session-local temporary metadata. + * @return array> + */ + private function foreign_key_metadata_rows( string $table_name, bool $temporary = false ): array { + $this->ensure_foreign_key_metadata_table( $temporary ); + + $stmt = $this->execute_duckdb_query( + 'SELECT constraint_name, ordinal_position, column_name, referenced_table_name, referenced_column_name, update_rule, delete_rule FROM ' + . $this->connection->quote_identifier( $this->foreign_key_metadata_table_name( $temporary ) ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' ORDER BY constraint_name, ordinal_position', + 'Failed to inspect DuckDB FOREIGN KEY metadata' + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + /** * Append one MySQL column metadata row when full table metadata is already recorded. * @@ -8260,13 +8708,15 @@ private function delete_table_lifecycle_metadata( string $table_name, bool $temp $this->ensure_column_metadata_table( $temporary ); $this->ensure_table_metadata_table( $temporary ); $this->ensure_check_metadata_table( $temporary ); + $this->ensure_foreign_key_metadata_table( $temporary ); foreach ( array( - $this->index_metadata_table_name( $temporary ) => 'index', - $this->column_metadata_table_name( $temporary ) => 'column', - $this->table_metadata_table_name( $temporary ) => 'table', - $this->check_metadata_table_name( $temporary ) => 'CHECK constraint', + $this->index_metadata_table_name( $temporary ) => 'index', + $this->column_metadata_table_name( $temporary ) => 'column', + $this->table_metadata_table_name( $temporary ) => 'table', + $this->check_metadata_table_name( $temporary ) => 'CHECK constraint', + $this->foreign_key_metadata_table_name( $temporary ) => 'FOREIGN KEY', ) as $metadata_table => $label ) { $this->execute_duckdb_query( @@ -9190,6 +9640,16 @@ private function information_schema_table_constraints_rows(): array { $rows[] = $this->information_schema_table_constraints_row( $constraint ); } + foreach ( $this->information_schema_foreign_key_constraint_rows() as $constraint ) { + $key = $constraint['table_name'] . "\0" . $constraint['constraint_type'] . "\0" . $constraint['constraint_name']; + if ( isset( $seen[ $key ] ) ) { + continue; + } + + $seen[ $key ] = true; + $rows[] = $this->information_schema_table_constraints_row( $constraint ); + } + foreach ( $this->information_schema_check_constraint_rows() as $constraint ) { $key = $constraint['table_name'] . "\0" . $constraint['constraint_type'] . "\0" . $constraint['constraint_name']; if ( isset( $seen[ $key ] ) ) { @@ -9286,6 +9746,9 @@ private function information_schema_key_column_usage_rows(): array { foreach ( $this->information_schema_key_constraint_rows() as $constraint ) { $rows[] = $this->information_schema_key_column_usage_row( $constraint ); } + foreach ( $this->information_schema_foreign_key_constraint_rows() as $constraint ) { + $rows[] = $this->information_schema_key_column_usage_row( $constraint ); + } return $rows; } @@ -9306,10 +9769,10 @@ private function information_schema_key_column_usage_row( array $constraint ): a 'TABLE_NAME' => $constraint['table_name'], 'COLUMN_NAME' => $constraint['column_name'], 'ORDINAL_POSITION' => $constraint['ordinal_position'], - 'POSITION_IN_UNIQUE_CONSTRAINT' => null, - 'REFERENCED_TABLE_SCHEMA' => $this->database, - 'REFERENCED_TABLE_NAME' => null, - 'REFERENCED_COLUMN_NAME' => null, + 'POSITION_IN_UNIQUE_CONSTRAINT' => $constraint['position_in_unique_constraint'] ?? null, + 'REFERENCED_TABLE_SCHEMA' => $constraint['referenced_table_schema'] ?? $this->database, + 'REFERENCED_TABLE_NAME' => $constraint['referenced_table_name'] ?? null, + 'REFERENCED_COLUMN_NAME' => $constraint['referenced_column_name'] ?? null, ); } @@ -9376,6 +9839,33 @@ private function information_schema_key_constraint_rows(): array { return $rows; } + /** + * Build normalized FOREIGN KEY constraint column rows. + * + * @return array + */ + private function information_schema_foreign_key_constraint_rows(): array { + $rows = array(); + + foreach ( $this->user_table_names() as $table_name ) { + foreach ( $this->foreign_key_metadata_rows( $table_name ) as $foreign_key ) { + $rows[] = array( + 'table_name' => $table_name, + 'constraint_name' => (string) $foreign_key['constraint_name'], + 'constraint_type' => 'FOREIGN KEY', + 'ordinal_position' => (int) $foreign_key['ordinal_position'], + 'column_name' => (string) $foreign_key['column_name'], + 'position_in_unique_constraint' => (int) $foreign_key['ordinal_position'], + 'referenced_table_schema' => $this->database, + 'referenced_table_name' => (string) $foreign_key['referenced_table_name'], + 'referenced_column_name' => (string) $foreign_key['referenced_column_name'], + ); + } + } + + return $rows; + } + /** * Build normalized CHECK constraint rows. * @@ -9493,6 +9983,8 @@ private function user_table_names(): array { . ' AND table_name <> ' . $this->connection->quote( self::CHECK_METADATA_TABLE ) . ' AND table_name <> ' + . $this->connection->quote( self::FOREIGN_KEY_METADATA_TABLE ) + . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_TABLES_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index cad3ed8bf..3ce27c551 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -889,6 +889,45 @@ public function test_table_level_check_constraints_match_sqlite(): void { $this->assertParityRows( 'SHOW CREATE TABLE check_metadata' ); } + public function test_simple_table_level_foreign_keys_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE fk_parent (id INT PRIMARY KEY)', + 'CREATE TABLE fk_child ( + id INT, + parent_id INT, + CONSTRAINT fk_parent_ref FOREIGN KEY (parent_id) REFERENCES fk_parent (id) ON DELETE RESTRICT ON UPDATE NO ACTION + )', + 'INSERT INTO fk_parent (id) VALUES (1)', + ) + ); + + $this->assertParityRowCount( 'INSERT INTO fk_child (id, parent_id) VALUES (10, 1)' ); + $this->assertParityErrorContains( + 'INSERT INTO fk_child (id, parent_id) VALUES (11, 404)', + 'constraint' + ); + $this->assertParityErrorContains( + 'DELETE FROM fk_parent WHERE id = 1', + 'constraint' + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'fk_child' + ORDER BY constraint_name" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, + POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_SCHEMA, + REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' AND table_name = 'fk_child' + ORDER BY constraint_name, ordinal_position" + ); + $this->assertParityRows( 'SHOW CREATE TABLE fk_child' ); + } + public function test_information_schema_tables_metadata_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 10505689b..b2744d396 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -2441,6 +2441,117 @@ public function test_create_table_check_constraints_use_native_enforcement_and_m ); } + public function test_create_table_foreign_keys_use_native_enforcement_and_mysql_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( 'CREATE TABLE parents (id INT PRIMARY KEY)' ); + $driver->query( + 'CREATE TABLE child_named ( + id INT, + parent_id INT, + CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES parents (id) ON DELETE RESTRICT ON UPDATE NO ACTION + )' + ); + $driver->query( + 'CREATE TABLE child_generated ( + id INT, + parent_id INT, + FOREIGN KEY (parent_id) REFERENCES parents (id) + )' + ); + + $create_queries = $driver->get_last_duckdb_queries(); + $this->assertContains( + 'CREATE TABLE "child_generated" ("id" INTEGER, "parent_id" INTEGER, CONSTRAINT "child_generated_ibfk_1" FOREIGN KEY ("parent_id") REFERENCES "parents" ("id"))', + $create_queries + ); + + $driver->query( 'INSERT INTO parents (id) VALUES (1)' ); + $this->assertSame( 1, $driver->query( 'INSERT INTO child_named (id, parent_id) VALUES (10, 1)' )->rowCount() ); + + try { + $driver->query( 'INSERT INTO child_named (id, parent_id) VALUES (11, 404)' ); + $this->fail( 'Expected FOREIGN KEY enforcement to reject a missing parent row.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'constraint', strtolower( $e->getMessage() ) ); + } + + try { + $driver->query( 'DELETE FROM parents WHERE id = 1' ); + $this->fail( 'Expected FOREIGN KEY enforcement to restrict deleting a referenced parent row.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'constraint', strtolower( $e->getMessage() ) ); + } + + $constraints = $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name IN ('child_generated', 'child_named') + ORDER BY table_name, constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'child_generated_ibfk_1', + 'CONSTRAINT_TYPE' => 'FOREIGN KEY', + 'ENFORCED' => 'YES', + ), + array( + 'CONSTRAINT_NAME' => 'fk_parent', + 'CONSTRAINT_TYPE' => 'FOREIGN KEY', + 'ENFORCED' => 'YES', + ), + ), + $constraints + ); + + $usage = $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, + POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_SCHEMA, + REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' AND table_name = 'child_named' + ORDER BY constraint_name, ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'fk_parent', + 'TABLE_NAME' => 'child_named', + 'COLUMN_NAME' => 'parent_id', + 'ORDINAL_POSITION' => 1, + 'POSITION_IN_UNIQUE_CONSTRAINT' => 1, + 'REFERENCED_TABLE_SCHEMA' => 'wp', + 'REFERENCED_TABLE_NAME' => 'parents', + 'REFERENCED_COLUMN_NAME' => 'id', + ), + ), + $usage + ); + + $create = $driver->query( 'SHOW CREATE TABLE child_named' )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `child_named` (', + ' `id` int DEFAULT NULL,', + ' `parent_id` int DEFAULT NULL,', + ' CONSTRAINT `fk_parent` FOREIGN KEY (`parent_id`) REFERENCES `parents` (`id`) ON DELETE RESTRICT', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $create['Create Table'] + ); + } + public function test_information_schema_tables_exposes_mysql_shaped_table_metadata(): void { $this->requireDuckDBRuntime(); @@ -3655,6 +3766,44 @@ public function test_unsupported_create_table_check_constraint_not_enforced_thro $driver->query( 'CREATE TABLE checks (id INT, CONSTRAINT positive CHECK (id > 0) NOT ENFORCED)' ); } + public function test_unsupported_create_table_foreign_key_actions_throw_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE parents (id INT PRIMARY KEY)' ); + + foreach ( + array( + 'CREATE TABLE child_cascade (parent_id INT, FOREIGN KEY (parent_id) REFERENCES parents (id) ON DELETE CASCADE)' => 'ON DELETE CASCADE is not supported', + 'CREATE TABLE child_set_null (parent_id INT, FOREIGN KEY (parent_id) REFERENCES parents (id) ON UPDATE SET NULL)' => 'ON UPDATE SET NULL is not supported', + 'CREATE TABLE child_set_default (parent_id INT DEFAULT 0, FOREIGN KEY (parent_id) REFERENCES parents (id) ON DELETE SET DEFAULT)' => 'ON DELETE SET DEFAULT is not supported', + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected unsupported FOREIGN KEY action to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + } + + $this->assertSame( + array( array( 'Tables_in_wp' => 'parents' ) ), + $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_inline_references_remain_unsupported(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE parents (id INT PRIMARY KEY)' ); + + $this->expectException( WP_DuckDB_Driver_Exception::class ); + $this->expectExceptionMessage( 'Unsupported inline REFERENCES constraint in DuckDB driver.' ); + $driver->query( 'CREATE TABLE child_inline (parent_id INT REFERENCES parents (id))' ); + } + public function test_unsupported_alter_table_add_auto_increment_throws_driver_exception(): void { $this->requireDuckDBRuntime(); From d34f74ec01010c3695d0c04676a5573afca29fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 23:14:54 +0000 Subject: [PATCH 037/317] Add DuckDB result metadata parity --- .../src/duckdb/class-wp-duckdb-driver.php | 442 +++++++++++++++++- .../class-wp-duckdb-result-statement.php | 37 +- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 50 ++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 132 ++++++ .../wp-includes/duckdb/class-wp-duckdb-db.php | 27 +- 5 files changed, 669 insertions(+), 19 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index a3d10c640..e4244b82a 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -446,6 +446,7 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $has_sql_calc_found_rows = $this->has_top_level_sql_calc_found_rows( $tokens ); $tokens = $this->normalize_select_helper_tokens( $tokens ); + $column_meta = $this->simple_select_column_metadata( $tokens ); $rewrite_information_schema_tables = $this->uses_information_schema_tables( $tokens ); $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); @@ -487,7 +488,8 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_table_constraints, $rewrite_information_schema_key_column_usage ); - return $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); + $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); + return $this->apply_result_column_metadata( $result, $column_meta ); } catch ( Throwable $e ) { $this->found_rows = 0; throw $e; @@ -496,9 +498,447 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); $this->found_rows = $sql; + return $this->apply_result_column_metadata( $result, $column_meta ); + } + + /** + * Attach optional column metadata when it matches the result shape. + * + * @param WP_DuckDB_Result_Statement $result Query result. + * @param array>|null $column_meta Optional column metadata. + * @return WP_DuckDB_Result_Statement Result with metadata attached when available. + */ + private function apply_result_column_metadata( WP_DuckDB_Result_Statement $result, ?array $column_meta ): WP_DuckDB_Result_Statement { + if ( null !== $column_meta && count( $column_meta ) === $result->columnCount() ) { + $result->setColumnMeta( $column_meta ); + } + return $result; } + /** + * Derive bounded result metadata for SELECT column_list FROM single_table. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return array>|null Column metadata, or null when the shape is outside the supported slice. + */ + private function simple_select_column_metadata( array $tokens ): ?array { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $from_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::FROM_SYMBOL ); + if ( null === $from_index || 1 === $from_index ) { + return null; + } + + $select_items = $this->split_top_level_comma_items( array_slice( $tokens, 1, $from_index - 1 ) ); + if ( count( $select_items ) === 0 ) { + return null; + } + + $columns = array(); + foreach ( $select_items as $item ) { + $column = $this->parse_simple_select_column_reference( $item ); + if ( null === $column ) { + return null; + } + $columns[] = $column; + } + + $table_tokens = array_slice( + $tokens, + $from_index + 1, + $this->simple_select_from_clause_end( $tokens, $from_index + 1 ) - $from_index - 1 + ); + $table = $this->parse_simple_select_table_reference( $table_tokens ); + if ( null === $table ) { + return null; + } + + foreach ( $columns as $column ) { + if ( + null !== $column['qualifier'] + && 0 !== strcasecmp( $column['qualifier'], $table['alias'] ) + && 0 !== strcasecmp( $column['qualifier'], $table['table_name'] ) + ) { + return null; + } + } + + $metadata_by_column = array(); + foreach ( $this->table_column_metadata_rows( $table['table_name'], $table['temporary'] ) as $metadata ) { + $metadata_by_column[ strtolower( (string) $metadata['column_name'] ) ] = $metadata; + } + + $column_meta = array(); + foreach ( $columns as $column ) { + $key = strtolower( $column['column_name'] ); + if ( ! isset( $metadata_by_column[ $key ] ) ) { + return null; + } + + $column_meta[] = $this->mysql_result_column_metadata( + $table['table_name'], + $table['alias'], + $metadata_by_column[ $key ], + $column['name'] + ); + } + + return $column_meta; + } + + /** + * Find the end of a simple SELECT FROM clause. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $start First token after FROM. + * @return int End index, exclusive. + */ + private function simple_select_from_clause_end( array $tokens, int $start ): int { + $clause_tokens = array( + WP_MySQL_Lexer::WHERE_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::WINDOW_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ); + $depth = 0; + + for ( $index = $start; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + continue; + } + if ( 0 === $depth && in_array( $tokens[ $index ]->id, $clause_tokens, true ) ) { + return $index; + } + } + + return count( $tokens ); + } + + /** + * Parse a single-table reference for bounded SELECT metadata. + * + * @param WP_Parser_Token[] $tokens Table reference tokens. + * @return array{table_name:string,alias:string,temporary:bool}|null Table reference, or null when unsupported. + */ + private function parse_simple_select_table_reference( array $tokens ): ?array { + if ( + count( $tokens ) === 0 + || $this->contains_top_level_token_id( $tokens, WP_MySQL_Lexer::COMMA_SYMBOL ) + || $this->contains_top_level_join_token( $tokens ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id + ) { + return null; + } + + $index = 0; + $database = null; + $table_name = $this->metadata_identifier_value( $tokens[ $index ] ?? null ); + if ( null === $table_name ) { + return null; + } + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index ]->id ) { + $database = $table_name; + ++$index; + $table_name = $this->metadata_identifier_value( $tokens[ $index ] ?? null ); + if ( null === $table_name ) { + return null; + } + ++$index; + + if ( 0 === strcasecmp( $database, 'information_schema' ) || 0 !== strcasecmp( $database, $this->database ) ) { + return null; + } + } + + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + if ( null === $table_reference ) { + return null; + } + + $alias = $table_reference['table_name']; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $parsed_alias = $this->metadata_identifier_value( $tokens[ $index ] ?? null ); + if ( null === $parsed_alias ) { + return null; + } + $alias = $parsed_alias; + ++$index; + } elseif ( isset( $tokens[ $index ] ) ) { + $parsed_alias = $this->metadata_identifier_value( $tokens[ $index ] ); + if ( null === $parsed_alias ) { + return null; + } + $alias = $parsed_alias; + ++$index; + } + + if ( count( $tokens ) !== $index ) { + return null; + } + + return array( + 'table_name' => $table_reference['table_name'], + 'alias' => $alias, + 'temporary' => $table_reference['temporary'], + ); + } + + /** + * Parse one SELECT list item for bounded result metadata. + * + * @param WP_Parser_Token[] $tokens SELECT item tokens. + * @return array{name:string,column_name:string,qualifier:string|null}|null Column reference, or null when unsupported. + */ + private function parse_simple_select_column_reference( array $tokens ): ?array { + $count = count( $tokens ); + if ( 0 === $count ) { + return null; + } + + $index = 0; + $qualifier = null; + $name = $this->metadata_identifier_value( $tokens[ $index ] ?? null ); + if ( null === $name ) { + return null; + } + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index ]->id ) { + $qualifier = $name; + ++$index; + $name = $this->metadata_identifier_value( $tokens[ $index ] ?? null ); + if ( null === $name ) { + return null; + } + ++$index; + } + + $alias = $name; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $parsed_alias = $this->metadata_identifier_value( $tokens[ $index ] ?? null ); + if ( null === $parsed_alias ) { + return null; + } + $alias = $parsed_alias; + ++$index; + } elseif ( isset( $tokens[ $index ] ) ) { + $parsed_alias = $this->metadata_identifier_value( $tokens[ $index ] ); + if ( null === $parsed_alias ) { + return null; + } + $alias = $parsed_alias; + ++$index; + } + + if ( $count !== $index ) { + return null; + } + + return array( + 'name' => $alias, + 'column_name' => $name, + 'qualifier' => $qualifier, + ); + } + + /** + * Read a metadata identifier without throwing for unsupported tokens. + * + * @param WP_Parser_Token|null $token Token. + * @return string|null Identifier value, or null when unsupported. + */ + private function metadata_identifier_value( $token ): ?string { + if ( ! $token instanceof WP_Parser_Token || $this->is_non_identifier_token( $token ) || WP_MySQL_Lexer::MULT_OPERATOR === $token->id ) { + return null; + } + + return $token->get_value(); + } + + /** + * Build PDO/MySQLi-shaped result metadata for one direct table column. + * + * @param string $table_name Original table name. + * @param string $table_alias Result table alias. + * @param array $column Stored column metadata. + * @param string $result_name Result column name. + * @return array Result column metadata. + */ + private function mysql_result_column_metadata( string $table_name, string $table_alias, array $column, string $result_name ): array { + $column_type = (string) $column['column_type']; + $type_attributes = $this->column_type_attributes( + $column_type, + null === $column['collation_name'] ? null : (string) $column['collation_name'] + ); + $type_info = $this->mysql_result_column_type_info( $type_attributes['data_type'], $column_type ); + + $length = $this->mysql_result_column_length( $column_type, $type_attributes, $type_info['length'] ); + $precision = $this->mysql_result_column_precision( $type_attributes, $type_info['precision'] ); + + return array( + 'native_type' => $type_info['native_type'], + 'flags' => array(), + 'table' => $table_alias, + 'name' => $result_name, + 'len' => $length, + 'precision' => $precision, + 'duckdb:decl_type' => $column_type, + 'mysqli:orgname' => (string) $column['column_name'], + 'mysqli:orgtable' => $table_name, + 'mysqli:db' => $this->database, + 'mysqli:charsetnr' => $this->mysql_result_column_charsetnr( $type_attributes['data_type'], $column['collation_name'] ), + 'mysqli:flags' => 0, + 'mysqli:type' => $type_info['mysqli_type'], + ); + } + + /** + * Map MySQL data types to PDO/MySQLi result metadata types. + * + * @param string $data_type Normalized MySQL data type. + * @param string $column_type Full MySQL column type. + * @return array{native_type:string,mysqli_type:int,length:int|null,precision:int|null} Type metadata. + */ + private function mysql_result_column_type_info( string $data_type, string $column_type ): array { + $type_map = array( + 'bit' => array( 'BIT', 16, 1, 0 ), + 'tinyint' => array( 'TINY', 1, 4, 0 ), + 'smallint' => array( 'SHORT', 2, 6, 0 ), + 'mediumint' => array( 'INT24', 9, 9, 0 ), + 'int' => array( 'LONG', 3, 11, 0 ), + 'bigint' => array( 'LONGLONG', 8, 20, 0 ), + 'float' => array( 'FLOAT', 4, 12, 31 ), + 'double' => array( 'DOUBLE', 5, 22, 31 ), + 'decimal' => array( 'NEWDECIMAL', 246, null, null ), + 'char' => array( 'STRING', 254, null, 0 ), + 'varchar' => array( 'VAR_STRING', 253, null, 0 ), + 'tinytext' => array( 'BLOB', 252, null, 0 ), + 'text' => array( 'BLOB', 252, null, 0 ), + 'mediumtext' => array( 'BLOB', 252, null, 0 ), + 'longtext' => array( 'BLOB', 252, null, 0 ), + 'json' => array( 'BLOB', 245, 4294967295, 0 ), + 'date' => array( 'DATE', 10, 10, 0 ), + 'time' => array( 'TIME', 11, 10, 0 ), + 'datetime' => array( 'DATETIME', 12, 19, 0 ), + 'timestamp' => array( 'TIMESTAMP', 7, 19, 0 ), + 'year' => array( 'YEAR', 13, 4, 0 ), + 'binary' => array( 'BLOB', 254, null, 0 ), + 'varbinary' => array( 'BLOB', 253, null, 0 ), + 'tinyblob' => array( 'BLOB', 252, null, 0 ), + 'blob' => array( 'BLOB', 252, null, 0 ), + 'mediumblob' => array( 'BLOB', 252, null, 0 ), + 'longblob' => array( 'BLOB', 252, null, 0 ), + ); + + $type_info = $type_map[ $data_type ] ?? array( 'VAR_STRING', 253, null, 0 ); + if ( 'tinyint(1)' === strtolower( trim( $column_type ) ) ) { + $type_info[2] = 1; + } + + return array( + 'native_type' => $type_info[0], + 'mysqli_type' => $type_info[1], + 'length' => $type_info[2], + 'precision' => $type_info[3], + ); + } + + /** + * Derive MySQLi result length from MySQL type attributes. + * + * @param string $column_type Full MySQL column type. + * @param array $type_attributes Derived type attributes. + * @param int|null $default_length Default mapped length. + * @return int Length. + */ + private function mysql_result_column_length( string $column_type, array $type_attributes, ?int $default_length ): int { + $length = $default_length; + $data_type = (string) $type_attributes['data_type']; + + if ( 'decimal' === $data_type ) { + $length = (int) $type_attributes['numeric_precision'] + (int) $type_attributes['numeric_scale']; + } elseif ( null !== $type_attributes['character_maximum_length'] ) { + $length = (int) $type_attributes['character_maximum_length']; + } + + if ( + null !== $length + && false !== strpos( strtolower( $column_type ), 'unsigned' ) + && false === strpos( strtolower( $column_type ), 'bigint' ) + ) { + --$length; + } + + if ( + null !== $length + && ( + false !== strpos( $data_type, 'text' ) + || false !== strpos( $data_type, 'char' ) + || 'enum' === $data_type + || 'set' === $data_type + ) + && 'longtext' !== $data_type + ) { + $length *= 4; + } + + return null === $length ? 0 : $length; + } + + /** + * Derive MySQLi result precision. + * + * @param array $type_attributes Derived type attributes. + * @param int|null $default_precision Default mapped precision. + * @return int Precision. + */ + private function mysql_result_column_precision( array $type_attributes, ?int $default_precision ): int { + if ( 'decimal' === $type_attributes['data_type'] ) { + return (int) $type_attributes['numeric_scale']; + } + + return null === $default_precision ? 0 : $default_precision; + } + + /** + * Derive a MySQLi charset number for the bounded metadata slice. + * + * @param string $data_type Normalized MySQL data type. + * @param mixed $collation_name Optional collation name. + * @return int MySQLi charset number. + */ + private function mysql_result_column_charsetnr( string $data_type, $collation_name ): int { + $charset = $this->character_set_from_collation( $collation_name ); + if ( + null !== $charset + && false === strpos( $data_type, 'blob' ) + && ! in_array( $data_type, array( 'binary', 'varbinary', 'date', 'time', 'datetime', 'timestamp', 'year' ), true ) + ) { + return 255; + } + + return 63; + } + /** * Parse SELECT FOUND_ROWS() with an optional alias and optional FROM DUAL. * diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php index c878a21d1..1216cf89d 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php @@ -21,6 +21,11 @@ class WP_DuckDB_Result_Statement implements IteratorAggregate { */ private $columns; + /** + * @var array> + */ + private $column_meta = array(); + /** * @var array> */ @@ -42,11 +47,12 @@ class WP_DuckDB_Result_Statement implements IteratorAggregate { private $default_fetch_mode = PDO::FETCH_BOTH; /** - * @param string[] $columns Column names. - * @param array> $rows Numeric rows. - * @param int $affected_rows Affected row count. + * @param string[] $columns Column names. + * @param array> $rows Numeric rows. + * @param int $affected_rows Affected row count. + * @param array> $column_meta Optional column metadata. */ - public function __construct( array $columns, array $rows, int $affected_rows = 0 ) { + public function __construct( array $columns, array $rows, int $affected_rows = 0, array $column_meta = array() ) { $this->columns = array_values( $columns ); $this->rows = array_values( array_map( @@ -57,6 +63,25 @@ function ( array $row ): array { ) ); $this->affected_rows = $affected_rows; + $this->setColumnMeta( $column_meta ); + } + + /** + * Set column metadata. + * + * @param array> $column_meta Column metadata keyed by zero-based column offset. + */ + public function setColumnMeta( array $column_meta ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $this->column_meta = array(); + foreach ( $this->columns as $index => $name ) { + $meta = isset( $column_meta[ $index ] ) && is_array( $column_meta[ $index ] ) + ? $column_meta[ $index ] + : array(); + if ( ! isset( $meta['name'] ) ) { + $meta['name'] = $name; + } + $this->column_meta[ $index ] = $meta; + } } /** @@ -169,9 +194,7 @@ public function getColumnMeta( $column ) { // phpcs:ignore WordPress.NamingConve return false; } - return array( - 'name' => $this->columns[ $column ], - ); + return $this->column_meta[ $column ]; } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index b2744d396..018ddaec8 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1396,6 +1396,56 @@ public function test_show_columns_and_full_fields_are_emulated(): void { ); } + public function test_simple_select_result_metadata_uses_recorded_table_columns(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wordpress_test', + ) + ); + $driver->query( + "CREATE TABLE `wp_posts` ( + `ID` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + `post_title` VARCHAR(191) NOT NULL DEFAULT '', + `post_content` LONGTEXT, + PRIMARY KEY (`ID`) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + + $result = $driver->query( 'SELECT p.ID AS post_id, p.post_title FROM wp_posts AS p WHERE p.ID = 0' ); + + $this->assertSame( 2, $result->columnCount() ); + $this->assertSame( array(), $result->fetchAll( PDO::FETCH_ASSOC ) ); + + $id_meta = $result->getColumnMeta( 0 ); + $this->assertSame( 'post_id', $id_meta['name'] ); + $this->assertSame( 'p', $id_meta['table'] ); + $this->assertSame( 'ID', $id_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_posts', $id_meta['mysqli:orgtable'] ); + $this->assertSame( 'wordpress_test', $id_meta['mysqli:db'] ); + $this->assertSame( 20, $id_meta['len'] ); + $this->assertSame( 63, $id_meta['mysqli:charsetnr'] ); + $this->assertSame( 8, $id_meta['mysqli:type'] ); + + $title_meta = $result->getColumnMeta( 1 ); + $this->assertSame( 'post_title', $title_meta['name'] ); + $this->assertSame( 'p', $title_meta['table'] ); + $this->assertSame( 'post_title', $title_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_posts', $title_meta['mysqli:orgtable'] ); + $this->assertSame( 764, $title_meta['len'] ); + $this->assertSame( 255, $title_meta['mysqli:charsetnr'] ); + $this->assertSame( 253, $title_meta['mysqli:type'] ); + + $expression = $driver->query( 'SELECT COUNT(*) AS post_count FROM wp_posts' ); + $this->assertSame( array( 'name' => 'post_count' ), $expression->getColumnMeta( 0 ) ); + + $update = $driver->query( "UPDATE wp_posts SET post_title = 'draft' WHERE ID = 0" ); + $this->assertSame( 0, $update->columnCount() ); + $this->assertFalse( $update->getColumnMeta( 0 ) ); + } + public function test_regexp_predicates_are_emulated(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index c429650f8..4a141e7df 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -172,6 +172,34 @@ public function test_duckdb_wpdb_flush_clears_statement_metadata(): void { $this->assertSame( array(), $result['after_flush_column_names'] ); } + public function test_duckdb_wpdb_col_info_uses_statement_metadata(): void { + $result = $this->run_col_info_state_script(); + + $this->assertTrue( $result['connected'] ); + $this->assertSame( 0, $result['select_return'] ); + $this->assertCount( 2, $result['select_col_info'] ); + + $this->assertSame( 'ID', $result['select_col_info'][0]['name'] ); + $this->assertSame( 'ID', $result['select_col_info'][0]['orgname'] ); + $this->assertSame( 'wp_posts', $result['select_col_info'][0]['table'] ); + $this->assertSame( 'wp_posts', $result['select_col_info'][0]['orgtable'] ); + $this->assertSame( 'wordpress_test', $result['select_col_info'][0]['db'] ); + $this->assertSame( 20, $result['select_col_info'][0]['length'] ); + $this->assertSame( 63, $result['select_col_info'][0]['charsetnr'] ); + $this->assertSame( 8, $result['select_col_info'][0]['type'] ); + $this->assertSame( 0, $result['select_col_info'][0]['decimals'] ); + + $this->assertSame( 'post_title', $result['select_col_info'][1]['name'] ); + $this->assertSame( 'post_title', $result['select_col_info'][1]['orgname'] ); + $this->assertSame( 'wp_posts', $result['select_col_info'][1]['table'] ); + $this->assertSame( 'wp_posts', $result['select_col_info'][1]['orgtable'] ); + $this->assertSame( 764, $result['select_col_info'][1]['length'] ); + $this->assertSame( 255, $result['select_col_info'][1]['charsetnr'] ); + $this->assertSame( 253, $result['select_col_info'][1]['type'] ); + $this->assertSame( 0, $result['update_return'] ); + $this->assertSame( array(), $result['update_col_info'] ); + } + public function test_duckdb_wpdb_db_connect_sets_filtered_sql_mode(): void { $result = $this->run_sql_mode_boot_state_script( false ); @@ -585,6 +613,110 @@ function ( $column ) { return $this->run_isolated_php( $code ); } + private function run_col_info_state_script(): array { + $plugin_dir = $this->get_plugin_dir(); + $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; + $code = $this->get_wordpress_stub_code(); + $code .= "\nrequire_once " . var_export( $driver_load, true ) . ";\n"; + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= <<<'PHP' + +class WP_DuckDB_Plugin_Col_Info_Test_Driver extends WP_DuckDB_Driver { + public function __construct() {} + + public function query( string $sql ): WP_DuckDB_Result_Statement { + if ( 'SELECT @@SESSION.sql_mode' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( '@@SESSION.sql_mode' ), + array( + array( 'NO_ENGINE_SUBSTITUTION' ), + ), + 0 + ); + } + + if ( "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'" === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( 'SELECT ID, post_title FROM wp_posts WHERE ID = 0' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( 'ID', 'post_title' ), + array(), + 0, + array( + array( + 'name' => 'ID', + 'native_type' => 'LONGLONG', + 'table' => 'wp_posts', + 'len' => 20, + 'precision' => 0, + 'mysqli:orgname' => 'ID', + 'mysqli:orgtable' => 'wp_posts', + 'mysqli:db' => 'wordpress_test', + 'mysqli:charsetnr' => 63, + 'mysqli:flags' => 0, + 'mysqli:type' => 8, + ), + array( + 'name' => 'post_title', + 'native_type' => 'VAR_STRING', + 'table' => 'wp_posts', + 'len' => 764, + 'precision' => 0, + 'mysqli:orgname' => 'post_title', + 'mysqli:orgtable' => 'wp_posts', + 'mysqli:db' => 'wordpress_test', + 'mysqli:charsetnr' => 255, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + ), + ) + ); + } + + if ( 'UPDATE wp_posts SET ID = ID WHERE ID = 0' === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } +} + +class WP_DuckDB_Plugin_Col_Info_Test_DB extends WP_DuckDB_DB { + public function exported_col_info() { + $this->load_col_info(); + return array_map( + function ( $column ) { + return (array) $column; + }, + $this->col_info + ); + } +} + +$GLOBALS['@duckdb_driver'] = new WP_DuckDB_Plugin_Col_Info_Test_Driver(); +$db = new WP_DuckDB_Plugin_Col_Info_Test_DB( 'wordpress_test' ); +$connected = $db->db_connect( false ); +$select_return = $db->query( 'SELECT ID, post_title FROM wp_posts WHERE ID = 0' ); +$select_col_info = $db->exported_col_info(); +$update_return = $db->query( 'UPDATE wp_posts SET ID = ID WHERE ID = 0' ); +$update_col_info = $db->exported_col_info(); + +echo json_encode( + array( + 'connected' => $connected, + 'select_return' => $select_return, + 'select_col_info' => $select_col_info, + 'update_return' => $update_return, + 'update_col_info' => $update_col_info, + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + private function get_dispatcher_include_code( string $include_file ): string { return "\ntry {\n" . "\trequire " . var_export( $include_file, true ) . ";\n" diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index 58f81071f..fe1c179d1 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -320,21 +320,26 @@ protected function load_col_info() { } for ( $i = 0; $i < $this->last_statement->columnCount(); ++$i ) { - $meta = $this->last_statement->getColumnMeta( $i ); + $meta = $this->last_statement->getColumnMeta( $i ); + if ( ! is_array( $meta ) ) { + continue; + } + + $name = isset( $meta['name'] ) ? $meta['name'] : ''; $this->col_info[] = (object) array( - 'name' => $meta['name'], - 'orgname' => $meta['name'], - 'table' => '', - 'orgtable' => '', + 'name' => $name, + 'orgname' => isset( $meta['mysqli:orgname'] ) ? $meta['mysqli:orgname'] : $name, + 'table' => isset( $meta['table'] ) ? $meta['table'] : '', + 'orgtable' => isset( $meta['mysqli:orgtable'] ) ? $meta['mysqli:orgtable'] : ( isset( $meta['table'] ) ? $meta['table'] : '' ), 'def' => '', - 'db' => $this->dbname, + 'db' => isset( $meta['mysqli:db'] ) ? $meta['mysqli:db'] : $this->dbname, 'catalog' => 'def', 'max_length' => 0, - 'length' => 0, - 'charsetnr' => 224, - 'flags' => 0, - 'type' => 253, - 'decimals' => 0, + 'length' => isset( $meta['len'] ) ? $meta['len'] : 0, + 'charsetnr' => isset( $meta['mysqli:charsetnr'] ) ? $meta['mysqli:charsetnr'] : 224, + 'flags' => isset( $meta['mysqli:flags'] ) ? $meta['mysqli:flags'] : 0, + 'type' => isset( $meta['mysqli:type'] ) ? $meta['mysqli:type'] : 253, + 'decimals' => isset( $meta['precision'] ) ? $meta['precision'] : 0, ); } } From e4c09d1f65960329ca9e9574fc3c1fab3c464c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 23:24:40 +0000 Subject: [PATCH 038/317] Add DuckDB user variable parity --- .../src/duckdb/class-wp-duckdb-driver.php | 320 ++++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 37 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 129 +++++++ 3 files changed, 463 insertions(+), 23 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index e4244b82a..afe4bb7ff 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -36,9 +36,11 @@ class WP_DuckDB_Driver { const INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE = '__wp_duckdb_information_schema_key_column_usage'; const SUPPORTED_SESSION_SYSTEM_VARIABLES = array( - 'autocommit' => true, - 'big_tables' => true, - 'sql_mode' => true, + 'autocommit' => true, + 'big_tables' => true, + 'foreign_key_checks' => true, + 'sql_mode' => true, + 'unique_checks' => true, ); const READ_ONLY_SYSTEM_VARIABLES = array( @@ -146,10 +148,17 @@ class WP_DuckDB_Driver { /** * MySQL session system variables emulated by this driver. * - * @var array + * @var array */ private $session_system_variables = array(); + /** + * MySQL user variables emulated by this driver. + * + * @var array + */ + private $user_variables = array(); + /** * Whether a MySQL LOCK TABLES statement opened the current transaction. * @@ -439,9 +448,9 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { return new WP_DuckDB_Result_Statement( array( $found_rows_alias ), array( array( $found_rows ) ), 0 ); } - $session_variable_select = $this->execute_session_system_variable_select( $tokens ); - if ( null !== $session_variable_select ) { - return $this->record_found_rows_from_result( $session_variable_select ); + $variable_select = $this->execute_variable_select( $tokens ); + if ( null !== $variable_select ) { + return $this->record_found_rows_from_result( $variable_select ); } $has_sql_calc_found_rows = $this->has_top_level_sql_calc_found_rows( $tokens ); @@ -1051,13 +1060,13 @@ private function record_found_rows_from_result( WP_DuckDB_Result_Statement $stat } /** - * Execute a simple SELECT list of supported session system variables. + * Execute a simple SELECT list of supported MySQL variables. * * @param WP_Parser_Token[] $tokens MySQL tokens. * @return WP_DuckDB_Result_Statement|null Statement when emulated, null for generic SELECT handling. */ - private function execute_session_system_variable_select( array $tokens ): ?WP_DuckDB_Result_Statement { - $variables = $this->parse_session_system_variable_select( $tokens ); + private function execute_variable_select( array $tokens ): ?WP_DuckDB_Result_Statement { + $variables = $this->parse_variable_select( $tokens ); if ( null === $variables ) { return null; } @@ -1066,19 +1075,21 @@ private function execute_session_system_variable_select( array $tokens ): ?WP_Du $row = array(); foreach ( $variables as $variable ) { $columns[] = $variable['alias']; - $row[] = $this->get_session_system_variable( $variable['name'] ); + $row[] = 'user' === $variable['type'] + ? $this->get_user_variable( $variable['name'] ) + : $this->get_session_system_variable( $variable['name'] ); } return new WP_DuckDB_Result_Statement( $columns, array( $row ), 0 ); } /** - * Parse a simple SELECT list of supported session system variables. + * Parse a simple SELECT list of supported MySQL variables. * * @param WP_Parser_Token[] $tokens MySQL tokens. - * @return array|null Variables, or null for generic SELECT handling. + * @return array|null Variables, or null for generic SELECT handling. */ - private function parse_session_system_variable_select( array $tokens ): ?array { + private function parse_variable_select( array $tokens ): ?array { if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { return null; } @@ -1100,10 +1111,18 @@ private function parse_session_system_variable_select( array $tokens ): ?array { $variables = array(); foreach ( $this->split_top_level_comma_items( $select_list ) as $item ) { $variable = $this->parse_session_system_variable_reference( $item ); + if ( null !== $variable ) { + $variable['type'] = 'system'; + $variables[] = $variable; + continue; + } + + $variable = $this->parse_user_variable_select_reference( $item ); if ( null === $variable ) { return null; } - $variables[] = $variable; + $variable['type'] = 'user'; + $variables[] = $variable; } return $variables; @@ -1144,6 +1163,38 @@ private function parse_session_system_variable_reference( array $tokens ): ?arra ); } + /** + * Parse one supported @user_variable reference with an optional alias. + * + * @param WP_Parser_Token[] $tokens Reference tokens. + * @return array{name:string,alias:string}|null Variable, or null when the item is not supported by this slice. + */ + private function parse_user_variable_select_reference( array $tokens ): ?array { + if ( ! isset( $tokens[0] ) || ! $this->is_user_variable_token( $tokens[0] ) ) { + return null; + } + + $index = 1; + $alias = $tokens[0]->get_bytes(); + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $alias = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + } elseif ( isset( $tokens[ $index ] ) ) { + $alias = $this->identifier_value( $tokens[ $index ] ); + ++$index; + } + + if ( count( $tokens ) !== $index ) { + return null; + } + + return array( + 'name' => $this->user_variable_name( $tokens[0] ), + 'alias' => $alias, + ); + } + /** * Normalize a supported session system variable token. * @@ -1158,6 +1209,40 @@ private function session_system_variable_name( WP_Parser_Token $token ): ?string return strtolower( $token->get_value() ); } + /** + * Get an emulated MySQL user variable value. + * + * @param string $name Normalized variable name. + * @return int|float|string|null Stored value, or null when unset. + */ + private function get_user_variable( string $name ) { + return array_key_exists( $name, $this->user_variables ) ? $this->user_variables[ $name ] : null; + } + + /** + * Normalize a user-variable token name. + * + * @param WP_Parser_Token $token User-variable token. + * @return string Lowercase variable name without the leading @. + */ + private function user_variable_name( WP_Parser_Token $token ): string { + if ( ! $this->is_user_variable_token( $token ) ) { + throw new WP_DuckDB_Driver_Exception( 'Expected a MySQL user variable in DuckDB driver statement.' ); + } + + return strtolower( substr( $token->get_value(), 1 ) ); + } + + /** + * Check whether a token is a MySQL @user_variable token. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token is a user variable. + */ + private function is_user_variable_token( WP_Parser_Token $token ): bool { + return WP_MySQL_Lexer::AT_TEXT_SUFFIX === $token->id; + } + /** * Check whether this bounded slice supports a system-variable reference. * @@ -2665,6 +2750,11 @@ private function execute_set_statement( array $tokens ): WP_DuckDB_Result_Statem continue; } + if ( $this->is_set_user_variable_assignment( $assignment ) ) { + $this->execute_set_user_variable_assignment( $assignment ); + continue; + } + $default_scope = $this->execute_set_session_system_variable_assignment( $assignment, $default_scope ); } @@ -2728,12 +2818,7 @@ private function execute_set_session_system_variable_assignment( array $tokens, ); ++$index; - $value_tokens = array_slice( $tokens, $index ); - if ( 1 !== count( $value_tokens ) ) { - throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); - } - - $value = $this->normalize_set_session_system_variable_value( $name, $value_tokens[0] ); + $value = $this->normalize_set_session_system_variable_value_tokens( $name, array_slice( $tokens, $index ) ); if ( 'sql_mode' === $name ) { $this->active_sql_modes = '' === $value ? array() : explode( ',', (string) $value ); } else { @@ -2743,6 +2828,35 @@ private function execute_set_session_system_variable_assignment( array $tokens, return $default_scope; } + /** + * Check whether a SET assignment targets a user variable. + * + * @param WP_Parser_Token[] $tokens Assignment tokens. + * @return bool Whether the assignment targets a user variable. + */ + private function is_set_user_variable_assignment( array $tokens ): bool { + return isset( $tokens[0] ) && $this->is_user_variable_token( $tokens[0] ); + } + + /** + * Execute one SET assignment for an emulated user variable. + * + * @param WP_Parser_Token[] $tokens Assignment tokens. + */ + private function execute_set_user_variable_assignment( array $tokens ): void { + $name = $this->user_variable_name( $tokens[0] ); + if ( + ! isset( $tokens[1] ) + || ( WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[1]->id && WP_MySQL_Lexer::ASSIGN_OPERATOR !== $tokens[1]->id ) + ) { + throw new WP_DuckDB_Driver_Exception( + 'Unsupported SET user variable statement in DuckDB driver. Expected "=" or ":=" for user variable assignment.' + ); + } + + $this->user_variables[ $name ] = $this->normalize_set_user_variable_value( array_slice( $tokens, 2 ) ); + } + /** * Parse the target side of one SET assignment. * @@ -2789,6 +2903,32 @@ private function parse_set_session_system_variable_target( array $tokens, int $i ); } + /** + * Normalize an emulated session system variable value from assignment tokens. + * + * @param string $name Normalized variable name. + * @param WP_Parser_Token[] $tokens Value tokens. + * @return int|string|null Normalized stored value. + */ + private function normalize_set_session_system_variable_value_tokens( string $name, array $tokens ) { + if ( 1 !== count( $tokens ) ) { + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + if ( $this->is_user_variable_token( $tokens[0] ) ) { + if ( ! in_array( $name, array( 'foreign_key_checks', 'unique_checks' ), true ) ) { + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + return $this->normalize_set_dump_check_variable_value( + $name, + $this->get_user_variable( $this->user_variable_name( $tokens[0] ) ) + ); + } + + return $this->normalize_set_session_system_variable_value( $name, $tokens[0] ); + } + /** * Normalize an emulated session system variable value. * @@ -2835,6 +2975,89 @@ private function normalize_set_session_system_variable_value( string $name, WP_P throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); } + /** + * Normalize a restored dump check variable value. + * + * @param string $name Normalized variable name. + * @param mixed $value Restored user-variable value. + * @return int|string|null Normalized stored value. + */ + private function normalize_set_dump_check_variable_value( string $name, $value ) { + if ( null === $value || 'DEFAULT' === $value ) { + return $value; + } + + if ( is_int( $value ) && ( 0 === $value || 1 === $value ) ) { + return $value; + } + + if ( is_string( $value ) ) { + $lower = strtolower( $value ); + if ( 'on' === $lower || 'true' === $lower || '1' === $value ) { + return 1; + } + if ( 'off' === $lower || 'false' === $lower || '0' === $value ) { + return 0; + } + } + + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + /** + * Normalize an emulated user variable assignment value. + * + * @param WP_Parser_Token[] $tokens Value tokens. + * @return int|float|string|null Normalized stored value. + */ + private function normalize_set_user_variable_value( array $tokens ) { + $system_variable = $this->parse_session_system_variable_reference( $tokens ); + if ( null !== $system_variable ) { + return $this->get_session_system_variable( $system_variable['name'] ); + } + + if ( 1 === count( $tokens ) && $this->is_user_variable_token( $tokens[0] ) ) { + return $this->get_user_variable( $this->user_variable_name( $tokens[0] ) ); + } + + return $this->normalize_set_user_variable_literal_value( $tokens ); + } + + /** + * Normalize a bounded literal for a user variable assignment. + * + * @param WP_Parser_Token[] $tokens Literal tokens. + * @return int|float|string|null Normalized stored value. + */ + private function normalize_set_user_variable_literal_value( array $tokens ) { + if ( count( $tokens ) === 2 && $this->is_sign_token( $tokens[0] ) && $this->is_number_token( $tokens[1] ) ) { + return $this->signed_number_token_value( $tokens[0], $tokens[1] ); + } + + if ( 1 !== count( $tokens ) ) { + throw $this->new_unsupported_set_user_variable_value_exception(); + } + + $token = $tokens[0]; + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $token->get_value(); + } + if ( $this->is_number_token( $token ) ) { + return $this->number_token_value( $token ); + } + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id || WP_MySQL_Lexer::NULL2_SYMBOL === $token->id ) { + return null; + } + if ( WP_MySQL_Lexer::TRUE_SYMBOL === $token->id ) { + return 1; + } + if ( WP_MySQL_Lexer::FALSE_SYMBOL === $token->id ) { + return 0; + } + + throw $this->new_unsupported_set_user_variable_value_exception(); + } + /** * Normalize a SET sql_mode value for driver-local readback. * @@ -2877,7 +3100,18 @@ private function new_unsupported_set_session_system_variable_value_exception( st return new WP_DuckDB_Driver_Exception( 'Unsupported SET value for ' . $name - . ' in DuckDB driver. Only ON, OFF, TRUE, FALSE, 1, 0, and DEFAULT are supported.' + . ' in DuckDB driver. Only ON, OFF, TRUE, FALSE, 1, 0, DEFAULT, and supported dump restores are supported.' + ); + } + + /** + * Build an unsupported user-variable SET value exception. + * + * @return WP_DuckDB_Driver_Exception Exception. + */ + private function new_unsupported_set_user_variable_value_exception(): WP_DuckDB_Driver_Exception { + return new WP_DuckDB_Driver_Exception( + 'Unsupported SET user variable value in DuckDB driver. Only literals, supported system variables, and simple user variable references are supported.' ); } @@ -2893,7 +3127,7 @@ private function normalize_supported_session_system_variable_name( string $name throw new WP_DuckDB_Driver_Exception( 'Unsupported SET session variable in DuckDB driver: ' . $name - . '. Only autocommit, big_tables, and sql_mode are supported.' + . '. Only autocommit, big_tables, foreign_key_checks, sql_mode, and unique_checks are supported.' ); } @@ -10915,6 +11149,46 @@ private function is_number_token( WP_Parser_Token $token ): bool { ); } + /** + * Check whether a token is a numeric sign. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token is + or -. + */ + private function is_sign_token( WP_Parser_Token $token ): bool { + return WP_MySQL_Lexer::PLUS_OPERATOR === $token->id || WP_MySQL_Lexer::MINUS_OPERATOR === $token->id; + } + + /** + * Convert a number token to a PHP scalar. + * + * @param WP_Parser_Token $token Number token. + * @return int|float Number value. + */ + private function number_token_value( WP_Parser_Token $token ) { + if ( WP_MySQL_Lexer::DECIMAL_NUMBER === $token->id || WP_MySQL_Lexer::FLOAT_NUMBER === $token->id ) { + return (float) $token->get_value(); + } + + return (int) $token->get_value(); + } + + /** + * Convert a signed number token pair to a PHP scalar. + * + * @param WP_Parser_Token $sign Sign token. + * @param WP_Parser_Token $token Number token. + * @return int|float Number value. + */ + private function signed_number_token_value( WP_Parser_Token $sign, WP_Parser_Token $token ) { + $value = $this->number_token_value( $token ); + if ( WP_MySQL_Lexer::MINUS_OPERATOR === $sign->id ) { + return -$value; + } + + return $value; + } + /** * Check if a DuckDB type is integer-like. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 3ce27c551..0b4cce3ac 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -111,6 +111,43 @@ public function test_session_variable_sql_matches_sqlite(): void { $this->assertParityRows( 'SELECT @@autocommit, @@big_tables' ); } + public function test_user_variable_sql_matches_sqlite(): void { + $this->assertParityRows( 'SELECT @missing, @missing AS missing_alias, @missing implicit_alias' ); + + $this->assertParityRowCount( + "SET @my_var = 1, @name := 'Ada', @copy = @name, @mode = @@SQL_MODE, @nothing = NULL" + ); + $this->assertParityRows( + 'SELECT @MY_VAR, @name AS name, @copy, @mode mode, @nothing AS nothing FROM DUAL' + ); + + $this->assertParityRowCount( 'SET @signed = -2, @decimal = +1.25, @flag = TRUE' ); + $this->assertParityRows( 'SELECT @signed, @decimal, @flag' ); + } + + public function test_dump_check_variable_backup_and_restore_sql_matches_sqlite(): void { + $this->assertParityRowCount( + '/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;' + ); + $this->assertParityRows( 'SELECT @OLD_UNIQUE_CHECKS, @@UNIQUE_CHECKS' ); + + $this->assertParityRowCount( + '/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;' + ); + $this->assertParityRows( 'SELECT @OLD_FOREIGN_KEY_CHECKS, @@FOREIGN_KEY_CHECKS' ); + + $this->assertParityRowCount( '/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;' ); + $this->assertParityRowCount( '/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;' ); + $this->assertParityRows( 'SELECT @@UNIQUE_CHECKS, @@FOREIGN_KEY_CHECKS' ); + + $this->assertParityRowCount( + 'SET @RESTORED_UNIQUE_CHECKS = 1, @RESTORED_FOREIGN_KEY_CHECKS = "0"' + ); + $this->assertParityRowCount( 'SET UNIQUE_CHECKS=@RESTORED_UNIQUE_CHECKS' ); + $this->assertParityRowCount( 'SET FOREIGN_KEY_CHECKS=@RESTORED_FOREIGN_KEY_CHECKS' ); + $this->assertParityRows( 'SELECT @@UNIQUE_CHECKS, @@FOREIGN_KEY_CHECKS' ); + } + public function test_sql_mode_bootstrap_sql_matches_sqlite(): void { $this->assertParityRowCount( 'SET NAMES utf8mb4' ); $this->assertParityRowCount( 'SET CHARSET utf8mb4' ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 018ddaec8..45c004d82 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -433,6 +433,113 @@ public function test_builtin_system_variables_are_emulated(): void { $this->assertSame( array(), $driver->get_last_duckdb_queries() ); } + public function test_user_variables_are_emulated_for_bounded_values(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + $read = $driver->query( 'SELECT @missing, @missing AS missing_alias, @missing implicit_alias' ); + $this->assertSame( array( 'name' => '@missing' ), $read->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => 'missing_alias' ), $read->getColumnMeta( 1 ) ); + $this->assertSame( array( 'name' => 'implicit_alias' ), $read->getColumnMeta( 2 ) ); + $this->assertSame( + array( + '@missing' => null, + 'missing_alias' => null, + 'implicit_alias' => null, + ), + $read->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $set = $driver->query( + "SET @my_var = 1, @name := 'Ada', @copy = @name, @mode = @@SQL_MODE, @nothing = NULL" + ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $default_sql_mode = 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,' + . 'NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES'; + $this->assertSame( + array( + '@MY_VAR' => 1, + 'name' => 'Ada', + '@copy' => 'Ada', + 'mode' => $default_sql_mode, + 'nothing' => null, + ), + $driver->query( + 'SELECT @MY_VAR, @name AS name, @copy, @mode mode, @nothing AS nothing FROM DUAL' + )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $driver->query( 'SET @signed = -2, @decimal = +1.25, @flag = TRUE' ); + $this->assertSame( + array( + '@signed' => -2, + '@decimal' => 1.25, + '@flag' => 1, + ), + $driver->query( 'SELECT @signed, @decimal, @flag' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + + public function test_dump_check_variable_backup_and_restore_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + $set = $driver->query( '/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;' ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + $this->assertSame( + array( + '@OLD_UNIQUE_CHECKS' => null, + '@@UNIQUE_CHECKS' => 0, + ), + $driver->query( 'SELECT @OLD_UNIQUE_CHECKS, @@UNIQUE_CHECKS' )->fetch( PDO::FETCH_ASSOC ) + ); + + $set = $driver->query( + '/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;' + ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + $this->assertSame( + array( + '@OLD_FOREIGN_KEY_CHECKS' => null, + '@@FOREIGN_KEY_CHECKS' => 0, + ), + $driver->query( 'SELECT @OLD_FOREIGN_KEY_CHECKS, @@FOREIGN_KEY_CHECKS' )->fetch( PDO::FETCH_ASSOC ) + ); + + $driver->query( '/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;' ); + $driver->query( '/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;' ); + $this->assertSame( + array( + '@@UNIQUE_CHECKS' => null, + '@@FOREIGN_KEY_CHECKS' => null, + ), + $driver->query( 'SELECT @@UNIQUE_CHECKS, @@FOREIGN_KEY_CHECKS' )->fetch( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'SET @RESTORED_UNIQUE_CHECKS = 1, @RESTORED_FOREIGN_KEY_CHECKS = "0"' ); + $driver->query( 'SET UNIQUE_CHECKS=@RESTORED_UNIQUE_CHECKS' ); + $driver->query( 'SET FOREIGN_KEY_CHECKS=@RESTORED_FOREIGN_KEY_CHECKS' ); + $this->assertSame( + array( + '@@UNIQUE_CHECKS' => 1, + '@@FOREIGN_KEY_CHECKS' => 0, + ), + $driver->query( 'SELECT @@UNIQUE_CHECKS, @@FOREIGN_KEY_CHECKS' )->fetch( PDO::FETCH_ASSOC ) + ); + } + public function test_session_variable_unsupported_set_forms_are_rejected(): void { $this->requireDuckDBRuntime(); @@ -458,6 +565,28 @@ public function test_session_variable_unsupported_set_forms_are_rejected(): void } } + public function test_user_variable_unsupported_expression_forms_are_rejected(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE real_table (id INT)' ); + $driver->query( 'SET @my_var = 1' ); + + foreach ( + array( + 'SET @my_var = @my_var + 1', + 'SET @my_var = DATABASE()', + 'SET @my_var', + 'SELECT @my_var AS alias, 1', + 'SELECT @my_var + 1', + 'SELECT COALESCE(@my_var, 1)', + 'SELECT @my_var FROM real_table', + ) as $sql + ) { + $this->assertDriverQueryRejected( $driver, $sql ); + } + } + public function test_session_variable_unsupported_select_shapes_are_rejected(): void { $this->requireDuckDBRuntime(); From 9667572bf28701fcdbdd5f75ef1592d7db1095b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 23:31:34 +0000 Subject: [PATCH 039/317] Add DuckDB SHOW FULL TABLES parity --- .../src/duckdb/class-wp-duckdb-driver.php | 106 +++++++++++++----- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 19 ++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 65 +++++++++++ 3 files changed, 160 insertions(+), 30 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index afe4bb7ff..9e92b4ccb 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -4741,8 +4741,18 @@ private function execute_alter_table_change_or_modify_column( string $table_name * @return WP_DuckDB_Result_Statement */ private function execute_show( array $tokens ): WP_DuckDB_Result_Statement { - if ( 2 === count( $tokens ) && WP_MySQL_Lexer::TABLES_SYMBOL === $tokens[1]->id ) { - return $this->execute_show_tables(); + if ( + isset( $tokens[1] ) + && ( + WP_MySQL_Lexer::TABLES_SYMBOL === $tokens[1]->id + || ( + isset( $tokens[2] ) + && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLES_SYMBOL === $tokens[2]->id + ) + ) + ) { + return $this->execute_show_tables( $tokens ); } if ( @@ -5153,37 +5163,73 @@ private function execute_show_columns( array $tokens ): WP_DuckDB_Result_Stateme } /** - * Execute SHOW TABLES. + * Execute SHOW [FULL] TABLES. * + * @param WP_Parser_Token[] $tokens MySQL tokens. * @return WP_DuckDB_Result_Statement */ - private function execute_show_tables(): WP_DuckDB_Result_Statement { - $column = 'Tables_in_' . $this->database; - return $this->execute_duckdb_query( - 'SELECT table_name AS ' . $this->connection->quote_identifier( $column ) - . " FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' AND table_name <> " - . $this->connection->quote( self::INDEX_METADATA_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::COLUMN_METADATA_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::TABLE_METADATA_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::CHECK_METADATA_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::FOREIGN_KEY_METADATA_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_TABLES_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_STATISTICS_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE ) - . ' ORDER BY table_name', - 'Failed to execute SHOW TABLES' - ); + private function execute_show_tables( array $tokens ): WP_DuckDB_Result_Statement { + $index = 1; + $full = false; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $index ]->id ) { + $full = true; + ++$index; + } + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::TABLES_SYMBOL, 'Expected TABLES in SHOW TABLES statement.' ); + ++$index; + + $database = $this->database; + if ( + isset( $tokens[ $index ] ) + && ( WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $index ]->id ) + ) { + ++$index; + $database = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + } + + $like_pattern = null; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $index ]->id ) { + if ( + ! isset( $tokens[ $index + 1 ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $index + 1 ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $index + 1 ]->id + ) + ) { + throw new WP_DuckDB_Driver_Exception( 'SHOW TABLES LIKE requires a string pattern in the DuckDB driver.' ); + } + $like_pattern = $tokens[ $index + 1 ]->get_value(); + $index += 2; + } + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW TABLES statement in DuckDB driver. Only optional FULL, FROM/IN, and LIKE are supported.' ); + } + + $columns = array( 'Tables_in_' . $database ); + if ( $full ) { + $columns[] = 'Table_type'; + } + + $rows = array(); + if ( 0 === strcasecmp( $database, $this->database ) ) { + foreach ( $this->user_table_names() as $table_name ) { + if ( null !== $like_pattern && ! $this->mysql_like_matches( $table_name, $like_pattern ) ) { + continue; + } + + $row = array( $table_name ); + if ( $full ) { + $row[] = 'BASE TABLE'; + } + $rows[] = $row; + } + } + + return new WP_DuckDB_Result_Statement( $columns, $rows ); } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 0b4cce3ac..eea3b7498 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -33,6 +33,25 @@ public function test_create_insert_select_update_delete_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_show_full_tables_sql_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE _tmp_table (id INT)', + 'CREATE TABLE _tmp_table_2 (id INT)', + 'CREATE TABLE shadow_show (id INT)', + 'CREATE TEMPORARY TABLE shadow_show (temp_id INT)', + 'CREATE TEMPORARY TABLE temp_only_show (id INT)', + ) + ); + + $this->assertParityRows( 'SHOW TABLES' ); + $this->assertParityRows( "SHOW TABLES LIKE '_tmp_table'" ); + $this->assertParityRows( 'SHOW FULL TABLES' ); + $this->assertParityRows( "SHOW FULL TABLES LIKE '_tmp_table'" ); + $this->assertParityRows( "SHOW FULL TABLES LIKE 'shadow_show'" ); + $this->assertParityRows( "SHOW FULL TABLES LIKE 'temp_only_show'" ); + } + public function test_transaction_sql_matches_sqlite(): void { $this->runParitySetup( array( 'CREATE TABLE tx_items (id INT)' ) ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 45c004d82..e518e5d9f 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -120,6 +120,60 @@ function ( string $sql ): bool { $this->assertSame( 'anonymous', $describe[1]['Default'] ); } + public function test_show_full_tables_reports_table_type_and_like_filter(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( 'CREATE TABLE _tmp_table (id INT)' ); + $driver->query( 'CREATE TABLE _tmp_table_2 (id INT)' ); + + $full = $driver->query( 'SHOW FULL TABLES' ); + $this->assertSame( 2, $full->columnCount() ); + $this->assertSame( array( 'name' => 'Tables_in_wp' ), $full->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => 'Table_type' ), $full->getColumnMeta( 1 ) ); + $this->assertSame( + array( + array( + 'Tables_in_wp' => '_tmp_table', + 'Table_type' => 'BASE TABLE', + ), + array( + 'Tables_in_wp' => '_tmp_table_2', + 'Table_type' => 'BASE TABLE', + ), + ), + $full->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Tables_in_wp' => '_tmp_table', + 'Table_type' => 'BASE TABLE', + ), + ), + $driver->query( "SHOW FULL TABLES LIKE '_tmp_table'" )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'Tables_in_wp' => '_tmp_table' ) ), + $driver->query( "SHOW TABLES LIKE '_tmp_table'" )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array(), + $driver->query( "SHOW FULL TABLES LIKE '__wp_duckdb_%'" )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array(), + $driver->query( 'SHOW FULL TABLES FROM other_database' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_sql_transaction_statements_update_connection_state_and_query_log(): void { $this->requireDuckDBRuntime(); @@ -3029,6 +3083,8 @@ public function test_temporary_table_lifecycle_uses_session_metadata(): void { $driver->query( 'SELECT id, name FROM temp_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) ); $this->assertSame( array(), $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( array(), $driver->query( 'SHOW FULL TABLES' )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( array(), $driver->query( "SHOW FULL TABLES LIKE 'temp_items'" )->fetchAll( PDO::FETCH_ASSOC ) ); $this->assertSame( array(), $driver->query( "SHOW TABLE STATUS LIKE 'temp_items'" )->fetchAll( PDO::FETCH_ASSOC ) ); $this->assertSame( array( 'PRIMARY', 'name_key' ), @@ -3097,6 +3153,15 @@ public function test_temporary_table_takes_precedence_over_persistent_table(): v array( 't' ), array_column( $driver->query( "SHOW TABLE STATUS LIKE 't'" )->fetchAll( PDO::FETCH_ASSOC ), 'Name' ) ); + $this->assertSame( + array( + array( + 'Tables_in_wp' => 't', + 'Table_type' => 'BASE TABLE', + ), + ), + $driver->query( "SHOW FULL TABLES LIKE 't'" )->fetchAll( PDO::FETCH_ASSOC ) + ); $driver->query( 'DROP TABLE t' ); $this->assertSame( From b4f97948e25a9caffec685e386911be22fbde5ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 26 Jun 2026 23:48:03 +0000 Subject: [PATCH 040/317] Add DuckDB constraint information schema parity --- .../src/duckdb/class-wp-duckdb-driver.php | 416 ++++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 128 +++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 203 ++++++++- 3 files changed, 707 insertions(+), 40 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 9e92b4ccb..f0b079c38 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -14,26 +14,28 @@ * throws WP_DuckDB_Driver_Exception for statements outside that subset. */ class WP_DuckDB_Driver { - const MYSQL_GRAMMAR_PATH = __DIR__ . '/../mysql/mysql-grammar.php'; - const DEFAULT_DATABASE = 'wp'; - const DEFAULT_MYSQL_VERSION = 80038; - const SEQUENCE_PREFIX = 'wp_duckdb_ai_'; - const INDEX_PREFIX = 'wp_duckdb_idx_'; - const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; - const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; - const TABLE_METADATA_TABLE = '__wp_duckdb_table_metadata'; - const CHECK_METADATA_TABLE = '__wp_duckdb_check_metadata'; - const FOREIGN_KEY_METADATA_TABLE = '__wp_duckdb_foreign_key_metadata'; - const TEMP_INDEX_METADATA_TABLE = '__wp_duckdb_temp_index_metadata'; - const TEMP_COLUMN_METADATA_TABLE = '__wp_duckdb_temp_column_metadata'; - const TEMP_TABLE_METADATA_TABLE = '__wp_duckdb_temp_table_metadata'; - const TEMP_CHECK_METADATA_TABLE = '__wp_duckdb_temp_check_metadata'; - const TEMP_FOREIGN_KEY_METADATA_TABLE = '__wp_duckdb_temp_foreign_key_metadata'; - const INFO_SCHEMA_TABLES_TABLE = '__wp_duckdb_information_schema_tables'; - const INFO_SCHEMA_COLUMNS_TABLE = '__wp_duckdb_information_schema_columns'; - const INFO_SCHEMA_STATISTICS_TABLE = '__wp_duckdb_information_schema_statistics'; - const INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE = '__wp_duckdb_information_schema_table_constraints'; - const INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE = '__wp_duckdb_information_schema_key_column_usage'; + const MYSQL_GRAMMAR_PATH = __DIR__ . '/../mysql/mysql-grammar.php'; + const DEFAULT_DATABASE = 'wp'; + const DEFAULT_MYSQL_VERSION = 80038; + const SEQUENCE_PREFIX = 'wp_duckdb_ai_'; + const INDEX_PREFIX = 'wp_duckdb_idx_'; + const INDEX_METADATA_TABLE = '__wp_duckdb_index_metadata'; + const COLUMN_METADATA_TABLE = '__wp_duckdb_column_metadata'; + const TABLE_METADATA_TABLE = '__wp_duckdb_table_metadata'; + const CHECK_METADATA_TABLE = '__wp_duckdb_check_metadata'; + const FOREIGN_KEY_METADATA_TABLE = '__wp_duckdb_foreign_key_metadata'; + const TEMP_INDEX_METADATA_TABLE = '__wp_duckdb_temp_index_metadata'; + const TEMP_COLUMN_METADATA_TABLE = '__wp_duckdb_temp_column_metadata'; + const TEMP_TABLE_METADATA_TABLE = '__wp_duckdb_temp_table_metadata'; + const TEMP_CHECK_METADATA_TABLE = '__wp_duckdb_temp_check_metadata'; + const TEMP_FOREIGN_KEY_METADATA_TABLE = '__wp_duckdb_temp_foreign_key_metadata'; + const INFO_SCHEMA_TABLES_TABLE = '__wp_duckdb_information_schema_tables'; + const INFO_SCHEMA_COLUMNS_TABLE = '__wp_duckdb_information_schema_columns'; + const INFO_SCHEMA_STATISTICS_TABLE = '__wp_duckdb_information_schema_statistics'; + const INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE = '__wp_duckdb_information_schema_table_constraints'; + const INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE = '__wp_duckdb_information_schema_key_column_usage'; + const INFO_SCHEMA_REFERENTIAL_CONSTRAINTS_TABLE = '__wp_duckdb_information_schema_referential_constraints'; + const INFO_SCHEMA_CHECK_CONSTRAINTS_TABLE = '__wp_duckdb_information_schema_check_constraints'; const SUPPORTED_SESSION_SYSTEM_VARIABLES = array( 'autocommit' => true, @@ -457,11 +459,13 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $tokens = $this->normalize_select_helper_tokens( $tokens ); $column_meta = $this->simple_select_column_metadata( $tokens ); - $rewrite_information_schema_tables = $this->uses_information_schema_tables( $tokens ); - $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); - $rewrite_information_schema_statistics = $this->uses_information_schema_statistics( $tokens ); - $rewrite_information_schema_table_constraints = $this->uses_information_schema_table_constraints( $tokens ); - $rewrite_information_schema_key_column_usage = $this->uses_information_schema_key_column_usage( $tokens ); + $rewrite_information_schema_tables = $this->uses_information_schema_tables( $tokens ); + $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); + $rewrite_information_schema_statistics = $this->uses_information_schema_statistics( $tokens ); + $rewrite_information_schema_table_constraints = $this->uses_information_schema_table_constraints( $tokens ); + $rewrite_information_schema_key_column_usage = $this->uses_information_schema_key_column_usage( $tokens ); + $rewrite_information_schema_referential_constraints = $this->uses_information_schema_referential_constraints( $tokens ); + $rewrite_information_schema_check_constraints = $this->uses_information_schema_check_constraints( $tokens ); if ( $rewrite_information_schema_tables ) { $this->refresh_information_schema_tables_table(); } @@ -477,6 +481,12 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { if ( $rewrite_information_schema_key_column_usage ) { $this->refresh_information_schema_key_column_usage_table(); } + if ( $rewrite_information_schema_referential_constraints ) { + $this->refresh_information_schema_referential_constraints_table(); + } + if ( $rewrite_information_schema_check_constraints ) { + $this->refresh_information_schema_check_constraints_table(); + } $sql = $this->translate_tokens_to_duckdb_sql( $tokens, @@ -484,7 +494,9 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_columns, $rewrite_information_schema_statistics, $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints ); if ( $has_sql_calc_found_rows ) { @@ -495,7 +507,9 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_columns, $rewrite_information_schema_statistics, $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints ); $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); return $this->apply_result_column_metadata( $result, $column_meta ); @@ -1018,7 +1032,9 @@ private function count_select_rows( bool $rewrite_information_schema_columns, bool $rewrite_information_schema_statistics, bool $rewrite_information_schema_table_constraints, - bool $rewrite_information_schema_key_column_usage + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints ): int { $sql = $this->translate_tokens_to_duckdb_sql( $tokens, @@ -1026,7 +1042,9 @@ private function count_select_rows( $rewrite_information_schema_columns, $rewrite_information_schema_statistics, $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints ); return (int) $this->execute_duckdb_query( @@ -7112,7 +7130,9 @@ private function translate_tokens_to_duckdb_sql( bool $rewrite_information_schema_columns = false, bool $rewrite_information_schema_statistics = false, bool $rewrite_information_schema_table_constraints = false, - bool $rewrite_information_schema_key_column_usage = false + bool $rewrite_information_schema_key_column_usage = false, + bool $rewrite_information_schema_referential_constraints = false, + bool $rewrite_information_schema_check_constraints = false ): string { $pieces = array(); @@ -7144,6 +7164,16 @@ private function translate_tokens_to_duckdb_sql( $index += 2; continue; } + if ( $rewrite_information_schema_referential_constraints && $this->is_information_schema_referential_constraints_reference( $tokens, $index ) ) { + $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_REFERENTIAL_CONSTRAINTS_TABLE ); + $index += 2; + continue; + } + if ( $rewrite_information_schema_check_constraints && $this->is_information_schema_check_constraints_reference( $tokens, $index ) ) { + $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_CHECK_CONSTRAINTS_TABLE ); + $index += 2; + continue; + } if ( WP_MySQL_Lexer::FROM_SYMBOL === $token->id @@ -7167,7 +7197,9 @@ private function translate_tokens_to_duckdb_sql( $rewrite_information_schema_columns, $rewrite_information_schema_statistics, $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints ); if ( null !== $field_function ) { $pieces[] = $field_function; @@ -7243,6 +7275,12 @@ private function translate_tokens_to_duckdb_sql( if ( null === $identifier && $rewrite_information_schema_key_column_usage ) { $identifier = $this->information_schema_key_column_usage_column_name( $token->get_value() ); } + if ( null === $identifier && $rewrite_information_schema_referential_constraints ) { + $identifier = $this->information_schema_referential_constraints_column_name( $token->get_value() ); + } + if ( null === $identifier && $rewrite_information_schema_check_constraints ) { + $identifier = $this->information_schema_check_constraints_column_name( $token->get_value() ); + } $pieces[] = $this->connection->quote_identifier( $identifier ?? $token->get_value() ); continue; } @@ -7284,6 +7322,22 @@ private function translate_tokens_to_duckdb_sql( } } + if ( $rewrite_information_schema_referential_constraints ) { + $identifier = $this->information_schema_referential_constraints_column_name( $token->get_value() ); + if ( null !== $identifier ) { + $pieces[] = $this->connection->quote_identifier( $identifier ); + continue; + } + } + + if ( $rewrite_information_schema_check_constraints ) { + $identifier = $this->information_schema_check_constraints_column_name( $token->get_value() ); + if ( null !== $identifier ) { + $pieces[] = $this->connection->quote_identifier( $identifier ); + continue; + } + } + $pieces[] = $token->get_bytes(); } @@ -7552,7 +7606,9 @@ private function translate_field_function_call( bool $rewrite_information_schema_columns, bool $rewrite_information_schema_statistics, bool $rewrite_information_schema_table_constraints, - bool $rewrite_information_schema_key_column_usage + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints ): ?string { if ( ! isset( $tokens[ $index + 1 ] ) @@ -7575,7 +7631,9 @@ private function translate_field_function_call( $rewrite_information_schema_columns, $rewrite_information_schema_statistics, $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints ); $needle_comparison = 'lower(CAST((' . $needle . ') AS VARCHAR))'; @@ -7587,7 +7645,9 @@ private function translate_field_function_call( $rewrite_information_schema_columns, $rewrite_information_schema_statistics, $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints ); $value_comparison = 'lower(CAST((' . $value_sql . ') AS VARCHAR))'; $cases[] = 'WHEN ' . $needle_comparison . ' = ' . $value_comparison . ' THEN ' . $item_index; @@ -9588,6 +9648,8 @@ private function invalidate_information_schema_compatibility_tables(): void { self::INFO_SCHEMA_STATISTICS_TABLE, self::INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE, self::INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE, + self::INFO_SCHEMA_REFERENTIAL_CONSTRAINTS_TABLE, + self::INFO_SCHEMA_CHECK_CONSTRAINTS_TABLE, ) as $table_name ) { $this->execute_duckdb_query( @@ -9768,6 +9830,66 @@ private function is_information_schema_key_column_usage_reference( array $tokens && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'key_column_usage' ); } + /** + * Check whether a SELECT references information_schema.referential_constraints. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether the query needs the compatibility table. + */ + private function uses_information_schema_referential_constraints( array $tokens ): bool { + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( $this->is_information_schema_referential_constraints_reference( $tokens, $index ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a token offset starts information_schema.referential_constraints. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return bool Whether the sequence is information_schema.referential_constraints. + */ + private function is_information_schema_referential_constraints_reference( array $tokens, int $index ): bool { + return isset( $tokens[ $index + 2 ] ) + && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'referential_constraints' ); + } + + /** + * Check whether a SELECT references information_schema.check_constraints. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether the query needs the compatibility table. + */ + private function uses_information_schema_check_constraints( array $tokens ): bool { + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( $this->is_information_schema_check_constraints_reference( $tokens, $index ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a token offset starts information_schema.check_constraints. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return bool Whether the sequence is information_schema.check_constraints. + */ + private function is_information_schema_check_constraints_reference( array $tokens, int $index ): bool { + return isset( $tokens[ $index + 2 ] ) + && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'check_constraints' ); + } + /** * Refresh a temporary MySQL-shaped information_schema.tables table. */ @@ -10522,6 +10644,228 @@ private function information_schema_key_column_usage_column_name( string $identi return $columns[ $key ] ?? null; } + /** + * Refresh a temporary MySQL-shaped information_schema.referential_constraints table. + */ + private function refresh_information_schema_referential_constraints_table(): void { + $this->refresh_information_schema_compatibility_table( + self::INFO_SCHEMA_REFERENTIAL_CONSTRAINTS_TABLE, + $this->information_schema_referential_constraints_definitions(), + $this->information_schema_referential_constraints_rows(), + 'information_schema.referential_constraints' + ); + } + + /** + * MySQL-shaped information_schema.referential_constraints definitions. + * + * @return array Column name to DuckDB type. + */ + private function information_schema_referential_constraints_definitions(): array { + return array( + 'CONSTRAINT_CATALOG' => 'VARCHAR COLLATE NOCASE', + 'CONSTRAINT_SCHEMA' => 'VARCHAR COLLATE NOCASE', + 'CONSTRAINT_NAME' => 'VARCHAR COLLATE NOCASE', + 'UNIQUE_CONSTRAINT_CATALOG' => 'VARCHAR COLLATE NOCASE', + 'UNIQUE_CONSTRAINT_SCHEMA' => 'VARCHAR COLLATE NOCASE', + 'UNIQUE_CONSTRAINT_NAME' => 'VARCHAR COLLATE NOCASE', + 'MATCH_OPTION' => 'VARCHAR COLLATE NOCASE', + 'UPDATE_RULE' => 'VARCHAR COLLATE NOCASE', + 'DELETE_RULE' => 'VARCHAR COLLATE NOCASE', + 'TABLE_NAME' => 'VARCHAR COLLATE NOCASE', + 'REFERENCED_TABLE_NAME' => 'VARCHAR COLLATE NOCASE', + ); + } + + /** + * Build MySQL-shaped information_schema.referential_constraints rows. + * + * @return array> + */ + private function information_schema_referential_constraints_rows(): array { + $rows = array(); + $seen = array(); + + foreach ( $this->user_table_names() as $table_name ) { + foreach ( $this->foreign_key_metadata_rows( $table_name ) as $foreign_key ) { + $key = $table_name . "\0" . (string) $foreign_key['constraint_name']; + if ( isset( $seen[ $key ] ) ) { + continue; + } + + $seen[ $key ] = true; + $rows[] = $this->information_schema_referential_constraints_row( $table_name, $foreign_key ); + } + } + + return $rows; + } + + /** + * Build one MySQL-shaped information_schema.referential_constraints row. + * + * @param string $table_name Table name. + * @param array $foreign_key FOREIGN KEY metadata row. + * @return array + */ + private function information_schema_referential_constraints_row( string $table_name, array $foreign_key ): array { + $referenced_table_name = (string) $foreign_key['referenced_table_name']; + $referenced_column_name = (string) $foreign_key['referenced_column_name']; + + return array( + 'CONSTRAINT_CATALOG' => 'def', + 'CONSTRAINT_SCHEMA' => $this->database, + 'CONSTRAINT_NAME' => $foreign_key['constraint_name'], + 'UNIQUE_CONSTRAINT_CATALOG' => 'def', + 'UNIQUE_CONSTRAINT_SCHEMA' => $this->database, + 'UNIQUE_CONSTRAINT_NAME' => $this->referenced_unique_constraint_name( $referenced_table_name, $referenced_column_name ), + 'MATCH_OPTION' => 'NONE', + 'UPDATE_RULE' => $foreign_key['update_rule'], + 'DELETE_RULE' => $foreign_key['delete_rule'], + 'TABLE_NAME' => $table_name, + 'REFERENCED_TABLE_NAME' => $referenced_table_name, + ); + } + + /** + * Return the referenced PRIMARY or UNIQUE constraint name for a single-column FK. + * + * @param string $table_name Referenced table name. + * @param string $column_name Referenced column name. + * @return string|null Constraint name when it can be resolved. + */ + private function referenced_unique_constraint_name( string $table_name, string $column_name ): ?string { + $primary_key_rows = $this->primary_key_index_rows( $table_name ); + if ( + 1 === count( $primary_key_rows ) + && 0 === strcasecmp( (string) $primary_key_rows[0][4], $column_name ) + ) { + return (string) $primary_key_rows[0][2]; + } + + $unique_columns_by_name = array(); + foreach ( $this->secondary_index_rows( $table_name ) as $index_row ) { + if ( 0 !== (int) $index_row[1] ) { + continue; + } + + $index_name = (string) $index_row[2]; + if ( ! isset( $unique_columns_by_name[ $index_name ] ) ) { + $unique_columns_by_name[ $index_name ] = array(); + } + $unique_columns_by_name[ $index_name ][] = (string) $index_row[4]; + } + + foreach ( $unique_columns_by_name as $index_name => $columns ) { + if ( 1 === count( $columns ) && 0 === strcasecmp( $columns[0], $column_name ) ) { + return (string) $index_name; + } + } + + return null; + } + + /** + * Return the canonical referential_constraints column name for a token value. + * + * @param string $identifier Identifier token value. + * @return string|null Canonical column name, or null when not a referential_constraints column. + */ + private function information_schema_referential_constraints_column_name( string $identifier ): ?string { + $columns = array( + 'constraint_catalog' => 'CONSTRAINT_CATALOG', + 'constraint_schema' => 'CONSTRAINT_SCHEMA', + 'constraint_name' => 'CONSTRAINT_NAME', + 'unique_constraint_catalog' => 'UNIQUE_CONSTRAINT_CATALOG', + 'unique_constraint_schema' => 'UNIQUE_CONSTRAINT_SCHEMA', + 'unique_constraint_name' => 'UNIQUE_CONSTRAINT_NAME', + 'match_option' => 'MATCH_OPTION', + 'update_rule' => 'UPDATE_RULE', + 'delete_rule' => 'DELETE_RULE', + 'table_name' => 'TABLE_NAME', + 'referenced_table_name' => 'REFERENCED_TABLE_NAME', + ); + $key = strtolower( $identifier ); + + return $columns[ $key ] ?? null; + } + + /** + * Refresh a temporary MySQL-shaped information_schema.check_constraints table. + */ + private function refresh_information_schema_check_constraints_table(): void { + $this->refresh_information_schema_compatibility_table( + self::INFO_SCHEMA_CHECK_CONSTRAINTS_TABLE, + $this->information_schema_check_constraints_definitions(), + $this->information_schema_check_constraints_rows(), + 'information_schema.check_constraints' + ); + } + + /** + * MySQL-shaped information_schema.check_constraints definitions. + * + * @return array Column name to DuckDB type. + */ + private function information_schema_check_constraints_definitions(): array { + return array( + 'CONSTRAINT_CATALOG' => 'VARCHAR COLLATE NOCASE', + 'CONSTRAINT_SCHEMA' => 'VARCHAR COLLATE NOCASE', + 'CONSTRAINT_NAME' => 'VARCHAR COLLATE NOCASE', + 'CHECK_CLAUSE' => 'VARCHAR', + ); + } + + /** + * Build MySQL-shaped information_schema.check_constraints rows. + * + * @return array> + */ + private function information_schema_check_constraints_rows(): array { + $rows = array(); + + foreach ( $this->user_table_names() as $table_name ) { + foreach ( $this->check_constraint_metadata_rows( $table_name ) as $check_constraint ) { + $rows[] = $this->information_schema_check_constraints_row( $check_constraint ); + } + } + + return $rows; + } + + /** + * Build one MySQL-shaped information_schema.check_constraints row. + * + * @param array $check_constraint CHECK metadata row. + * @return array + */ + private function information_schema_check_constraints_row( array $check_constraint ): array { + return array( + 'CONSTRAINT_CATALOG' => 'def', + 'CONSTRAINT_SCHEMA' => $this->database, + 'CONSTRAINT_NAME' => $check_constraint['constraint_name'], + 'CHECK_CLAUSE' => $check_constraint['check_clause'], + ); + } + + /** + * Return the canonical check_constraints column name for a token value. + * + * @param string $identifier Identifier token value. + * @return string|null Canonical column name, or null when not a check_constraints column. + */ + private function information_schema_check_constraints_column_name( string $identifier ): ?string { + $columns = array( + 'constraint_catalog' => 'CONSTRAINT_CATALOG', + 'constraint_schema' => 'CONSTRAINT_SCHEMA', + 'constraint_name' => 'CONSTRAINT_NAME', + 'check_clause' => 'CHECK_CLAUSE', + ); + $key = strtolower( $identifier ); + + return $columns[ $key ] ?? null; + } + /** * Build normalized primary and unique constraint column rows. * @@ -10714,6 +11058,10 @@ private function user_table_names(): array { . $this->connection->quote( self::INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE ) . ' AND table_name <> ' . $this->connection->quote( self::INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_REFERENTIAL_CONSTRAINTS_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_CHECK_CONSTRAINTS_TABLE ) . ' ORDER BY table_name', 'Failed to inspect DuckDB tables' ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index eea3b7498..be2aac070 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -942,6 +942,21 @@ public function test_table_level_check_constraints_match_sqlite(): void { WHERE table_schema = 'wp' AND table_name = 'check_metadata' ORDER BY constraint_name" ); + $this->assertParityRows( + "SELECT CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" + ); + $this->assertParityRows( + "SELECT tc.CONSTRAINT_NAME, tc.TABLE_NAME, cc.CHECK_CLAUSE + FROM information_schema.table_constraints AS tc + JOIN information_schema.check_constraints AS cc + ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + WHERE tc.table_schema = 'wp' AND tc.table_name = 'check_metadata' + ORDER BY tc.constraint_name" + ); $this->assertParityRows( 'SHOW CREATE TABLE check_metadata' ); } @@ -954,11 +969,17 @@ public function test_simple_table_level_foreign_keys_match_sqlite(): void { parent_id INT, CONSTRAINT fk_parent_ref FOREIGN KEY (parent_id) REFERENCES fk_parent (id) ON DELETE RESTRICT ON UPDATE NO ACTION )', + 'CREATE TABLE fk_child_generated ( + id INT, + parent_id INT, + FOREIGN KEY (parent_id) REFERENCES fk_parent (id) + )', 'INSERT INTO fk_parent (id) VALUES (1)', ) ); $this->assertParityRowCount( 'INSERT INTO fk_child (id, parent_id) VALUES (10, 1)' ); + $this->assertParityRowCount( 'INSERT INTO fk_child_generated (id, parent_id) VALUES (20, 1)' ); $this->assertParityErrorContains( 'INSERT INTO fk_child (id, parent_id) VALUES (11, 404)', 'constraint' @@ -970,18 +991,117 @@ public function test_simple_table_level_foreign_keys_match_sqlite(): void { $this->assertParityRows( "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED FROM information_schema.table_constraints - WHERE table_schema = 'wp' AND table_name = 'fk_child' - ORDER BY constraint_name" + WHERE table_schema = 'wp' + AND table_name IN ('fk_child', 'fk_child_generated') + ORDER BY table_name, constraint_name" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME, + UNIQUE_CONSTRAINT_CATALOG, UNIQUE_CONSTRAINT_SCHEMA, + UNIQUE_CONSTRAINT_NAME, MATCH_OPTION, UPDATE_RULE, DELETE_RULE, + TABLE_NAME, REFERENCED_TABLE_NAME + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' + AND table_name IN ('fk_child', 'fk_child_generated') + ORDER BY table_name, constraint_name" ); $this->assertParityRows( "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_SCHEMA, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME FROM information_schema.key_column_usage - WHERE table_schema = 'wp' AND table_name = 'fk_child' - ORDER BY constraint_name, ordinal_position" + WHERE table_schema = 'wp' + AND table_name IN ('fk_child', 'fk_child_generated') + ORDER BY table_name, constraint_name, ordinal_position" + ); + $this->assertParityRows( + "SELECT rc.CONSTRAINT_NAME, rc.TABLE_NAME, rc.REFERENCED_TABLE_NAME, + k.REFERENCED_COLUMN_NAME + FROM information_schema.referential_constraints AS rc + JOIN information_schema.key_column_usage AS k + ON k.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA + AND k.CONSTRAINT_NAME = rc.CONSTRAINT_NAME + WHERE rc.constraint_schema = 'wp' + ORDER BY rc.table_name, rc.constraint_name" ); $this->assertParityRows( 'SHOW CREATE TABLE fk_child' ); + $this->assertParityRows( 'SHOW CREATE TABLE fk_child_generated' ); + } + + public function test_unique_key_foreign_key_metadata_documents_current_duckdb_gap(): void { + $sqlite_driver = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), + 'wp' + ); + $duckdb_driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $parent_sql = 'CREATE TABLE unique_parent ( + id INT PRIMARY KEY, + code INT, + UNIQUE KEY code_u (code) + )'; + $child_sql = 'CREATE TABLE unique_child ( + id INT, + parent_code INT, + CONSTRAINT fk_parent_code FOREIGN KEY (parent_code) REFERENCES unique_parent (code) + )'; + + $sqlite_driver->query( $parent_sql, PDO::FETCH_ASSOC ); + $duckdb_driver->query( $parent_sql ); + $sqlite_driver->query( $child_sql, PDO::FETCH_ASSOC ); + + try { + $duckdb_driver->query( $child_sql ); + $this->fail( 'Expected DuckDB to reject a foreign key referencing a driver-managed unique index.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'primary key or unique constraint', strtolower( $e->getMessage() ) ); + } + + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'fk_parent_code', + 'UNIQUE_CONSTRAINT_NAME' => 'code_u', + 'TABLE_NAME' => 'unique_child', + 'REFERENCED_TABLE_NAME' => 'unique_parent', + ), + ), + $sqlite_driver->query( + "SELECT CONSTRAINT_NAME, UNIQUE_CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' AND table_name = 'unique_child' + ORDER BY constraint_name", + PDO::FETCH_ASSOC + ) + ); + + $sqlite_usage = $sqlite_driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, + POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_NAME, + REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' AND table_name = 'unique_child' + ORDER BY constraint_name, ordinal_position", + PDO::FETCH_ASSOC + ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'fk_parent_code', + 'TABLE_NAME' => 'unique_child', + 'COLUMN_NAME' => 'parent_code', + 'POSITION_IN_UNIQUE_CONSTRAINT' => '1', + 'REFERENCED_TABLE_NAME' => 'unique_parent', + 'REFERENCED_COLUMN_NAME' => 'code', + ), + ), + $sqlite_usage + ); } public function test_information_schema_tables_metadata_matches_sqlite(): void { diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index e518e5d9f..79438944c 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -2424,6 +2424,20 @@ public function test_information_schema_constraints_expose_mysql_shaped_metadata )->fetch( PDO::FETCH_ASSOC ); $this->assertSame( 0, (int) $key_column_usage_count['count'] ); + $referential_constraints_count = $driver->query( + "SELECT COUNT(*) AS count + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp'" + )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( 0, (int) $referential_constraints_count['count'] ); + + $check_constraints_count = $driver->query( + "SELECT COUNT(*) AS count + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp'" + )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( 0, (int) $check_constraints_count['count'] ); + $driver->query( 'CREATE TABLE empty_table (id INT, note TEXT)' ); $driver->query( "CREATE TABLE metadata ( @@ -2657,6 +2671,40 @@ public function test_create_table_check_constraints_use_native_enforcement_and_m $constraints ); + $check_constraints = $driver->query( + "SELECT CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_CATALOG' => 'def', + 'CONSTRAINT_SCHEMA' => 'wp', + 'CONSTRAINT_NAME' => 'amount_positive', + 'CHECK_CLAUSE' => 'amount > 0', + ), + array( + 'CONSTRAINT_CATALOG' => 'def', + 'CONSTRAINT_SCHEMA' => 'wp', + 'CONSTRAINT_NAME' => 'checks_chk_1', + 'CHECK_CLAUSE' => 'id IS NULL OR id >= 0', + ), + ), + $check_constraints + ); + + $internal_checks = $driver->query( + "SELECT cc.CONSTRAINT_NAME + FROM information_schema.check_constraints AS cc + JOIN information_schema.table_constraints AS tc + ON tc.CONSTRAINT_SCHEMA = cc.CONSTRAINT_SCHEMA + AND tc.CONSTRAINT_NAME = cc.CONSTRAINT_NAME + WHERE tc.TABLE_NAME LIKE '__wp_duckdb_%'" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $internal_checks ); + $create = $driver->query( 'SHOW CREATE TABLE checks' )->fetch( PDO::FETCH_ASSOC ); $this->assertSame( implode( @@ -2745,16 +2793,69 @@ public function test_create_table_foreign_keys_use_native_enforcement_and_mysql_ $constraints ); + $referential_constraints = $driver->query( + "SELECT CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME, + UNIQUE_CONSTRAINT_CATALOG, UNIQUE_CONSTRAINT_SCHEMA, + UNIQUE_CONSTRAINT_NAME, MATCH_OPTION, UPDATE_RULE, DELETE_RULE, + TABLE_NAME, REFERENCED_TABLE_NAME + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' + AND table_name IN ('child_generated', 'child_named') + ORDER BY table_name, constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_CATALOG' => 'def', + 'CONSTRAINT_SCHEMA' => 'wp', + 'CONSTRAINT_NAME' => 'child_generated_ibfk_1', + 'UNIQUE_CONSTRAINT_CATALOG' => 'def', + 'UNIQUE_CONSTRAINT_SCHEMA' => 'wp', + 'UNIQUE_CONSTRAINT_NAME' => 'PRIMARY', + 'MATCH_OPTION' => 'NONE', + 'UPDATE_RULE' => 'NO ACTION', + 'DELETE_RULE' => 'NO ACTION', + 'TABLE_NAME' => 'child_generated', + 'REFERENCED_TABLE_NAME' => 'parents', + ), + array( + 'CONSTRAINT_CATALOG' => 'def', + 'CONSTRAINT_SCHEMA' => 'wp', + 'CONSTRAINT_NAME' => 'fk_parent', + 'UNIQUE_CONSTRAINT_CATALOG' => 'def', + 'UNIQUE_CONSTRAINT_SCHEMA' => 'wp', + 'UNIQUE_CONSTRAINT_NAME' => 'PRIMARY', + 'MATCH_OPTION' => 'NONE', + 'UPDATE_RULE' => 'NO ACTION', + 'DELETE_RULE' => 'RESTRICT', + 'TABLE_NAME' => 'child_named', + 'REFERENCED_TABLE_NAME' => 'parents', + ), + ), + $referential_constraints + ); + $usage = $driver->query( "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_SCHEMA, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME FROM information_schema.key_column_usage - WHERE table_schema = 'wp' AND table_name = 'child_named' - ORDER BY constraint_name, ordinal_position" + WHERE table_schema = 'wp' + AND table_name IN ('child_generated', 'child_named') + ORDER BY table_name, constraint_name, ordinal_position" )->fetchAll( PDO::FETCH_ASSOC ); $this->assertSame( array( + array( + 'CONSTRAINT_NAME' => 'child_generated_ibfk_1', + 'TABLE_NAME' => 'child_generated', + 'COLUMN_NAME' => 'parent_id', + 'ORDINAL_POSITION' => 1, + 'POSITION_IN_UNIQUE_CONSTRAINT' => 1, + 'REFERENCED_TABLE_SCHEMA' => 'wp', + 'REFERENCED_TABLE_NAME' => 'parents', + 'REFERENCED_COLUMN_NAME' => 'id', + ), array( 'CONSTRAINT_NAME' => 'fk_parent', 'TABLE_NAME' => 'child_named', @@ -2769,6 +2870,14 @@ public function test_create_table_foreign_keys_use_native_enforcement_and_mysql_ $usage ); + $internal_references = $driver->query( + "SELECT table_name + FROM information_schema.referential_constraints + WHERE table_name LIKE '__wp_duckdb_%' + OR referenced_table_name LIKE '__wp_duckdb_%'" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $internal_references ); + $create = $driver->query( 'SHOW CREATE TABLE child_named' )->fetch( PDO::FETCH_ASSOC ); $this->assertSame( implode( @@ -2785,6 +2894,96 @@ public function test_create_table_foreign_keys_use_native_enforcement_and_mysql_ ); } + public function test_foreign_keys_referencing_driver_managed_unique_keys_are_not_supported(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( + 'CREATE TABLE unique_parent ( + id INT PRIMARY KEY, + code INT, + UNIQUE KEY code_u (code) + )' + ); + + $parent_constraints = $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'unique_parent' + ORDER BY constraint_type, constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'PRIMARY', + 'CONSTRAINT_TYPE' => 'PRIMARY KEY', + ), + array( + 'CONSTRAINT_NAME' => 'code_u', + 'CONSTRAINT_TYPE' => 'UNIQUE', + ), + ), + $parent_constraints + ); + + $parent_usage = $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, + POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_NAME, + REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' AND table_name = 'unique_parent' + ORDER BY constraint_name, ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'code_u', + 'TABLE_NAME' => 'unique_parent', + 'COLUMN_NAME' => 'code', + 'POSITION_IN_UNIQUE_CONSTRAINT' => null, + 'REFERENCED_TABLE_NAME' => null, + 'REFERENCED_COLUMN_NAME' => null, + ), + array( + 'CONSTRAINT_NAME' => 'PRIMARY', + 'TABLE_NAME' => 'unique_parent', + 'COLUMN_NAME' => 'id', + 'POSITION_IN_UNIQUE_CONSTRAINT' => null, + 'REFERENCED_TABLE_NAME' => null, + 'REFERENCED_COLUMN_NAME' => null, + ), + ), + $parent_usage + ); + + try { + $driver->query( + 'CREATE TABLE unique_child ( + id INT, + parent_code INT, + CONSTRAINT fk_parent_code FOREIGN KEY (parent_code) REFERENCES unique_parent (code) + )' + ); + $this->fail( 'Expected DuckDB to reject a foreign key referencing a driver-managed unique index.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'primary key or unique constraint', strtolower( $e->getMessage() ) ); + } + + $referential_constraints = $driver->query( + "SELECT CONSTRAINT_NAME + FROM information_schema.referential_constraints + WHERE table_name = 'unique_child' + OR referenced_table_name = 'unique_parent'" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array(), $referential_constraints ); + } + public function test_information_schema_tables_exposes_mysql_shaped_table_metadata(): void { $this->requireDuckDBRuntime(); From 160e1b25ad194879228c0dad6a586f9968fa6a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 00:02:55 +0000 Subject: [PATCH 041/317] Add DuckDB inline constraint parity --- .../src/duckdb/class-wp-duckdb-driver.php | 162 ++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 100 +++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 405 +++++++++++++++++- 3 files changed, 652 insertions(+), 15 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index f0b079c38..b72aafa49 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1403,9 +1403,22 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE constraint in DuckDB driver: ' . $item[0]->get_bytes() . '.' ); } - list( $column_sql, $sequence_sql, $column_indexes, $column_metadata ) = $this->translate_create_table_column( $table_name, $item, true, false, $temporary, $auto_increment_seed, $table_metadata['table_collation'] ); - $columns[] = $column_sql; - $metadata[] = $column_metadata; + list( $column_sql, $sequence_sql, $column_indexes, $column_metadata, $column_constraints, $column_check_constraints, $column_foreign_keys ) = $this->translate_create_table_column( + $table_name, + $item, + true, + false, + $temporary, + $auto_increment_seed, + $table_metadata['table_collation'], + $check_names, + $foreign_key_names + ); + $columns[] = $column_sql; + $metadata[] = $column_metadata; + $constraints = array_merge( $constraints, $column_constraints ); + $check_constraints = array_merge( $check_constraints, $column_check_constraints ); + $foreign_keys = array_merge( $foreign_keys, $column_foreign_keys ); if ( null !== $sequence_sql ) { $sequences[] = array( 'sql' => $sequence_sql, @@ -5849,9 +5862,11 @@ private function split_top_level_comma_items( array $tokens ): array { * @param bool $temporary Whether the target is a temporary table. * @param int|null $auto_increment_seed Optional AUTO_INCREMENT table option. * @param string|null $default_collation_name Effective table collation for text columns without an explicit collation. - * @return array{0:string,1:string|null,2:array}>,3:array} + * @param array|null $check_names Existing MySQL-facing CHECK names, keyed lowercase. + * @param array|null $foreign_key_names Existing FOREIGN KEY names, keyed lowercase. + * @return array{0:string,1:string|null,2:array}>,3:array,4:array,5:array,6:array} */ - private function translate_create_table_column( string $table_name, array $tokens, bool $include_inline_constraints = true, bool $allow_position_options = false, bool $temporary = false, ?int $auto_increment_seed = null, ?string $default_collation_name = null ): array { + private function translate_create_table_column( string $table_name, array $tokens, bool $include_inline_constraints = true, bool $allow_position_options = false, bool $temporary = false, ?int $auto_increment_seed = null, ?string $default_collation_name = null, ?array &$check_names = null, ?array &$foreign_key_names = null ): array { $index = 0; $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; @@ -5871,6 +5886,16 @@ private function translate_create_table_column( string $table_name, array $token $unique_key = false; $auto_increment = false; $default_sql = null; + $constraints = array(); + $checks = array(); + $foreign_keys = array(); + + if ( null === $check_names ) { + $check_names = array(); + } + if ( null === $foreign_key_names ) { + $foreign_key_names = array(); + } while ( $index < count( $tokens ) ) { $token = $tokens[ $index ]; @@ -5937,8 +5962,22 @@ private function translate_create_table_column( string $table_name, array $token } $index = $this->skip_option_value( $tokens, $index + 2 ); break; + case WP_MySQL_Lexer::CHECK_SYMBOL: + if ( ! $include_inline_constraints ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported inline CHECK constraint in DuckDB driver. Inline CHECK constraints are only supported in CREATE TABLE.' ); + } + $check = $this->translate_inline_check_constraint( $table_name, $tokens, $index, $check_names ); + $constraints[] = $check['sql']; + $checks[] = $check['metadata']; + break; case WP_MySQL_Lexer::REFERENCES_SYMBOL: - throw new WP_DuckDB_Driver_Exception( 'Unsupported inline REFERENCES constraint in DuckDB driver. Use a table-level FOREIGN KEY constraint.' ); + if ( ! $include_inline_constraints ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported inline REFERENCES constraint in DuckDB driver. Inline REFERENCES constraints are only supported in CREATE TABLE.' ); + } + $foreign_key = $this->translate_inline_foreign_key_constraint( $table_name, $column_name, $tokens, $index, $foreign_key_names ); + $constraints[] = $foreign_key['sql']; + $foreign_keys[] = $foreign_key['metadata']; + break; default: throw new WP_DuckDB_Driver_Exception( 'Unsupported column attribute in DuckDB driver: ' . $token->get_bytes() . '.' ); } @@ -6015,7 +6054,116 @@ private function translate_create_table_column( string $table_name, array $token '_default_sql' => $auto_increment ? null : $default_sql, ); - return array( $column_sql, $sequence, $indexes, $metadata ); + return array( $column_sql, $sequence, $indexes, $metadata, $constraints, $checks, $foreign_keys ); + } + + /** + * Translate an inline column CHECK constraint into table-level DuckDB SQL and metadata. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Column definition tokens. + * @param int $index Current index, advanced past the CHECK clause. + * @param array $check_names Existing MySQL-facing CHECK names, keyed lowercase. + * @return array{sql:string,metadata:array{constraint_name:string,check_clause:string,enforced:string}} + */ + private function translate_inline_check_constraint( string $table_name, array $tokens, int &$index, array &$check_names ): array { + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CHECK_SYMBOL, 'Expected CHECK constraint.' ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Expected CHECK expression.' ); + + $expression_end = $this->skip_balanced_parentheses( $tokens, $index ); + $expression_tokens = array_slice( $tokens, $index + 1, $expression_end - $index - 2 ); + if ( count( $expression_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'CHECK constraint requires an expression in the DuckDB driver.' ); + } + + $index = $expression_end; + if ( isset( $tokens[ $index ] ) ) { + if ( + WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $index ]->id + && isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::ENFORCED_SYMBOL === $tokens[ $index + 1 ]->id + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE CHECK constraint in DuckDB driver: NOT ENFORCED is not supported.' ); + } + + if ( WP_MySQL_Lexer::ENFORCED_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + } + + $constraint_name = $this->generate_check_constraint_name( $table_name, $check_names ); + $this->register_check_constraint_name( $constraint_name, $check_names ); + + $duckdb_expression = $this->translate_tokens_to_duckdb_sql( $expression_tokens ); + $mysql_expression = $this->mysql_check_clause_from_tokens( $expression_tokens ); + + return array( + 'sql' => 'CONSTRAINT ' + . $this->connection->quote_identifier( $constraint_name ) + . ' CHECK (' + . $duckdb_expression + . ')', + 'metadata' => array( + 'constraint_name' => $constraint_name, + 'check_clause' => $mysql_expression, + 'enforced' => 'YES', + ), + ); + } + + /** + * Translate an inline column REFERENCES constraint into table-level DuckDB SQL and metadata. + * + * @param string $table_name Table name. + * @param string $column_name Referencing column name. + * @param WP_Parser_Token[] $tokens Column definition tokens. + * @param int $index Current index, advanced past the REFERENCES clause. + * @param array $foreign_key_names Existing FOREIGN KEY names, keyed lowercase. + * @return array{sql:string,metadata:array{constraint_name:string,columns:string[],referenced_table_name:string,referenced_columns:string[],update_rule:string,delete_rule:string}} + */ + private function translate_inline_foreign_key_constraint( string $table_name, string $column_name, array $tokens, int &$index, array &$foreign_key_names ): array { + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::REFERENCES_SYMBOL, 'Expected REFERENCES in FOREIGN KEY constraint.' ); + ++$index; + + $referenced_table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE FOREIGN KEY constraint in DuckDB driver. Schema-qualified references are not supported.' ); + } + + list( $referenced_columns, $index ) = $this->parse_foreign_key_column_list( $tokens, $index, 'FOREIGN KEY referenced column list' ); + if ( 1 !== count( $referenced_columns ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE FOREIGN KEY constraint in DuckDB driver. Only single-column foreign keys are supported.' ); + } + + list( $update_rule, $delete_rule, $index ) = $this->parse_foreign_key_actions( $tokens, $index ); + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE FOREIGN KEY constraint in DuckDB driver.' ); + } + + $constraint_name = $this->generate_foreign_key_constraint_name( $table_name, $foreign_key_names ); + $this->register_foreign_key_constraint_name( $constraint_name, $foreign_key_names ); + + return array( + 'sql' => 'CONSTRAINT ' + . $this->connection->quote_identifier( $constraint_name ) + . ' FOREIGN KEY (' + . $this->connection->quote_identifier( $column_name ) + . ') REFERENCES ' + . $this->connection->quote_identifier( $referenced_table_name ) + . ' (' + . $this->connection->quote_identifier( $referenced_columns[0] ) + . ')', + 'metadata' => array( + 'constraint_name' => $constraint_name, + 'columns' => array( $column_name ), + 'referenced_table_name' => $referenced_table_name, + 'referenced_columns' => $referenced_columns, + 'update_rule' => $update_rule, + 'delete_rule' => $delete_rule, + ), + ); } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index be2aac070..440d8411b 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -960,6 +960,44 @@ public function test_table_level_check_constraints_match_sqlite(): void { $this->assertParityRows( 'SHOW CREATE TABLE check_metadata' ); } + public function test_inline_check_constraints_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE inline_check_metadata ( + id INT CHECK (id >= 0), + amount INT CHECK (amount > 0) ENFORCED + )', + ) + ); + + $this->assertParityRowCount( 'INSERT INTO inline_check_metadata (id, amount) VALUES (1, 10)' ); + $this->assertParityErrorContains( + 'INSERT INTO inline_check_metadata (id, amount) VALUES (-1, 1)', + 'CHECK constraint failed' + ); + $this->assertParityErrorContains( + 'INSERT INTO inline_check_metadata (id, amount) VALUES (2, -1)', + 'CHECK constraint failed' + ); + $this->assertParityErrorContains( + 'UPDATE inline_check_metadata SET amount = 0 WHERE id = 1', + 'CHECK constraint failed' + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'inline_check_metadata' + ORDER BY constraint_name" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" + ); + $this->assertParityRows( 'SHOW CREATE TABLE inline_check_metadata' ); + } + public function test_simple_table_level_foreign_keys_match_sqlite(): void { $this->runParitySetup( array( @@ -1028,6 +1066,68 @@ public function test_simple_table_level_foreign_keys_match_sqlite(): void { $this->assertParityRows( 'SHOW CREATE TABLE fk_child_generated' ); } + public function test_simple_inline_foreign_keys_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE inline_fk_parent (id INT PRIMARY KEY)', + 'CREATE TABLE inline_fk_child ( + id INT, + parent_id INT REFERENCES inline_fk_parent (id) ON DELETE RESTRICT ON UPDATE NO ACTION + )', + 'CREATE TABLE inline_fk_child_default ( + id INT, + parent_id INT REFERENCES inline_fk_parent (id) + )', + 'INSERT INTO inline_fk_parent (id) VALUES (1)', + ) + ); + + $this->assertParityRowCount( 'INSERT INTO inline_fk_child (id, parent_id) VALUES (10, 1)' ); + $this->assertParityRowCount( 'INSERT INTO inline_fk_child_default (id, parent_id) VALUES (20, 1)' ); + $this->assertParityErrorContains( + 'INSERT INTO inline_fk_child (id, parent_id) VALUES (11, 404)', + 'constraint' + ); + $this->assertParityErrorContains( + 'UPDATE inline_fk_child SET parent_id = 404 WHERE id = 10', + 'constraint' + ); + $this->assertParityErrorContains( + 'UPDATE inline_fk_parent SET id = 2 WHERE id = 1', + 'constraint' + ); + $this->assertParityErrorContains( + 'DELETE FROM inline_fk_parent WHERE id = 1', + 'constraint' + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' + AND table_name IN ('inline_fk_child', 'inline_fk_child_default') + ORDER BY table_name, constraint_name" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, UNIQUE_CONSTRAINT_NAME, MATCH_OPTION, + UPDATE_RULE, DELETE_RULE, TABLE_NAME, REFERENCED_TABLE_NAME + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' + AND table_name IN ('inline_fk_child', 'inline_fk_child_default') + ORDER BY table_name, constraint_name" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, + POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_SCHEMA, + REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name IN ('inline_fk_child', 'inline_fk_child_default') + ORDER BY table_name, constraint_name, ordinal_position" + ); + $this->assertParityRows( 'SHOW CREATE TABLE inline_fk_child' ); + $this->assertParityRows( 'SHOW CREATE TABLE inline_fk_child_default' ); + } + public function test_unique_key_foreign_key_metadata_documents_current_duckdb_gap(): void { $sqlite_driver = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 79438944c..493079ece 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -2722,6 +2722,126 @@ public function test_create_table_check_constraints_use_native_enforcement_and_m ); } + public function test_create_table_inline_check_constraints_use_native_enforcement_and_mysql_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( + 'CREATE TABLE inline_checks ( + id INT CHECK (id >= 0), + amount INT CHECK (amount > 0) ENFORCED + )' + ); + + $this->assertSame( 2, $driver->query( 'INSERT INTO inline_checks (id, amount) VALUES (1, 10), (NULL, 2)' )->rowCount() ); + + try { + $driver->query( 'INSERT INTO inline_checks (id, amount) VALUES (-1, 1)' ); + $this->fail( 'Expected inline CHECK constraint enforcement to reject a negative id.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + try { + $driver->query( 'INSERT INTO inline_checks (id, amount) VALUES (2, -1)' ); + $this->fail( 'Expected inline CHECK constraint enforcement to reject a negative amount.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + try { + $driver->query( 'UPDATE inline_checks SET amount = 0 WHERE id = 1' ); + $this->fail( 'Expected inline CHECK constraint enforcement to reject an invalid UPDATE.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + try { + $driver->query( 'UPDATE inline_checks SET id = -2 WHERE amount = 2' ); + $this->fail( 'Expected inline CHECK constraint enforcement to reject an invalid UPDATE.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + $this->assertSame( + array( + array( + 'id' => 1, + 'amount' => 10, + ), + array( + 'id' => null, + 'amount' => 2, + ), + ), + $driver->query( 'SELECT id, amount FROM inline_checks ORDER BY amount DESC' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $constraints = $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'inline_checks' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'inline_checks_chk_1', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'YES', + ), + array( + 'CONSTRAINT_NAME' => 'inline_checks_chk_2', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'YES', + ), + ), + $constraints + ); + + $check_constraints = $driver->query( + "SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'inline_checks_chk_1', + 'CHECK_CLAUSE' => 'id >= 0', + ), + array( + 'CONSTRAINT_NAME' => 'inline_checks_chk_2', + 'CHECK_CLAUSE' => 'amount > 0', + ), + ), + $check_constraints + ); + + $create = $driver->query( 'SHOW CREATE TABLE inline_checks' )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `inline_checks` (', + ' `id` int DEFAULT NULL,', + ' `amount` int DEFAULT NULL,', + ' CONSTRAINT `inline_checks_chk_1` CHECK (id >= 0),', + ' CONSTRAINT `inline_checks_chk_2` CHECK (amount > 0)', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $create['Create Table'] + ); + } + public function test_create_table_foreign_keys_use_native_enforcement_and_mysql_metadata(): void { $this->requireDuckDBRuntime(); @@ -2894,6 +3014,187 @@ public function test_create_table_foreign_keys_use_native_enforcement_and_mysql_ ); } + public function test_create_table_inline_foreign_keys_use_native_enforcement_and_mysql_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( 'CREATE TABLE parents (id INT PRIMARY KEY)' ); + $driver->query( + 'CREATE TABLE child_inline ( + id INT, + parent_id INT REFERENCES parents (id) ON DELETE RESTRICT ON UPDATE NO ACTION + )' + ); + $driver->query( + 'CREATE TABLE child_inline_default ( + id INT, + parent_id INT REFERENCES parents (id) + )' + ); + + $create_queries = $driver->get_last_duckdb_queries(); + $this->assertContains( + 'CREATE TABLE "child_inline_default" ("id" INTEGER, "parent_id" INTEGER, CONSTRAINT "child_inline_default_ibfk_1" FOREIGN KEY ("parent_id") REFERENCES "parents" ("id"))', + $create_queries + ); + + $driver->query( 'INSERT INTO parents (id) VALUES (1)' ); + $this->assertSame( 1, $driver->query( 'INSERT INTO child_inline (id, parent_id) VALUES (10, 1)' )->rowCount() ); + $this->assertSame( 1, $driver->query( 'INSERT INTO child_inline_default (id, parent_id) VALUES (20, 1)' )->rowCount() ); + + try { + $driver->query( 'INSERT INTO child_inline (id, parent_id) VALUES (11, 404)' ); + $this->fail( 'Expected inline FOREIGN KEY enforcement to reject a missing parent row.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'constraint', strtolower( $e->getMessage() ) ); + } + + try { + $driver->query( 'UPDATE child_inline SET parent_id = 404 WHERE id = 10' ); + $this->fail( 'Expected inline FOREIGN KEY enforcement to reject updating a child to a missing parent row.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'constraint', strtolower( $e->getMessage() ) ); + } + + try { + $driver->query( 'UPDATE parents SET id = 2 WHERE id = 1' ); + $this->fail( 'Expected inline FOREIGN KEY enforcement to restrict updating a referenced parent key.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'constraint', strtolower( $e->getMessage() ) ); + } + + $this->assertSame( + array( + array( + 'id' => 10, + 'parent_id' => 1, + ), + ), + $driver->query( 'SELECT id, parent_id FROM child_inline' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'id' => 1 ) ), + $driver->query( 'SELECT id FROM parents' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + try { + $driver->query( 'DELETE FROM parents WHERE id = 1' ); + $this->fail( 'Expected inline FOREIGN KEY enforcement to restrict deleting a referenced parent row.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'constraint', strtolower( $e->getMessage() ) ); + } + + $constraints = $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name IN ('child_inline', 'child_inline_default') + ORDER BY table_name, constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'child_inline_ibfk_1', + 'CONSTRAINT_TYPE' => 'FOREIGN KEY', + 'ENFORCED' => 'YES', + ), + array( + 'CONSTRAINT_NAME' => 'child_inline_default_ibfk_1', + 'CONSTRAINT_TYPE' => 'FOREIGN KEY', + 'ENFORCED' => 'YES', + ), + ), + $constraints + ); + + $referential_constraints = $driver->query( + "SELECT CONSTRAINT_NAME, UNIQUE_CONSTRAINT_NAME, MATCH_OPTION, + UPDATE_RULE, DELETE_RULE, TABLE_NAME, REFERENCED_TABLE_NAME + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' + AND table_name IN ('child_inline', 'child_inline_default') + ORDER BY table_name, constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'child_inline_ibfk_1', + 'UNIQUE_CONSTRAINT_NAME' => 'PRIMARY', + 'MATCH_OPTION' => 'NONE', + 'UPDATE_RULE' => 'NO ACTION', + 'DELETE_RULE' => 'RESTRICT', + 'TABLE_NAME' => 'child_inline', + 'REFERENCED_TABLE_NAME' => 'parents', + ), + array( + 'CONSTRAINT_NAME' => 'child_inline_default_ibfk_1', + 'UNIQUE_CONSTRAINT_NAME' => 'PRIMARY', + 'MATCH_OPTION' => 'NONE', + 'UPDATE_RULE' => 'NO ACTION', + 'DELETE_RULE' => 'NO ACTION', + 'TABLE_NAME' => 'child_inline_default', + 'REFERENCED_TABLE_NAME' => 'parents', + ), + ), + $referential_constraints + ); + + $usage = $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, + POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_SCHEMA, + REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name IN ('child_inline', 'child_inline_default') + ORDER BY table_name, constraint_name, ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'child_inline_ibfk_1', + 'TABLE_NAME' => 'child_inline', + 'COLUMN_NAME' => 'parent_id', + 'ORDINAL_POSITION' => 1, + 'POSITION_IN_UNIQUE_CONSTRAINT' => 1, + 'REFERENCED_TABLE_SCHEMA' => 'wp', + 'REFERENCED_TABLE_NAME' => 'parents', + 'REFERENCED_COLUMN_NAME' => 'id', + ), + array( + 'CONSTRAINT_NAME' => 'child_inline_default_ibfk_1', + 'TABLE_NAME' => 'child_inline_default', + 'COLUMN_NAME' => 'parent_id', + 'ORDINAL_POSITION' => 1, + 'POSITION_IN_UNIQUE_CONSTRAINT' => 1, + 'REFERENCED_TABLE_SCHEMA' => 'wp', + 'REFERENCED_TABLE_NAME' => 'parents', + 'REFERENCED_COLUMN_NAME' => 'id', + ), + ), + $usage + ); + + $create = $driver->query( 'SHOW CREATE TABLE child_inline' )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `child_inline` (', + ' `id` int DEFAULT NULL,', + ' `parent_id` int DEFAULT NULL,', + ' CONSTRAINT `child_inline_ibfk_1` FOREIGN KEY (`parent_id`) REFERENCES `parents` (`id`) ON DELETE RESTRICT', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $create['Create Table'] + ); + } + public function test_foreign_keys_referencing_driver_managed_unique_keys_are_not_supported(): void { $this->requireDuckDBRuntime(); @@ -3299,6 +3600,60 @@ public function test_temporary_table_lifecycle_uses_session_metadata(): void { $this->assertSame( array(), $driver->query( 'SHOW CREATE TABLE temp_items' )->fetchAll( PDO::FETCH_ASSOC ) ); } + public function test_temporary_table_inline_checks_use_temp_metadata_only(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( + 'CREATE TEMPORARY TABLE temp_inline_checks ( + id INT CHECK (id >= 0), + amount INT CHECK (amount > 0) + )' + ); + + $this->assertSame( 1, $driver->query( 'INSERT INTO temp_inline_checks (id, amount) VALUES (1, 10)' )->rowCount() ); + + try { + $driver->query( 'UPDATE temp_inline_checks SET amount = -1 WHERE id = 1' ); + $this->fail( 'Expected temporary inline CHECK constraint enforcement to reject an invalid UPDATE.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + $create_rows = $driver->query( 'SHOW CREATE TABLE temp_inline_checks' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TEMPORARY TABLE `temp_inline_checks` (', + ' `id` int DEFAULT NULL,', + ' `amount` int DEFAULT NULL,', + ' CONSTRAINT `temp_inline_checks_chk_1` CHECK (id >= 0),', + ' CONSTRAINT `temp_inline_checks_chk_2` CHECK (amount > 0)', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $create_rows[0]['Create Table'] + ); + + $this->assertSame( array(), $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( + array(), + $driver->query( + "SELECT CONSTRAINT_NAME + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_temporary_table_takes_precedence_over_persistent_table(): void { $this->requireDuckDBRuntime(); @@ -4204,9 +4559,24 @@ public function test_unsupported_create_table_check_constraint_not_enforced_thro $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); - $this->expectException( WP_DuckDB_Driver_Exception::class ); - $this->expectExceptionMessage( 'Unsupported CREATE TABLE CHECK constraint in DuckDB driver: NOT ENFORCED is not supported.' ); - $driver->query( 'CREATE TABLE checks (id INT, CONSTRAINT positive CHECK (id > 0) NOT ENFORCED)' ); + foreach ( + array( + 'CREATE TABLE checks_named (id INT, CONSTRAINT positive CHECK (id > 0) NOT ENFORCED)', + 'CREATE TABLE checks_inline (id INT CHECK (id > 0) NOT ENFORCED)', + ) as $sql + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected unsupported CHECK NOT ENFORCED to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( + 'Unsupported CREATE TABLE CHECK constraint in DuckDB driver: NOT ENFORCED is not supported.', + $e->getMessage() + ); + } + } + + $this->assertSame( array(), $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) ); } public function test_unsupported_create_table_foreign_key_actions_throw_before_mutation(): void { @@ -4220,6 +4590,9 @@ public function test_unsupported_create_table_foreign_key_actions_throw_before_m 'CREATE TABLE child_cascade (parent_id INT, FOREIGN KEY (parent_id) REFERENCES parents (id) ON DELETE CASCADE)' => 'ON DELETE CASCADE is not supported', 'CREATE TABLE child_set_null (parent_id INT, FOREIGN KEY (parent_id) REFERENCES parents (id) ON UPDATE SET NULL)' => 'ON UPDATE SET NULL is not supported', 'CREATE TABLE child_set_default (parent_id INT DEFAULT 0, FOREIGN KEY (parent_id) REFERENCES parents (id) ON DELETE SET DEFAULT)' => 'ON DELETE SET DEFAULT is not supported', + 'CREATE TABLE child_inline_cascade (parent_id INT REFERENCES parents (id) ON DELETE CASCADE)' => 'ON DELETE CASCADE is not supported', + 'CREATE TABLE child_inline_set_null (parent_id INT REFERENCES parents (id) ON UPDATE SET NULL)' => 'ON UPDATE SET NULL is not supported', + 'CREATE TABLE child_inline_set_default (parent_id INT DEFAULT 0 REFERENCES parents (id) ON DELETE SET DEFAULT)' => 'ON DELETE SET DEFAULT is not supported', ) as $sql => $message ) { try { @@ -4236,15 +4609,31 @@ public function test_unsupported_create_table_foreign_key_actions_throw_before_m ); } - public function test_inline_references_remain_unsupported(): void { + public function test_unsupported_inline_references_shapes_throw_before_mutation(): void { $this->requireDuckDBRuntime(); $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); - $driver->query( 'CREATE TABLE parents (id INT PRIMARY KEY)' ); + $driver->query( 'CREATE TABLE parents (id INT PRIMARY KEY, other_id INT)' ); - $this->expectException( WP_DuckDB_Driver_Exception::class ); - $this->expectExceptionMessage( 'Unsupported inline REFERENCES constraint in DuckDB driver.' ); - $driver->query( 'CREATE TABLE child_inline (parent_id INT REFERENCES parents (id))' ); + foreach ( + array( + 'CREATE TABLE child_inline_schema (parent_id INT REFERENCES wp.parents (id))' => 'Schema-qualified references are not supported', + 'CREATE TABLE child_inline_composite (parent_id INT REFERENCES parents (id, other_id))' => 'Only single-column foreign keys are supported', + 'CREATE TABLE child_inline_missing_list (parent_id INT REFERENCES parents)' => 'Expected FOREIGN KEY referenced column list', + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected unsupported inline REFERENCES shape to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + } + + $this->assertSame( + array( array( 'Tables_in_wp' => 'parents' ) ), + $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) + ); } public function test_unsupported_alter_table_add_auto_increment_throws_driver_exception(): void { From 787285445311b49b4034a6901366cbd2a86f7605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 00:20:59 +0000 Subject: [PATCH 042/317] Add DuckDB CHECK TABLE parity --- .../src/duckdb/class-wp-duckdb-driver.php | 164 +++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 24 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 195 ++++++++++++++++++ 3 files changed, 383 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index b72aafa49..325f2494f 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -274,6 +274,9 @@ public function query( string $query ): WP_DuckDB_Result_Statement { case WP_MySQL_Lexer::ALTER_SYMBOL: $this->found_rows = 0; return $this->execute_alter_table( $tokens ); + case WP_MySQL_Lexer::CHECK_SYMBOL: + $this->found_rows = 0; + return $this->execute_check_table( $tokens ); case WP_MySQL_Lexer::SHOW_SYMBOL: return $this->record_found_rows_from_result( $this->execute_show( $tokens ) ); case WP_MySQL_Lexer::DESCRIBE_SYMBOL: @@ -3322,6 +3325,167 @@ private function execute_unlock_tables_statement( array $tokens ): WP_DuckDB_Res return $this->empty_ddl_result(); } + /** + * Execute CHECK TABLE as a MySQL-shaped no-op status report. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_check_table( array $tokens ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CHECK_SYMBOL, 'Expected CHECK.' ); + ++$index; + if ( + ! isset( $tokens[ $index ] ) + || ( WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $index ]->id && WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[ $index ]->id ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Expected TABLE in CHECK TABLE statement.' ); + } + ++$index; + + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'CHECK TABLE requires at least one table name.' ); + } + + $requested_table_names = array(); + while ( $index < count( $tokens ) ) { + $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'CHECK TABLE' ); + $requested_table_names[] = $reference['requested_table_name']; + $index = $reference['next_index']; + + if ( $index >= count( $tokens ) ) { + break; + } + + if ( $this->is_check_table_option_start_token( $tokens[ $index ] ) ) { + $index = $this->consume_check_table_options( $tokens, $index ); + break; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( + 'Unsupported CHECK TABLE statement in DuckDB driver. Only table names followed by optional CHECK options are supported.' + ); + } + ++$index; + + if ( $index >= count( $tokens ) ) { + throw new WP_DuckDB_Driver_Exception( 'Expected table name after comma in CHECK TABLE statement.' ); + } + } + + $rows = array(); + foreach ( $requested_table_names as $requested_table_name ) { + $rows = array_merge( $rows, $this->check_table_status_rows( $requested_table_name ) ); + } + + return new WP_DuckDB_Result_Statement( + array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + $rows + ); + } + + /** + * Consume legal CHECK TABLE options. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index of the first option token. + * @return int Index after the consumed options. + */ + private function consume_check_table_options( array $tokens, int $index ): int { + while ( $index < count( $tokens ) ) { + if ( + in_array( + $tokens[ $index ]->id, + array( + WP_MySQL_Lexer::QUICK_SYMBOL, + WP_MySQL_Lexer::FAST_SYMBOL, + WP_MySQL_Lexer::MEDIUM_SYMBOL, + WP_MySQL_Lexer::EXTENDED_SYMBOL, + WP_MySQL_Lexer::CHANGED_SYMBOL, + ), + true + ) + ) { + ++$index; + continue; + } + + if ( WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $index ]->id ) { + if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::UPGRADE_SYMBOL !== $tokens[ $index + 1 ]->id ) { + throw new WP_DuckDB_Driver_Exception( + 'Unsupported CHECK TABLE statement in DuckDB driver. FOR must be followed by UPGRADE.' + ); + } + $index += 2; + continue; + } + + throw new WP_DuckDB_Driver_Exception( + 'Unsupported CHECK TABLE statement in DuckDB driver. Only QUICK, FAST, MEDIUM, EXTENDED, CHANGED, and FOR UPGRADE options are supported.' + ); + } + + return $index; + } + + /** + * Check whether a token can begin CHECK TABLE options. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token starts CHECK TABLE options. + */ + private function is_check_table_option_start_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::QUICK_SYMBOL, + WP_MySQL_Lexer::FAST_SYMBOL, + WP_MySQL_Lexer::MEDIUM_SYMBOL, + WP_MySQL_Lexer::EXTENDED_SYMBOL, + WP_MySQL_Lexer::CHANGED_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + ), + true + ); + } + + /** + * Build CHECK TABLE result rows for one requested table. + * + * @param string $requested_table_name Requested table name. + * @return array> + */ + private function check_table_status_rows( string $requested_table_name ): array { + $table_label = $this->database . '.' . $requested_table_name; + + if ( null === $this->resolve_visible_user_table_reference( $requested_table_name ) ) { + return array( + array( + $table_label, + 'check', + 'Error', + "Table '{$requested_table_name}' doesn't exist", + ), + array( + $table_label, + 'check', + 'status', + 'Operation failed', + ), + ); + } + + return array( + array( + $table_label, + 'check', + 'status', + 'OK', + ), + ); + } + /** * Execute BEGIN [WORK]. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 440d8411b..e95a11701 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1235,6 +1235,30 @@ public function test_information_schema_tables_metadata_matches_sqlite(): void { ); } + public function test_check_table_status_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE check_items (id INT)', + 'CREATE TABLE check_second (id INT)', + 'CREATE TEMPORARY TABLE check_temp_only (id INT)', + 'INSERT INTO check_items VALUES (1)', + ) + ); + + $this->assertParityRows( 'SELECT id FROM check_items' ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + $this->assertParityRows( 'CHECK TABLE check_items' ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + $this->assertParityRows( 'CHECK TABLE wp.check_items' ); + $this->assertParityRows( 'CHECK TABLES check_items' ); + $this->assertParityRows( 'CHECK TABLE check_items QUICK FAST MEDIUM EXTENDED CHANGED FOR UPGRADE' ); + $this->assertParityRows( 'CHECK TABLE check_items, check_second' ); + $this->assertParityRows( 'CHECK TABLE check_temp_only' ); + $this->assertParityRows( 'CHECK TABLE missing_check_table' ); + $this->assertParityRows( 'CHECK TABLE check_items, missing_check_table' ); + $this->assertParityErrorContains( 'CHECK TABLE information_schema.tables', "to database 'information_schema'" ); + } + public function test_show_table_status_metadata_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 493079ece..52576dbad 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3459,6 +3459,201 @@ public function test_show_table_status_exposes_mysql_shaped_table_metadata(): vo $this->assertSame( array(), $internal ); } + public function test_check_table_returns_mysql_shaped_status_rows(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE check_items (id INT)' ); + $driver->query( 'CREATE TABLE check_second (id INT)' ); + $driver->query( 'CREATE TEMPORARY TABLE check_temp_only (id INT)' ); + $driver->query( 'INSERT INTO check_items VALUES (1)' ); + + $driver->query( 'SELECT id FROM check_items' ); + $this->assertSame( + array( array( 'found_rows' => 1 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $check = $driver->query( 'CHECK TABLE check_items' ); + $this->assertSame( 4, $check->columnCount() ); + $this->assertSame( array( 'name' => 'Table' ), $check->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => 'Op' ), $check->getColumnMeta( 1 ) ); + $this->assertSame( array( 'name' => 'Msg_type' ), $check->getColumnMeta( 2 ) ); + $this->assertSame( array( 'name' => 'Msg_text' ), $check->getColumnMeta( 3 ) ); + $this->assertSame( 0, $check->rowCount() ); + $this->assertSame( + array( + array( + 'Table' => 'wp.check_items', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $check->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'found_rows' => 0 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.check_items', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $driver->query( 'CHECK TABLE wp.check_items' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.check_items', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $driver->query( 'CHECK TABLES check_items' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.check_items', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $driver->query( + 'CHECK TABLE check_items QUICK FAST MEDIUM EXTENDED CHANGED FOR UPGRADE' + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.check_items', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + array( + 'Table' => 'wp.check_second', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $driver->query( 'CHECK TABLE check_items, check_second' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.check_temp_only', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $driver->query( 'CHECK TABLE check_temp_only' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array(), + $driver->query( + "SELECT TABLE_NAME + FROM information_schema.tables + WHERE TABLE_NAME = 'check_temp_only' + OR TABLE_NAME LIKE '__wp_duckdb_%' + ORDER BY TABLE_NAME" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.missing_check_table', + 'Op' => 'check', + 'Msg_type' => 'Error', + 'Msg_text' => "Table 'missing_check_table' doesn't exist", + ), + array( + 'Table' => 'wp.missing_check_table', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $driver->query( 'CHECK TABLE missing_check_table' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.check_items', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + array( + 'Table' => 'wp.missing_check_table', + 'Op' => 'check', + 'Msg_type' => 'Error', + 'Msg_text' => "Table 'missing_check_table' doesn't exist", + ), + array( + 'Table' => 'wp.missing_check_table', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $driver->query( 'CHECK TABLE check_items, missing_check_table' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_check_table_rejects_unsupported_shapes(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE check_items (id INT)' ); + + foreach ( + array( + 'CHECK TABLE check_items QUICK junk' => 'DuckDB driver could not parse MySQL statement', + 'CHECK TABLE check_items FOR' => 'DuckDB driver could not parse MySQL statement', + 'CHECK TABLE other_database.check_items' => 'Only the current database is supported', + 'CHECK TABLE __wp_duckdb_column_metadata' => 'Internal DuckDB metadata tables cannot be modified', + 'CHECK TABLE information_schema.tables' => + "Access denied for user 'duckdb'@'%' to database 'information_schema'", + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected CHECK TABLE rejection for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + } + } + public function test_show_create_table_reconstructs_mysql_shaped_ddl(): void { $this->requireDuckDBRuntime(); From 3e67e09456c2ee3e7b6b542b758cc1241bb44365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 00:35:25 +0000 Subject: [PATCH 043/317] Add DuckDB table administration parity --- .../src/duckdb/class-wp-duckdb-driver.php | 308 ++++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 53 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 247 ++++++++++++++ 3 files changed, 589 insertions(+), 19 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 325f2494f..717999d9a 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -274,9 +274,12 @@ public function query( string $query ): WP_DuckDB_Result_Statement { case WP_MySQL_Lexer::ALTER_SYMBOL: $this->found_rows = 0; return $this->execute_alter_table( $tokens ); + case WP_MySQL_Lexer::ANALYZE_SYMBOL: case WP_MySQL_Lexer::CHECK_SYMBOL: + case WP_MySQL_Lexer::OPTIMIZE_SYMBOL: + case WP_MySQL_Lexer::REPAIR_SYMBOL: $this->found_rows = 0; - return $this->execute_check_table( $tokens ); + return $this->execute_table_administration_statement( $tokens ); case WP_MySQL_Lexer::SHOW_SYMBOL: return $this->record_found_rows_from_result( $this->execute_show( $tokens ) ); case WP_MySQL_Lexer::DESCRIBE_SYMBOL: @@ -3326,30 +3329,41 @@ private function execute_unlock_tables_statement( array $tokens ): WP_DuckDB_Res } /** - * Execute CHECK TABLE as a MySQL-shaped no-op status report. + * Execute table administration statements as MySQL-shaped status reports. * * @param WP_Parser_Token[] $tokens MySQL tokens. * @return WP_DuckDB_Result_Statement */ - private function execute_check_table( array $tokens ): WP_DuckDB_Result_Statement { - $index = 0; - $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CHECK_SYMBOL, 'Expected CHECK.' ); + private function execute_table_administration_statement( array $tokens ): WP_DuckDB_Result_Statement { + $operation = $this->table_administration_operation( $tokens[0] ); + $statement = strtoupper( $operation ) . ' TABLE'; + $index = 0; + $this->expect_token( $tokens, $index, $tokens[0]->id, 'Expected ' . strtoupper( $operation ) . '.' ); ++$index; + + if ( + 'check' !== $operation + && isset( $tokens[ $index ] ) + && $this->is_table_administration_log_modifier_token( $tokens[ $index ] ) + ) { + ++$index; + } + if ( ! isset( $tokens[ $index ] ) || ( WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $index ]->id && WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[ $index ]->id ) ) { - throw new WP_DuckDB_Driver_Exception( 'Expected TABLE in CHECK TABLE statement.' ); + throw new WP_DuckDB_Driver_Exception( 'Expected TABLE in ' . $statement . ' statement.' ); } ++$index; if ( ! isset( $tokens[ $index ] ) ) { - throw new WP_DuckDB_Driver_Exception( 'CHECK TABLE requires at least one table name.' ); + throw new WP_DuckDB_Driver_Exception( $statement . ' requires at least one table name.' ); } $requested_table_names = array(); while ( $index < count( $tokens ) ) { - $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'CHECK TABLE' ); + $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, $statement ); $requested_table_names[] = $reference['requested_table_name']; $index = $reference['next_index']; @@ -3357,26 +3371,39 @@ private function execute_check_table( array $tokens ): WP_DuckDB_Result_Statemen break; } - if ( $this->is_check_table_option_start_token( $tokens[ $index ] ) ) { + if ( 'check' === $operation && $this->is_check_table_option_start_token( $tokens[ $index ] ) ) { $index = $this->consume_check_table_options( $tokens, $index ); break; } + if ( 'repair' === $operation && $this->is_repair_table_option_start_token( $tokens[ $index ] ) ) { + $index = $this->consume_repair_table_options( $tokens, $index ); + break; + } + + if ( 'analyze' === $operation && $this->is_analyze_table_histogram_start_token( $tokens[ $index ] ) ) { + $index = $this->consume_analyze_table_histogram_options( $tokens, $index ); + break; + } + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $index ]->id ) { throw new WP_DuckDB_Driver_Exception( - 'Unsupported CHECK TABLE statement in DuckDB driver. Only table names followed by optional CHECK options are supported.' + $this->unsupported_table_administration_message( $statement, $operation ) ); } ++$index; if ( $index >= count( $tokens ) ) { - throw new WP_DuckDB_Driver_Exception( 'Expected table name after comma in CHECK TABLE statement.' ); + throw new WP_DuckDB_Driver_Exception( 'Expected table name after comma in ' . $statement . ' statement.' ); } } $rows = array(); foreach ( $requested_table_names as $requested_table_name ) { - $rows = array_merge( $rows, $this->check_table_status_rows( $requested_table_name ) ); + $rows = array_merge( + $rows, + $this->table_administration_status_rows( $requested_table_name, $operation ) + ); } return new WP_DuckDB_Result_Statement( @@ -3385,6 +3412,67 @@ private function execute_check_table( array $tokens ): WP_DuckDB_Result_Statemen ); } + /** + * Get the result-row operation label for a table administration statement. + * + * @param WP_Parser_Token $token Statement token. + * @return string Operation label. + */ + private function table_administration_operation( WP_Parser_Token $token ): string { + switch ( $token->id ) { + case WP_MySQL_Lexer::ANALYZE_SYMBOL: + return 'analyze'; + case WP_MySQL_Lexer::CHECK_SYMBOL: + return 'check'; + case WP_MySQL_Lexer::OPTIMIZE_SYMBOL: + return 'optimize'; + case WP_MySQL_Lexer::REPAIR_SYMBOL: + return 'repair'; + } + + throw $this->new_unsupported_statement_exception( $token ); + } + + /** + * Check whether a token is an ignored table administration log modifier. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether this token is LOCAL or NO_WRITE_TO_BINLOG. + */ + private function is_table_administration_log_modifier_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::LOCAL_SYMBOL, + WP_MySQL_Lexer::NO_WRITE_TO_BINLOG_SYMBOL, + ), + true + ); + } + + /** + * Build a stable unsupported-shape message for table administration statements. + * + * @param string $statement Statement name. + * @param string $operation Operation label. + * @return string Error message. + */ + private function unsupported_table_administration_message( string $statement, string $operation ): string { + if ( 'check' === $operation ) { + return 'Unsupported CHECK TABLE statement in DuckDB driver. Only table names followed by optional CHECK options are supported.'; + } + + if ( 'repair' === $operation ) { + return 'Unsupported REPAIR TABLE statement in DuckDB driver. Only table names followed by optional REPAIR options are supported.'; + } + + if ( 'analyze' === $operation ) { + return 'Unsupported ANALYZE TABLE statement in DuckDB driver. Only table names followed by optional histogram clauses are supported.'; + } + + return 'Unsupported ' . $statement . ' statement in DuckDB driver. Only table names are supported.'; + } + /** * Consume legal CHECK TABLE options. * @@ -3451,35 +3539,217 @@ private function is_check_table_option_start_token( WP_Parser_Token $token ): bo } /** - * Build CHECK TABLE result rows for one requested table. + * Consume legal REPAIR TABLE options. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index of the first option token. + * @return int Index after the consumed options. + */ + private function consume_repair_table_options( array $tokens, int $index ): int { + while ( $index < count( $tokens ) ) { + if ( $this->is_repair_table_option_start_token( $tokens[ $index ] ) ) { + ++$index; + continue; + } + + throw new WP_DuckDB_Driver_Exception( + 'Unsupported REPAIR TABLE statement in DuckDB driver. Only QUICK, EXTENDED, and USE_FRM options are supported.' + ); + } + + return $index; + } + + /** + * Check whether a token can begin REPAIR TABLE options. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token starts REPAIR TABLE options. + */ + private function is_repair_table_option_start_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::QUICK_SYMBOL, + WP_MySQL_Lexer::EXTENDED_SYMBOL, + WP_MySQL_Lexer::USE_FRM_SYMBOL, + ), + true + ); + } + + /** + * Consume ignored ANALYZE TABLE histogram clauses. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index of UPDATE or DROP. + * @return int Index after the consumed histogram clause. + */ + private function consume_analyze_table_histogram_options( array $tokens, int $index ): int { + $is_update = WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $index ]->id; + $is_drop = WP_MySQL_Lexer::DROP_SYMBOL === $tokens[ $index ]->id; + if ( ! $is_update && ! $is_drop ) { + throw new WP_DuckDB_Driver_Exception( + 'Unsupported ANALYZE TABLE statement in DuckDB driver. Histogram clauses must start with UPDATE or DROP.' + ); + } + ++$index; + + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::HISTOGRAM_SYMBOL !== $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( + 'Unsupported ANALYZE TABLE statement in DuckDB driver. UPDATE or DROP must be followed by HISTOGRAM.' + ); + } + ++$index; + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ON_SYMBOL, 'Expected ON in ANALYZE TABLE histogram clause.' ); + ++$index; + + $index = $this->consume_analyze_table_histogram_column_list( $tokens, $index ); + + if ( + $is_update + && isset( $tokens[ $index ] ) + && WP_MySQL_Lexer::WITH_SYMBOL === $tokens[ $index ]->id + ) { + $index = $this->consume_analyze_table_histogram_bucket_count( $tokens, $index ); + } + + if ( $index < count( $tokens ) ) { + throw new WP_DuckDB_Driver_Exception( + 'Unsupported ANALYZE TABLE statement in DuckDB driver. Only UPDATE HISTOGRAM ON columns and DROP HISTOGRAM ON columns are supported.' + ); + } + + return $index; + } + + /** + * Check whether a token can begin an ANALYZE TABLE histogram clause. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token starts a histogram clause. + */ + private function is_analyze_table_histogram_start_token( WP_Parser_Token $token ): bool { + return WP_MySQL_Lexer::UPDATE_SYMBOL === $token->id || WP_MySQL_Lexer::DROP_SYMBOL === $token->id; + } + + /** + * Consume an ANALYZE TABLE histogram column list. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index of the first column token. + * @return int Index after the consumed column list. + */ + private function consume_analyze_table_histogram_column_list( array $tokens, int $index ): int { + $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + while ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + } + + return $index; + } + + /** + * Consume an ANALYZE TABLE histogram bucket count. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index of WITH. + * @return int Index after the consumed bucket count. + */ + private function consume_analyze_table_histogram_bucket_count( array $tokens, int $index ): int { + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::WITH_SYMBOL, 'Expected WITH in ANALYZE TABLE histogram clause.' ); + ++$index; + + if ( ! isset( $tokens[ $index ] ) || ! $this->is_integer_number_token( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Expected bucket count in ANALYZE TABLE histogram clause.' ); + } + ++$index; + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::BUCKETS_SYMBOL, 'Expected BUCKETS in ANALYZE TABLE histogram clause.' ); + ++$index; + + return $index; + } + + /** + * Check whether a token is an integer number token. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token is an integer number. + */ + private function is_integer_number_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ); + } + + /** + * Build table administration result rows for one requested table. * * @param string $requested_table_name Requested table name. + * @param string $operation Operation label. * @return array> */ - private function check_table_status_rows( string $requested_table_name ): array { - $table_label = $this->database . '.' . $requested_table_name; + private function table_administration_status_rows( string $requested_table_name, string $operation ): array { + $table_label = $this->database . '.' . $requested_table_name; + $table_reference = $this->resolve_visible_user_table_reference( $requested_table_name ); - if ( null === $this->resolve_visible_user_table_reference( $requested_table_name ) ) { + if ( null === $table_reference ) { return array( array( $table_label, - 'check', + $operation, 'Error', "Table '{$requested_table_name}' doesn't exist", ), array( $table_label, - 'check', + $operation, 'status', 'Operation failed', ), ); } + if ( 'analyze' === $operation ) { + try { + $this->execute_duckdb_query( + 'ANALYZE ' . $this->connection->quote_identifier( $table_reference['table_name'] ), + 'Failed to analyze DuckDB table' + ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + return array( + array( + $table_label, + $operation, + 'Error', + $e->getMessage(), + ), + array( + $table_label, + $operation, + 'status', + 'Operation failed', + ), + ); + } + } + return array( array( $table_label, - 'check', + $operation, 'status', 'OK', ), diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index e95a11701..82f63baac 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1259,6 +1259,59 @@ public function test_check_table_status_matches_sqlite(): void { $this->assertParityErrorContains( 'CHECK TABLE information_schema.tables', "to database 'information_schema'" ); } + public function test_administration_table_status_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE admin_items (id INT)', + 'CREATE TABLE admin_second (id INT)', + 'CREATE TEMPORARY TABLE admin_temp_only (id INT)', + 'CREATE TABLE admin_shadow (base_id INT)', + 'CREATE TEMPORARY TABLE admin_shadow (temp_id INT)', + 'INSERT INTO admin_items VALUES (1)', + 'INSERT INTO admin_shadow VALUES (9)', + ) + ); + + foreach ( array( 'ANALYZE TABLE', 'OPTIMIZE TABLE', 'REPAIR TABLE' ) as $statement ) { + $this->assertParityRows( 'SELECT id FROM admin_items' ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + $this->assertParityRows( $statement . ' admin_items' ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + $this->assertParityRows( $statement . ' wp.admin_items' ); + $this->assertParityRows( str_replace( ' TABLE', ' TABLES', $statement ) . ' admin_items' ); + $this->assertParityRows( $statement . ' admin_items, admin_second' ); + $this->assertParityRows( $statement . ' admin_temp_only' ); + $this->assertParityRows( $statement . ' admin_shadow' ); + $this->assertParityRows( $statement . ' missing_admin_table' ); + $this->assertParityRows( $statement . ' admin_items, missing_admin_table' ); + $this->assertParityErrorContains( $statement . ' information_schema.tables', "to database 'information_schema'" ); + $this->assertParityErrorContains( $statement . ' admin_items, information_schema.tables', "to database 'information_schema'" ); + } + + $this->assertParityRows( 'ANALYZE LOCAL TABLE admin_items' ); + $this->assertParityRows( 'ANALYZE NO_WRITE_TO_BINLOG TABLES admin_items' ); + $this->assertParityRows( 'OPTIMIZE LOCAL TABLE admin_items' ); + $this->assertParityRows( 'OPTIMIZE NO_WRITE_TO_BINLOG TABLES admin_items' ); + $this->assertParityRows( 'REPAIR LOCAL TABLE admin_items' ); + $this->assertParityRows( 'REPAIR NO_WRITE_TO_BINLOG TABLES admin_items' ); + $this->assertParityRows( 'REPAIR TABLE admin_items QUICK' ); + $this->assertParityRows( 'REPAIR TABLE admin_items EXTENDED' ); + $this->assertParityRows( 'REPAIR TABLE admin_items USE_FRM' ); + $this->assertParityRows( 'REPAIR TABLE admin_items QUICK EXTENDED USE_FRM' ); + $this->assertParityRows( 'ANALYZE TABLE admin_items UPDATE HISTOGRAM ON id' ); + $this->assertParityRows( 'ANALYZE TABLE admin_items DROP HISTOGRAM ON id' ); + $this->assertParityErrorContains( 'OPTIMIZE TABLE admin_items QUICK', 'parse' ); + + $this->assertParityRows( + "SELECT TABLE_NAME + FROM information_schema.tables + WHERE TABLE_NAME = 'admin_temp_only' + OR TABLE_NAME LIKE '__wp_duckdb_%' + ORDER BY TABLE_NAME" + ); + $this->assertParityRows( 'SELECT temp_id FROM admin_shadow' ); + } + public function test_show_table_status_metadata_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 52576dbad..ccf58cf89 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3624,6 +3624,214 @@ public function test_check_table_returns_mysql_shaped_status_rows(): void { ); } + public function test_administration_table_statements_return_mysql_shaped_status_rows(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE admin_items (id INT)' ); + $driver->query( 'CREATE TABLE admin_second (id INT)' ); + $driver->query( 'CREATE TEMPORARY TABLE admin_temp_only (id INT)' ); + $driver->query( 'CREATE TABLE admin_shadow (base_id INT)' ); + $driver->query( 'CREATE TEMPORARY TABLE admin_shadow (temp_id INT)' ); + $driver->query( 'INSERT INTO admin_items VALUES (1)' ); + $driver->query( 'INSERT INTO admin_shadow VALUES (9)' ); + + foreach ( + array( + 'ANALYZE TABLE' => 'analyze', + 'OPTIMIZE TABLE' => 'optimize', + 'REPAIR TABLE' => 'repair', + ) as $statement => $operation + ) { + $driver->query( 'SELECT id FROM admin_items' ); + $this->assertSame( + array( array( 'found_rows' => 1 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $result = $driver->query( $statement . ' admin_items' ); + $this->assertSame( 4, $result->columnCount() ); + $this->assertSame( array( 'name' => 'Table' ), $result->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => 'Op' ), $result->getColumnMeta( 1 ) ); + $this->assertSame( array( 'name' => 'Msg_type' ), $result->getColumnMeta( 2 ) ); + $this->assertSame( array( 'name' => 'Msg_text' ), $result->getColumnMeta( 3 ) ); + $this->assertSame( 0, $result->rowCount() ); + $this->assertSame( + array( + array( + 'Table' => 'wp.admin_items', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $result->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'found_rows' => 0 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.admin_items', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $driver->query( $statement . ' wp.admin_items' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.admin_items', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $driver->query( str_replace( ' TABLE', ' TABLES', $statement ) . ' admin_items' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.admin_items', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + array( + 'Table' => 'wp.admin_second', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $driver->query( $statement . ' admin_items, admin_second' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.admin_temp_only', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $driver->query( $statement . ' admin_temp_only' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.admin_shadow', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $driver->query( $statement . ' admin_shadow' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.missing_admin_table', + 'Op' => $operation, + 'Msg_type' => 'Error', + 'Msg_text' => "Table 'missing_admin_table' doesn't exist", + ), + array( + 'Table' => 'wp.missing_admin_table', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $driver->query( $statement . ' missing_admin_table' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Table' => 'wp.admin_items', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + array( + 'Table' => 'wp.missing_admin_table', + 'Op' => $operation, + 'Msg_type' => 'Error', + 'Msg_text' => "Table 'missing_admin_table' doesn't exist", + ), + array( + 'Table' => 'wp.missing_admin_table', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $driver->query( $statement . ' admin_items, missing_admin_table' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + foreach ( + array( + 'ANALYZE LOCAL TABLE admin_items' => 'analyze', + 'ANALYZE NO_WRITE_TO_BINLOG TABLES admin_items' => 'analyze', + 'OPTIMIZE LOCAL TABLE admin_items' => 'optimize', + 'OPTIMIZE NO_WRITE_TO_BINLOG TABLES admin_items' => 'optimize', + 'REPAIR LOCAL TABLE admin_items' => 'repair', + 'REPAIR NO_WRITE_TO_BINLOG TABLES admin_items' => 'repair', + 'REPAIR TABLE admin_items QUICK' => 'repair', + 'REPAIR TABLE admin_items EXTENDED' => 'repair', + 'REPAIR TABLE admin_items USE_FRM' => 'repair', + 'REPAIR TABLE admin_items QUICK EXTENDED USE_FRM' => 'repair', + 'ANALYZE TABLE admin_items UPDATE HISTOGRAM ON id' => 'analyze', + 'ANALYZE TABLE admin_items DROP HISTOGRAM ON id' => 'analyze', + ) as $sql => $operation + ) { + $this->assertSame( + array( + array( + 'Table' => 'wp.admin_items', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $driver->query( $sql )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + $this->assertSame( + array(), + $driver->query( + "SELECT TABLE_NAME + FROM information_schema.tables + WHERE TABLE_NAME = 'admin_temp_only' + OR TABLE_NAME LIKE '__wp_duckdb_%' + ORDER BY TABLE_NAME" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( array( 'temp_id' => 9 ) ), + $driver->query( 'SELECT temp_id FROM admin_shadow' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_check_table_rejects_unsupported_shapes(): void { $this->requireDuckDBRuntime(); @@ -3654,6 +3862,45 @@ public function test_check_table_rejects_unsupported_shapes(): void { } } + public function test_administration_table_statements_reject_unsupported_targets(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE admin_items (id INT)' ); + + foreach ( array( 'ANALYZE TABLE', 'OPTIMIZE TABLE', 'REPAIR TABLE' ) as $statement ) { + foreach ( + array( + $statement . ' other_database.admin_items' => 'Only the current database is supported', + $statement . ' __wp_duckdb_column_metadata' => 'Internal DuckDB metadata tables cannot be modified', + $statement . ' information_schema.tables' => + "Access denied for user 'duckdb'@'%' to database 'information_schema'", + $statement . ' admin_items, information_schema.tables' => + "Access denied for user 'duckdb'@'%' to database 'information_schema'", + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected table administration rejection for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + } + } + + try { + $driver->query( 'OPTIMIZE TABLE admin_items QUICK' ); + $this->fail( 'Expected OPTIMIZE TABLE trailing option rejection.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'could not parse MySQL statement', $e->getMessage() ); + } + } + public function test_show_create_table_reconstructs_mysql_shaped_ddl(): void { $this->requireDuckDBRuntime(); From 0f514fc7b582d1e8282bffd9fc7fa97d22b483e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 00:52:36 +0000 Subject: [PATCH 044/317] Add DuckDB SHOW admin metadata parity --- .../src/duckdb/class-wp-duckdb-driver.php | 305 ++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 61 ++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 243 ++++++++++++++ 3 files changed, 609 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 717999d9a..167e6cbbe 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -5206,6 +5206,32 @@ private function execute_alter_table_change_or_modify_column( string $table_name * @return WP_DuckDB_Result_Statement */ private function execute_show( array $tokens ): WP_DuckDB_Result_Statement { + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::COLLATION_SYMBOL === $tokens[1]->id ) { + return $this->execute_show_collation( $tokens ); + } + + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::DATABASES_SYMBOL === $tokens[1]->id ) { + return $this->execute_show_databases( $tokens ); + } + + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::GRANTS_SYMBOL === $tokens[1]->id ) { + return $this->execute_show_grants( $tokens ); + } + + if ( + isset( $tokens[1] ) + && ( + WP_MySQL_Lexer::VARIABLES_SYMBOL === $tokens[1]->id + || ( + isset( $tokens[2] ) + && in_array( $tokens[1]->id, array( WP_MySQL_Lexer::GLOBAL_SYMBOL, WP_MySQL_Lexer::LOCAL_SYMBOL, WP_MySQL_Lexer::SESSION_SYMBOL ), true ) + && WP_MySQL_Lexer::VARIABLES_SYMBOL === $tokens[2]->id + ) + ) + ) { + return $this->execute_show_variables( $tokens ); + } + if ( isset( $tokens[1] ) && ( @@ -5260,6 +5286,285 @@ private function execute_show( array $tokens ): WP_DuckDB_Result_Statement { throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW statement in DuckDB driver.' ); } + /** + * Execute SHOW COLLATION. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_show_collation( array $tokens ): WP_DuckDB_Result_Statement { + $this->expect_token( $tokens, 1, WP_MySQL_Lexer::COLLATION_SYMBOL, 'Expected COLLATION in SHOW COLLATION statement.' ); + + return $this->execute_static_show_metadata_statement( + array( 'Collation', 'Charset', 'Id', 'Default', 'Compiled', 'Sortlen', 'Pad_attribute' ), + $this->show_collation_rows(), + 'Collation', + $tokens, + 2, + 'SHOW COLLATION' + ); + } + + /** + * Build SQLite-compatible SHOW COLLATION rows. + * + * @return array> + */ + private function show_collation_rows(): array { + return array( + array( 'binary', 'binary', 63, 'Yes', 'Yes', 1, 'NO PAD' ), + array( 'utf8_bin', 'utf8', 83, '', 'Yes', 1, 'PAD SPACE' ), + array( 'utf8_general_ci', 'utf8', 33, 'Yes', 'Yes', 1, 'PAD SPACE' ), + array( 'utf8_unicode_ci', 'utf8', 192, '', 'Yes', 8, 'PAD SPACE' ), + array( 'utf8mb4_bin', 'utf8mb4', 46, '', 'Yes', 1, 'PAD SPACE' ), + array( 'utf8mb4_unicode_ci', 'utf8mb4', 224, '', 'Yes', 8, 'PAD SPACE' ), + array( 'utf8mb4_0900_ai_ci', 'utf8mb4', 255, 'Yes', 'Yes', 0, 'NO PAD' ), + ); + } + + /** + * Execute SHOW DATABASES. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_show_databases( array $tokens ): WP_DuckDB_Result_Statement { + $this->expect_token( $tokens, 1, WP_MySQL_Lexer::DATABASES_SYMBOL, 'Expected DATABASES in SHOW DATABASES statement.' ); + + return $this->execute_static_show_metadata_statement( + array( 'Database' ), + array( + array( 'information_schema' ), + array( $this->database ), + ), + 'Database', + $tokens, + 2, + 'SHOW DATABASES' + ); + } + + /** + * Execute SHOW GRANTS. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_show_grants( array $tokens ): WP_DuckDB_Result_Statement { + $this->expect_token( $tokens, 1, WP_MySQL_Lexer::GRANTS_SYMBOL, 'Expected GRANTS in SHOW GRANTS statement.' ); + + if ( 2 !== count( $tokens ) ) { + if ( + ! isset( $tokens[2], $tokens[3] ) + || WP_MySQL_Lexer::FOR_SYMBOL !== $tokens[2]->id + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW GRANTS statement in DuckDB driver. Use SHOW GRANTS or SHOW GRANTS FOR a parsed target.' ); + } + } + + return new WP_DuckDB_Result_Statement( + array( 'Grants for root@%' ), + array( + array( + 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION', + ), + ) + ); + } + + /** + * Execute SHOW VARIABLES. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_show_variables( array $tokens ): WP_DuckDB_Result_Statement { + $index = 1; + if ( + isset( $tokens[ $index ] ) + && in_array( $tokens[ $index ]->id, array( WP_MySQL_Lexer::GLOBAL_SYMBOL, WP_MySQL_Lexer::LOCAL_SYMBOL, WP_MySQL_Lexer::SESSION_SYMBOL ), true ) + ) { + ++$index; + } + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::VARIABLES_SYMBOL, 'Expected VARIABLES in SHOW VARIABLES statement.' ); + ++$index; + + $this->consume_show_like_or_where_clause( $tokens, $index, 'SHOW VARIABLES' ); + + return new WP_DuckDB_Result_Statement( + array( 'Variable_name', 'Value' ), + array() + ); + } + + /** + * Execute a static SHOW metadata statement with optional LIKE or WHERE. + * + * @param string[] $columns Result column names. + * @param array> $rows Result rows. + * @param string $like_column Column filtered by LIKE. + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index after the SHOW statement name. + * @param string $statement Statement label for errors. + * @return WP_DuckDB_Result_Statement + */ + private function execute_static_show_metadata_statement( array $columns, array $rows, string $like_column, array $tokens, int $index, string $statement ): WP_DuckDB_Result_Statement { + $condition = $this->consume_show_like_or_where_clause( $tokens, $index, $statement, $like_column, $columns ); + + $select_columns = array_map( + function ( string $column ): string { + return $this->connection->quote_identifier( $column ); + }, + $columns + ); + + $aliased_columns = array_merge( + array( $this->connection->quote_identifier( '__wp_order' ) ), + $select_columns + ); + + $value_rows = array(); + foreach ( $rows as $ordinal => $row ) { + $values = array( (int) $ordinal ); + foreach ( $row as $value ) { + $values[] = $value; + } + $value_rows[] = '(' . implode( + ', ', + array_map( + function ( $value ): string { + return $this->connection->quote( $value ); + }, + $values + ) + ) . ')'; + } + + $sql = 'SELECT ' + . implode( ', ', $select_columns ) + . ' FROM (VALUES ' + . implode( ', ', $value_rows ) + . ') AS ' + . $this->connection->quote_identifier( '__wp_show' ) + . '(' + . implode( ', ', $aliased_columns ) + . ')' + . $condition + . ' ORDER BY ' + . $this->connection->quote_identifier( '__wp_order' ); + + return $this->execute_duckdb_query( $sql, 'Failed to execute ' . $statement ); + } + + /** + * Consume an optional SHOW LIKE or WHERE clause. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Clause start index. + * @param string $statement Statement label for errors. + * @param string|null $like_column Column filtered by LIKE, or null to only validate. + * @param string[] $where_columns Result column aliases available to WHERE. + * @return string DuckDB SQL condition. + */ + private function consume_show_like_or_where_clause( array $tokens, int $index, string $statement, ?string $like_column = null, array $where_columns = array() ): string { + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $index ]->id ) { + if ( + ! isset( $tokens[ $index + 1 ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $index + 1 ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $index + 1 ]->id + ) + ) { + throw new WP_DuckDB_Driver_Exception( $statement . ' LIKE requires a string pattern in the DuckDB driver.' ); + } + + $index += 2; + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only optional LIKE and WHERE are supported.' ); + } + + if ( null === $like_column ) { + return ''; + } + + return ' WHERE ' + . $this->connection->quote_identifier( $like_column ) + . ' LIKE ' + . $this->connection->quote( $tokens[ $index - 1 ]->get_value() ) + . ' ESCAPE ' + . $this->connection->quote( '\\' ); + } + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( $statement . ' WHERE requires an expression in the DuckDB driver.' ); + } + + $condition_tokens = array_slice( $tokens, $index ); + $index = count( $tokens ); + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only optional LIKE and WHERE are supported.' ); + } + + if ( null === $like_column ) { + return ''; + } + + if ( 1 === count( $condition_tokens ) && $this->is_number_token( $condition_tokens[0] ) ) { + return ' WHERE ' . ( 0.0 === (float) $this->number_token_value( $condition_tokens[0] ) ? 'FALSE' : 'TRUE' ); + } + + return ' WHERE ' . $this->translate_static_show_where_condition( $condition_tokens, $where_columns ); + } + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only optional LIKE and WHERE are supported.' ); + } + + return ''; + } + + /** + * Translate a static SHOW WHERE expression with result-alias identifiers. + * + * SQLite accepts SHOW output aliases in WHERE. DuckDB needs keyword-like + * aliases such as Collation quoted when filtering a VALUES-backed result. + * + * @param WP_Parser_Token[] $tokens WHERE expression tokens. + * @param string[] $columns Result column aliases. + * @return string DuckDB SQL expression. + */ + private function translate_static_show_where_condition( array $tokens, array $columns ): string { + $column_map = array(); + foreach ( $columns as $column ) { + $column_map[ strtolower( $column ) ] = $column; + } + + $pieces = array(); + foreach ( $tokens as $index => $token ) { + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + $column = $column_map[ strtolower( $token->get_value() ) ] ?? null; + $pieces[] = $this->connection->quote_identifier( $column ?? $token->get_value() ); + continue; + } + + $next_token_starts_call = isset( $tokens[ $index + 1 ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index + 1 ]->id; + if ( ! $next_token_starts_call && ! $this->is_non_identifier_token( $token ) ) { + $column = $column_map[ strtolower( $token->get_value() ) ] ?? null; + if ( null !== $column ) { + $pieces[] = $this->connection->quote_identifier( $column ); + continue; + } + } + + $pieces[] = $this->translate_token_to_duckdb_sql( $token ); + } + + return $this->join_sql_pieces( $pieces ); + } + /** * Execute SHOW CREATE TABLE. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 82f63baac..882ed0395 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -52,6 +52,67 @@ public function test_show_full_tables_sql_matches_sqlite(): void { $this->assertParityRows( "SHOW FULL TABLES LIKE 'temp_only_show'" ); } + public function test_show_admin_metadata_sql_matches_sqlite(): void { + $this->assertParityRows( 'SHOW COLLATION' ); + $this->assertParityRows( "SHOW COLLATION LIKE 'utf8%'" ); + $this->assertParityRows( "SHOW COLLATION WHERE Collation = 'utf8_bin'" ); + $this->assertParityRows( 'SHOW COLLATION WHERE 0' ); + $this->assertParityRows( "SHOW COLLATION LIKE 'missing%'" ); + + $this->assertParityRows( 'SHOW DATABASES' ); + $this->assertParityRows( 'SHOW DATABASES LIKE "w%"' ); + $this->assertParityRows( 'SHOW DATABASES WHERE `Database` = "wp"' ); + $this->assertParityRows( 'SHOW DATABASES WHERE `Database` = "information_schema"' ); + $this->assertParityRows( "SHOW DATABASES LIKE 'missing%'" ); + $this->assertParityRows( 'SHOW SCHEMAS' ); + $this->assertParityRows( "SHOW SCHEMAS LIKE 'wp'" ); + $this->assertParityRows( 'SHOW SCHEMAS WHERE `Database` = "wp"' ); + $this->assertParityRows( 'SHOW SCHEMAS WHERE 0' ); + + $this->assertParityRows( 'SHOW GRANTS' ); + $this->assertParityRows( 'SHOW GRANTS FOR current_user()' ); + $this->assertParityRows( 'SHOW GRANTS FOR CURRENT_USER' ); + $this->assertParityRows( 'SHOW GRANTS FOR root@localhost' ); + $this->assertParityRows( "SHOW GRANTS FOR 'root'@'localhost'" ); + $this->assertParityRows( 'SHOW GRANTS FOR usera@localhost' ); + $this->assertParityRows( 'SHOW GRANTS FOR root' ); + + $this->assertParityRows( 'SHOW VARIABLES' ); + $this->assertParityRows( "SHOW VARIABLES LIKE 'version'" ); + $this->assertParityRows( "SHOW VARIABLES WHERE Variable_name = 'version'" ); + $this->assertParityRows( 'SHOW VARIABLES WHERE 0' ); + $this->assertParityRows( 'SHOW GLOBAL VARIABLES' ); + $this->assertParityRows( 'SHOW SESSION VARIABLES' ); + $this->assertParityRows( 'SHOW LOCAL VARIABLES' ); + $this->assertParityRows( "SHOW GLOBAL VARIABLES LIKE 'version'" ); + $this->assertParityRows( "SHOW SESSION VARIABLES WHERE Variable_name = 'version'" ); + $this->assertParityRows( "SHOW LOCAL VARIABLES WHERE Variable_name = 'version'" ); + } + + public function test_show_admin_metadata_found_rows_match_sqlite(): void { + foreach ( + array( + 'SHOW COLLATION', + "SHOW COLLATION LIKE 'utf8_bin'", + "SHOW COLLATION LIKE 'missing%'", + 'SHOW DATABASES', + "SHOW DATABASES LIKE 'missing%'", + "SHOW SCHEMAS LIKE 'wp'", + 'SHOW SCHEMAS WHERE 0', + 'SHOW GRANTS', + 'SHOW GRANTS FOR current_user()', + 'SHOW VARIABLES', + 'SHOW GLOBAL VARIABLES', + 'SHOW SESSION VARIABLES', + 'SHOW LOCAL VARIABLES', + 'SHOW VARIABLES WHERE 0', + ) as $sql + ) { + $this->assertParityRows( $sql ); + $this->assertParityRows( 'SELECT FOUND_ROWS()' ); + } + } + public function test_transaction_sql_matches_sqlite(): void { $this->runParitySetup( array( 'CREATE TABLE tx_items (id INT)' ) ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index ccf58cf89..4a00e8cf6 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -174,6 +174,249 @@ public function test_show_full_tables_reports_table_type_and_like_filter(): void ); } + public function test_show_admin_metadata_statements_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $collations = $driver->query( 'SHOW COLLATION' ); + $this->assertSame( 7, $collations->columnCount() ); + $this->assertSame( 0, $collations->rowCount() ); + $this->assertSame( array( 'name' => 'Collation' ), $collations->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => 'Charset' ), $collations->getColumnMeta( 1 ) ); + $this->assertSame( array( 'name' => 'Id' ), $collations->getColumnMeta( 2 ) ); + $this->assertSame( array( 'name' => 'Default' ), $collations->getColumnMeta( 3 ) ); + $this->assertSame( array( 'name' => 'Compiled' ), $collations->getColumnMeta( 4 ) ); + $this->assertSame( array( 'name' => 'Sortlen' ), $collations->getColumnMeta( 5 ) ); + $this->assertSame( array( 'name' => 'Pad_attribute' ), $collations->getColumnMeta( 6 ) ); + $this->assertSame( + array( + array( + 'Collation' => 'binary', + 'Charset' => 'binary', + 'Id' => 63, + 'Default' => 'Yes', + 'Compiled' => 'Yes', + 'Sortlen' => 1, + 'Pad_attribute' => 'NO PAD', + ), + array( + 'Collation' => 'utf8_bin', + 'Charset' => 'utf8', + 'Id' => 83, + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => 1, + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8_general_ci', + 'Charset' => 'utf8', + 'Id' => 33, + 'Default' => 'Yes', + 'Compiled' => 'Yes', + 'Sortlen' => 1, + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8_unicode_ci', + 'Charset' => 'utf8', + 'Id' => 192, + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => 8, + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8mb4_bin', + 'Charset' => 'utf8mb4', + 'Id' => 46, + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => 1, + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8mb4_unicode_ci', + 'Charset' => 'utf8mb4', + 'Id' => 224, + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => 8, + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8mb4_0900_ai_ci', + 'Charset' => 'utf8mb4', + 'Id' => 255, + 'Default' => 'Yes', + 'Compiled' => 'Yes', + 'Sortlen' => 0, + 'Pad_attribute' => 'NO PAD', + ), + ), + $collations->fetchAll( PDO::FETCH_ASSOC ) + ); + + $utf8_collations = $driver->query( "SHOW COLLATION LIKE 'utf8%'" ); + $this->assertSame( 0, $utf8_collations->rowCount() ); + $this->assertSame( + array( 'utf8_bin', 'utf8_general_ci', 'utf8_unicode_ci', 'utf8mb4_bin', 'utf8mb4_unicode_ci', 'utf8mb4_0900_ai_ci' ), + array_column( $utf8_collations->fetchAll( PDO::FETCH_ASSOC ), 'Collation' ) + ); + $this->assertSame( + array( array( 'found_rows' => 6 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $filtered_collations = $driver->query( "SHOW COLLATION WHERE Collation = 'utf8_bin'" ); + $this->assertSame( 0, $filtered_collations->rowCount() ); + $this->assertSame( + array( + array( + 'Collation' => 'utf8_bin', + 'Charset' => 'utf8', + 'Id' => 83, + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => 1, + 'Pad_attribute' => 'PAD SPACE', + ), + ), + $filtered_collations->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'found_rows' => 1 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $missing_collations = $driver->query( "SHOW COLLATION LIKE 'missing%'" ); + $this->assertSame( 0, $missing_collations->rowCount() ); + $this->assertSame( array(), $missing_collations->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( + array( array( 'found_rows' => 0 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $databases = $driver->query( 'SHOW DATABASES' ); + $this->assertSame( 1, $databases->columnCount() ); + $this->assertSame( 0, $databases->rowCount() ); + $this->assertSame( array( 'name' => 'Database' ), $databases->getColumnMeta( 0 ) ); + $this->assertSame( + array( + array( 'Database' => 'information_schema' ), + array( 'Database' => 'wp' ), + ), + $databases->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'Database' => 'wp' ) ), + $driver->query( 'SHOW DATABASES LIKE "w%"' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'Database' => 'information_schema' ) ), + $driver->query( 'SHOW DATABASES WHERE `Database` = "information_schema"' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $schemas = $driver->query( 'SHOW SCHEMAS' ); + $this->assertSame( 1, $schemas->columnCount() ); + $this->assertSame( 0, $schemas->rowCount() ); + $this->assertSame( array( 'name' => 'Database' ), $schemas->getColumnMeta( 0 ) ); + $this->assertSame( + array( + array( 'Database' => 'information_schema' ), + array( 'Database' => 'wp' ), + ), + $schemas->fetchAll( PDO::FETCH_ASSOC ) + ); + + $filtered_schemas = $driver->query( "SHOW SCHEMAS LIKE 'wp'" ); + $this->assertSame( 0, $filtered_schemas->rowCount() ); + $this->assertSame( + array( array( 'Database' => 'wp' ) ), + $filtered_schemas->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'found_rows' => 1 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $missing_schemas = $driver->query( "SHOW SCHEMAS WHERE `Database` = 'missing'" ); + $this->assertSame( 0, $missing_schemas->rowCount() ); + $this->assertSame( array(), $missing_schemas->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( + array( array( 'found_rows' => 0 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + foreach ( + array( + 'SHOW GRANTS', + 'SHOW GRANTS FOR current_user()', + 'SHOW GRANTS FOR CURRENT_USER', + 'SHOW GRANTS FOR root@localhost', + "SHOW GRANTS FOR 'root'@'localhost'", + 'SHOW GRANTS FOR usera@localhost', + 'SHOW GRANTS FOR root', + ) as $sql + ) { + $grants = $driver->query( $sql ); + $this->assertSame( 1, $grants->columnCount() ); + $this->assertSame( 0, $grants->rowCount() ); + $this->assertSame( array( 'name' => 'Grants for root@%' ), $grants->getColumnMeta( 0 ) ); + $this->assertSame( + 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', + ), + ), + $grants->fetchAll( PDO::FETCH_ASSOC ) + ); + } + $this->assertSame( + array( array( 'found_rows' => 1 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + foreach ( + array( + 'SHOW VARIABLES', + "SHOW VARIABLES LIKE 'version'", + "SHOW VARIABLES WHERE Variable_name = 'version'", + 'SHOW GLOBAL VARIABLES', + 'SHOW SESSION VARIABLES', + 'SHOW LOCAL VARIABLES', + "SHOW GLOBAL VARIABLES LIKE 'version'", + "SHOW SESSION VARIABLES WHERE Variable_name = 'version'", + "SHOW LOCAL VARIABLES WHERE Variable_name = 'version'", + ) as $sql + ) { + $variables = $driver->query( $sql ); + $this->assertSame( 2, $variables->columnCount() ); + $this->assertSame( 0, $variables->rowCount() ); + $this->assertSame( array( 'name' => 'Variable_name' ), $variables->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => 'Value' ), $variables->getColumnMeta( 1 ) ); + $this->assertSame( array(), $variables->fetchAll( PDO::FETCH_ASSOC ) ); + } + $this->assertSame( + array( array( 'found_rows' => 0 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_show_grants_rejects_unsupported_like_forms(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + $this->assertDriverQueryRejected( $driver, "SHOW GRANTS LIKE '%'" ); + } + public function test_sql_transaction_statements_update_connection_state_and_query_log(): void { $this->requireDuckDBRuntime(); From 6392aa23c62095f5bc26c8d9f67d6d4070e97647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 01:07:02 +0000 Subject: [PATCH 045/317] Add DuckDB LOCK TABLES option parity --- .../src/duckdb/class-wp-duckdb-driver.php | 91 +++++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 15 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 86 ++++++++++++++++++ 3 files changed, 185 insertions(+), 7 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 167e6cbbe..49606be66 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -3230,7 +3230,7 @@ private function set_statement_type_name( int $scope ): string { } /** - * Execute LOCK TABLE[S] ... READ|WRITE. + * Execute LOCK TABLE[S] ... READ [LOCAL]|[LOW_PRIORITY] WRITE. * * @param WP_Parser_Token[] $tokens MySQL tokens. * @return WP_DuckDB_Result_Statement @@ -3243,7 +3243,7 @@ private function execute_lock_tables_statement( array $tokens ): WP_DuckDB_Resul ! isset( $tokens[ $index ] ) || ( WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $index ]->id && WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[ $index ]->id ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported LOCK statement in DuckDB driver. Only LOCK TABLES ... READ|WRITE is supported.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported LOCK statement in DuckDB driver. Only LOCK TABLES ... READ [LOCAL] or [LOW_PRIORITY] WRITE is supported.' ); } ++$index; @@ -3290,17 +3290,94 @@ private function parse_lock_table_reference( array $tokens, int $index ): array $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'LOCK TABLES' ); $index = $reference['next_index']; + $index = $this->consume_lock_table_alias( $tokens, $index ); + $index = $this->consume_lock_table_option( $tokens, $index ); + + return array( + 'requested_table_name' => $reference['requested_table_name'], + 'next_index' => $index, + ); + } + + /** + * Consume an optional LOCK TABLES table alias. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index after the table reference. + * @return int New index. + */ + private function consume_lock_table_alias( array $tokens, int $index ): int { + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $this->identifier_value( $tokens[ $index ] ?? null ); + return $index + 1; + } + if ( ! isset( $tokens[ $index ] ) - || ( WP_MySQL_Lexer::READ_SYMBOL !== $tokens[ $index ]->id && WP_MySQL_Lexer::WRITE_SYMBOL !== $tokens[ $index ]->id ) + || $this->is_non_identifier_token( $tokens[ $index ] ) + || $this->is_lock_table_option_start_token( $tokens[ $index ] ) ) { + return $index; + } + + $this->identifier_value( $tokens[ $index ] ); + return $index + 1; + } + + /** + * Consume a LOCK TABLES lock option. + * + * Supports MySQL's READ, READ LOCAL, WRITE, and LOW_PRIORITY WRITE lock + * options. DuckDB still emulates these as transaction boundaries only. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Index of the lock option. + * @return int New index. + */ + private function consume_lock_table_option( array $tokens, int $index ): int { + if ( ! isset( $tokens[ $index ] ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported LOCK TABLES statement in DuckDB driver. Each table must specify READ or WRITE.' ); } - ++$index; - return array( - 'requested_table_name' => $reference['requested_table_name'], - 'next_index' => $index, + if ( WP_MySQL_Lexer::READ_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::LOCAL_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + return $index; + } + + if ( WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::WRITE_SYMBOL !== $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported LOCK TABLES statement in DuckDB driver. LOW_PRIORITY must be followed by WRITE.' ); + } + return $index + 1; + } + + if ( WP_MySQL_Lexer::WRITE_SYMBOL === $tokens[ $index ]->id ) { + return $index + 1; + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported LOCK TABLES statement in DuckDB driver. Each table must specify READ or WRITE.' ); + } + + /** + * Check whether a token starts a LOCK TABLES lock option. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token starts a lock option. + */ + private function is_lock_table_option_start_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::READ_SYMBOL, + WP_MySQL_Lexer::WRITE_SYMBOL, + WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL, + ), + true ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 882ed0395..a9f2be472 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -274,6 +274,20 @@ public function test_lock_unlock_sql_matches_sqlite(): void { $this->assertParityRowCount( 'UNLOCK TABLES' ); $this->assertParityRowCount( 'LOCK TABLES lock_temp READ, lock_items WRITE' ); $this->assertParityRowCount( 'UNLOCK TABLES' ); + $this->assertParityRowCount( 'LOCK TABLES lock_items AS li READ' ); + $this->assertParityRowCount( 'UNLOCK TABLES' ); + $this->assertParityRowCount( 'LOCK TABLES lock_items li READ' ); + $this->assertParityRowCount( 'UNLOCK TABLES' ); + $this->assertParityRowCount( 'LOCK TABLES lock_items READ LOCAL' ); + $this->assertParityRowCount( 'UNLOCK TABLES' ); + $this->assertParityRowCount( 'LOCK TABLES lock_items LOW_PRIORITY WRITE' ); + $this->assertParityRowCount( 'UNLOCK TABLES' ); + $this->assertParityRowCount( 'LOCK TABLE lock_items AS li READ LOCAL' ); + $this->assertParityRowCount( 'UNLOCK TABLES' ); + $this->assertParityRowCount( 'LOCK TABLE lock_items li LOW_PRIORITY WRITE' ); + $this->assertParityRowCount( 'UNLOCK TABLES' ); + $this->assertParityRowCount( 'LOCK TABLES lock_temp AS lt READ LOCAL, lock_items li LOW_PRIORITY WRITE' ); + $this->assertParityRowCount( 'UNLOCK TABLES' ); $this->runParitySetup( array( @@ -300,6 +314,7 @@ public function test_lock_unlock_sql_matches_sqlite(): void { $this->assertParityErrorContains( 'LOCK TABLES missing_lock_item READ', "Table 'wp.missing_lock_item' doesn't exist" ); $this->assertParityErrorContains( 'LOCK TABLES lock_items READ, missing_lock_item WRITE', "Table 'wp.missing_lock_item' doesn't exist" ); + $this->assertParityErrorContains( 'LOCK TABLES lock_items AS li READ LOCAL, missing_lock_item missing LOW_PRIORITY WRITE', "Table 'wp.missing_lock_item' doesn't exist" ); $this->assertParityErrorContains( 'LOCK TABLES information_schema.tables READ', "to database 'information_schema'" ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 4a00e8cf6..ca007319d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -993,6 +993,42 @@ public function test_lock_unlock_table_statements_update_transaction_state(): vo ); } + public function test_lock_table_aliases_and_options_are_accepted(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE lock_items (id INT)' ); + $driver->query( 'CREATE TABLE lock_two (id INT)' ); + $driver->query( 'CREATE TEMPORARY TABLE lock_temp (id INT)' ); + + foreach ( + array( + 'LOCK TABLES lock_items AS li READ', + 'LOCK TABLES lock_items li READ', + 'LOCK TABLES lock_items READ LOCAL', + 'LOCK TABLES lock_items LOW_PRIORITY WRITE', + 'LOCK TABLES wp.lock_items AS li READ LOCAL', + 'LOCK TABLES wp.lock_items LOW_PRIORITY WRITE', + 'LOCK TABLE lock_items AS li READ LOCAL', + 'LOCK TABLE lock_items li LOW_PRIORITY WRITE', + 'LOCK TABLES lock_temp AS lt READ LOCAL, lock_items li LOW_PRIORITY WRITE, wp.lock_two AS two READ', + ) as $sql + ) { + $lock = $driver->query( $sql ); + $this->assertSame( 0, $lock->rowCount(), 'LOCK row count mismatch for SQL: ' . $sql ); + $this->assertSame( 0, $lock->columnCount(), 'LOCK column count mismatch for SQL: ' . $sql ); + $this->assertTrue( $driver->get_connection()->inTransaction(), 'LOCK did not open a transaction for SQL: ' . $sql ); + $this->assertSame( 'BEGIN TRANSACTION', $this->lastDuckDBQuery( $driver ) ); + $driver->query( 'UNLOCK TABLES' ); + $this->assertFalse( $driver->get_connection()->inTransaction(), 'UNLOCK did not close the lock transaction for SQL: ' . $sql ); + } + } + public function test_lock_table_validation_matches_mysql_shaped_errors(): void { $this->requireDuckDBRuntime(); @@ -1017,6 +1053,27 @@ public function test_lock_table_validation_matches_mysql_shaped_errors(): void { $driver->query( 'ROLLBACK' ); $this->assertSame( array(), $driver->query( 'SELECT id FROM lock_one' )->fetchAll( PDO::FETCH_ASSOC ) ); + $driver->query( 'BEGIN' ); + $driver->query( 'INSERT INTO lock_one (id) VALUES (2)' ); + try { + $driver->query( 'LOCK TABLES lock_one AS one READ LOCAL, missing_table missing LOW_PRIORITY WRITE, lock_three AS three READ' ); + $this->fail( 'Expected LOCK TABLES to reject a missing table in an aliased/optioned list.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "Table 'wp.missing_table' doesn't exist", $e->getMessage() ); + } + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $driver->query( 'ROLLBACK' ); + $this->assertSame( array(), $driver->query( 'SELECT id FROM lock_one' )->fetchAll( PDO::FETCH_ASSOC ) ); + + try { + $driver->query( 'LOCK TABLES lock_one AS one READ LOCAL, missing_table missing LOW_PRIORITY WRITE, lock_three AS three READ' ); + $this->fail( 'Expected LOCK TABLES to reject a missing table in an aliased/optioned list without opening a transaction.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "Table 'wp.missing_table' doesn't exist", $e->getMessage() ); + } + $this->assertFalse( $driver->get_connection()->inTransaction() ); + $this->assertNotContains( 'BEGIN TRANSACTION', $driver->get_last_duckdb_queries() ); + foreach ( array( 'LOCK TABLES information_schema.tables READ' => "Access denied for user 'duckdb'@'%' to database 'information_schema'", @@ -1035,6 +1092,35 @@ public function test_lock_table_validation_matches_mysql_shaped_errors(): void { } } + public function test_lock_table_malformed_option_order_is_rejected(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE lock_items (id INT)' ); + + foreach ( + array( + 'LOCK TABLES lock_items LOW_PRIORITY READ', + 'LOCK TABLES lock_items WRITE LOCAL', + 'LOCK TABLES lock_items READ LOCAL LOW_PRIORITY', + 'LOCK TABLES lock_items LOW_PRIORITY WRITE LOCAL', + ) as $sql + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected malformed LOCK TABLES option order to be rejected: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertNotSame( '', $e->getMessage() ); + } + $this->assertFalse( $driver->get_connection()->inTransaction(), 'Malformed LOCK TABLES opened a transaction for SQL: ' . $sql ); + } + } + public function test_update_delete_alias_order_limit_are_rewritten(): void { $this->requireDuckDBRuntime(); From 0f6b45b23a251fe30a8c2503beb75fc1ca13e38b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 01:26:28 +0000 Subject: [PATCH 046/317] Add DuckDB joined DELETE parity --- .../src/duckdb/class-wp-duckdb-driver.php | 146 +++++++++++----- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 62 +++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 156 +++++++++++++++++- 3 files changed, 321 insertions(+), 43 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 49606be66..d1e03f651 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1817,7 +1817,7 @@ private function execute_delete( array $tokens ): WP_DuckDB_Result_Statement { * Parse supported multi-table DELETE shapes. * * @param WP_Parser_Token[] $tokens MySQL tokens. - * @return array{targets:array,from_sql:string,where_tokens:array,temp_table:string}|null Parsed shape, or null for single-table DELETE. + * @return array{targets:array,from_sql:string,join_predicates:array>,where_tokens:array,temp_table:string}|null Parsed shape, or null for single-table DELETE. */ private function parse_multi_table_delete_shape( array $tokens ): ?array { if ( ! isset( $tokens[1] ) ) { @@ -1836,7 +1836,8 @@ private function parse_multi_table_delete_shape( array $tokens ): ?array { $table_ref_start = null; $first_clause_pos = null; - if ( WP_MySQL_Lexer::FROM_SYMBOL === $tokens[1]->id ) { + $using_form = WP_MySQL_Lexer::FROM_SYMBOL === $tokens[1]->id; + if ( $using_form ) { $using_index = $this->find_top_level_token_index( $tokens, 2, WP_MySQL_Lexer::USING_SYMBOL ); if ( null === $using_index ) { return null; @@ -1863,9 +1864,13 @@ private function parse_multi_table_delete_shape( array $tokens ): ?array { throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Multi-table DELETE requires target aliases and table references.' ); } - $target_aliases = $this->parse_multi_delete_target_aliases( $target_tokens ); - $references = $this->parse_multi_delete_table_references( $table_ref_tokens ); - $targets = array(); + $target_aliases = $this->parse_multi_delete_target_aliases( $target_tokens ); + $joined_rejection_message = $using_form + ? 'Unsupported DELETE statement in DuckDB driver. Joined table references in DELETE ... USING are not supported yet.' + : 'Unsupported DELETE statement in DuckDB driver. Joined table references in multi-target DELETE are not supported yet.'; + $allow_joined = 1 === count( $target_aliases ); + $references = $this->parse_multi_delete_table_references( $table_ref_tokens, $allow_joined, $joined_rejection_message ); + $targets = array(); foreach ( $target_aliases as $offset => $target_alias ) { $key = strtolower( $target_alias ); if ( ! isset( $references['by_alias'][ $key ] ) ) { @@ -1887,17 +1892,18 @@ private function parse_multi_table_delete_shape( array $tokens ): ?array { } return array( - 'targets' => $targets, - 'from_sql' => $references['sql'], - 'where_tokens' => $where_tokens, - 'temp_table' => '__wp_duckdb_dml_delete_' . substr( hash( 'sha256', (string) $this->last_mysql_query ), 0, 16 ), + 'targets' => $targets, + 'from_sql' => $references['sql'], + 'join_predicates' => $references['join_predicates'], + 'where_tokens' => $where_tokens, + 'temp_table' => '__wp_duckdb_dml_delete_' . substr( hash( 'sha256', (string) $this->last_mysql_query ), 0, 16 ), ); } /** * Execute a parsed multi-table DELETE. * - * @param array{targets:array,from_sql:string,where_tokens:array,temp_table:string} $shape Parsed shape. + * @param array{targets:array,from_sql:string,join_predicates:array>,where_tokens:array,temp_table:string} $shape Parsed shape. * @return WP_DuckDB_Result_Statement */ private function execute_multi_table_delete( array $shape ): WP_DuckDB_Result_Statement { @@ -1916,14 +1922,21 @@ function () use ( $shape ): WP_DuckDB_Result_Statement { . $this->connection->quote_identifier( $target['column'] ); } - $sql = 'CREATE TEMP TABLE ' + $sql = 'CREATE TEMP TABLE ' . $this->connection->quote_identifier( $temp_table ) . ' AS SELECT DISTINCT ' . implode( ', ', $select_list ) . ' FROM ' . $shape['from_sql']; + $where_clauses = array(); if ( count( $shape['where_tokens'] ) > 0 ) { - $sql .= ' WHERE ' . $this->translate_tokens_to_duckdb_sql( $shape['where_tokens'] ); + $where_clauses[] = $this->translate_tokens_to_duckdb_sql( $shape['where_tokens'] ); + } + foreach ( $shape['join_predicates'] as $predicate ) { + $where_clauses[] = $this->translate_tokens_to_duckdb_sql( $predicate ); + } + if ( count( $where_clauses ) > 0 ) { + $sql .= ' WHERE (' . implode( ') AND (', $where_clauses ) . ')'; } $this->execute_duckdb_query( $sql, 'Failed to collect DuckDB multi-table DELETE targets' ); @@ -1985,11 +1998,16 @@ private function parse_multi_delete_target_aliases( array $tokens ): array { * Parse comma-separated table references for a bounded multi-table DELETE. * * @param WP_Parser_Token[] $tokens Table reference tokens. - * @return array{sql:string,by_alias:array} SQL and references keyed by lowercase alias. + * @param bool $allow_joined Whether joined single-target table references are allowed. + * @param string $joined_rejection_message Message for unsupported joined references. + * @return array{sql:string,by_alias:array,join_predicates:array>} SQL and references keyed by lowercase alias. */ - private function parse_multi_delete_table_references( array $tokens ): array { + private function parse_multi_delete_table_references( array $tokens, bool $allow_joined = false, string $joined_rejection_message = 'Unsupported DELETE statement in DuckDB driver. Joined table references in multi-target DELETE are not supported yet.' ): array { if ( $this->contains_top_level_join_token( $tokens ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Joined table references in multi-table DELETE are not supported yet.' ); + if ( $allow_joined ) { + return $this->parse_joined_single_delete_table_references( $tokens ); + } + throw new WP_DuckDB_Driver_Exception( $joined_rejection_message ); } $sql_items = array(); @@ -2012,8 +2030,51 @@ private function parse_multi_delete_table_references( array $tokens ): array { } return array( - 'sql' => implode( ', ', $sql_items ), - 'by_alias' => $by_alias, + 'sql' => implode( ', ', $sql_items ), + 'by_alias' => $by_alias, + 'join_predicates' => array(), + ); + } + + /** + * Parse joined table references for a bounded single-target DELETE. + * + * @param WP_Parser_Token[] $tokens Table reference tokens. + * @return array{sql:string,by_alias:array,join_predicates:array>} SQL and references keyed by lowercase alias. + */ + private function parse_joined_single_delete_table_references( array $tokens ): array { + $joined_references = $this->parse_joined_update_table_references( $tokens, 'DELETE', false ); + $references = array_merge( array( $joined_references['target'] ), $joined_references['sources'] ); + $sql_items = array(); + $by_alias = array(); + $seen_aliases = array(); + + foreach ( $references as $reference ) { + $key = strtolower( $reference['alias'] ); + if ( isset( $seen_aliases[ $key ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Duplicate table alias '{$reference['alias']}' in DELETE statement." ); + } + $seen_aliases[ $key ] = true; + } + + foreach ( $references as $reference ) { + if ( null === $reference['table_name'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Derived table sources are not supported.' ); + } + + $key = strtolower( $reference['alias'] ); + $by_alias[ $key ] = array( + 'alias' => $reference['alias'], + 'table_name' => $reference['table_name'], + 'temporary' => $reference['temporary'], + ); + $sql_items[] = $reference['sql']; + } + + return array( + 'sql' => implode( ', ', $sql_items ), + 'by_alias' => $by_alias, + 'join_predicates' => $joined_references['join_predicates'], ); } @@ -2078,30 +2139,33 @@ private function parse_multi_delete_table_reference( array $tokens ): array { * Parse joined UPDATE table references. * * @param WP_Parser_Token[] $tokens Table reference tokens. - * @return array{target:array{alias:string,table_name:string,requested_table_name:string},sources:array,join_predicates:array>} + * @param string $statement Statement name for diagnostics. + * @param bool $first_factor_must_be_base Whether the first table factor must be a base table. + * @return array{target:array{alias:string,sql:string,table_name:string|null,requested_table_name:string,temporary:bool},sources:array,join_predicates:array>} */ - private function parse_joined_update_table_references( array $tokens ): array { + private function parse_joined_update_table_references( array $tokens, string $statement = 'UPDATE', bool $first_factor_must_be_base = true ): array { $items = $this->split_top_level_comma_items( $tokens ); $target_item = array_shift( $items ); - $target_factor = $this->parse_joined_update_table_factor( $target_item, 0, false, true ); + $target_factor = $this->parse_joined_update_table_factor( $target_item, 0, ! $first_factor_must_be_base, true, $statement ); $target = $target_factor['reference']; $sources = array(); $join_predicates = array(); - if ( null === $target['table_name'] ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Derived tables cannot be updated.' ); + if ( $first_factor_must_be_base && null === $target['table_name'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Derived tables cannot be ' . ( 'UPDATE' === $statement ? 'updated' : 'deleted' ) . '.' ); } - $this->parse_joined_update_join_chain( $target_item, $target_factor['next_index'], $sources, $join_predicates ); + $this->parse_joined_update_join_chain( $target_item, $target_factor['next_index'], $sources, $join_predicates, $statement ); foreach ( $items as $item ) { - $source = $this->parse_joined_update_table_factor( $item, 0, true, false ); + $source = $this->parse_joined_update_table_factor( $item, 0, true, false, $statement ); $sources[] = $source['reference']; - $this->parse_joined_update_join_chain( $item, $source['next_index'], $sources, $join_predicates ); + $this->parse_joined_update_join_chain( $item, $source['next_index'], $sources, $join_predicates, $statement ); } return array( 'target' => array( 'alias' => $target['alias'], + 'sql' => $target['sql'], 'table_name' => $target['table_name'], 'temporary' => $target['temporary'], 'requested_table_name' => $target['requested_table_name'], @@ -2118,22 +2182,23 @@ private function parse_joined_update_table_references( array $tokens ): array { * @param int $index Current index. * @param bool $allow_derived Whether derived tables are allowed. * @param bool $is_target Whether this factor is the UPDATE target. - * @return array{reference:array{alias:string,sql:string,table_name:string|null,requested_table_name:string},next_index:int} + * @param string $statement Statement name for diagnostics. + * @return array{reference:array{alias:string,sql:string,table_name:string|null,temporary:bool,requested_table_name:string},next_index:int} */ - private function parse_joined_update_table_factor( array $tokens, int $index, bool $allow_derived, bool $is_target ): array { + private function parse_joined_update_table_factor( array $tokens, int $index, bool $allow_derived, bool $is_target, string $statement = 'UPDATE' ): array { if ( ! isset( $tokens[ $index ] ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Expected table reference.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Expected table reference.' ); } if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { if ( ! $allow_derived ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Derived tables cannot be updated.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Derived tables cannot be ' . ( 'UPDATE' === $statement ? 'updated' : 'deleted' ) . '.' ); } $close_index = $this->skip_balanced_parentheses( $tokens, $index ) - 1; $inner = array_slice( $tokens, $index + 1, $close_index - $index - 1 ); if ( ! isset( $inner[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $inner[0]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only derived SELECT sources are supported.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only derived SELECT sources are supported.' ); } if ( $this->contains_information_schema_reference( $inner ) ) { throw new WP_DuckDB_Driver_Exception( "Access denied for user 'duckdb'@'%' to database 'information_schema'" ); @@ -2176,17 +2241,17 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo } if ( 0 !== strcasecmp( $database, $this->database ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only the current database is supported.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only the current database is supported.' ); } } if ( $this->is_duckdb_internal_table_name( $table_name ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Internal DuckDB metadata tables cannot be modified.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Internal DuckDB metadata tables cannot be modified.' ); } $table_reference = $this->resolve_visible_user_table_reference( $table_name ); if ( null === $table_reference ) { - throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in UPDATE statement." ); + throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in {$statement} statement." ); } $alias = $table_name; @@ -2220,33 +2285,34 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo * @param int $index Current index. * @param array $sources Source references. * @param array $join_predicates Join predicate token lists. + * @param string $statement Statement name for diagnostics. */ - private function parse_joined_update_join_chain( array $tokens, int $index, array &$sources, array &$join_predicates ): void { + private function parse_joined_update_join_chain( array $tokens, int $index, array &$sources, array &$join_predicates, string $statement = 'UPDATE' ): void { while ( $index < count( $tokens ) ) { if ( WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $index ]->id ) { ++$index; $this->expect_token( $tokens, $index, WP_MySQL_Lexer::JOIN_SYMBOL, 'Expected JOIN after INNER.' ); } elseif ( WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { if ( $this->is_unsupported_joined_update_join_token( $tokens[ $index ] ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only comma joins and INNER JOIN ... ON are supported.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only comma joins and INNER JOIN ... ON are supported.' ); } - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Table reference options are not supported.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Table reference options are not supported.' ); } ++$index; - $source = $this->parse_joined_update_table_factor( $tokens, $index, true, false ); + $source = $this->parse_joined_update_table_factor( $tokens, $index, true, false, $statement ); $sources[] = $source['reference']; $index = $source['next_index']; if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::USING_SYMBOL === $tokens[ $index ]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. JOIN ... USING is not supported.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. JOIN ... USING is not supported.' ); } - $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ON_SYMBOL, 'Expected ON in joined UPDATE statement.' ); + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ON_SYMBOL, 'Expected ON in joined ' . $statement . ' statement.' ); ++$index; $predicate_end = $this->find_next_joined_update_join_index( $tokens, $index ) ?? count( $tokens ); if ( $predicate_end === $index ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. JOIN predicate is required.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. JOIN predicate is required.' ); } $join_predicates[] = array_slice( $tokens, $index, $predicate_end - $index ); $index = $predicate_end; diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index a9f2be472..adabfa02e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -633,6 +633,68 @@ public function test_multi_table_delete_using_and_single_target_match_sqlite(): $this->assertParityRows( 'SELECT id, note FROM t2 ORDER BY id' ); } + public function test_single_target_joined_delete_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20))', + 'CREATE TABLE t2 (id INT, target_id INT, flag VARCHAR(20), note VARCHAR(20))', + "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e'), (6, 'f')", + "INSERT INTO t2 VALUES + (10, 1, 'drop', 'x'), + (11, 1, 'drop', 'duplicate'), + (12, 2, 'keep', 'y'), + (13, 3, 'drop', 'z'), + (14, 4, 'drop', 'w'), + (15, 5, 'source', 's'), + (16, 6, 'source', 'q')", + ) + ); + + $this->assertParityRowCount( + "DELETE a FROM t1 a + JOIN t2 b ON b.target_id = a.id + WHERE b.flag = 'drop' AND a.id = 1" + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + "DELETE FROM a USING t1 a + JOIN t2 b ON b.target_id = a.id + WHERE b.flag = 'drop' AND a.id = 3" + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + "DELETE b FROM t1 a + JOIN t2 b ON b.target_id = a.id + WHERE a.id = 5 AND b.flag = 'source'" + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + "DELETE t1 FROM t1 + JOIN t2 ON t2.target_id = t1.id + WHERE t2.flag = 'drop' AND t1.id = 4" + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( 'DELETE a FROM t1 a WHERE a.id = 6' ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + 'DELETE child FROM t1 parent + JOIN t1 child ON child.id = 5 + WHERE parent.id = 2' + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM t2 ORDER BY id' ); + } + public function test_insert_set_match_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index ca007319d..406f1be90 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1806,6 +1806,108 @@ public function test_multi_table_delete_using_form_and_single_target_are_rewritt ); } + public function test_single_target_joined_delete_rewrites_join_using_and_alias_forms(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, target_id INT, flag VARCHAR(20), note VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e'), (6, 'f')" ); + $driver->query( + "INSERT INTO t2 VALUES + (10, 1, 'drop', 'x'), + (11, 1, 'drop', 'duplicate'), + (12, 2, 'keep', 'y'), + (13, 3, 'drop', 'z'), + (14, 4, 'drop', 'w'), + (15, 5, 'source', 's'), + (16, 6, 'source', 'q')" + ); + + $duplicate_match = $driver->query( + "DELETE a FROM t1 a + JOIN t2 b ON b.target_id = a.id + WHERE b.flag = 'drop' AND a.id = 1" + ); + $this->assertSame( 1, $duplicate_match->rowCount() ); + + $using_delete = $driver->query( + "DELETE FROM a USING t1 a + JOIN t2 b ON b.target_id = a.id + WHERE b.flag = 'drop' AND a.id = 3" + ); + $this->assertSame( 1, $using_delete->rowCount() ); + + $target_not_first = $driver->query( + "DELETE b FROM t1 a + JOIN t2 b ON b.target_id = a.id + WHERE a.id = 5 AND b.flag = 'source'" + ); + $this->assertSame( 1, $target_not_first->rowCount() ); + + $table_name_target = $driver->query( + "DELETE t1 FROM t1 + JOIN t2 ON t2.target_id = t1.id + WHERE t2.flag = 'drop' AND t1.id = 4" + ); + $this->assertSame( 1, $table_name_target->rowCount() ); + + $alias_only = $driver->query( 'DELETE a FROM t1 a WHERE a.id = 6' ); + $this->assertSame( 1, $alias_only->rowCount() ); + + $same_base_aliases = $driver->query( + 'DELETE child FROM t1 parent + JOIN t1 child ON child.id = 5 + WHERE parent.id = 2' + ); + $this->assertSame( 1, $same_base_aliases->rowCount() ); + + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'b', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 10, + 'target_id' => 1, + 'flag' => 'drop', + ), + array( + 'id' => 11, + 'target_id' => 1, + 'flag' => 'drop', + ), + array( + 'id' => 12, + 'target_id' => 2, + 'flag' => 'keep', + ), + array( + 'id' => 13, + 'target_id' => 3, + 'flag' => 'drop', + ), + array( + 'id' => 14, + 'target_id' => 4, + 'flag' => 'drop', + ), + array( + 'id' => 16, + 'target_id' => 6, + 'flag' => 'source', + ), + ), + $driver->query( 'SELECT id, target_id, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_multi_table_delete_rejects_unsupported_shapes(): void { $this->requireDuckDBRuntime(); @@ -1817,8 +1919,52 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { foreach ( array( array( - 'sql' => 'DELETE a FROM t1 a JOIN t2 b ON a.id = b.id', - 'message' => 'Joined table references in multi-table DELETE are not supported yet', + 'sql' => 'DELETE a, b FROM t1 a JOIN t2 b ON a.id = b.id', + 'message' => 'Joined table references in multi-target DELETE are not supported yet', + ), + array( + 'sql' => 'DELETE FROM a, b USING t1 a JOIN t2 b ON a.id = b.id', + 'message' => 'Joined table references in DELETE ... USING are not supported yet', + ), + array( + 'sql' => 'DELETE a FROM t1 a LEFT JOIN t2 b ON a.id = b.id', + 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + ), + array( + 'sql' => 'DELETE a FROM t1 a RIGHT JOIN t2 b ON a.id = b.id', + 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + ), + array( + 'sql' => 'DELETE a FROM t1 a CROSS JOIN t2 b', + 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + ), + array( + 'sql' => 'DELETE a FROM t1 a NATURAL JOIN t2 b', + 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + ), + array( + 'sql' => 'DELETE a FROM t1 a STRAIGHT_JOIN t2 b ON a.id = b.id', + 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + ), + array( + 'sql' => 'DELETE a FROM t1 a JOIN t2 b USING (id)', + 'message' => 'JOIN ... USING is not supported', + ), + array( + 'sql' => 'DELETE t1 FROM t1 a JOIN t2 b ON a.id = b.id', + 'message' => "Unknown DELETE target alias 't1'", + ), + array( + 'sql' => 'DELETE a FROM t1 a JOIN t2 a ON a.id = a.id', + 'message' => "Duplicate table alias 'a'", + ), + array( + 'sql' => 'DELETE a FROM t1 a JOIN (SELECT id FROM t2) a ON a.id = a.id', + 'message' => "Duplicate table alias 'a'", + ), + array( + 'sql' => 'DELETE a FROM t1 a JOIN (SELECT id FROM t2) b ON a.id = b.id', + 'message' => 'Derived table sources are not supported', ), array( 'sql' => 'DELETE a.* FROM t1 a', @@ -1829,9 +1975,13 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { 'message' => "Access denied for user 'duckdb'@'%' to database 'information_schema'", ), array( - 'sql' => 'DELETE r FROM has_rowid r WHERE r.id = 1', + 'sql' => 'DELETE r FROM has_rowid r JOIN t2 b ON r.id = b.id WHERE r.id = 1', 'message' => 'ORDER BY/LIMIT rewrites require a table without a user-defined rowid column.', ), + array( + 'sql' => 'DELETE a FROM t1 a JOIN t2 b ON a.id = b.id ORDER BY a.id LIMIT 1', + 'message' => 'DuckDB driver could not parse MySQL statement', + ), ) as $rejection ) { try { From c9af3d656efeeeb83945158da54bd572e9bcee7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 01:33:48 +0000 Subject: [PATCH 047/317] Add DuckDB multi-target joined DELETE parity --- .../src/duckdb/class-wp-duckdb-driver.php | 23 ++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 50 ++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 115 +++++++++++++++--- 3 files changed, 156 insertions(+), 32 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index d1e03f651..fa206a4b1 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1864,13 +1864,9 @@ private function parse_multi_table_delete_shape( array $tokens ): ?array { throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Multi-table DELETE requires target aliases and table references.' ); } - $target_aliases = $this->parse_multi_delete_target_aliases( $target_tokens ); - $joined_rejection_message = $using_form - ? 'Unsupported DELETE statement in DuckDB driver. Joined table references in DELETE ... USING are not supported yet.' - : 'Unsupported DELETE statement in DuckDB driver. Joined table references in multi-target DELETE are not supported yet.'; - $allow_joined = 1 === count( $target_aliases ); - $references = $this->parse_multi_delete_table_references( $table_ref_tokens, $allow_joined, $joined_rejection_message ); - $targets = array(); + $target_aliases = $this->parse_multi_delete_target_aliases( $target_tokens ); + $references = $this->parse_multi_delete_table_references( $table_ref_tokens ); + $targets = array(); foreach ( $target_aliases as $offset => $target_alias ) { $key = strtolower( $target_alias ); if ( ! isset( $references['by_alias'][ $key ] ) ) { @@ -1998,16 +1994,11 @@ private function parse_multi_delete_target_aliases( array $tokens ): array { * Parse comma-separated table references for a bounded multi-table DELETE. * * @param WP_Parser_Token[] $tokens Table reference tokens. - * @param bool $allow_joined Whether joined single-target table references are allowed. - * @param string $joined_rejection_message Message for unsupported joined references. * @return array{sql:string,by_alias:array,join_predicates:array>} SQL and references keyed by lowercase alias. */ - private function parse_multi_delete_table_references( array $tokens, bool $allow_joined = false, string $joined_rejection_message = 'Unsupported DELETE statement in DuckDB driver. Joined table references in multi-target DELETE are not supported yet.' ): array { + private function parse_multi_delete_table_references( array $tokens ): array { if ( $this->contains_top_level_join_token( $tokens ) ) { - if ( $allow_joined ) { - return $this->parse_joined_single_delete_table_references( $tokens ); - } - throw new WP_DuckDB_Driver_Exception( $joined_rejection_message ); + return $this->parse_joined_multi_delete_table_references( $tokens ); } $sql_items = array(); @@ -2037,12 +2028,12 @@ private function parse_multi_delete_table_references( array $tokens, bool $allow } /** - * Parse joined table references for a bounded single-target DELETE. + * Parse joined table references for a bounded multi-table DELETE. * * @param WP_Parser_Token[] $tokens Table reference tokens. * @return array{sql:string,by_alias:array,join_predicates:array>} SQL and references keyed by lowercase alias. */ - private function parse_joined_single_delete_table_references( array $tokens ): array { + private function parse_joined_multi_delete_table_references( array $tokens ): array { $joined_references = $this->parse_joined_update_table_references( $tokens, 'DELETE', false ); $references = array_merge( array( $joined_references['target'] ), $joined_references['sources'] ); $sql_items = array(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index adabfa02e..5f238ad65 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -695,6 +695,56 @@ public function test_single_target_joined_delete_matches_sqlite(): void { $this->assertParityRows( 'SELECT id, target_id, flag FROM t2 ORDER BY id' ); } + public function test_multi_target_joined_delete_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20))', + 'CREATE TABLE t2 (id INT, target_id INT, flag VARCHAR(20), note VARCHAR(20))', + "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e'), (6, 'f')", + "INSERT INTO t2 VALUES + (10, 1, 'drop', 'x'), + (11, 1, 'drop', 'duplicate'), + (12, 2, 'keep', 'y'), + (13, 3, 'drop', 'z'), + (14, 4, 'drop', 'w'), + (15, 5, 'same-table', 's'), + (16, 6, 'keep', 'q')", + ) + ); + + $this->assertParityRowCount( + "DELETE a, b FROM t1 a + JOIN t2 b ON b.target_id = a.id + WHERE b.flag = 'drop' AND a.id = 1" + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + "DELETE FROM a, b USING t1 a + JOIN t2 b ON b.target_id = a.id + WHERE b.flag = 'drop' AND a.id = 3" + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + "DELETE t1, t2 FROM t1 + INNER JOIN t2 ON t2.target_id = t1.id + WHERE t2.flag = 'drop' AND t1.id = 4" + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + 'DELETE parent, child FROM t1 parent + JOIN t1 child ON child.id = 5 + WHERE parent.id = 2' + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM t2 ORDER BY id' ); + } + public function test_insert_set_match_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 406f1be90..a94944f5d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1908,6 +1908,93 @@ public function test_single_target_joined_delete_rewrites_join_using_and_alias_f ); } + public function test_multi_target_joined_delete_rewrites_join_using_and_alias_forms(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, target_id INT, flag VARCHAR(20), note VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e'), (6, 'f'), (7, 'g')" ); + $driver->query( + "INSERT INTO t2 VALUES + (10, 1, 'drop', 'x'), + (11, 1, 'drop', 'duplicate'), + (12, 2, 'keep', 'y'), + (13, 3, 'drop', 'z'), + (14, 4, 'drop', 'w'), + (15, 5, 'drop', 'as-alias'), + (16, 6, 'drop', 'same-table'), + (17, 7, 'keep', 'survivor')" + ); + + $duplicate_match = $driver->query( + "DELETE a, b FROM t1 a + JOIN t2 b ON b.target_id = a.id + WHERE b.flag = 'drop' AND a.id = 1" + ); + $this->assertSame( 3, $duplicate_match->rowCount() ); + + $using_delete = $driver->query( + "DELETE FROM a, b USING t1 a + JOIN t2 b ON b.target_id = a.id + WHERE b.flag = 'drop' AND a.id = 3" + ); + $this->assertSame( 2, $using_delete->rowCount() ); + + $table_name_target = $driver->query( + "DELETE t1, t2 FROM t1 + INNER JOIN t2 ON t2.target_id = t1.id + WHERE t2.flag = 'drop' AND t1.id = 4" + ); + $this->assertSame( 2, $table_name_target->rowCount() ); + + $as_alias_target = $driver->query( + "DELETE a, b FROM t1 AS a + JOIN t2 AS b ON b.target_id = a.id + WHERE b.flag = 'drop' AND a.id = 5" + ); + $this->assertSame( 2, $as_alias_target->rowCount() ); + + $same_base_aliases = $driver->query( + 'DELETE parent, child FROM t1 parent + JOIN t1 child ON child.id = 6 + WHERE parent.id = 2' + ); + $this->assertSame( 2, $same_base_aliases->rowCount() ); + + $same_base_overlap = $driver->query( + 'DELETE left_alias, right_alias FROM t1 left_alias + JOIN t1 right_alias ON right_alias.id = left_alias.id + WHERE left_alias.id = 7' + ); + $this->assertSame( 1, $same_base_overlap->rowCount() ); + + $this->assertSame( + array(), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 12, + 'target_id' => 2, + 'flag' => 'keep', + ), + array( + 'id' => 16, + 'target_id' => 6, + 'flag' => 'drop', + ), + array( + 'id' => 17, + 'target_id' => 7, + 'flag' => 'keep', + ), + ), + $driver->query( 'SELECT id, target_id, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_multi_table_delete_rejects_unsupported_shapes(): void { $this->requireDuckDBRuntime(); @@ -1919,35 +2006,27 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { foreach ( array( array( - 'sql' => 'DELETE a, b FROM t1 a JOIN t2 b ON a.id = b.id', - 'message' => 'Joined table references in multi-target DELETE are not supported yet', - ), - array( - 'sql' => 'DELETE FROM a, b USING t1 a JOIN t2 b ON a.id = b.id', - 'message' => 'Joined table references in DELETE ... USING are not supported yet', - ), - array( - 'sql' => 'DELETE a FROM t1 a LEFT JOIN t2 b ON a.id = b.id', + 'sql' => 'DELETE a, b FROM t1 a LEFT JOIN t2 b ON a.id = b.id', 'message' => 'Only comma joins and INNER JOIN ... ON are supported', ), array( - 'sql' => 'DELETE a FROM t1 a RIGHT JOIN t2 b ON a.id = b.id', + 'sql' => 'DELETE a, b FROM t1 a RIGHT JOIN t2 b ON a.id = b.id', 'message' => 'Only comma joins and INNER JOIN ... ON are supported', ), array( - 'sql' => 'DELETE a FROM t1 a CROSS JOIN t2 b', + 'sql' => 'DELETE a, b FROM t1 a CROSS JOIN t2 b', 'message' => 'Only comma joins and INNER JOIN ... ON are supported', ), array( - 'sql' => 'DELETE a FROM t1 a NATURAL JOIN t2 b', + 'sql' => 'DELETE a, b FROM t1 a NATURAL JOIN t2 b', 'message' => 'Only comma joins and INNER JOIN ... ON are supported', ), array( - 'sql' => 'DELETE a FROM t1 a STRAIGHT_JOIN t2 b ON a.id = b.id', + 'sql' => 'DELETE a, b FROM t1 a STRAIGHT_JOIN t2 b ON a.id = b.id', 'message' => 'Only comma joins and INNER JOIN ... ON are supported', ), array( - 'sql' => 'DELETE a FROM t1 a JOIN t2 b USING (id)', + 'sql' => 'DELETE a, b FROM t1 a JOIN t2 b USING (id)', 'message' => 'JOIN ... USING is not supported', ), array( @@ -1963,7 +2042,7 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { 'message' => "Duplicate table alias 'a'", ), array( - 'sql' => 'DELETE a FROM t1 a JOIN (SELECT id FROM t2) b ON a.id = b.id', + 'sql' => 'DELETE a, b FROM t1 a JOIN (SELECT id FROM t2) b ON a.id = b.id', 'message' => 'Derived table sources are not supported', ), array( @@ -1975,9 +2054,13 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { 'message' => "Access denied for user 'duckdb'@'%' to database 'information_schema'", ), array( - 'sql' => 'DELETE r FROM has_rowid r JOIN t2 b ON r.id = b.id WHERE r.id = 1', + 'sql' => 'DELETE r, b FROM has_rowid r JOIN t2 b ON r.id = b.id WHERE r.id = 1', 'message' => 'ORDER BY/LIMIT rewrites require a table without a user-defined rowid column.', ), + array( + 'sql' => 'DELETE a, m FROM t1 a JOIN __wp_duckdb_column_metadata m ON a.id = m.id', + 'message' => 'Internal DuckDB metadata tables cannot be modified', + ), array( 'sql' => 'DELETE a FROM t1 a JOIN t2 b ON a.id = b.id ORDER BY a.id LIMIT 1', 'message' => 'DuckDB driver could not parse MySQL statement', From f5fcd2a3b2fd971859a2c72eb3f8508eab2e1814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 01:46:53 +0000 Subject: [PATCH 048/317] Add DuckDB view lifecycle parity limits --- .../src/duckdb/class-wp-duckdb-driver.php | 75 ++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 26 ++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 137 ++++++++++++++++++ 3 files changed, 238 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index fa206a4b1..674b20cb2 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1303,6 +1303,10 @@ private function concatenate_token_bytes( array $tokens ): string { * @return WP_DuckDB_Result_Statement */ private function execute_create( array $tokens ): WP_DuckDB_Result_Statement { + if ( $this->is_create_view_statement( $tokens ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE VIEW statement in DuckDB driver. MySQL view lifecycle metadata is not supported (createView).' ); + } + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[1]->id ) { return $this->execute_create_table( $tokens ); } @@ -1322,6 +1326,21 @@ private function execute_create( array $tokens ): WP_DuckDB_Result_Statement { throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE statement in DuckDB driver. Only CREATE TABLE and CREATE INDEX are supported.' ); } + /** + * Check whether a CREATE statement targets a view. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return bool Whether this is a CREATE VIEW statement. + */ + private function is_create_view_statement( array $tokens ): bool { + $view_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::VIEW_SYMBOL ); + if ( null === $view_index ) { + return false; + } + + return null !== $this->find_top_level_token_index( $tokens, $view_index + 1, WP_MySQL_Lexer::AS_SYMBOL ); + } + /** * Execute a supported CREATE TABLE statement. * @@ -2453,6 +2472,10 @@ private function contains_information_schema_reference( array $tokens ): bool { * @return WP_DuckDB_Result_Statement */ private function execute_drop( array $tokens ): WP_DuckDB_Result_Statement { + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[1]->id ) { + return $this->execute_drop_view( $tokens ); + } + if ( isset( $tokens[1], $tokens[2] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id @@ -2479,6 +2502,50 @@ private function execute_drop( array $tokens ): WP_DuckDB_Result_Statement { throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP statement in DuckDB driver. Only DROP TABLE and DROP INDEX are supported.' ); } + /** + * Execute bounded DROP VIEW passthrough for native DuckDB views. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_drop_view( array $tokens ): WP_DuckDB_Result_Statement { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::DROP_SYMBOL, 'Expected DROP.' ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::VIEW_SYMBOL, 'Expected VIEW in DROP VIEW statement.' ); + ++$index; + + $if_exists = false; + if ( + isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $index ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $index + 1 ]->id + ) { + $if_exists = true; + $index += 2; + } + + $view_tokens = array_slice( $tokens, $index ); + if ( count( $view_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'DROP VIEW requires a view name.' ); + } + if ( count( $this->split_top_level_comma_items( $view_tokens ) ) > 1 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP VIEW statement in DuckDB driver. Only a single view target is supported.' ); + } + + $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'DROP VIEW' ); + if ( count( $tokens ) !== $reference['next_index'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP VIEW statement in DuckDB driver. View aliases and extra options are not supported.' ); + } + + return $this->execute_duckdb_query( + 'DROP VIEW ' + . ( $if_exists ? 'IF EXISTS ' : '' ) + . $this->connection->quote_identifier( $reference['requested_table_name'] ), + 'Failed to drop DuckDB view' + ); + } + /** * Execute DROP TABLE. * @@ -5380,6 +5447,14 @@ private function execute_show( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_show_tables( $tokens ); } + if ( + isset( $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::CREATE_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[2]->id + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW CREATE VIEW statement in DuckDB driver. MySQL view lifecycle metadata is not supported (showStatement > CREATE).' ); + } + if ( isset( $tokens[1], $tokens[2] ) && WP_MySQL_Lexer::CREATE_SYMBOL === $tokens[1]->id diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 5f238ad65..0dc5a34de 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -52,6 +52,32 @@ public function test_show_full_tables_sql_matches_sqlite(): void { $this->assertParityRows( "SHOW FULL TABLES LIKE 'temp_only_show'" ); } + public function test_view_lifecycle_limitations_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE view_source (id INT, name VARCHAR(20))', + ) + ); + + $this->assertParityErrorContains( + 'CREATE VIEW visible_view AS SELECT id, name FROM view_source', + 'createView' + ); + $this->assertParityErrorContains( + 'CREATE OR REPLACE VIEW visible_view AS SELECT id FROM view_source', + 'createView' + ); + $this->assertParityErrorContains( 'SHOW CREATE VIEW visible_view', 'CREATE' ); + $this->assertParityErrorContains( 'DROP VIEW missing_view', 'missing_view' ); + $this->runParitySetup( array( 'DROP VIEW IF EXISTS missing_view' ) ); + $this->assertParityRows( "SHOW FULL TABLES LIKE 'view_source'" ); + $this->assertParityRows( + "SELECT TABLE_NAME, TABLE_TYPE + FROM information_schema.tables + WHERE TABLE_SCHEMA = 'wp' AND TABLE_NAME = 'view_source'" + ); + } + public function test_show_admin_metadata_sql_matches_sqlite(): void { $this->assertParityRows( 'SHOW COLLATION' ); $this->assertParityRows( "SHOW COLLATION LIKE 'utf8%'" ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index a94944f5d..e927067ca 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -174,6 +174,143 @@ public function test_show_full_tables_reports_table_type_and_like_filter(): void ); } + public function test_create_and_show_create_view_remain_explicitly_unsupported(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE view_source (id INT, name VARCHAR(20))' ); + + foreach ( + array( + 'CREATE VIEW visible_view AS SELECT id, name FROM view_source' => 'CREATE VIEW statement', + 'CREATE OR REPLACE VIEW visible_view AS SELECT id FROM view_source' => 'CREATE VIEW statement', + 'CREATE VIEW internal_view AS SELECT table_name FROM information_schema.tables' => 'CREATE VIEW statement', + 'SHOW CREATE VIEW visible_view' => 'SHOW CREATE VIEW statement', + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected view lifecycle statement to be unsupported: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + $this->assertStringContainsString( 'view lifecycle metadata is not supported', strtolower( $e->getMessage() ) ); + } + } + } + + public function test_native_views_can_be_selected_and_dropped_but_remain_omitted_from_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE view_source (id INT, name VARCHAR(20))' ); + $driver->query( "INSERT INTO view_source (id, name) VALUES (1, 'Ada'), (2, 'Grace')" ); + $driver->get_connection()->query( 'CREATE VIEW "native_view" AS SELECT id, name FROM "view_source"' ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'Ada', + ), + array( + 'id' => 2, + 'name' => 'Grace', + ), + ), + $driver->query( 'SELECT id, name FROM native_view ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( array(), $driver->query( "SHOW TABLES LIKE 'native_view'" )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( array(), $driver->query( "SHOW FULL TABLES LIKE 'native_view'" )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( array(), $driver->query( "SHOW TABLE STATUS LIKE 'native_view'" )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( + array(), + $driver->query( + "SELECT TABLE_NAME, TABLE_TYPE + FROM information_schema.tables + WHERE TABLE_SCHEMA = 'wp' AND TABLE_NAME = 'native_view'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->query( 'SHOW CREATE TABLE native_view' )->fetchAll( PDO::FETCH_ASSOC ) ); + + $this->assertSame( + array( + array( + 'Tables_in_wp' => 'view_source', + 'Table_type' => 'BASE TABLE', + ), + ), + $driver->query( 'SHOW FULL TABLES' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'TABLE_NAME' => 'view_source', + 'TABLE_TYPE' => 'BASE TABLE', + ), + ), + $driver->query( + "SELECT TABLE_NAME, TABLE_TYPE + FROM information_schema.tables + WHERE TABLE_SCHEMA = 'wp' + ORDER BY TABLE_NAME" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( 0, $driver->query( 'DROP VIEW native_view' )->rowCount() ); + try { + $driver->query( 'SELECT id FROM native_view' ); + $this->fail( 'Expected dropped native view to be unavailable.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'native_view', $e->getMessage() ); + } + + try { + $driver->query( 'DROP VIEW missing_native_view' ); + $this->fail( 'Expected DROP VIEW to report a missing native view.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'missing_native_view', $e->getMessage() ); + } + $this->assertSame( 0, $driver->query( 'DROP VIEW IF EXISTS missing_native_view' )->rowCount() ); + } + + public function test_drop_view_lifecycle_validation_is_bounded(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + foreach ( + array( + 'DROP VIEW information_schema.tables' => "Access denied for user 'duckdb'@'%' to database 'information_schema'", + 'DROP VIEW __wp_duckdb_column_metadata' => 'Internal DuckDB metadata tables cannot be modified', + 'DROP VIEW other_database.native_view' => 'Only the current database is supported', + 'DROP VIEW first_view, second_view' => 'Only a single view target is supported', + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected DROP VIEW validation to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + } + } + public function test_show_admin_metadata_statements_are_emulated(): void { $this->requireDuckDBRuntime(); From b733456a3988dca27e77e1626727545c268f0f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 02:03:21 +0000 Subject: [PATCH 049/317] Add DuckDB ALTER constraint rejection parity --- .../src/duckdb/class-wp-duckdb-driver.php | 138 +++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 48 ++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 145 ++++++++++++++++++ 3 files changed, 331 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 674b20cb2..0a9180dc8 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -4442,6 +4442,8 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD COLUMN, ADD INDEX, DROP COLUMN, and DROP INDEX are supported.' ); } + $this->reject_unsupported_alter_table_constraint_actions( $actions ); + $result = null; foreach ( $actions as $action ) { if ( ! isset( $action[0] ) ) { @@ -4481,6 +4483,142 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen return $result ?? new WP_DuckDB_Result_Statement( array(), array(), 0 ); } + /** + * Reject unsupported ALTER TABLE CHECK/FOREIGN KEY/CONSTRAINT actions before mutation. + * + * @param array $actions ALTER action token groups. + */ + private function reject_unsupported_alter_table_constraint_actions( array $actions ): void { + foreach ( $actions as $action ) { + if ( ! isset( $action[0] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Empty action.' ); + } + + $this->reject_unsupported_alter_table_constraint_action( $action ); + } + } + + /** + * Reject unsupported ALTER TABLE CHECK/FOREIGN KEY/CONSTRAINT actions before mutation. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens. + */ + private function reject_unsupported_alter_table_constraint_action( array $tokens ): void { + if ( ! isset( $tokens[0] ) ) { + return; + } + + if ( WP_MySQL_Lexer::ADD_SYMBOL === $tokens[0]->id ) { + $this->reject_unsupported_alter_table_add_constraint_action( array_slice( $tokens, 1 ) ); + return; + } + + if ( WP_MySQL_Lexer::DROP_SYMBOL === $tokens[0]->id ) { + $this->reject_unsupported_alter_table_drop_constraint_action( $tokens ); + return; + } + + if ( + WP_MySQL_Lexer::CHANGE_SYMBOL === $tokens[0]->id + || WP_MySQL_Lexer::MODIFY_SYMBOL === $tokens[0]->id + ) { + $this->reject_unsupported_alter_table_inline_constraint_action( $tokens ); + } + } + + /** + * Reject unsupported ALTER TABLE ... ADD constraint actions before ADD COLUMN fallback. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + */ + private function reject_unsupported_alter_table_add_constraint_action( array $tokens ): void { + if ( ! isset( $tokens[0] ) ) { + return; + } + + if ( WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[0]->id ) { + $this->reject_unsupported_alter_table_inline_constraint_action( array_slice( $tokens, 1 ) ); + return; + } + + foreach ( $this->alter_table_add_items_for_constraint_detection( $tokens ) as $item ) { + if ( $this->is_create_table_check_constraint( $item ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD CHECK is not supported.' ); + } + + if ( $this->is_create_table_foreign_key_constraint( $item ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD FOREIGN KEY is not supported.' ); + } + + if ( isset( $item[0] ) && WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $item[0]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD CONSTRAINT is not supported.' ); + } + + if ( ! $this->is_create_table_index_item( $item ) ) { + $this->reject_unsupported_alter_table_inline_constraint_action( $item ); + } + } + } + + /** + * Reject unsupported inline CHECK/REFERENCES constraints before mutation. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens. + */ + private function reject_unsupported_alter_table_inline_constraint_action( array $tokens ): void { + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::CHECK_SYMBOL === $token->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported inline CHECK constraint in DuckDB driver. Inline CHECK constraints are only supported in CREATE TABLE.' ); + } + + if ( WP_MySQL_Lexer::REFERENCES_SYMBOL === $token->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported inline REFERENCES constraint in DuckDB driver. Inline REFERENCES constraints are only supported in CREATE TABLE.' ); + } + } + } + + /** + * Return ADD action item(s) for constraint detection. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @return array + */ + private function alter_table_add_items_for_constraint_detection( array $tokens ): array { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[0]->id ) { + return array( $tokens ); + } + + list( $items, ) = $this->collect_parenthesized_items( $tokens, 1 ); + return $items; + } + + /** + * Reject unsupported ALTER TABLE ... DROP constraint actions before DROP COLUMN fallback. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens starting at DROP. + */ + private function reject_unsupported_alter_table_drop_constraint_action( array $tokens ): void { + if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[1]->id ) { + return; + } + + if ( WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[1]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP CHECK is not supported.' ); + } + + if ( WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[1]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP CONSTRAINT is not supported.' ); + } + + if ( + WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[1]->id + && isset( $tokens[2] ) + && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[2]->id + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP FOREIGN KEY is not supported.' ); + } + } + /** * Execute ALTER TABLE ... AUTO_INCREMENT = N. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 0dc5a34de..9ce3b8044 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1342,6 +1342,54 @@ public function test_simple_inline_foreign_keys_match_sqlite(): void { $this->assertParityRows( 'SHOW CREATE TABLE inline_fk_child_default' ); } + public function test_alter_table_check_constraint_actions_document_current_duckdb_gap(): void { + $sqlite_driver = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), + 'wp' + ); + $duckdb_driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $create_sql = 'CREATE TABLE alter_check_gap (id INT, CONSTRAINT existing_check CHECK (id >= 0))'; + $sqlite_driver->query( $create_sql, PDO::FETCH_ASSOC ); + $duckdb_driver->query( $create_sql ); + + $sqlite_driver->query( 'ALTER TABLE alter_check_gap ADD CONSTRAINT added_check CHECK (id < 10)', PDO::FETCH_ASSOC ); + $sqlite_create = $sqlite_driver->query( 'SHOW CREATE TABLE alter_check_gap', PDO::FETCH_ASSOC ); + $this->assertStringContainsString( 'added_check', $sqlite_create[0]['Create Table'] ); + + $duckdb_before = $duckdb_driver->query( 'SHOW CREATE TABLE alter_check_gap' )->fetchAll( PDO::FETCH_ASSOC ); + try { + $duckdb_driver->query( 'ALTER TABLE alter_check_gap ADD CONSTRAINT added_check CHECK (id < 10)' ); + $this->fail( 'Expected DuckDB to reject ALTER TABLE ADD CHECK.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'ADD CHECK is not supported', $e->getMessage() ); + } + $this->assertSame( + $duckdb_before, + $duckdb_driver->query( 'SHOW CREATE TABLE alter_check_gap' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $sqlite_driver->query( 'ALTER TABLE alter_check_gap DROP CHECK existing_check', PDO::FETCH_ASSOC ); + $sqlite_create = $sqlite_driver->query( 'SHOW CREATE TABLE alter_check_gap', PDO::FETCH_ASSOC ); + $this->assertStringNotContainsString( 'existing_check', $sqlite_create[0]['Create Table'] ); + + try { + $duckdb_driver->query( 'ALTER TABLE alter_check_gap DROP CHECK existing_check' ); + $this->fail( 'Expected DuckDB to reject ALTER TABLE DROP CHECK.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'DROP CHECK is not supported', $e->getMessage() ); + } + $this->assertSame( + $duckdb_before, + $duckdb_driver->query( 'SHOW CREATE TABLE alter_check_gap' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_unique_key_foreign_key_metadata_documents_current_duckdb_gap(): void { $sqlite_driver = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index e927067ca..c0c5a52a4 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -5695,6 +5695,125 @@ public function test_unsupported_alter_table_add_column_constraints_throw_driver $driver->query( 'ALTER TABLE users ADD COLUMN id INT PRIMARY KEY' ); } + public function test_unsupported_alter_table_constraint_actions_throw_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE alter_parent (id INT PRIMARY KEY)' ); + $driver->query( 'INSERT INTO alter_parent (id) VALUES (1)' ); + $driver->query( + 'CREATE TABLE alter_constraint_guard ( + id INT, + parent_id INT, + `check` INT, + `constraint` INT, + `foreign` INT, + CONSTRAINT existing_check CHECK (id >= 0), + CONSTRAINT existing_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id) + )' + ); + $driver->query( 'INSERT INTO alter_constraint_guard (id, parent_id, `check`, `constraint`, `foreign`) VALUES (1, 1, 7, 8, 9)' ); + + $before = $this->alter_table_constraint_guard_snapshot( $driver ); + + foreach ( + array( + 'ALTER TABLE alter_constraint_guard ADD CHECK (id >= 0)' => 'ADD CHECK is not supported', + 'ALTER TABLE alter_constraint_guard ADD (CHECK (id >= 0))' => 'ADD CHECK is not supported', + 'ALTER TABLE alter_constraint_guard ADD CONSTRAINT added_check CHECK (id >= 0)' => 'ADD CHECK is not supported', + 'ALTER TABLE alter_constraint_guard ADD FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD FOREIGN KEY is not supported', + 'ALTER TABLE alter_constraint_guard ADD CONSTRAINT added_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD FOREIGN KEY is not supported', + 'ALTER TABLE alter_constraint_guard ADD CONSTRAINT added_unique UNIQUE KEY (id)' => 'ADD CONSTRAINT is not supported', + 'ALTER TABLE alter_constraint_guard ADD COLUMN score INT CHECK (score >= 0)' => 'Inline CHECK constraints are only supported in CREATE TABLE', + 'ALTER TABLE alter_constraint_guard ADD COLUMN parent_ref INT REFERENCES alter_parent (id)' => 'Inline REFERENCES constraints are only supported in CREATE TABLE', + 'ALTER TABLE alter_constraint_guard DROP CHECK existing_check' => 'DROP CHECK is not supported', + 'ALTER TABLE alter_constraint_guard DROP CONSTRAINT existing_check' => 'DROP CONSTRAINT is not supported', + 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY existing_fk' => 'DROP FOREIGN KEY is not supported', + 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY' => 'DROP FOREIGN KEY is not supported', + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected unsupported ALTER TABLE constraint action to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + + $this->assertSame( + $before, + $this->alter_table_constraint_guard_snapshot( $driver ), + 'ALTER TABLE constraint rejection mutated schema or data for SQL: ' . $sql + ); + } + + foreach ( + array( + 'ALTER TABLE alter_constraint_guard DROP CHECK', + 'ALTER TABLE alter_constraint_guard DROP CONSTRAINT', + ) as $sql + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected malformed ALTER TABLE constraint action to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'DuckDB driver could not parse MySQL statement', $e->getMessage() ); + } + + $this->assertSame( + $before, + $this->alter_table_constraint_guard_snapshot( $driver ), + 'Malformed ALTER TABLE constraint rejection mutated schema or data for SQL: ' . $sql + ); + } + } + + public function test_unsupported_alter_table_constraint_actions_in_multi_action_statements_throw_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE alter_parent (id INT PRIMARY KEY)' ); + $driver->query( 'INSERT INTO alter_parent (id) VALUES (1)' ); + $driver->query( + 'CREATE TABLE alter_constraint_guard ( + id INT, + parent_id INT, + `check` INT, + `constraint` INT, + `foreign` INT, + CONSTRAINT existing_check CHECK (id >= 0), + CONSTRAINT existing_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id) + )' + ); + $driver->query( 'INSERT INTO alter_constraint_guard (id, parent_id, `check`, `constraint`, `foreign`) VALUES (1, 1, 7, 8, 9)' ); + + $before = $this->alter_table_constraint_guard_snapshot( $driver ); + + foreach ( + array( + 'ALTER TABLE alter_constraint_guard ADD CHECK (id >= 0), ADD COLUMN should_not_exist INT DEFAULT 2' => 'ADD CHECK is not supported', + 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY existing_fk, ADD COLUMN should_not_exist INT DEFAULT 2' => 'DROP FOREIGN KEY is not supported', + 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD CHECK (id >= 0)' => 'ADD CHECK is not supported', + 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT added_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD FOREIGN KEY is not supported', + 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, DROP CONSTRAINT existing_check' => 'DROP CONSTRAINT is not supported', + 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD COLUMN inline_check INT CHECK (inline_check >= 0)' => 'Inline CHECK constraints are only supported in CREATE TABLE', + 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD COLUMN inline_parent INT REFERENCES alter_parent (id)' => 'Inline REFERENCES constraints are only supported in CREATE TABLE', + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected multi-action ALTER TABLE constraint action to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + + $this->assertSame( + $before, + $this->alter_table_constraint_guard_snapshot( $driver ), + 'Multi-action ALTER TABLE constraint rejection mutated schema or data for SQL: ' . $sql + ); + } + } + public function test_unsupported_create_table_check_constraint_not_enforced_throws_driver_exception(): void { $this->requireDuckDBRuntime(); @@ -5800,6 +5919,32 @@ public function test_unsupported_alter_table_add_not_null_without_default_on_non $driver->query( 'ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL' ); } + private function alter_table_constraint_guard_snapshot( WP_DuckDB_Driver $driver ): array { + return array( + 'columns' => array_column( $driver->query( 'SHOW COLUMNS FROM alter_constraint_guard' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ), + 'rows' => $driver->query( 'SELECT id, parent_id, `check`, `constraint`, `foreign` FROM alter_constraint_guard' )->fetchAll( PDO::FETCH_ASSOC ), + 'table_constraints' => $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'alter_constraint_guard' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'check_constraints' => $driver->query( + "SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp' AND constraint_name = 'existing_check' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'referential_constraints' => $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME, UPDATE_RULE, DELETE_RULE + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' AND table_name = 'alter_constraint_guard' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'show_create' => $driver->query( 'SHOW CREATE TABLE alter_constraint_guard' )->fetchAll( PDO::FETCH_ASSOC ), + ); + } + private function assertDriverQueryRejected( WP_DuckDB_Driver $driver, string $sql ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid try { $driver->query( $sql ); From 9276d5ceded88471290994b17500c166029bf44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 02:16:16 +0000 Subject: [PATCH 050/317] Add DuckDB joined UPDATE target inference parity --- .../src/duckdb/class-wp-duckdb-driver.php | 118 ++++++++---- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 64 +++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 173 ++++++++++++++++-- 3 files changed, 301 insertions(+), 54 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 0a9180dc8..38b2fcf5e 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1772,14 +1772,24 @@ private function parse_joined_update_shape( array $tokens ): ?array { * @return WP_DuckDB_Result_Statement */ private function execute_joined_update( array $shape ): WP_DuckDB_Result_Statement { + $references = array_merge( array( $shape['target'] ), $shape['sources'] ); + $update = $this->translate_joined_update_assignment_tokens_to_duckdb_sql( $shape['update_tokens'], $references ); + $target = $references[ $update['target_index'] ]; + $sources = array(); + foreach ( $references as $index => $reference ) { + if ( $update['target_index'] !== $index ) { + $sources[] = $reference; + } + } + $sql = 'UPDATE ' - . $this->connection->quote_identifier( $shape['target']['table_name'] ) + . $this->connection->quote_identifier( $target['table_name'] ) . ' AS ' - . $this->connection->quote_identifier( $shape['target']['alias'] ) + . $this->connection->quote_identifier( $target['alias'] ) . ' SET ' - . $this->translate_joined_update_assignment_tokens_to_duckdb_sql( $shape['update_tokens'], $shape['target'], $shape['sources'] ) + . $update['sql'] . ' FROM ' - . implode( ', ', array_column( $shape['sources'], 'sql' ) ); + . implode( ', ', array_column( $sources, 'sql' ) ); $where_clauses = array(); if ( count( $shape['where_tokens'] ) > 0 ) { @@ -4191,31 +4201,33 @@ private function translate_update_assignment_tokens_to_duckdb_sql( array $tokens /** * Translate joined UPDATE assignments and enforce a single writable target. * - * @param WP_Parser_Token[] $tokens Update-list tokens. - * @param array{alias:string,table_name:string,requested_table_name:string} $target Target reference. - * @param array $sources Source references. - * @return string DuckDB update-list SQL. + * @param WP_Parser_Token[] $tokens Update-list tokens. + * @param array> $references Joined references. + * @return array{target_index:int,sql:string} Resolved writable target index and DuckDB update-list SQL. */ - private function translate_joined_update_assignment_tokens_to_duckdb_sql( array $tokens, array $target, array $sources ): string { - $target_qualifiers = array_map( - 'strtolower', - array_unique( + private function translate_joined_update_assignment_tokens_to_duckdb_sql( array $tokens, array $references ): array { + $qualifier_references = array(); + foreach ( $references as $index => $reference ) { + $qualifiers = array_filter( array( - $target['alias'], - $target['requested_table_name'], - $target['table_name'], - ) - ) - ); - $source_aliases = array(); - foreach ( $sources as $source ) { - $source_aliases[ strtolower( $source['alias'] ) ] = $source; - if ( null !== $source['table_name'] ) { - $source_aliases[ strtolower( $source['table_name'] ) ] = $source; + $reference['alias'], + $reference['requested_table_name'], + $reference['table_name'], + ), + 'is_string' + ); + + foreach ( array_unique( $qualifiers ) as $qualifier ) { + $key = strtolower( $qualifier ); + if ( ! isset( $qualifier_references[ $key ] ) ) { + $qualifier_references[ $key ] = array(); + } + $qualifier_references[ $key ][] = $index; } } - $items = array(); + $items = array(); + $target_index = null; foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { $equals_index = $this->find_top_level_token_index( $item, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); if ( null === $equals_index ) { @@ -4229,40 +4241,66 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array } if ( 1 === count( $left_tokens ) ) { - $column = $this->identifier_value( $left_tokens[0] ); - if ( ! $this->table_has_column( $target['table_name'], $column, $target['temporary'] ) ) { - throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target column '{$column}' in DuckDB driver." ); - } - foreach ( $sources as $source ) { - if ( null !== $source['table_name'] && $this->table_has_column( $source['table_name'], $column, $source['temporary'] ) ) { - throw new WP_DuckDB_Driver_Exception( "Ambiguous unqualified UPDATE target column '{$column}' in DuckDB driver." ); + $column = $this->identifier_value( $left_tokens[0] ); + $assignment_target_index = null; + foreach ( $references as $index => $reference ) { + $has_column = null !== $reference['table_name'] + && $this->table_has_column( $reference['table_name'], $column, $reference['temporary'] ); + if ( $has_column ) { + if ( null !== $assignment_target_index ) { + throw new WP_DuckDB_Driver_Exception( "Ambiguous unqualified UPDATE target column '{$column}' in DuckDB driver." ); + } + $assignment_target_index = $index; } } + if ( null === $assignment_target_index ) { + throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target column '{$column}' in DuckDB driver." ); + } } elseif ( 3 === count( $left_tokens ) && WP_MySQL_Lexer::DOT_SYMBOL === $left_tokens[1]->id ) { - $qualifier = strtolower( $this->identifier_value( $left_tokens[0] ) ); - $column = $this->identifier_value( $left_tokens[2] ); - if ( ! in_array( $qualifier, $target_qualifiers, true ) ) { - if ( isset( $source_aliases[ $qualifier ] ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE statement modifying multiple tables is not supported.' ); - } - throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target qualifier '{$this->identifier_value( $left_tokens[0] )}' in DuckDB driver." ); + $qualifier_label = $this->identifier_value( $left_tokens[0] ); + $qualifier = strtolower( $qualifier_label ); + $column = $this->identifier_value( $left_tokens[2] ); + if ( ! isset( $qualifier_references[ $qualifier ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target qualifier '{$qualifier_label}' in DuckDB driver." ); + } + $matched_references = array_values( array_unique( $qualifier_references[ $qualifier ] ) ); + if ( 1 !== count( $matched_references ) ) { + throw new WP_DuckDB_Driver_Exception( "Ambiguous UPDATE target qualifier '{$qualifier_label}' in DuckDB driver." ); } - if ( ! $this->table_has_column( $target['table_name'], $column, $target['temporary'] ) ) { + $assignment_target_index = $matched_references[0]; + $assignment_target = $references[ $assignment_target_index ]; + if ( null === $assignment_target['table_name'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Derived tables cannot be updated.' ); + } + if ( ! $this->table_has_column( $assignment_target['table_name'], $column, $assignment_target['temporary'] ) ) { throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target column '{$column}' in DuckDB driver." ); } } else { throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only simple column assignments are supported.' ); } + if ( null === $target_index ) { + $target_index = $assignment_target_index; + } elseif ( $target_index !== $assignment_target_index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE statement modifying multiple tables is not supported.' ); + } + $items[] = $this->connection->quote_identifier( $column ) . ' = ' . $this->translate_tokens_to_duckdb_sql( $right_tokens ); } - return implode( ', ', $items ); + if ( null === $target_index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE list is required.' ); + } + + return array( + 'target_index' => $target_index, + 'sql' => implode( ', ', $items ), + ); } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 9ce3b8044..e7ab9b295 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -560,6 +560,70 @@ public function test_joined_update_matches_sqlite(): void { $this->assertParityRows( 'SELECT id, status, score FROM posts ORDER BY id' ); } + public function test_joined_update_non_first_target_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20))', + 'CREATE TABLE t2 (id INT, note VARCHAR(20))', + "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')", + "INSERT INTO t2 VALUES (1, 'x'), (2, 'y'), (3, 'q')", + 'CREATE TABLE tree (id INT, parent_id INT, label VARCHAR(20))', + "INSERT INTO tree VALUES (1, NULL, 'root'), (2, 1, 'child'), (3, 1, 'sibling')", + ) + ); + + $this->assertParityRowCount( + "UPDATE t1 a JOIN t2 b ON a.id = b.id + SET b.note = 'z' + WHERE a.id IN (1, 3)" + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + "UPDATE t1 a, t2 b + SET b.note = 'comma' + WHERE a.id = b.id AND a.id = 2" + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + "UPDATE tree parent JOIN tree child ON child.parent_id = parent.id + SET child.label = 'claimed' + WHERE parent.id = 1 AND child.id = 2" + ); + $this->assertParityRows( 'SELECT id, label FROM tree ORDER BY id' ); + } + + public function test_multi_target_joined_update_rejection_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20))', + 'CREATE TABLE t2 (id INT, note VARCHAR(20))', + "INSERT INTO t1 VALUES (1, 'a'), (2, 'b')", + "INSERT INTO t2 VALUES (1, 'x'), (3, 'z')", + ) + ); + + $this->assertParityErrorContains( + "UPDATE t1 a JOIN t2 b ON a.id = b.id + SET a.note = 'target', b.note = 'source'", + 'UPDATE statement modifying multiple tables' + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note FROM t2 ORDER BY id' ); + + $this->assertParityErrorContains( + "UPDATE t1 a, t2 b + SET a.note = 'target', b.note = 'source' + WHERE a.id = b.id", + 'UPDATE statement modifying multiple tables' + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note FROM t2 ORDER BY id' ); + } + public function test_joined_update_derived_table_claim_query_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index c0c5a52a4..18f52e4a3 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1470,6 +1470,133 @@ public function test_joined_update_rewrites_derived_table_claim_query(): void { ); } + public function test_joined_update_infers_non_first_writable_target(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, note VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'x'), (2, 'y'), (3, 'q')" ); + + $joined = $driver->query( + "UPDATE t1 a JOIN t2 b ON a.id = b.id + SET b.note = 'z' + WHERE a.id IN (1, 3)" + ); + + $this->assertSame( 2, $joined->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'a', + ), + array( + 'id' => 2, + 'note' => 'b', + ), + array( + 'id' => 3, + 'note' => 'c', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'z', + ), + array( + 'id' => 2, + 'note' => 'y', + ), + array( + 'id' => 3, + 'note' => 'z', + ), + ), + $driver->query( 'SELECT id, note FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $comma = $driver->query( + "UPDATE t1 a, t2 b + SET b.note = 'comma' + WHERE a.id = b.id AND a.id = 2" + ); + + $this->assertSame( 1, $comma->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'a', + ), + array( + 'id' => 2, + 'note' => 'b', + ), + array( + 'id' => 3, + 'note' => 'c', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'z', + ), + array( + 'id' => 2, + 'note' => 'comma', + ), + array( + 'id' => 3, + 'note' => 'z', + ), + ), + $driver->query( 'SELECT id, note FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_joined_update_infers_same_base_non_first_alias_target(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE tree (id INT, parent_id INT, label VARCHAR(20))' ); + $driver->query( "INSERT INTO tree VALUES (1, NULL, 'root'), (2, 1, 'child'), (3, 1, 'sibling')" ); + + $updated = $driver->query( + "UPDATE tree parent JOIN tree child ON child.parent_id = parent.id + SET child.label = 'claimed' + WHERE parent.id = 1 AND child.id = 2" + ); + + $this->assertSame( 1, $updated->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'label' => 'root', + ), + array( + 'id' => 2, + 'label' => 'claimed', + ), + array( + 'id' => 3, + 'label' => 'sibling', + ), + ), + $driver->query( 'SELECT id, label FROM tree ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_joined_update_rejects_unsupported_shapes(): void { $this->requireDuckDBRuntime(); @@ -1479,6 +1606,27 @@ public function test_joined_update_rejects_unsupported_shapes(): void { $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b')" ); $driver->query( "INSERT INTO t2 VALUES (1, 'x'), (3, 'z')" ); + $expected_t1_rows = array( + array( + 'id' => 1, + 'note' => 'a', + ), + array( + 'id' => 2, + 'note' => 'b', + ), + ); + $expected_t2_rows = array( + array( + 'id' => 1, + 'note' => 'x', + ), + array( + 'id' => 3, + 'note' => 'z', + ), + ); + foreach ( array( array( @@ -1509,21 +1657,18 @@ public function test_joined_update_rejects_unsupported_shapes(): void { } catch ( WP_DuckDB_Driver_Exception $e ) { $this->assertStringContainsString( $rejection['message'], $e->getMessage() ); } - } - $this->assertSame( - array( - array( - 'id' => 1, - 'note' => 'a', - ), - array( - 'id' => 2, - 'note' => 'b', - ), - ), - $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) - ); + $this->assertSame( + $expected_t1_rows, + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'Rejected joined UPDATE mutated t1 for SQL: ' . $rejection['sql'] + ); + $this->assertSame( + $expected_t2_rows, + $driver->query( 'SELECT id, note FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'Rejected joined UPDATE mutated t2 for SQL: ' . $rejection['sql'] + ); + } } public function test_escaped_like_predicates_use_mysql_backslash_semantics(): void { From 4313ae2319ec94c0ad94cb3bf479a90e842cc016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 02:32:21 +0000 Subject: [PATCH 051/317] Bound DuckDB joined UPDATE edge parity --- .../src/duckdb/class-wp-duckdb-driver.php | 45 ++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 300 ++++++++++++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 116 +++++++ 3 files changed, 454 insertions(+), 7 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 38b2fcf5e..0738a4a2d 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1708,7 +1708,7 @@ private function execute_update( array $tokens ): WP_DuckDB_Result_Statement { * Parse supported joined UPDATE shapes. * * @param WP_Parser_Token[] $tokens MySQL tokens. - * @return array{target:array{alias:string,table_name:string,requested_table_name:string},sources:array,join_predicates:array>,update_tokens:array,where_tokens:array}|null Parsed shape, or null for single-table UPDATE. + * @return array{target:array{alias:string,explicit_alias:bool,table_name:string,requested_table_name:string},sources:array,join_predicates:array>,update_tokens:array,where_tokens:array}|null Parsed shape, or null for single-table UPDATE. */ private function parse_joined_update_shape( array $tokens ): ?array { if ( ! isset( $tokens[1] ) ) { @@ -1768,7 +1768,7 @@ private function parse_joined_update_shape( array $tokens ): ?array { /** * Execute a parsed joined UPDATE. * - * @param array{target:array{alias:string,table_name:string,requested_table_name:string},sources:array,join_predicates:array>,update_tokens:array,where_tokens:array} $shape Parsed shape. + * @param array{target:array{alias:string,explicit_alias:bool,table_name:string,requested_table_name:string},sources:array,join_predicates:array>,update_tokens:array,where_tokens:array} $shape Parsed shape. * @return WP_DuckDB_Result_Statement */ private function execute_joined_update( array $shape ): WP_DuckDB_Result_Statement { @@ -2161,7 +2161,7 @@ private function parse_multi_delete_table_reference( array $tokens ): array { * @param WP_Parser_Token[] $tokens Table reference tokens. * @param string $statement Statement name for diagnostics. * @param bool $first_factor_must_be_base Whether the first table factor must be a base table. - * @return array{target:array{alias:string,sql:string,table_name:string|null,requested_table_name:string,temporary:bool},sources:array,join_predicates:array>} + * @return array{target:array{alias:string,explicit_alias:bool,sql:string,table_name:string|null,requested_table_name:string,temporary:bool},sources:array,join_predicates:array>} */ private function parse_joined_update_table_references( array $tokens, string $statement = 'UPDATE', bool $first_factor_must_be_base = true ): array { $items = $this->split_top_level_comma_items( $tokens ); @@ -2185,6 +2185,7 @@ private function parse_joined_update_table_references( array $tokens, string $st return array( 'target' => array( 'alias' => $target['alias'], + 'explicit_alias' => $target['explicit_alias'], 'sql' => $target['sql'], 'table_name' => $target['table_name'], 'temporary' => $target['temporary'], @@ -2203,7 +2204,7 @@ private function parse_joined_update_table_references( array $tokens, string $st * @param bool $allow_derived Whether derived tables are allowed. * @param bool $is_target Whether this factor is the UPDATE target. * @param string $statement Statement name for diagnostics. - * @return array{reference:array{alias:string,sql:string,table_name:string|null,temporary:bool,requested_table_name:string},next_index:int} + * @return array{reference:array{alias:string,explicit_alias:bool,sql:string,table_name:string|null,temporary:bool,requested_table_name:string},next_index:int} */ private function parse_joined_update_table_factor( array $tokens, int $index, bool $allow_derived, bool $is_target, string $statement = 'UPDATE' ): array { if ( ! isset( $tokens[ $index ] ) ) { @@ -2234,6 +2235,7 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo return array( 'reference' => array( 'alias' => $alias, + 'explicit_alias' => true, 'sql' => '( ' . $this->translate_tokens_to_duckdb_sql( $this->strip_for_update_locking_clause( $inner ) ) . ' ) AS ' @@ -2274,19 +2276,23 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in {$statement} statement." ); } - $alias = $table_name; + $alias = $table_name; + $explicit_alias = false; if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { ++$index; - $alias = $this->identifier_value( $tokens[ $index ] ?? null ); + $alias = $this->identifier_value( $tokens[ $index ] ?? null ); + $explicit_alias = true; ++$index; } elseif ( isset( $tokens[ $index ] ) && ! $this->is_joined_update_table_reference_boundary( $tokens[ $index ] ) ) { - $alias = $this->identifier_value( $tokens[ $index ] ); + $alias = $this->identifier_value( $tokens[ $index ] ); + $explicit_alias = true; ++$index; } return array( 'reference' => array( 'alias' => $alias, + 'explicit_alias' => $explicit_alias, 'sql' => $this->connection->quote_identifier( $table_reference['table_name'] ) . ' AS ' . $this->connection->quote_identifier( $alias ), @@ -4256,6 +4262,9 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array if ( null === $assignment_target_index ) { throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target column '{$column}' in DuckDB driver." ); } + if ( $this->is_unsafe_unqualified_joined_update_target( $references, $assignment_target_index ) ) { + throw new WP_DuckDB_Driver_Exception( "Unqualified UPDATE target column '{$column}' is not supported for aliased joined UPDATE targets in DuckDB driver. Qualify the target column." ); + } } elseif ( 3 === count( $left_tokens ) && WP_MySQL_Lexer::DOT_SYMBOL === $left_tokens[1]->id @@ -4303,6 +4312,28 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array ); } + /** + * Check whether unqualified joined UPDATE target inference can diverge. + * + * @param array> $references Joined references. + * @param int $target_index Assignment target index. + * @return bool Whether unqualified target inference should be rejected. + */ + private function is_unsafe_unqualified_joined_update_target( array $references, int $target_index ): bool { + if ( empty( $references[ $target_index ]['explicit_alias'] ) ) { + return false; + } + + $base_reference_count = 0; + foreach ( $references as $reference ) { + if ( null !== $reference['table_name'] ) { + ++$base_reference_count; + } + } + + return $base_reference_count > 1; + } + /** * Check whether a table has a column. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index e7ab9b295..b35abb798 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -624,6 +624,215 @@ public function test_multi_target_joined_update_rejection_matches_sqlite(): void $this->assertParityRows( 'SELECT id, note FROM t2 ORDER BY id' ); } + public function test_joined_update_unqualified_unique_unaliased_target_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)', + 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)', + "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30), (4, 'a4', 40)", + "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (5, 'b5', 1)", + ) + ); + + $this->assertParityRowCount( + 'UPDATE t1 JOIN t2 ON t1.id = t2.id + SET only_t1 = 99 + WHERE t2.flag = 1' + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + } + + public function test_joined_update_unsupported_join_forms_document_current_duckdb_gap(): void { + foreach ( + array( + array( + 'sql' => "UPDATE t1 a LEFT JOIN t2 b ON a.id = b.id SET a.note = 'left'", + 'duckdb_message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'sqlite_row_count' => 2, + 'sqlite_rows' => array( + array( + 'id' => '1', + 'note' => 'left', + 'only_t1' => '10', + ), + array( + 'id' => '2', + 'note' => 'a2', + 'only_t1' => '20', + ), + array( + 'id' => '3', + 'note' => 'left', + 'only_t1' => '30', + ), + array( + 'id' => '4', + 'note' => 'a4', + 'only_t1' => '40', + ), + ), + ), + array( + 'sql' => "UPDATE t1 a RIGHT JOIN t2 b ON a.id = b.id SET a.note = 'right'", + 'duckdb_message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'sqlite_row_count' => 2, + 'sqlite_rows' => array( + array( + 'id' => '1', + 'note' => 'right', + 'only_t1' => '10', + ), + array( + 'id' => '2', + 'note' => 'a2', + 'only_t1' => '20', + ), + array( + 'id' => '3', + 'note' => 'right', + 'only_t1' => '30', + ), + array( + 'id' => '4', + 'note' => 'a4', + 'only_t1' => '40', + ), + ), + ), + array( + 'sql' => "UPDATE t1 a JOIN t2 b USING (id) SET a.note = 'using'", + 'duckdb_message' => 'JOIN ... USING is not supported', + 'sqlite_row_count' => 4, + 'sqlite_rows' => array( + array( + 'id' => '1', + 'note' => 'using', + 'only_t1' => '10', + ), + array( + 'id' => '2', + 'note' => 'using', + 'only_t1' => '20', + ), + array( + 'id' => '3', + 'note' => 'using', + 'only_t1' => '30', + ), + array( + 'id' => '4', + 'note' => 'using', + 'only_t1' => '40', + ), + ), + ), + array( + 'sql' => "UPDATE t1 a NATURAL JOIN t2 b SET a.note = 'natural'", + 'duckdb_message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'sqlite_row_count' => 4, + 'sqlite_rows' => array( + array( + 'id' => '1', + 'note' => 'natural', + 'only_t1' => '10', + ), + array( + 'id' => '2', + 'note' => 'natural', + 'only_t1' => '20', + ), + array( + 'id' => '3', + 'note' => 'natural', + 'only_t1' => '30', + ), + array( + 'id' => '4', + 'note' => 'natural', + 'only_t1' => '40', + ), + ), + ), + ) as $case + ) { + $drivers = $this->createJoinedUpdateGapDrivers(); + + $this->assertSame( + $case['sqlite_row_count'], + (int) $drivers['sqlite']->query( $case['sql'], PDO::FETCH_ASSOC ), + 'SQLite row count changed for SQL: ' . $case['sql'] + ); + $this->assertDuckDBGapQueryRejected( $drivers['duckdb'], $case['sql'], $case['duckdb_message'] ); + + $this->assertSame( + $case['sqlite_rows'], + $drivers['sqlite']->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id', PDO::FETCH_ASSOC ), + 'SQLite rows changed for SQL: ' . $case['sql'] + ); + $this->assertSame( + $this->joinedUpdateGapInitialDuckDBRows(), + $drivers['duckdb']->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'DuckDB rejected joined UPDATE mutated t1 for SQL: ' . $case['sql'] + ); + $this->assertSame( + $this->joinedUpdateGapInitialDuckDBSourceRows(), + $drivers['duckdb']->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'DuckDB rejected joined UPDATE mutated t2 for SQL: ' . $case['sql'] + ); + } + } + + public function test_joined_update_unqualified_unique_aliased_target_rejects_duckdb_gap(): void { + $drivers = $this->createJoinedUpdateGapDrivers(); + $sql = 'UPDATE t1 a JOIN t2 b ON a.id = b.id + SET only_t1 = 99 + WHERE b.flag = 1'; + + $this->assertSame( 4, (int) $drivers['sqlite']->query( $sql, PDO::FETCH_ASSOC ) ); + $this->assertDuckDBGapQueryRejected( + $drivers['duckdb'], + $sql, + "Unqualified UPDATE target column 'only_t1' is not supported for aliased joined UPDATE targets" + ); + + $this->assertSame( + array( + array( + 'id' => '1', + 'note' => 'a1', + 'only_t1' => '99', + ), + array( + 'id' => '2', + 'note' => 'a2', + 'only_t1' => '99', + ), + array( + 'id' => '3', + 'note' => 'a3', + 'only_t1' => '99', + ), + array( + 'id' => '4', + 'note' => 'a4', + 'only_t1' => '99', + ), + ), + $drivers['sqlite']->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id', PDO::FETCH_ASSOC ) + ); + $this->assertSame( + $this->joinedUpdateGapInitialDuckDBRows(), + $drivers['duckdb']->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'DuckDB rejected joined UPDATE mutated t1.' + ); + $this->assertSame( + $this->joinedUpdateGapInitialDuckDBSourceRows(), + $drivers['duckdb']->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'DuckDB rejected joined UPDATE mutated t2.' + ); + } + public function test_joined_update_derived_table_claim_query_matches_sqlite(): void { $this->runParitySetup( array( @@ -1945,6 +2154,97 @@ public function test_drop_index_lifecycle_matches_sqlite(): void { $this->assertParityRows( 'SELECT name FROM lifecycle_idx ORDER BY id' ); } + private function createJoinedUpdateGapDrivers(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $sqlite_driver = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), + 'wp' + ); + $duckdb_driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $setup_queries = array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)', + 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)', + "INSERT INTO t1 VALUES + (1, 'a1', 10), + (2, 'a2', 20), + (3, 'a3', 30), + (4, 'a4', 40)", + "INSERT INTO t2 VALUES + (1, 'b1', 1), + (3, 'b3', 1), + (5, 'b5', 1)", + ); + + foreach ( $setup_queries as $query ) { + $sqlite_driver->query( $query, PDO::FETCH_ASSOC ); + $duckdb_driver->query( $query ); + } + + return array( + 'sqlite' => $sqlite_driver, + 'duckdb' => $duckdb_driver, + ); + } + + private function assertDuckDBGapQueryRejected( WP_DuckDB_Driver $driver, string $sql, string $message ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + try { + $driver->query( $sql ); + $this->fail( 'Expected DuckDB joined UPDATE rejection for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + } + + private function joinedUpdateGapInitialDuckDBRows(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return array( + array( + 'id' => 1, + 'note' => 'a1', + 'only_t1' => 10, + ), + array( + 'id' => 2, + 'note' => 'a2', + 'only_t1' => 20, + ), + array( + 'id' => 3, + 'note' => 'a3', + 'only_t1' => 30, + ), + array( + 'id' => 4, + 'note' => 'a4', + 'only_t1' => 40, + ), + ); + } + + private function joinedUpdateGapInitialDuckDBSourceRows(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return array( + array( + 'id' => 1, + 'note' => 'b1', + 'flag' => 1, + ), + array( + 'id' => 3, + 'note' => 'b3', + 'flag' => 1, + ), + array( + 'id' => 5, + 'note' => 'b5', + 'flag' => 1, + ), + ); + } + private function lifecycleTableSql( string $table_name ): string { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid return "CREATE TABLE {$table_name} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 18f52e4a3..eb99daf29 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1597,6 +1597,106 @@ public function test_joined_update_infers_same_base_non_first_alias_target(): vo ); } + public function test_joined_update_infers_unqualified_unique_target_column(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)' ); + $driver->query( 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30)" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'b1', 1), (2, 'b2', 0), (3, 'b3', 1)" ); + + $updated = $driver->query( + 'UPDATE t1 JOIN t2 ON t1.id = t2.id + SET only_t1 = 99 + WHERE t2.flag = 1' + ); + + $this->assertSame( 2, $updated->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'only_t1' => 99, + ), + array( + 'id' => 2, + 'only_t1' => 20, + ), + array( + 'id' => 3, + 'only_t1' => 99, + ), + ), + $driver->query( 'SELECT id, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_joined_update_rejects_unqualified_unique_aliased_target_column(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)' ); + $driver->query( 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30)" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'b1', 1), (2, 'b2', 0), (3, 'b3', 1)" ); + + try { + $driver->query( + 'UPDATE t1 a JOIN t2 b ON a.id = b.id + SET only_t1 = 99 + WHERE b.flag = 1' + ); + $this->fail( 'Expected joined UPDATE rejection for unqualified aliased target column.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( + "Unqualified UPDATE target column 'only_t1' is not supported for aliased joined UPDATE targets", + $e->getMessage() + ); + } + + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'a1', + 'only_t1' => 10, + ), + array( + 'id' => 2, + 'note' => 'a2', + 'only_t1' => 20, + ), + array( + 'id' => 3, + 'note' => 'a3', + 'only_t1' => 30, + ), + ), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'b1', + 'flag' => 1, + ), + array( + 'id' => 2, + 'note' => 'b2', + 'flag' => 0, + ), + array( + 'id' => 3, + 'note' => 'b3', + 'flag' => 1, + ), + ), + $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_joined_update_rejects_unsupported_shapes(): void { $this->requireDuckDBRuntime(); @@ -1641,6 +1741,22 @@ public function test_joined_update_rejects_unsupported_shapes(): void { 'sql' => "UPDATE t1 a LEFT JOIN t2 b ON a.id = b.id SET a.note = 'target'", 'message' => 'Only comma joins and INNER JOIN ... ON are supported', ), + array( + 'sql' => "UPDATE t1 a RIGHT JOIN t2 b ON a.id = b.id SET a.note = 'target'", + 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + ), + array( + 'sql' => "UPDATE t1 a NATURAL JOIN t2 b SET a.note = 'target'", + 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + ), + array( + 'sql' => "UPDATE t1 a JOIN t2 b USING (id) SET a.note = 'target'", + 'message' => 'JOIN ... USING is not supported', + ), + array( + 'sql' => "UPDATE t1 a JOIN t2 b ON a.id = b.id SET note = 'ambiguous'", + 'message' => "Ambiguous unqualified UPDATE target column 'note'", + ), array( 'sql' => "UPDATE t1 a JOIN t2 b ON a.id = b.id SET a.note = 'target' ORDER BY a.id LIMIT 1", 'message' => 'Joined UPDATE with ORDER BY or LIMIT is not supported', From 2dcf3e032ba66086ae73165f17266ae4e8ac2b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 03:01:41 +0000 Subject: [PATCH 052/317] Add DuckDB ALTER CHECK lifecycle parity --- .../src/duckdb/class-wp-duckdb-driver.php | 538 ++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 80 +- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 729 +++++++++++++++++- 3 files changed, 1279 insertions(+), 68 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 0738a4a2d..31ad71640 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -4511,7 +4511,8 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD COLUMN, ADD INDEX, DROP COLUMN, and DROP INDEX are supported.' ); } - $this->reject_unsupported_alter_table_constraint_actions( $actions ); + $this->validate_alter_table_check_rebuild_action_combination( $table_name, $actions, $temporary ); + $this->validate_alter_table_constraint_actions( $table_name, $actions, $temporary ); $result = null; foreach ( $actions as $action ) { @@ -4520,6 +4521,11 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen } if ( WP_MySQL_Lexer::DROP_SYMBOL === $action[0]->id ) { + if ( $this->is_alter_table_drop_check_constraint_action( $action ) ) { + $result = $this->execute_alter_table_drop_check_constraint( $table_name, $action, $temporary ); + continue; + } + $result = $this->is_alter_table_drop_index_action( $action ) ? $this->execute_alter_table_drop_index( $table_name, $action, $temporary ) : $this->execute_alter_table_drop_column( $table_name, $action, $temporary ); @@ -4544,6 +4550,11 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen $this->expect_token( $action, 0, WP_MySQL_Lexer::ADD_SYMBOL, 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD, DROP, CHANGE, MODIFY, and AUTO_INCREMENT actions are supported.' ); $alter_item = array_slice( $action, 1 ); + if ( $this->is_alter_table_add_check_constraint_action( $alter_item ) ) { + $result = $this->execute_alter_table_add_check_constraint( $table_name, $alter_item, $temporary ); + continue; + } + $result = $this->is_create_table_index_item( $alter_item ) ? $this->execute_alter_table_add_index( $table_name, $alter_item, $temporary ) : $this->execute_alter_table_add_column( $table_name, $alter_item, $temporary ); @@ -4553,37 +4564,130 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen } /** - * Reject unsupported ALTER TABLE CHECK/FOREIGN KEY/CONSTRAINT actions before mutation. + * Reject combined ALTER TABLE CHECK rebuild actions before any mutation. + * + * @param string $table_name Table name. + * @param array $actions ALTER action token groups. + * @param bool $temporary Whether the target is a temporary table. + */ + private function validate_alter_table_check_rebuild_action_combination( string $table_name, array $actions, bool $temporary = false ): void { + if ( count( $actions ) <= 1 ) { + return; + } + + foreach ( $actions as $action ) { + if ( $this->is_alter_table_check_rebuild_action( $table_name, $action, $temporary ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD/DROP CHECK cannot be combined with other ALTER TABLE actions.' ); + } + } + } + + /** + * Check whether an ALTER action requires a CHECK table rebuild. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @param bool $temporary Whether the target is a temporary table. + * @return bool Whether the action rebuilds CHECK constraints. + */ + private function is_alter_table_check_rebuild_action( string $table_name, array $tokens, bool $temporary = false ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::ADD_SYMBOL === $tokens[0]->id ) { + return $this->is_alter_table_add_check_constraint_action( array_slice( $tokens, 1 ) ); + } + + if ( + WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id + || ! $this->is_alter_table_drop_check_constraint_action( $tokens ) + ) { + return false; + } + + if ( WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[1]->id ) { + $constraint_name = $this->parse_alter_table_drop_check_constraint_name( $tokens, WP_MySQL_Lexer::CONSTRAINT_SYMBOL ); + return 'CHECK' === $this->resolve_alter_table_drop_constraint_type( $table_name, $constraint_name, $temporary ); + } + + return true; + } + + /** + * Validate ALTER TABLE CHECK/FOREIGN KEY/CONSTRAINT actions before mutation. * + * @param string $table_name Table name. * @param array $actions ALTER action token groups. + * @param bool $temporary Whether the target is a temporary table. */ - private function reject_unsupported_alter_table_constraint_actions( array $actions ): void { + private function validate_alter_table_constraint_actions( string $table_name, array $actions, bool $temporary = false ): void { + $check_names = array(); + $check_names_loaded = false; + foreach ( $actions as $action ) { if ( ! isset( $action[0] ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Empty action.' ); } - $this->reject_unsupported_alter_table_constraint_action( $action ); + $this->validate_alter_table_constraint_action( $table_name, $action, $check_names, $check_names_loaded, $temporary ); } } /** - * Reject unsupported ALTER TABLE CHECK/FOREIGN KEY/CONSTRAINT actions before mutation. + * Return recorded CHECK constraint names keyed lowercase. * - * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + * @return array CHECK names keyed lowercase. + */ + private function check_constraint_name_map( string $table_name, bool $temporary = false ): array { + $names = array(); + foreach ( $this->check_constraint_metadata_rows( $table_name, $temporary ) as $check_constraint ) { + $names[ strtolower( (string) $check_constraint['constraint_name'] ) ] = true; + } + + return $names; + } + + /** + * Load recorded CHECK constraint names once for ALTER TABLE preflight. + * + * @param string $table_name Table name. + * @param array $check_names CHECK names keyed lowercase. + * @param bool $check_names_loaded Whether the map has already been loaded. + * @param bool $temporary Whether the target is a temporary table. */ - private function reject_unsupported_alter_table_constraint_action( array $tokens ): void { + private function ensure_alter_table_check_constraint_name_map( string $table_name, array &$check_names, bool &$check_names_loaded, bool $temporary = false ): void { + if ( $check_names_loaded ) { + return; + } + + $check_names = $this->check_constraint_name_map( $table_name, $temporary ); + $check_names_loaded = true; + } + + /** + * Validate ALTER TABLE CHECK/FOREIGN KEY/CONSTRAINT actions before mutation. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @param array $check_names Existing and planned CHECK names, keyed lowercase. + * @param bool $check_names_loaded Whether CHECK names have been loaded. + * @param bool $temporary Whether the target is a temporary table. + */ + private function validate_alter_table_constraint_action( string $table_name, array $tokens, array &$check_names, bool &$check_names_loaded, bool $temporary = false ): void { if ( ! isset( $tokens[0] ) ) { return; } if ( WP_MySQL_Lexer::ADD_SYMBOL === $tokens[0]->id ) { - $this->reject_unsupported_alter_table_add_constraint_action( array_slice( $tokens, 1 ) ); + $this->validate_alter_table_add_constraint_action( $table_name, array_slice( $tokens, 1 ), $check_names, $check_names_loaded, $temporary ); return; } if ( WP_MySQL_Lexer::DROP_SYMBOL === $tokens[0]->id ) { - $this->reject_unsupported_alter_table_drop_constraint_action( $tokens ); + $this->validate_alter_table_drop_constraint_action( $table_name, $tokens, $check_names, $check_names_loaded, $temporary ); return; } @@ -4596,11 +4700,15 @@ private function reject_unsupported_alter_table_constraint_action( array $tokens } /** - * Reject unsupported ALTER TABLE ... ADD constraint actions before ADD COLUMN fallback. + * Validate ALTER TABLE ... ADD constraint actions before ADD COLUMN fallback. * - * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @param array $check_names Existing and planned CHECK names, keyed lowercase. + * @param bool $check_names_loaded Whether CHECK names have been loaded. + * @param bool $temporary Whether the target is a temporary table. */ - private function reject_unsupported_alter_table_add_constraint_action( array $tokens ): void { + private function validate_alter_table_add_constraint_action( string $table_name, array $tokens, array &$check_names, bool &$check_names_loaded, bool $temporary = false ): void { if ( ! isset( $tokens[0] ) ) { return; } @@ -4610,9 +4718,33 @@ private function reject_unsupported_alter_table_add_constraint_action( array $to return; } - foreach ( $this->alter_table_add_items_for_constraint_detection( $tokens ) as $item ) { + $items = $this->alter_table_add_items_for_constraint_detection( $tokens ); + $contains_check = false; + foreach ( $items as $item ) { + if ( $this->is_create_table_check_constraint( $item ) ) { + $contains_check = true; + break; + } + } + + if ( $contains_check ) { + $parenthesized_end = count( $tokens ); + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id ) { + list( , $parenthesized_end ) = $this->collect_parenthesized_items( $tokens, 1 ); + } + + if ( 1 !== count( $items ) || count( $tokens ) !== $parenthesized_end ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD CHECK constraint is supported.' ); + } + } + + foreach ( $items as $item ) { if ( $this->is_create_table_check_constraint( $item ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD CHECK is not supported.' ); + $this->assert_no_active_transaction_for_alter_table_check_rebuild(); + $this->assert_not_referenced_parent_for_alter_table_check_rebuild( $table_name, $temporary ); + $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); + $this->translate_table_check_constraint( $table_name, $item, $check_names ); + continue; } if ( $this->is_create_table_foreign_key_constraint( $item ) ) { @@ -4662,21 +4794,43 @@ private function alter_table_add_items_for_constraint_detection( array $tokens ) } /** - * Reject unsupported ALTER TABLE ... DROP constraint actions before DROP COLUMN fallback. + * Validate ALTER TABLE ... DROP constraint actions before DROP COLUMN fallback. * - * @param WP_Parser_Token[] $tokens ALTER action tokens starting at DROP. + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens starting at DROP. + * @param array $check_names Existing and planned CHECK names, keyed lowercase. + * @param bool $check_names_loaded Whether CHECK names have been loaded. + * @param bool $temporary Whether the target is a temporary table. */ - private function reject_unsupported_alter_table_drop_constraint_action( array $tokens ): void { + private function validate_alter_table_drop_constraint_action( string $table_name, array $tokens, array &$check_names, bool &$check_names_loaded, bool $temporary = false ): void { if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[1]->id ) { return; } if ( WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[1]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP CHECK is not supported.' ); + $constraint_name = $this->parse_alter_table_drop_check_constraint_name( $tokens, WP_MySQL_Lexer::CHECK_SYMBOL ); + $check = $this->resolve_check_constraint_metadata_row( $table_name, $constraint_name, $temporary ); + if ( null === $check ) { + return; + } + $this->assert_no_active_transaction_for_alter_table_check_rebuild(); + $this->assert_not_referenced_parent_for_alter_table_check_rebuild( $table_name, $temporary ); + $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); + unset( $check_names[ strtolower( (string) $check['constraint_name'] ) ] ); + return; } if ( WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[1]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP CONSTRAINT is not supported.' ); + $constraint_name = $this->parse_alter_table_drop_check_constraint_name( $tokens, WP_MySQL_Lexer::CONSTRAINT_SYMBOL ); + $constraint_type = $this->resolve_alter_table_drop_constraint_type( $table_name, $constraint_name, $temporary ); + if ( 'CHECK' !== $constraint_type ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP CONSTRAINT currently supports CHECK constraints only.' ); + } + $this->assert_no_active_transaction_for_alter_table_check_rebuild(); + $this->assert_not_referenced_parent_for_alter_table_check_rebuild( $table_name, $temporary ); + $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); + unset( $check_names[ strtolower( $constraint_name ) ] ); + return; } if ( @@ -4688,6 +4842,352 @@ private function reject_unsupported_alter_table_drop_constraint_action( array $t } } + /** + * Reject CHECK table rebuilds inside an active transaction. + */ + private function assert_no_active_transaction_for_alter_table_check_rebuild(): void { + if ( $this->connection->inTransaction() ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD/DROP CHECK cannot run inside an active DuckDB transaction.' ); + } + } + + /** + * Reject CHECK rebuilds on referenced parent tables before mutation. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + */ + private function assert_not_referenced_parent_for_alter_table_check_rebuild( string $table_name, bool $temporary = false ): void { + foreach ( $this->foreign_key_references_to_table( $table_name, $temporary ) as $reference ) { + throw new WP_DuckDB_Driver_Exception( + "Unsupported ALTER TABLE statement in DuckDB driver. ADD/DROP CHECK on table '{$this->database}.{$table_name}' is not supported because it is referenced by FOREIGN KEY '{$reference['constraint_name']}' on table '{$reference['table_name']}'." + ); + } + } + + /** + * Return recorded foreign keys that reference a table. + * + * @param string $table_name Referenced table name. + * @param bool $temporary Whether the referenced table is temporary. + * @return array + */ + private function foreign_key_references_to_table( string $table_name, bool $temporary = false ): array { + $references = array(); + $table_sets = array( + array( + 'temporary' => false, + 'tables' => $this->user_table_names(), + ), + array( + 'temporary' => true, + 'tables' => $this->temporary_user_table_names(), + ), + ); + + foreach ( $table_sets as $table_set ) { + if ( $temporary && ! $table_set['temporary'] ) { + continue; + } + + foreach ( $table_set['tables'] as $candidate_table_name ) { + foreach ( $this->foreign_key_metadata_rows( $candidate_table_name, $table_set['temporary'] ) as $foreign_key ) { + if ( 0 === strcasecmp( (string) $foreign_key['referenced_table_name'], $table_name ) ) { + $references[] = array( + 'table_name' => $candidate_table_name, + 'constraint_name' => (string) $foreign_key['constraint_name'], + ); + } + } + } + } + + return $references; + } + + /** + * Check whether ALTER TABLE ... ADD targets a CHECK constraint. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @return bool Whether this is an ADD CHECK action. + */ + private function is_alter_table_add_check_constraint_action( array $tokens ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + foreach ( $this->alter_table_add_items_for_constraint_detection( $tokens ) as $item ) { + if ( $this->is_create_table_check_constraint( $item ) ) { + return true; + } + } + + return false; + } + + /** + * Execute ALTER TABLE ... ADD CHECK. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_add_check_constraint( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $item = $this->single_alter_table_add_check_constraint_item( $tokens ); + $check_names = $this->check_constraint_name_map( $table_name, $temporary ); + $check = $this->translate_table_check_constraint( $table_name, $item, $check_names ); + $metadata = $this->check_constraint_metadata_rows( $table_name, $temporary ); + $metadata[] = $check['metadata']; + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $metadata, $temporary ): WP_DuckDB_Result_Statement { + $this->rebuild_table_with_check_constraints( $table_name, $metadata, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + + /** + * Return the single CHECK item from a supported ALTER TABLE ... ADD CHECK action. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @return WP_Parser_Token[] CHECK constraint tokens. + */ + private function single_alter_table_add_check_constraint_item( array $tokens ): array { + if ( ! isset( $tokens[0] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD CHECK requires a CHECK constraint.' ); + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[0]->id ) { + return $tokens; + } + + list( $items, $index ) = $this->collect_parenthesized_items( $tokens, 1 ); + if ( 1 !== count( $items ) || count( $tokens ) !== $index || ! $this->is_create_table_check_constraint( $items[0] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD CHECK constraint is supported.' ); + } + + return $items[0]; + } + + /** + * Check whether ALTER TABLE ... DROP targets a CHECK constraint. + * + * @param WP_Parser_Token[] $tokens Action tokens starting at DROP. + * @return bool Whether this is a DROP CHECK/CONSTRAINT action. + */ + private function is_alter_table_drop_check_constraint_action( array $tokens ): bool { + return isset( $tokens[1] ) + && ( + WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[1]->id + || WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[1]->id + ); + } + + /** + * Execute ALTER TABLE ... DROP CHECK or DROP CONSTRAINT for CHECK constraints. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens starting at DROP. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_drop_check_constraint( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $constraint_name = $this->parse_alter_table_drop_check_constraint_name( $tokens, $tokens[1]->id ); + $check = $this->resolve_check_constraint_metadata_row( $table_name, $constraint_name, $temporary ); + if ( null === $check ) { + if ( WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[1]->id ) { + return $this->empty_ddl_result(); + } + throw new WP_DuckDB_Driver_Exception( "Unknown CHECK constraint '{$constraint_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + + $metadata = array_values( + array_filter( + $this->check_constraint_metadata_rows( $table_name, $temporary ), + function ( array $constraint ) use ( $check ): bool { + return 0 !== strcasecmp( (string) $constraint['constraint_name'], (string) $check['constraint_name'] ); + } + ) + ); + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $metadata, $temporary ): WP_DuckDB_Result_Statement { + $this->rebuild_table_with_check_constraints( $table_name, $metadata, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + + /** + * Resolve a recorded CHECK constraint row by name. + * + * @param string $table_name Table name. + * @param string $constraint_name Constraint name. + * @param bool $temporary Whether the target is a temporary table. + * @return array|null CHECK metadata row, or null when not found. + */ + private function resolve_check_constraint_metadata_row( string $table_name, string $constraint_name, bool $temporary = false ): ?array { + foreach ( $this->check_constraint_metadata_rows( $table_name, $temporary ) as $check_constraint ) { + if ( 0 === strcasecmp( (string) $check_constraint['constraint_name'], $constraint_name ) ) { + return $check_constraint; + } + } + + return null; + } + + /** + * Parse ALTER TABLE ... DROP CHECK|CONSTRAINT name. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens starting at DROP. + * @param int $constraint_id Expected CHECK or CONSTRAINT token id. + * @return string Constraint name. + */ + private function parse_alter_table_drop_check_constraint_name( array $tokens, int $constraint_id ): string { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::DROP_SYMBOL, 'Expected DROP in ALTER TABLE action.' ); + ++$index; + $this->expect_token( $tokens, $index, $constraint_id, 'Expected CHECK or CONSTRAINT in ALTER TABLE DROP action.' ); + ++$index; + + $constraint_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP CHECK options are not supported.' ); + } + + return $constraint_name; + } + + /** + * Resolve the type of a generic ALTER TABLE ... DROP CONSTRAINT target. + * + * @param string $table_name Table name. + * @param string $constraint_name Constraint name. + * @param bool $temporary Whether the target is a temporary table. + * @return string Constraint type. + */ + private function resolve_alter_table_drop_constraint_type( string $table_name, string $constraint_name, bool $temporary = false ): string { + $types = $this->constraint_types_for_name( $table_name, $constraint_name, $temporary ); + + if ( count( $types ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( "Unknown constraint '{$constraint_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + + if ( count( $types ) > 1 ) { + throw new WP_DuckDB_Driver_Exception( "Ambiguous constraint '{$constraint_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + + return $types[0]; + } + + /** + * Return MySQL-facing constraint types matching a name. + * + * @param string $table_name Table name. + * @param string $constraint_name Constraint name. + * @param bool $temporary Whether the target is a temporary table. + * @return string[] Matching constraint types. + */ + private function constraint_types_for_name( string $table_name, string $constraint_name, bool $temporary = false ): array { + $types = array(); + + if ( 0 === strcasecmp( $constraint_name, 'PRIMARY' ) && count( $this->primary_key_index_rows( $table_name ) ) > 0 ) { + $types['PRIMARY KEY'] = 'PRIMARY KEY'; + } + + foreach ( $this->secondary_index_rows( $table_name, $temporary ) as $index_row ) { + if ( 0 === (int) $index_row[1] && 0 === strcasecmp( (string) $index_row[2], $constraint_name ) ) { + $types['UNIQUE'] = 'UNIQUE'; + } + } + + foreach ( $this->foreign_key_metadata_rows( $table_name, $temporary ) as $foreign_key ) { + if ( 0 === strcasecmp( (string) $foreign_key['constraint_name'], $constraint_name ) ) { + $types['FOREIGN KEY'] = 'FOREIGN KEY'; + } + } + + foreach ( $this->check_constraint_metadata_rows( $table_name, $temporary ) as $check_constraint ) { + if ( 0 === strcasecmp( (string) $check_constraint['constraint_name'], $constraint_name ) ) { + $types['CHECK'] = 'CHECK'; + } + } + + return array_values( $types ); + } + + /** + * Rebuild a table with a new CHECK constraint metadata set. + * + * @param string $table_name Table name. + * @param array> $check_constraints New CHECK metadata rows. + * @param bool $temporary Whether the target is a temporary table. + */ + private function rebuild_table_with_check_constraints( string $table_name, array $check_constraints, bool $temporary = false ): void { + $sequence_names = $this->auto_increment_sequences_for_table( $table_name, $temporary ); + $metadata_rows = $this->table_column_metadata_rows( $table_name, $temporary ); + $column_names = array_map( + function ( array $column ): string { + return (string) $column['column_name']; + }, + $metadata_rows + ); + $quoted_columns = implode( + ', ', + array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ); + }, + $column_names + ) + ); + $backup_table = '__wp_duckdb_check_rebuild_' . substr( hash( 'sha256', $table_name . "\0" . microtime( true ) . "\0" . mt_rand() ), 0, 16 ); + + $this->record_check_metadata( $table_name, $check_constraints, $temporary ); + $create_sql = $this->mysql_create_table_statement( $table_name, $table_name, $temporary ); + + $this->execute_duckdb_query( + 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $backup_table ), + 'Failed to reset DuckDB CHECK rebuild backup table' + ); + $this->execute_duckdb_query( + 'CREATE TEMP TABLE ' + . $this->connection->quote_identifier( $backup_table ) + . ' AS SELECT ' + . $quoted_columns + . ' FROM ' + . $this->connection->quote_identifier( $table_name ), + 'Failed to back up DuckDB table for CHECK rebuild' + ); + $this->execute_duckdb_query( + 'DROP TABLE ' . $this->connection->quote_identifier( $table_name ), + 'Failed to rebuild DuckDB CHECK table' + ); + $this->drop_auto_increment_sequences( $sequence_names ); + $this->execute_create_table( $this->tokenize_and_validate( $create_sql ) ); + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( $table_name ) + . ' (' + . $quoted_columns + . ') SELECT ' + . $quoted_columns + . ' FROM ' + . $this->connection->quote_identifier( $backup_table ), + 'Failed to restore DuckDB table rows after CHECK rebuild' + ); + $this->execute_duckdb_query( + 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $backup_table ), + 'Failed to drop DuckDB CHECK rebuild backup table' + ); + } + /** * Execute ALTER TABLE ... AUTO_INCREMENT = N. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index b35abb798..0db2791a4 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1615,52 +1615,54 @@ public function test_simple_inline_foreign_keys_match_sqlite(): void { $this->assertParityRows( 'SHOW CREATE TABLE inline_fk_child_default' ); } - public function test_alter_table_check_constraint_actions_document_current_duckdb_gap(): void { - $sqlite_driver = new WP_SQLite_Driver( - new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), - 'wp' - ); - $duckdb_driver = new WP_DuckDB_Driver( + public function test_alter_table_check_constraint_actions_match_sqlite(): void { + $this->runParitySetup( array( - 'path' => ':memory:', - 'database' => 'wp', + 'CREATE TABLE alter_check_gap (id INT, label VARCHAR(20), CONSTRAINT existing_check CHECK (id >= 0), KEY label_idx (label))', + "INSERT INTO alter_check_gap (id, label) VALUES (1, 'one')", + 'ALTER TABLE alter_check_gap ADD CONSTRAINT added_check CHECK (id < 10)', + 'ALTER TABLE alter_check_gap ADD CHECK (id IS NULL OR id <> 7)', ) ); - $create_sql = 'CREATE TABLE alter_check_gap (id INT, CONSTRAINT existing_check CHECK (id >= 0))'; - $sqlite_driver->query( $create_sql, PDO::FETCH_ASSOC ); - $duckdb_driver->query( $create_sql ); - - $sqlite_driver->query( 'ALTER TABLE alter_check_gap ADD CONSTRAINT added_check CHECK (id < 10)', PDO::FETCH_ASSOC ); - $sqlite_create = $sqlite_driver->query( 'SHOW CREATE TABLE alter_check_gap', PDO::FETCH_ASSOC ); - $this->assertStringContainsString( 'added_check', $sqlite_create[0]['Create Table'] ); - - $duckdb_before = $duckdb_driver->query( 'SHOW CREATE TABLE alter_check_gap' )->fetchAll( PDO::FETCH_ASSOC ); - try { - $duckdb_driver->query( 'ALTER TABLE alter_check_gap ADD CONSTRAINT added_check CHECK (id < 10)' ); - $this->fail( 'Expected DuckDB to reject ALTER TABLE ADD CHECK.' ); - } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( 'ADD CHECK is not supported', $e->getMessage() ); - } - $this->assertSame( - $duckdb_before, - $duckdb_driver->query( 'SHOW CREATE TABLE alter_check_gap' )->fetchAll( PDO::FETCH_ASSOC ) + $this->assertParityRows( 'SHOW CREATE TABLE alter_check_gap' ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'alter_check_gap' + ORDER BY constraint_name" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" + ); + $this->assertParityErrorContains( + "INSERT INTO alter_check_gap (id, label) VALUES (20, 'too_high')", + 'CHECK constraint failed' + ); + $this->assertParityRowColumns( + 'SHOW INDEX FROM alter_check_gap', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) ); - $sqlite_driver->query( 'ALTER TABLE alter_check_gap DROP CHECK existing_check', PDO::FETCH_ASSOC ); - $sqlite_create = $sqlite_driver->query( 'SHOW CREATE TABLE alter_check_gap', PDO::FETCH_ASSOC ); - $this->assertStringNotContainsString( 'existing_check', $sqlite_create[0]['Create Table'] ); - - try { - $duckdb_driver->query( 'ALTER TABLE alter_check_gap DROP CHECK existing_check' ); - $this->fail( 'Expected DuckDB to reject ALTER TABLE DROP CHECK.' ); - } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( 'DROP CHECK is not supported', $e->getMessage() ); - } - $this->assertSame( - $duckdb_before, - $duckdb_driver->query( 'SHOW CREATE TABLE alter_check_gap' )->fetchAll( PDO::FETCH_ASSOC ) + $this->assertParityRowCount( 'ALTER TABLE alter_check_gap DROP CHECK existing_check' ); + $this->assertParityRowCount( 'ALTER TABLE alter_check_gap DROP CONSTRAINT added_check' ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'alter_check_gap' + ORDER BY constraint_name" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" ); + $this->assertParityRows( 'SHOW CREATE TABLE alter_check_gap' ); + $this->assertParityRowCount( "INSERT INTO alter_check_gap (id, label) VALUES (-1, 'after_drop')" ); } public function test_unique_key_foreign_key_metadata_documents_current_duckdb_gap(): void { diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index eb99daf29..fbf30788e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -5956,6 +5956,677 @@ public function test_unsupported_alter_table_add_column_constraints_throw_driver $driver->query( 'ALTER TABLE users ADD COLUMN id INT PRIMARY KEY' ); } + public function test_alter_table_add_check_constraint_rebuilds_table_metadata_and_indexes(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE alter_check_lifecycle ( + id INT, + amount INT, + label VARCHAR(20), + CONSTRAINT existing_check CHECK (amount >= 0), + UNIQUE KEY label_unique (label), + KEY amount_idx (amount) + )' + ); + $driver->query( "INSERT INTO alter_check_lifecycle (id, amount, label) VALUES (1, 10, 'a'), (2, 20, 'b')" ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_check_lifecycle ADD CONSTRAINT amount_limit CHECK (amount < 100)' )->rowCount() ); + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_check_lifecycle ADD CHECK (id IS NULL OR id >= 0)' )->rowCount() ); + + $constraints = $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'alter_check_lifecycle' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'alter_check_lifecycle_chk_1', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'YES', + ), + array( + 'CONSTRAINT_NAME' => 'amount_limit', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'YES', + ), + array( + 'CONSTRAINT_NAME' => 'existing_check', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'YES', + ), + array( + 'CONSTRAINT_NAME' => 'label_unique', + 'CONSTRAINT_TYPE' => 'UNIQUE', + 'ENFORCED' => 'YES', + ), + ), + $constraints + ); + + $check_clauses = array_column( + $driver->query( + "SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'CHECK_CLAUSE', + 'CONSTRAINT_NAME' + ); + $this->assertSame( + array( + 'alter_check_lifecycle_chk_1' => 'id IS NULL OR id >= 0', + 'amount_limit' => 'amount < 100', + 'existing_check' => 'amount >= 0', + ), + $check_clauses + ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE alter_check_lifecycle' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertStringContainsString( 'CONSTRAINT `existing_check` CHECK (amount >= 0)', $create_rows[0]['Create Table'] ); + $this->assertStringContainsString( 'CONSTRAINT `amount_limit` CHECK (amount < 100)', $create_rows[0]['Create Table'] ); + $this->assertStringContainsString( 'CONSTRAINT `alter_check_lifecycle_chk_1` CHECK (id IS NULL OR id >= 0)', $create_rows[0]['Create Table'] ); + + $this->assertSame( + array( 'amount_idx', 'label_unique' ), + array_values( array_unique( array_column( $driver->query( 'SHOW INDEX FROM alter_check_lifecycle' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) ) ) + ); + + $driver->query( "INSERT INTO alter_check_lifecycle (id, amount, label) VALUES (3, 30, 'c')" ); + + try { + $driver->query( "INSERT INTO alter_check_lifecycle (id, amount, label) VALUES (4, 150, 'd')" ); + $this->fail( 'Expected added CHECK constraint to reject future inserts.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + try { + $driver->query( "UPDATE alter_check_lifecycle SET amount = -1 WHERE label = 'c'" ); + $this->fail( 'Expected preserved CHECK constraint to reject future updates.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + try { + $driver->query( "INSERT INTO alter_check_lifecycle (id, amount, label) VALUES (5, 50, 'a')" ); + $this->fail( 'Expected rebuilt unique index to reject duplicate labels.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + } + + public function test_alter_table_add_check_constraint_violation_rolls_back_schema_data_and_indexes(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE alter_check_rollback ( + id INT, + amount INT, + label VARCHAR(20), + CONSTRAINT existing_check CHECK (amount >= 0), + UNIQUE KEY label_unique (label), + KEY amount_idx (amount) + )' + ); + $driver->query( "INSERT INTO alter_check_rollback (id, amount, label) VALUES (1, 25, 'a')" ); + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_rollback' ); + + try { + $driver->query( 'ALTER TABLE alter_check_rollback ADD CONSTRAINT too_small CHECK (amount < 10)' ); + $this->fail( 'Expected ADD CHECK to reject existing rows that violate the new constraint.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_rollback' ) ); + + $driver->query( "INSERT INTO alter_check_rollback (id, amount, label) VALUES (2, 30, 'b')" ); + $this->assertSame( + array( + array( + 'id' => 1, + 'amount' => 25, + 'label' => 'a', + ), + array( + 'id' => 2, + 'amount' => 30, + 'label' => 'b', + ), + ), + $driver->query( 'SELECT id, amount, label FROM alter_check_rollback ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_alter_table_add_check_rollback_preserves_auto_increment_sequence_state(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE alter_check_auto_increment ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + amount INT, + CONSTRAINT amount_positive CHECK (amount > 0) + )' + ); + $driver->query( 'INSERT INTO alter_check_auto_increment (amount) VALUES (10), (20)' ); + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_auto_increment' ); + + try { + $driver->query( 'ALTER TABLE alter_check_auto_increment ADD CONSTRAINT too_small CHECK (amount < 15)' ); + $this->fail( 'Expected ADD CHECK to reject existing rows that violate the new constraint.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_auto_increment' ) ); + + $driver->query( 'INSERT INTO alter_check_auto_increment (amount) VALUES (30)' ); + $this->assertSame( + array( + array( + 'id' => 1, + 'amount' => 10, + ), + array( + 'id' => 2, + 'amount' => 20, + ), + array( + 'id' => 3, + 'amount' => 30, + ), + ), + $driver->query( 'SELECT id, amount FROM alter_check_auto_increment ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_alter_table_check_rebuild_preserves_auto_increment_sequence_state(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE alter_check_auto_increment_rebuild ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + amount INT, + CONSTRAINT amount_positive CHECK (amount > 0) + )' + ); + $driver->query( 'INSERT INTO alter_check_auto_increment_rebuild (amount) VALUES (10), (20)' ); + $driver->query( 'ALTER TABLE alter_check_auto_increment_rebuild ADD CONSTRAINT amount_limit CHECK (amount < 100)' ); + $driver->query( 'INSERT INTO alter_check_auto_increment_rebuild (amount) VALUES (30)' ); + $driver->query( 'ALTER TABLE alter_check_auto_increment_rebuild DROP CHECK amount_limit' ); + $driver->query( 'INSERT INTO alter_check_auto_increment_rebuild (amount) VALUES (40)' ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'amount' => 10, + ), + array( + 'id' => 2, + 'amount' => 20, + ), + array( + 'id' => 3, + 'amount' => 30, + ), + array( + 'id' => 4, + 'amount' => 40, + ), + ), + $driver->query( 'SELECT id, amount FROM alter_check_auto_increment_rebuild ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_alter_table_drop_check_constraint_rebuilds_table_and_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE alter_check_drop ( + id INT, + label VARCHAR(20), + CONSTRAINT c1 CHECK (id > 0), + CONSTRAINT c2 CHECK (id < 10), + KEY id_idx (id) + )' + ); + $driver->query( "INSERT INTO alter_check_drop (id, label) VALUES (5, 'a')" ); + + try { + $driver->query( "INSERT INTO alter_check_drop (id, label) VALUES (0, 'blocked')" ); + $this->fail( 'Expected original CHECK constraint to reject invalid rows.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_check_drop DROP CONSTRAINT c1' )->rowCount() ); + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_check_drop DROP CHECK c2' )->rowCount() ); + + $this->assertSame( + array(), + $driver->query( + "SELECT CONSTRAINT_NAME + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'alter_check_drop' AND constraint_type = 'CHECK'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array(), + $driver->query( + "SELECT CONSTRAINT_NAME + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE alter_check_drop' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertStringNotContainsString( 'CONSTRAINT `c1`', $create_rows[0]['Create Table'] ); + $this->assertStringNotContainsString( 'CONSTRAINT `c2`', $create_rows[0]['Create Table'] ); + $this->assertStringContainsString( 'KEY `id_idx` (`id`)', $create_rows[0]['Create Table'] ); + + $driver->query( "INSERT INTO alter_check_drop (id, label) VALUES (0, 'after_drop'), (20, 'also_after_drop')" ); + $this->assertSame( + array( + array( 'id' => 0 ), + array( 'id' => 5 ), + array( 'id' => 20 ), + ), + $driver->query( 'SELECT id FROM alter_check_drop ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_alter_table_check_constraint_actions_target_temporary_shadow_table(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE shadow_check (id INT, amount INT, CONSTRAINT persistent_positive CHECK (amount > 0))' ); + $driver->query( 'INSERT INTO shadow_check (id, amount) VALUES (1, 10)' ); + $driver->query( 'CREATE TEMPORARY TABLE shadow_check (id INT, amount INT, CONSTRAINT temp_non_negative CHECK (id >= 0))' ); + $driver->query( 'INSERT INTO shadow_check (id, amount) VALUES (2, 20)' ); + + $driver->query( 'ALTER TABLE shadow_check ADD CONSTRAINT temp_amount_limit CHECK (amount < 100)' ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE shadow_check' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertStringStartsWith( 'CREATE TEMPORARY TABLE `shadow_check`', $create_rows[0]['Create Table'] ); + $this->assertStringContainsString( 'CONSTRAINT `temp_non_negative` CHECK (id >= 0)', $create_rows[0]['Create Table'] ); + $this->assertStringContainsString( 'CONSTRAINT `temp_amount_limit` CHECK (amount < 100)', $create_rows[0]['Create Table'] ); + $this->assertStringNotContainsString( 'persistent_positive', $create_rows[0]['Create Table'] ); + + try { + $driver->query( 'INSERT INTO shadow_check (id, amount) VALUES (3, 150)' ); + $this->fail( 'Expected temporary CHECK constraint to reject invalid rows.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + $driver->query( 'ALTER TABLE shadow_check DROP CHECK temp_non_negative' ); + $driver->query( 'INSERT INTO shadow_check (id, amount) VALUES (-1, 30)' ); + $driver->query( 'DROP TEMPORARY TABLE shadow_check' ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE shadow_check' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertStringStartsWith( 'CREATE TABLE `shadow_check`', $create_rows[0]['Create Table'] ); + $this->assertStringContainsString( 'CONSTRAINT `persistent_positive` CHECK (amount > 0)', $create_rows[0]['Create Table'] ); + $this->assertStringNotContainsString( 'temp_amount_limit', $create_rows[0]['Create Table'] ); + $this->assertSame( + array( + array( + 'id' => 1, + 'amount' => 10, + ), + ), + $driver->query( 'SELECT id, amount FROM shadow_check ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_alter_table_add_drop_check_rejects_duplicate_and_missing_names_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE alter_check_names ( + id INT, + amount INT, + CONSTRAINT existing_check CHECK (amount >= 0), + KEY amount_idx (amount) + )' + ); + $driver->query( 'INSERT INTO alter_check_names (id, amount) VALUES (1, 10)' ); + + foreach ( + array( + 'ALTER TABLE alter_check_names ADD CONSTRAINT existing_check CHECK (id > 0)' => 'Duplicate CHECK constraint name', + 'ALTER TABLE alter_check_names ADD CONSTRAINT EXISTING_CHECK CHECK (id > 0)' => 'Duplicate CHECK constraint name', + 'ALTER TABLE alter_check_names DROP CONSTRAINT missing_check' => "Unknown constraint 'missing_check'", + ) as $sql => $message + ) { + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_names' ); + + try { + $driver->query( $sql ); + $this->fail( 'Expected ALTER TABLE CHECK name rejection for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + + $this->assertSame( + $before, + $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_names' ), + 'ALTER TABLE CHECK name rejection mutated schema or data for SQL: ' . $sql + ); + } + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_names' ); + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_check_names DROP CHECK missing_check' )->rowCount() ); + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_names' ) ); + } + + public function test_alter_table_add_check_allows_names_used_by_other_constraint_types(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE alter_check_name_parent (id INT PRIMARY KEY)' ); + $driver->query( + 'CREATE TABLE alter_check_name_reuse ( + id INT, + parent_id INT, + UNIQUE KEY reused_unique (id), + CONSTRAINT reused_fk FOREIGN KEY (parent_id) REFERENCES alter_check_name_parent (id) + )' + ); + $driver->query( 'ALTER TABLE alter_check_name_reuse ADD CONSTRAINT reused_unique CHECK (id > 0)' ); + $driver->query( 'ALTER TABLE alter_check_name_reuse ADD CONSTRAINT reused_fk CHECK (parent_id IS NULL OR parent_id > 0)' ); + + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'reused_fk', + 'CONSTRAINT_TYPE' => 'CHECK', + ), + array( + 'CONSTRAINT_NAME' => 'reused_unique', + 'CONSTRAINT_TYPE' => 'CHECK', + ), + array( + 'CONSTRAINT_NAME' => 'reused_fk', + 'CONSTRAINT_TYPE' => 'FOREIGN KEY', + ), + array( + 'CONSTRAINT_NAME' => 'reused_unique', + 'CONSTRAINT_TYPE' => 'UNIQUE', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'alter_check_name_reuse' + ORDER BY constraint_type, constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + try { + $driver->query( 'ALTER TABLE alter_check_name_reuse DROP CONSTRAINT reused_fk' ); + $this->fail( 'Expected generic DROP CONSTRAINT with cross-type duplicate names to be ambiguous.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "Ambiguous constraint 'reused_fk'", $e->getMessage() ); + } + } + + public function test_alter_table_check_constraint_actions_reject_multi_action_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE alter_check_multi (id INT, amount INT, CONSTRAINT amount_positive CHECK (amount > 0))' ); + $driver->query( 'INSERT INTO alter_check_multi (id, amount) VALUES (1, 20)' ); + + foreach ( + array( + 'ALTER TABLE alter_check_multi ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT too_small CHECK (amount < 10)', + 'ALTER TABLE alter_check_multi ADD CONSTRAINT too_small CHECK (amount < 10), DROP COLUMN amount', + 'ALTER TABLE alter_check_multi DROP CHECK amount_positive, ADD COLUMN should_not_exist INT DEFAULT 2', + ) as $sql + ) { + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_multi' ); + + try { + $driver->query( $sql ); + $this->fail( 'Expected multi-action ALTER TABLE CHECK rebuild rejection for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'ADD/DROP CHECK cannot be combined with other ALTER TABLE actions', $e->getMessage() ); + } + + $this->assertSame( + $before, + $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_multi' ), + 'Multi-action ALTER TABLE CHECK rebuild rejection mutated schema or data for SQL: ' . $sql + ); + } + } + + public function test_alter_table_check_rebuild_preserves_foreign_keys(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE alter_check_fk_parent (id INT PRIMARY KEY)' ); + $driver->query( 'INSERT INTO alter_check_fk_parent (id) VALUES (1)' ); + $driver->query( + 'CREATE TABLE alter_check_fk_child ( + id INT, + parent_id INT, + amount INT, + CONSTRAINT child_fk FOREIGN KEY (parent_id) REFERENCES alter_check_fk_parent (id), + CONSTRAINT amount_positive CHECK (amount > 0) + )' + ); + $driver->query( 'INSERT INTO alter_check_fk_child (id, parent_id, amount) VALUES (1, 1, 10)' ); + + $driver->query( 'ALTER TABLE alter_check_fk_child ADD CONSTRAINT amount_limit CHECK (amount < 100)' ); + + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'amount_limit', + 'CONSTRAINT_TYPE' => 'CHECK', + ), + array( + 'CONSTRAINT_NAME' => 'amount_positive', + 'CONSTRAINT_TYPE' => 'CHECK', + ), + array( + 'CONSTRAINT_NAME' => 'child_fk', + 'CONSTRAINT_TYPE' => 'FOREIGN KEY', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'alter_check_fk_child' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertStringContainsString( + 'CONSTRAINT `child_fk` FOREIGN KEY (`parent_id`) REFERENCES `alter_check_fk_parent` (`id`)', + $driver->query( 'SHOW CREATE TABLE alter_check_fk_child' )->fetch( PDO::FETCH_ASSOC )['Create Table'] + ); + + try { + $driver->query( 'INSERT INTO alter_check_fk_child (id, parent_id, amount) VALUES (2, 2, 20)' ); + $this->fail( 'Expected rebuilt foreign key to reject missing parent rows.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + + $driver->query( 'ALTER TABLE alter_check_fk_child DROP CHECK amount_limit' ); + + try { + $driver->query( 'INSERT INTO alter_check_fk_child (id, parent_id, amount) VALUES (3, 3, 30)' ); + $this->fail( 'Expected foreign key to remain enforced after DROP CHECK rebuild.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + + $driver->query( 'INSERT INTO alter_check_fk_child (id, parent_id, amount) VALUES (4, 1, 150)' ); + $this->assertSame( + array( + array( + 'id' => 1, + 'parent_id' => 1, + 'amount' => 10, + ), + array( + 'id' => 4, + 'parent_id' => 1, + 'amount' => 150, + ), + ), + $driver->query( 'SELECT id, parent_id, amount FROM alter_check_fk_child ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_alter_table_check_rebuild_rejects_referenced_parent_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE alter_check_parent_guard ( + id INT PRIMARY KEY, + amount INT, + CONSTRAINT parent_positive CHECK (amount > 0) + )' + ); + $driver->query( + 'CREATE TABLE alter_check_child_guard ( + id INT, + parent_id INT, + CONSTRAINT child_parent_fk FOREIGN KEY (parent_id) REFERENCES alter_check_parent_guard (id) + )' + ); + + $before = $this->alter_table_referenced_parent_snapshot( $driver ); + + try { + $driver->query( 'ALTER TABLE alter_check_parent_guard ADD CONSTRAINT parent_limit CHECK (amount < 100)' ); + $this->fail( 'Expected referenced parent ADD CHECK rebuild to be rejected before mutation.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'referenced by FOREIGN KEY', $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_referenced_parent_snapshot( $driver ) ); + + $driver->query( 'INSERT INTO alter_check_parent_guard (id, amount) VALUES (1, 10)' ); + $driver->query( 'INSERT INTO alter_check_child_guard (id, parent_id) VALUES (10, 1)' ); + $before = $this->alter_table_referenced_parent_snapshot( $driver ); + + try { + $driver->query( 'ALTER TABLE alter_check_parent_guard DROP CHECK parent_positive' ); + $this->fail( 'Expected referenced parent DROP CHECK rebuild to be rejected before mutation.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'referenced by FOREIGN KEY', $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_referenced_parent_snapshot( $driver ) ); + } + + public function test_alter_table_add_drop_check_rejects_active_transactions_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE alter_check_tx (id INT, CONSTRAINT c1 CHECK (id >= 0), KEY id_idx (id))' ); + $driver->query( 'INSERT INTO alter_check_tx (id) VALUES (1)' ); + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_tx' ); + + $driver->query( 'BEGIN' ); + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_check_tx DROP CHECK missing_check' )->rowCount() ); + foreach ( + array( + 'ALTER TABLE alter_check_tx ADD CONSTRAINT c2 CHECK (id < 10)', + 'ALTER TABLE alter_check_tx DROP CHECK c1', + ) as $sql + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected active transaction CHECK rebuild rejection for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'cannot run inside an active DuckDB transaction', $e->getMessage() ); + } + } + $driver->query( 'ROLLBACK' ); + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_tx' ) ); + } + public function test_unsupported_alter_table_constraint_actions_throw_before_mutation(): void { $this->requireDuckDBRuntime(); @@ -5970,7 +6641,8 @@ public function test_unsupported_alter_table_constraint_actions_throw_before_mut `constraint` INT, `foreign` INT, CONSTRAINT existing_check CHECK (id >= 0), - CONSTRAINT existing_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id) + CONSTRAINT existing_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id), + UNIQUE KEY id_unique (id) )' ); $driver->query( 'INSERT INTO alter_constraint_guard (id, parent_id, `check`, `constraint`, `foreign`) VALUES (1, 1, 7, 8, 9)' ); @@ -5979,16 +6651,13 @@ public function test_unsupported_alter_table_constraint_actions_throw_before_mut foreach ( array( - 'ALTER TABLE alter_constraint_guard ADD CHECK (id >= 0)' => 'ADD CHECK is not supported', - 'ALTER TABLE alter_constraint_guard ADD (CHECK (id >= 0))' => 'ADD CHECK is not supported', - 'ALTER TABLE alter_constraint_guard ADD CONSTRAINT added_check CHECK (id >= 0)' => 'ADD CHECK is not supported', 'ALTER TABLE alter_constraint_guard ADD FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD FOREIGN KEY is not supported', 'ALTER TABLE alter_constraint_guard ADD CONSTRAINT added_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD FOREIGN KEY is not supported', 'ALTER TABLE alter_constraint_guard ADD CONSTRAINT added_unique UNIQUE KEY (id)' => 'ADD CONSTRAINT is not supported', 'ALTER TABLE alter_constraint_guard ADD COLUMN score INT CHECK (score >= 0)' => 'Inline CHECK constraints are only supported in CREATE TABLE', 'ALTER TABLE alter_constraint_guard ADD COLUMN parent_ref INT REFERENCES alter_parent (id)' => 'Inline REFERENCES constraints are only supported in CREATE TABLE', - 'ALTER TABLE alter_constraint_guard DROP CHECK existing_check' => 'DROP CHECK is not supported', - 'ALTER TABLE alter_constraint_guard DROP CONSTRAINT existing_check' => 'DROP CONSTRAINT is not supported', + 'ALTER TABLE alter_constraint_guard DROP CONSTRAINT id_unique' => 'DROP CONSTRAINT currently supports CHECK constraints only', + 'ALTER TABLE alter_constraint_guard DROP CONSTRAINT existing_fk' => 'DROP CONSTRAINT currently supports CHECK constraints only', 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY existing_fk' => 'DROP FOREIGN KEY is not supported', 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY' => 'DROP FOREIGN KEY is not supported', ) as $sql => $message @@ -6042,7 +6711,8 @@ public function test_unsupported_alter_table_constraint_actions_in_multi_action_ `constraint` INT, `foreign` INT, CONSTRAINT existing_check CHECK (id >= 0), - CONSTRAINT existing_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id) + CONSTRAINT existing_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id), + UNIQUE KEY id_unique (id) )' ); $driver->query( 'INSERT INTO alter_constraint_guard (id, parent_id, `check`, `constraint`, `foreign`) VALUES (1, 1, 7, 8, 9)' ); @@ -6051,11 +6721,11 @@ public function test_unsupported_alter_table_constraint_actions_in_multi_action_ foreach ( array( - 'ALTER TABLE alter_constraint_guard ADD CHECK (id >= 0), ADD COLUMN should_not_exist INT DEFAULT 2' => 'ADD CHECK is not supported', + 'ALTER TABLE alter_constraint_guard ADD CHECK (id >= 0), ADD CONSTRAINT added_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD/DROP CHECK cannot be combined with other ALTER TABLE actions', 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY existing_fk, ADD COLUMN should_not_exist INT DEFAULT 2' => 'DROP FOREIGN KEY is not supported', - 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD CHECK (id >= 0)' => 'ADD CHECK is not supported', 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT added_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD FOREIGN KEY is not supported', - 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, DROP CONSTRAINT existing_check' => 'DROP CONSTRAINT is not supported', + 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT added_unique UNIQUE KEY (id)' => 'ADD CONSTRAINT is not supported', + 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, DROP CONSTRAINT existing_fk' => 'DROP CONSTRAINT currently supports CHECK constraints only', 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD COLUMN inline_check INT CHECK (inline_check >= 0)' => 'Inline CHECK constraints are only supported in CREATE TABLE', 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD COLUMN inline_parent INT REFERENCES alter_parent (id)' => 'Inline REFERENCES constraints are only supported in CREATE TABLE', ) as $sql => $message @@ -6206,6 +6876,45 @@ private function alter_table_constraint_guard_snapshot( WP_DuckDB_Driver $driver ); } + private function alter_table_check_lifecycle_snapshot( WP_DuckDB_Driver $driver, string $table_name ): array { + return array( + 'columns' => $driver->query( 'SHOW COLUMNS FROM ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), + 'rows' => $driver->query( 'SELECT * FROM ' . $table_name . ' ORDER BY 1' )->fetchAll( PDO::FETCH_ASSOC ), + 'indexes' => $driver->query( 'SHOW INDEX FROM ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), + 'table_constraints' => $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = '{$table_name}' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'check_constraints' => $driver->query( + "SELECT tc.CONSTRAINT_NAME, cc.CHECK_CLAUSE + FROM information_schema.table_constraints AS tc + JOIN information_schema.check_constraints AS cc + ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + WHERE tc.table_schema = 'wp' AND tc.table_name = '{$table_name}' + ORDER BY tc.constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'show_create' => $driver->query( 'SHOW CREATE TABLE ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), + ); + } + + private function alter_table_referenced_parent_snapshot( WP_DuckDB_Driver $driver ): array { + return array( + 'parent' => $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_parent_guard' ), + 'child_rows' => $driver->query( 'SELECT id, parent_id FROM alter_check_child_guard ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'referential_constraints' => $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' + AND (table_name = 'alter_check_child_guard' OR referenced_table_name = 'alter_check_parent_guard') + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'child_show_create' => $driver->query( 'SHOW CREATE TABLE alter_check_child_guard' )->fetchAll( PDO::FETCH_ASSOC ), + ); + } + private function assertDriverQueryRejected( WP_DuckDB_Driver $driver, string $sql ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid try { $driver->query( $sql ); From a44f78f79e962f00675c8d927a7af8897d9b21c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 03:06:05 +0000 Subject: [PATCH 053/317] Improve DuckDB result statement fetch parity --- .../class-wp-duckdb-result-statement.php | 177 +++++++++++++++++- .../duckdb/WP_DuckDB_Connection_Tests.php | 130 +++++++++++++ 2 files changed, 298 insertions(+), 9 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php index 1216cf89d..faf25e9a4 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php @@ -46,6 +46,11 @@ class WP_DuckDB_Result_Statement implements IteratorAggregate { */ private $default_fetch_mode = PDO::FETCH_BOTH; + /** + * @var array + */ + private $default_fetch_args = array(); + /** * @param string[] $columns Column names. * @param array> $rows Numeric rows. @@ -92,7 +97,13 @@ public function setColumnMeta( array $column_meta ): void { // phpcs:ignore Word * @return bool */ public function setFetchMode( $mode, ...$args ): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid - $this->default_fetch_mode = (int) $mode; + $mode = (int) $mode; + if ( defined( 'PDO::FETCH_DEFAULT' ) && PDO::FETCH_DEFAULT === $mode ) { + $mode = PDO::FETCH_BOTH; + } + + $this->default_fetch_mode = $mode; + $this->default_fetch_args = $args; return true; } @@ -128,7 +139,10 @@ public function fetch( $mode = null ) { $row = $this->rows[ $this->cursor ]; ++$this->cursor; - return $this->format_row( $row, $mode ?? $this->default_fetch_mode ); + $fetch_mode = $mode ?? $this->default_fetch_mode; + $fetch_args = null === $mode ? $this->default_fetch_args : array(); + + return $this->format_row( $row, $fetch_mode, $fetch_args ); } /** @@ -139,9 +153,41 @@ public function fetch( $mode = null ) { * @return array */ public function fetchAll( $mode = null, ...$args ): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $fetch_mode = $mode ?? $this->default_fetch_mode; + if ( defined( 'PDO::FETCH_DEFAULT' ) && PDO::FETCH_DEFAULT === $fetch_mode ) { + $fetch_mode = $this->default_fetch_mode; + } + $fetch_args = null === $mode ? $this->default_fetch_args : $args; + + if ( PDO::FETCH_COLUMN === $fetch_mode ) { + $column = isset( $fetch_args[0] ) ? (int) $fetch_args[0] : 0; + $this->assert_valid_column_index( $column ); + + $rows = array(); + while ( isset( $this->rows[ $this->cursor ] ) ) { + $rows[] = $this->fetchColumn( $column ); + } + return $rows; + } + + if ( PDO::FETCH_KEY_PAIR === $fetch_mode ) { + $this->assert_valid_column_index( 0 ); + $this->assert_valid_column_index( 1 ); + + $rows = array(); + while ( isset( $this->rows[ $this->cursor ] ) ) { + $row = $this->rows[ $this->cursor ]; + ++$this->cursor; + $rows[ $row[0] ] = $row[1]; + } + return $rows; + } + $rows = array(); - while ( false !== ( $row = $this->fetch( $mode ) ) ) { - $rows[] = $row; + while ( isset( $this->rows[ $this->cursor ] ) ) { + $row = $this->rows[ $this->cursor ]; + ++$this->cursor; + $rows[] = $this->format_row( $row, $fetch_mode, $fetch_args ); } return $rows; } @@ -153,6 +199,9 @@ public function fetchAll( $mode = null, ...$args ): array { // phpcs:ignore Word * @return mixed */ public function fetchColumn( $column = 0 ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $column = (int) $column; + $this->assert_valid_column_index( $column ); + if ( ! isset( $this->rows[ $this->cursor ] ) ) { return false; } @@ -160,7 +209,7 @@ public function fetchColumn( $column = 0 ) { // phpcs:ignore WordPress.NamingCon $row = $this->rows[ $this->cursor ]; ++$this->cursor; - return $row[ (int) $column ] ?? false; + return array_key_exists( $column, $row ) ? $row[ $column ] : false; } /** @@ -197,6 +246,43 @@ public function getColumnMeta( $column ) { // phpcs:ignore WordPress.NamingConve return $this->column_meta[ $column ]; } + /** + * Close the cursor. + * + * @return bool + */ + public function closeCursor(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $this->cursor = count( $this->rows ); + return true; + } + + /** + * DuckDB results do not expose multiple rowsets. + * + * @return false + */ + public function nextRowset(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return false; + } + + /** + * Return the statement error code. + * + * @return string + */ + public function errorCode(): string { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return '00000'; + } + + /** + * Return the statement error info tuple. + * + * @return array{0:string,1:null,2:null} + */ + public function errorInfo(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return array( '00000', null, null ); + } + /** * Return rows as an iterator from the current cursor. * @@ -211,24 +297,39 @@ public function getIterator(): Traversable { /** * Format a row for a fetch mode. * - * @param array $row Numeric row. - * @param int $mode Fetch mode. + * @param array $row Numeric row. + * @param int $mode Fetch mode. + * @param array $args Fetch mode arguments. * @return mixed */ - private function format_row( array $row, int $mode ) { + private function format_row( array $row, int $mode, array $args = array() ) { if ( defined( 'PDO::FETCH_DEFAULT' ) && PDO::FETCH_DEFAULT === $mode ) { $mode = $this->default_fetch_mode; + $args = $this->default_fetch_args; } switch ( $mode ) { case PDO::FETCH_ASSOC: return $this->assoc_row( $row ); + case PDO::FETCH_NAMED: + return $this->named_row( $row ); case PDO::FETCH_NUM: return $row; case PDO::FETCH_OBJ: return (object) $this->assoc_row( $row ); case PDO::FETCH_COLUMN: - return $row[0] ?? false; + $column = isset( $args[0] ) ? (int) $args[0] : 0; + $this->assert_valid_column_index( $column ); + return array_key_exists( $column, $row ) ? $row[ $column ] : false; + case PDO::FETCH_CLASS: + $class = isset( $args[0] ) ? $args[0] : 'stdClass'; + $constructor_args = isset( $args[1] ) && is_array( $args[1] ) ? $args[1] : array(); + return $this->class_row( $row, $class, $constructor_args ); + case PDO::FETCH_FUNC: + if ( ! isset( $args[0] ) || ! is_callable( $args[0] ) ) { + throw new TypeError( 'PDO::FETCH_FUNC requires a callable fetch argument.' ); + } + return call_user_func_array( $args[0], $row ); case PDO::FETCH_BOTH: default: return $this->both_row( $row ); @@ -249,6 +350,27 @@ private function assoc_row( array $row ): array { return $assoc; } + /** + * Build a PDO::FETCH_NAMED row. + * + * @param array $row Numeric row. + * @return array + */ + private function named_row( array $row ): array { + $named = array(); + foreach ( $this->columns as $index => $name ) { + $value = $row[ $index ] ?? null; + if ( ! array_key_exists( $name, $named ) ) { + $named[ $name ] = $value; + } elseif ( is_array( $named[ $name ] ) ) { + $named[ $name ][] = $value; + } else { + $named[ $name ] = array( $named[ $name ], $value ); + } + } + return $named; + } + /** * Build a PDO::FETCH_BOTH row. * @@ -262,4 +384,41 @@ private function both_row( array $row ): array { } return $both; } + + /** + * Build a PDO::FETCH_CLASS row. + * + * @param array $row Numeric row. + * @param string $class Class name. + * @param array $constructor_args Constructor arguments. + * @return object + */ + private function class_row( array $row, string $class, array $constructor_args ): object { + $object = new $class( ...$constructor_args ); + foreach ( $this->assoc_row( $row ) as $name => $value ) { + $object->$name = $value; + } + return $object; + } + + /** + * Validate a zero-based column index. + * + * @param int $column Column index. + */ + private function assert_valid_column_index( int $column ): void { + if ( $column < 0 ) { + if ( class_exists( 'ValueError' ) ) { + throw new ValueError( 'Column index must be greater than or equal to 0' ); + } + throw new PDOException( 'Invalid column index' ); + } + + if ( $column >= count( $this->columns ) ) { + if ( class_exists( 'ValueError' ) ) { + throw new ValueError( 'Invalid column index' ); + } + throw new PDOException( 'Invalid column index' ); + } + } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index 631062e03..f23a64f93 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -6,6 +6,136 @@ * @group duckdb */ class WP_DuckDB_Connection_Tests extends WP_DuckDB_TestCase { + public function test_result_statement_fetch_named_preserves_duplicate_columns(): void { + $stmt = new WP_DuckDB_Result_Statement( + array( 'id', 'id', 'name' ), + array( + array( 1, 2, 'Ada' ), + ) + ); + + $this->assertSame( + array( + 'id' => array( 1, 2 ), + 'name' => 'Ada', + ), + $stmt->fetch( PDO::FETCH_NAMED ) + ); + $this->assertFalse( $stmt->fetch() ); + } + + public function test_result_statement_fetch_column_preserves_nulls_and_validates_indexes(): void { + $stmt = new WP_DuckDB_Result_Statement( + array( 'id', 'label' ), + array( + array( 1, 'first' ), + array( 2, null ), + ) + ); + + $this->assertSame( 'first', $stmt->fetchColumn( 1 ) ); + $this->assertNull( $stmt->fetchColumn( 1 ) ); + $this->assertFalse( $stmt->fetchColumn( 1 ) ); + + $stmt = new WP_DuckDB_Result_Statement( array( 'id' ), array( array( 1 ) ) ); + if ( PHP_VERSION_ID < 80000 ) { + $this->expectException( PDOException::class ); + $this->expectExceptionMessage( 'Invalid column index' ); + } else { + $this->expectException( ValueError::class ); + $this->expectExceptionMessage( 'Invalid column index' ); + } + $stmt->fetchColumn( 1 ); + } + + public function test_result_statement_fetch_all_column_key_pair_class_and_func_modes(): void { + $stmt = new WP_DuckDB_Result_Statement( + array( 'id', 'name' ), + array( + array( 1, 'Ada' ), + array( 2, 'Grace' ), + ) + ); + $this->assertSame( array( 'Ada', 'Grace' ), $stmt->fetchAll( PDO::FETCH_COLUMN, 1 ) ); + + $stmt = new WP_DuckDB_Result_Statement( + array( 'id', 'name' ), + array( + array( 1, 'Ada' ), + array( 2, 'Grace' ), + ) + ); + $this->assertSame( + array( + 1 => 'Ada', + 2 => 'Grace', + ), + $stmt->fetchAll( PDO::FETCH_KEY_PAIR ) + ); + + $stmt = new WP_DuckDB_Result_Statement( array( 'name' ), array( array( 'Ada' ) ) ); + $rows = $stmt->fetchAll( PDO::FETCH_CLASS, stdClass::class ); + $this->assertCount( 1, $rows ); + $this->assertInstanceOf( stdClass::class, $rows[0] ); + $this->assertSame( 'Ada', $rows[0]->name ); + + $stmt = new WP_DuckDB_Result_Statement( + array( 'first', 'second' ), + array( + array( 'a', 'b' ), + array( 'c', 'd' ), + ) + ); + $this->assertSame( + array( 'a:b', 'c:d' ), + $stmt->fetchAll( + PDO::FETCH_FUNC, + function ( $first, $second ) { + return $first . ':' . $second; + } + ) + ); + } + + public function test_result_statement_cursor_and_metadata_methods_match_pdo_shape(): void { + $stmt = new WP_DuckDB_Result_Statement( + array( 'id', 'name' ), + array( + array( 1, 'Ada' ), + array( 2, 'Grace' ), + ), + 0, + array( + array( + 'name' => 'id', + 'native_type' => 'BIGINT', + 'mysqli:orgname' => 'id', + 'mysqli:orgtable' => 'users', + 'mysqli:custom_key' => 'preserved', + ), + ) + ); + + $this->assertSame( 2, $stmt->columnCount() ); + $this->assertSame( + array( + 'name' => 'id', + 'native_type' => 'BIGINT', + 'mysqli:orgname' => 'id', + 'mysqli:orgtable' => 'users', + 'mysqli:custom_key' => 'preserved', + ), + $stmt->getColumnMeta( 0 ) + ); + $this->assertSame( array( 'name' => 'name' ), $stmt->getColumnMeta( 1 ) ); + $this->assertFalse( $stmt->getColumnMeta( 2 ) ); + $this->assertSame( '00000', $stmt->errorCode() ); + $this->assertSame( array( '00000', null, null ), $stmt->errorInfo() ); + $this->assertFalse( $stmt->nextRowset() ); + $this->assertTrue( $stmt->closeCursor() ); + $this->assertFalse( $stmt->fetch() ); + } + public function test_in_memory_connection_executes_query(): void { $this->requireDuckDBRuntime(); From a2fdd898d1b5ef7e73fc13267c7041d264eb5521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 03:09:07 +0000 Subject: [PATCH 054/317] Add DuckDB CAST and binary expression parity --- .../src/duckdb/class-wp-duckdb-driver.php | 584 ++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 42 ++ 2 files changed, 626 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 31ad71640..3662dc934 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -9052,18 +9052,97 @@ private function translate_tokens_to_duckdb_sql( continue; } + $binary_comparison = $this->translate_binary_comparison_predicate( + $tokens, + $index, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + if ( null !== $binary_comparison ) { + $pieces[] = $binary_comparison; + continue; + } + + $cast_expression = $this->translate_cast_expression( + $tokens, + $index, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + if ( null !== $cast_expression ) { + $pieces[] = $cast_expression; + continue; + } + + $convert_expression = $this->translate_convert_expression( + $tokens, + $index, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + if ( null !== $convert_expression ) { + $pieces[] = $convert_expression; + continue; + } + + $binary_expression = $this->translate_binary_expression( + $tokens, + $index, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + if ( null !== $binary_expression ) { + $pieces[] = $binary_expression; + continue; + } + $unix_timestamp_comparison = $this->translate_unix_timestamp_comparison( $tokens, $index ); if ( null !== $unix_timestamp_comparison ) { $pieces[] = $unix_timestamp_comparison; continue; } + $like_binary_predicate = $this->translate_like_binary_predicate( $tokens, $index ); + if ( null !== $like_binary_predicate ) { + $pieces[] = $like_binary_predicate; + continue; + } + $like_escape_predicate = $this->translate_like_escape_predicate( $tokens, $index ); if ( null !== $like_escape_predicate ) { $pieces[] = $like_escape_predicate; continue; } + if ( + WP_MySQL_Lexer::COLLATE_SYMBOL === $token->id + && isset( $tokens[ $index + 1 ] ) + && $this->is_mysql_expression_collation( $tokens[ $index + 1 ] ) + ) { + ++$index; + continue; + } + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { $identifier = null; if ( $rewrite_information_schema_tables ) { @@ -10176,6 +10255,511 @@ private function translate_token_to_duckdb_sql( WP_Parser_Token $token ): string return $token->get_bytes(); } + /** + * Translate MySQL CAST(expr AS type) expressions. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current index, advanced on match. + * @return string|null DuckDB SQL, or null when the token does not start CAST(). + */ + private function translate_cast_expression( + array $tokens, + int &$index, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints + ): ?string { + if ( + ! isset( $tokens[ $index + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $index ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + return null; + } + + $end_index = $this->skip_balanced_parentheses( $tokens, $index + 1 ); + $body = array_slice( $tokens, $index + 2, $end_index - $index - 3 ); + $as_index = $this->find_top_level_token_index( $body, 0, WP_MySQL_Lexer::AS_SYMBOL ); + if ( null === $as_index ) { + return null; + } + + $expr_tokens = array_slice( $body, 0, $as_index ); + $type_tokens = array_slice( $body, $as_index + 1 ); + if ( count( $expr_tokens ) === 0 || count( $type_tokens ) === 0 ) { + return null; + } + + $expr_sql = $this->translate_tokens_to_duckdb_sql( + $expr_tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + $type_sql = $this->translate_cast_type_to_duckdb_sql( $type_tokens ); + + $index = $end_index - 1; + if ( $this->cast_type_has_token( $type_tokens, WP_MySQL_Lexer::BINARY_SYMBOL ) ) { + return 'CAST(' . $expr_sql . ' AS VARCHAR)'; + } + return 'CAST(' . $expr_sql . ' AS ' . $type_sql . ')'; + } + + /** + * Translate MySQL CONVERT(expr, type) and CONVERT(expr USING charset). + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current index, advanced on match. + * @return string|null DuckDB SQL, or null when the token does not start CONVERT(). + */ + private function translate_convert_expression( + array $tokens, + int &$index, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints + ): ?string { + if ( + ! isset( $tokens[ $index + 1 ] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[ $index ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + return null; + } + + $end_index = $this->skip_balanced_parentheses( $tokens, $index + 1 ); + $body = array_slice( $tokens, $index + 2, $end_index - $index - 3 ); + $using_index = $this->find_top_level_token_index( $body, 0, WP_MySQL_Lexer::USING_SYMBOL ); + $items = null === $using_index ? $this->split_top_level_comma_items( $body ) : array(); + + if ( null !== $using_index ) { + $expr_tokens = array_slice( $body, 0, $using_index ); + if ( count( $expr_tokens ) === 0 ) { + return null; + } + + $index = $end_index - 1; + return $this->translate_tokens_to_duckdb_sql( + $expr_tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + } + + if ( 2 !== count( $items ) || count( $items[0] ) === 0 || count( $items[1] ) === 0 ) { + return null; + } + + $expr_sql = $this->translate_tokens_to_duckdb_sql( + $items[0], + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + $type_sql = $this->translate_cast_type_to_duckdb_sql( $items[1] ); + + $index = $end_index - 1; + if ( $this->cast_type_has_token( $items[1], WP_MySQL_Lexer::BINARY_SYMBOL ) ) { + return 'CAST(' . $expr_sql . ' AS VARCHAR)'; + } + return 'CAST(' . $expr_sql . ' AS ' . $type_sql . ')'; + } + + /** + * Translate equality predicates where one side is explicitly BINARY. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current index, advanced on match. + * @return string|null DuckDB SQL, or null when no supported predicate starts here. + */ + private function translate_binary_comparison_predicate( + array $tokens, + int &$index, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints + ): ?string { + if ( WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $index ]->id ) { + list( $left_tokens, $operator_index ) = $this->collect_binary_operand_tokens( $tokens, $index + 1 ); + if ( + count( $left_tokens ) === 0 + || ! isset( $tokens[ $operator_index + 1 ] ) + || ! in_array( $tokens[ $operator_index ]->id, array( WP_MySQL_Lexer::EQUAL_OPERATOR, WP_MySQL_Lexer::NOT_EQUAL_OPERATOR ), true ) + ) { + return null; + } + + list( $right_tokens, $next_index ) = $this->collect_binary_operand_tokens( $tokens, $operator_index + 1 ); + if ( count( $right_tokens ) === 0 ) { + return null; + } + + $index = $next_index - 1; + return $this->binary_string_hash_sql( + $left_tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ) + . ' ' + . $tokens[ $operator_index ]->get_bytes() + . ' ' + . $this->binary_string_hash_sql( + $right_tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + } + + if ( + isset( $tokens[ $index + 2 ], $tokens[ $index + 3 ] ) + && ! $this->is_non_identifier_token( $tokens[ $index ] ) + && in_array( $tokens[ $index + 1 ]->id, array( WP_MySQL_Lexer::EQUAL_OPERATOR, WP_MySQL_Lexer::NOT_EQUAL_OPERATOR ), true ) + && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $index + 2 ]->id + ) { + $left_tokens = array( $tokens[ $index ] ); + $operator_token = $tokens[ $index + 1 ]; + + list( $right_tokens, $next_index ) = $this->collect_binary_operand_tokens( $tokens, $index + 3 ); + if ( count( $right_tokens ) === 0 ) { + return null; + } + + $index = $next_index - 1; + return $this->binary_string_hash_sql( + $left_tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ) + . ' ' + . $operator_token->get_bytes() + . ' ' + . $this->binary_string_hash_sql( + $right_tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + } + + return null; + } + + /** + * Build an exact string comparison key for a BINARY predicate operand. + * + * @param WP_Parser_Token[] $tokens Operand tokens. + * @return string DuckDB SQL. + */ + private function binary_string_hash_sql( + array $tokens, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints + ): string { + return 'md5(CAST((' + . $this->translate_tokens_to_duckdb_sql( + $tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ) + . ') AS VARCHAR))'; + } + + /** + * Translate MySQL unary BINARY expr to a case-sensitive text expression. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current index, advanced on match. + * @return string|null DuckDB SQL, or null when the token does not start unary BINARY. + */ + private function translate_binary_expression( + array $tokens, + int &$index, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints + ): ?string { + if ( WP_MySQL_Lexer::BINARY_SYMBOL !== $tokens[ $index ]->id || ! isset( $tokens[ $index + 1 ] ) ) { + return null; + } + + list( $operand_tokens, $next_index ) = $this->collect_binary_operand_tokens( $tokens, $index + 1 ); + if ( count( $operand_tokens ) === 0 ) { + return null; + } + + $operand_sql = $this->translate_tokens_to_duckdb_sql( + $operand_tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + + $index = $next_index - 1; + return 'hex(encode(CAST(' . $operand_sql . ' AS VARCHAR)))'; + } + + /** + * Translate a compact MySQL cast type to a DuckDB type with SQLite parity. + * + * @param WP_Parser_Token[] $type_tokens Cast type tokens. + * @return string DuckDB type. + */ + private function translate_cast_type_to_duckdb_sql( array $type_tokens ): string { + foreach ( $type_tokens as $token ) { + switch ( $token->id ) { + case WP_MySQL_Lexer::BINARY_SYMBOL: + case WP_MySQL_Lexer::CHAR_SYMBOL: + case WP_MySQL_Lexer::NCHAR_SYMBOL: + case WP_MySQL_Lexer::NATIONAL_SYMBOL: + case WP_MySQL_Lexer::VARCHAR_SYMBOL: + case WP_MySQL_Lexer::DATE_SYMBOL: + case WP_MySQL_Lexer::TIME_SYMBOL: + case WP_MySQL_Lexer::DATETIME_SYMBOL: + case WP_MySQL_Lexer::JSON_SYMBOL: + return 'VARCHAR'; + case WP_MySQL_Lexer::SIGNED_SYMBOL: + case WP_MySQL_Lexer::UNSIGNED_SYMBOL: + return 'BIGINT'; + case WP_MySQL_Lexer::DECIMAL_SYMBOL: + case WP_MySQL_Lexer::FLOAT_SYMBOL: + case WP_MySQL_Lexer::REAL_SYMBOL: + case WP_MySQL_Lexer::DOUBLE_SYMBOL: + return 'DOUBLE'; + } + } + + return $this->translate_tokens_to_duckdb_sql( $type_tokens ); + } + + /** + * Check whether a cast type contains a token. + * + * @param WP_Parser_Token[] $type_tokens Cast type tokens. + * @param int $token_id Token ID. + * @return bool Whether the token is present. + */ + private function cast_type_has_token( array $type_tokens, int $token_id ): bool { + foreach ( $type_tokens as $token ) { + if ( $token_id === $token->id ) { + return true; + } + } + return false; + } + + /** + * Collect the operand for a unary BINARY expression. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Operand start index. + * @return array{0: WP_Parser_Token[], 1: int} Operand tokens and next index. + */ + private function collect_binary_operand_tokens( array $tokens, int $index ): array { + if ( ! isset( $tokens[ $index ] ) ) { + return array( array(), $index ); + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + $next_index = $this->skip_balanced_parentheses( $tokens, $index ); + return array( array_slice( $tokens, $index, $next_index - $index ), $next_index ); + } + + if ( + isset( $tokens[ $index + 1 ] ) + && in_array( $tokens[ $index ]->id, array( WP_MySQL_Lexer::CAST_SYMBOL, WP_MySQL_Lexer::CONVERT_SYMBOL ), true ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index + 1 ]->id + ) { + $next_index = $this->skip_balanced_parentheses( $tokens, $index + 1 ); + return array( array_slice( $tokens, $index, $next_index - $index ), $next_index ); + } + + if ( + isset( $tokens[ $index + 2 ] ) + && ! $this->is_non_identifier_token( $tokens[ $index ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && ! $this->is_non_identifier_token( $tokens[ $index + 2 ] ) + ) { + return array( array_slice( $tokens, $index, 3 ), $index + 3 ); + } + + return array( array( $tokens[ $index ] ), $index + 1 ); + } + + /** + * Translate MySQL LIKE BINARY predicates with literal patterns. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index, advanced on match. + * @return string|null Translated predicate, or null when the pattern does not match. + */ + private function translate_like_binary_predicate( array $tokens, int &$index ): ?string { + if ( ! isset( $tokens[ $index ] ) || $this->is_non_identifier_token( $tokens[ $index ] ) ) { + return null; + } + + $operator_index = $index + 1; + $left_tokens = array( $tokens[ $index ] ); + if ( + isset( $tokens[ $index + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && ! $this->is_non_identifier_token( $tokens[ $index + 2 ] ) + ) { + $left_tokens = array( $tokens[ $index ], $tokens[ $index + 1 ], $tokens[ $index + 2 ] ); + $operator_index = $index + 3; + } + + $is_not_like = false; + $pattern_index = $operator_index + 2; + if ( + isset( $tokens[ $operator_index + 2 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $operator_index ]->id + && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $operator_index + 1 ]->id + ) { + $is_not_like = false; + } elseif ( + isset( $tokens[ $operator_index + 3 ] ) + && in_array( $tokens[ $operator_index ]->id, array( WP_MySQL_Lexer::NOT_SYMBOL, WP_MySQL_Lexer::NOT2_SYMBOL ), true ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $operator_index + 1 ]->id + && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $operator_index + 2 ]->id + ) { + $is_not_like = true; + $pattern_index = $operator_index + 3; + } else { + return null; + } + + if ( + ! isset( $tokens[ $pattern_index ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $pattern_index ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $pattern_index ]->id + ) + ) { + return null; + } + + $left_sql = $this->translate_tokens_to_duckdb_sql( $left_tokens ); + $index = $pattern_index; + $predicate = 'regexp_full_match(CAST(' + . $left_sql + . ' AS VARCHAR), ' + . $this->connection->quote( $this->like_pattern_to_regex_pattern( $tokens[ $pattern_index ]->get_value() ) ) + . ')'; + + return $is_not_like ? '(NOT ' . $predicate . ')' : $predicate; + } + + /** + * Convert a MySQL LIKE pattern to a full-match regular expression. + * + * @param string $pattern LIKE pattern. + * @return string Regular expression pattern. + */ + private function like_pattern_to_regex_pattern( string $pattern ): string { + $chars = preg_split( '//u', $pattern, -1, PREG_SPLIT_NO_EMPTY ); + if ( false === $chars ) { + $chars = str_split( $pattern ); + } + + $regex = '^'; + for ( $index = 0; $index < count( $chars ); ++$index ) { + $char = $chars[ $index ]; + if ( '\\' === $char && isset( $chars[ $index + 1 ] ) ) { + $regex .= preg_quote( $chars[ $index + 1 ], '/' ); + ++$index; + continue; + } + if ( '%' === $char ) { + $regex .= '.*'; + continue; + } + if ( '_' === $char ) { + $regex .= '.'; + continue; + } + $regex .= preg_quote( $char, '/' ); + } + + return $regex . '$'; + } + + /** + * Check whether a collation token is a MySQL expression collation unsupported by DuckDB. + * + * @param WP_Parser_Token $token Collation token. + * @return bool Whether the collation should be treated as a no-op. + */ + private function is_mysql_expression_collation( WP_Parser_Token $token ): bool { + if ( $this->is_non_identifier_token( $token ) ) { + return false; + } + + $collation = strtolower( $token->get_value() ); + return 1 === preg_match( '/^(utf8|utf8mb3|utf8mb4|latin1)[a-z0-9_]*$/', $collation ); + } + /** * Translate MySQL's numeric column coercion around UNIX_TIMESTAMP(). * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 0db2791a4..b9b8ad692 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -18,6 +18,48 @@ public function test_common_scalar_functions_match_sqlite(): void { $this->assertParityRows( "SELECT SUBSTR('abcdef', 2, 3) AS short_substr, SUBSTRING('abcdef', 2, 3) AS long_substring" ); } + public function test_select_cast_convert_binary_expressions_match_sqlite(): void { + foreach ( + array( + "SELECT CAST('42' AS SIGNED) AS v", + "SELECT CAST('-10' AS SIGNED) AS v", + "SELECT CAST('3.5' AS DECIMAL(10,2)) AS v", + 'SELECT CAST(123 AS CHAR) AS v', + "SELECT CAST('abc' AS BINARY) = 'abc' AS v", + "SELECT CONVERT('abc', BINARY) = 'abc' AS v", + "SELECT CONVERT('abc', CHAR) AS v", + "SELECT CONVERT('-10', SIGNED) AS v", + "SELECT CONVERT('Customer' USING utf8mb4) AS v", + "SELECT CONVERT('Customer' USING utf8mb4) COLLATE utf8mb4_bin AS v", + "SELECT BINARY 'abc' = 'abc' AS v", + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_select_like_binary_expressions_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE expr_strings (name VARCHAR(20))', + "INSERT INTO expr_strings (name) VALUES ('abc'), ('ABC'), ('a_c'), ('a%c'), ('ábC')", + ) + ); + + foreach ( + array( + "SELECT name FROM expr_strings WHERE BINARY name = 'ABC' ORDER BY name", + "SELECT name FROM expr_strings WHERE name LIKE BINARY 'A%' ORDER BY name", + "SELECT name FROM expr_strings WHERE name NOT LIKE BINARY 'A%' ORDER BY name", + "SELECT name FROM expr_strings WHERE name LIKE BINARY 'a\\_%' ORDER BY name", + "SELECT name FROM expr_strings WHERE name LIKE BINARY 'a\\%%' ORDER BY name", + 'SELECT name FROM expr_strings ORDER BY BINARY name', + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + public function test_create_insert_select_update_delete_match_sqlite(): void { $this->runParitySetup( array( From 3d4ea5532b527d13c01e85160a90574ea43eefe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 03:20:16 +0000 Subject: [PATCH 055/317] Add DuckDB DROP PRIMARY KEY parity --- .../src/duckdb/class-wp-duckdb-driver.php | 485 ++++++++++++++---- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 211 ++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 215 +++++++- 3 files changed, 824 insertions(+), 87 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 3662dc934..ab4977568 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -4511,7 +4511,7 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD COLUMN, ADD INDEX, DROP COLUMN, and DROP INDEX are supported.' ); } - $this->validate_alter_table_check_rebuild_action_combination( $table_name, $actions, $temporary ); + $this->validate_alter_table_rebuild_action_combination( $table_name, $actions, $temporary ); $this->validate_alter_table_constraint_actions( $table_name, $actions, $temporary ); $result = null; @@ -4564,13 +4564,13 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen } /** - * Reject combined ALTER TABLE CHECK rebuild actions before any mutation. + * Reject combined ALTER TABLE rebuild actions before any mutation. * - * @param string $table_name Table name. + * @param string $table_name Table name. * @param array $actions ALTER action token groups. - * @param bool $temporary Whether the target is a temporary table. + * @param bool $temporary Whether the target is a temporary table. */ - private function validate_alter_table_check_rebuild_action_combination( string $table_name, array $actions, bool $temporary = false ): void { + private function validate_alter_table_rebuild_action_combination( string $table_name, array $actions, bool $temporary = false ): void { if ( count( $actions ) <= 1 ) { return; } @@ -4579,6 +4579,9 @@ private function validate_alter_table_check_rebuild_action_combination( string $ if ( $this->is_alter_table_check_rebuild_action( $table_name, $action, $temporary ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD/DROP CHECK cannot be combined with other ALTER TABLE actions.' ); } + if ( $this->is_alter_table_drop_primary_key_action( $action ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP PRIMARY KEY cannot be combined with other ALTER TABLE actions.' ); + } } } @@ -4740,8 +4743,8 @@ private function validate_alter_table_add_constraint_action( string $table_name, foreach ( $items as $item ) { if ( $this->is_create_table_check_constraint( $item ) ) { - $this->assert_no_active_transaction_for_alter_table_check_rebuild(); - $this->assert_not_referenced_parent_for_alter_table_check_rebuild( $table_name, $temporary ); + $this->assert_no_active_transaction_for_table_rebuild( 'ADD/DROP CHECK' ); + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD/DROP CHECK', $temporary ); $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); $this->translate_table_check_constraint( $table_name, $item, $check_names ); continue; @@ -4807,14 +4810,19 @@ private function validate_alter_table_drop_constraint_action( string $table_name return; } + if ( $this->is_alter_table_drop_primary_key_action( $tokens ) ) { + $this->assert_alter_table_drop_primary_key_supported( $table_name, $temporary ); + return; + } + if ( WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[1]->id ) { $constraint_name = $this->parse_alter_table_drop_check_constraint_name( $tokens, WP_MySQL_Lexer::CHECK_SYMBOL ); $check = $this->resolve_check_constraint_metadata_row( $table_name, $constraint_name, $temporary ); if ( null === $check ) { return; } - $this->assert_no_active_transaction_for_alter_table_check_rebuild(); - $this->assert_not_referenced_parent_for_alter_table_check_rebuild( $table_name, $temporary ); + $this->assert_no_active_transaction_for_table_rebuild( 'ADD/DROP CHECK' ); + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD/DROP CHECK', $temporary ); $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); unset( $check_names[ strtolower( (string) $check['constraint_name'] ) ] ); return; @@ -4826,8 +4834,8 @@ private function validate_alter_table_drop_constraint_action( string $table_name if ( 'CHECK' !== $constraint_type ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP CONSTRAINT currently supports CHECK constraints only.' ); } - $this->assert_no_active_transaction_for_alter_table_check_rebuild(); - $this->assert_not_referenced_parent_for_alter_table_check_rebuild( $table_name, $temporary ); + $this->assert_no_active_transaction_for_table_rebuild( 'ADD/DROP CHECK' ); + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD/DROP CHECK', $temporary ); $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); unset( $check_names[ strtolower( $constraint_name ) ] ); return; @@ -4843,24 +4851,27 @@ private function validate_alter_table_drop_constraint_action( string $table_name } /** - * Reject CHECK table rebuilds inside an active transaction. + * Reject table rebuilds inside an active transaction. + * + * @param string $operation Operation label for the error message. */ - private function assert_no_active_transaction_for_alter_table_check_rebuild(): void { + private function assert_no_active_transaction_for_table_rebuild( string $operation ): void { if ( $this->connection->inTransaction() ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD/DROP CHECK cannot run inside an active DuckDB transaction.' ); + throw new WP_DuckDB_Driver_Exception( "Unsupported ALTER TABLE statement in DuckDB driver. {$operation} cannot run inside an active DuckDB transaction." ); } } /** - * Reject CHECK rebuilds on referenced parent tables before mutation. + * Reject rebuilds on referenced parent tables before mutation. * * @param string $table_name Table name. + * @param string $operation Operation label for the error message. * @param bool $temporary Whether the target is a temporary table. */ - private function assert_not_referenced_parent_for_alter_table_check_rebuild( string $table_name, bool $temporary = false ): void { + private function assert_not_referenced_parent_for_table_rebuild( string $table_name, string $operation, bool $temporary = false ): void { foreach ( $this->foreign_key_references_to_table( $table_name, $temporary ) as $reference ) { throw new WP_DuckDB_Driver_Exception( - "Unsupported ALTER TABLE statement in DuckDB driver. ADD/DROP CHECK on table '{$this->database}.{$table_name}' is not supported because it is referenced by FOREIGN KEY '{$reference['constraint_name']}' on table '{$reference['table_name']}'." + "Unsupported ALTER TABLE statement in DuckDB driver. {$operation} on table '{$this->database}.{$table_name}' is not supported because it is referenced by FOREIGN KEY '{$reference['constraint_name']}' on table '{$reference['table_name']}'." ); } } @@ -5130,13 +5141,39 @@ private function constraint_types_for_name( string $table_name, string $constrai * @param bool $temporary Whether the target is a temporary table. */ private function rebuild_table_with_check_constraints( string $table_name, array $check_constraints, bool $temporary = false ): void { + $this->rebuild_table_from_metadata_plan( + $table_name, + $this->table_column_metadata_rows( $table_name, $temporary ), + $this->primary_key_columns_for_table( $table_name ), + $this->secondary_index_definitions_for_table( $table_name, $temporary ), + $check_constraints, + $this->show_create_table_foreign_key_groups( $table_name, $temporary ), + null, + 'CHECK', + $temporary + ); + } + + /** + * Rebuild a table from explicit MySQL-facing metadata. + * + * @param string $table_name Table name. + * @param array> $column_metadata_rows Column metadata rows. + * @param string[] $primary_key_columns Planned primary key columns. + * @param array}> $secondary_indexes Planned secondary indexes. + * @param array> $check_constraints Planned CHECK constraints. + * @param array> $foreign_key_constraints Planned FOREIGN KEY constraints. + * @param int|null $auto_increment_override Optional AUTO_INCREMENT value override. + * @param string $context Rebuild context for error messages. + * @param bool $temporary Whether the target is a temporary table. + */ + private function rebuild_table_from_metadata_plan( string $table_name, array $column_metadata_rows, array $primary_key_columns, array $secondary_indexes, array $check_constraints, array $foreign_key_constraints, ?int $auto_increment_override, string $context, bool $temporary = false ): void { $sequence_names = $this->auto_increment_sequences_for_table( $table_name, $temporary ); - $metadata_rows = $this->table_column_metadata_rows( $table_name, $temporary ); $column_names = array_map( function ( array $column ): string { return (string) $column['column_name']; }, - $metadata_rows + $column_metadata_rows ); $quoted_columns = implode( ', ', @@ -5147,14 +5184,22 @@ function ( string $column_name ): string { $column_names ) ); - $backup_table = '__wp_duckdb_check_rebuild_' . substr( hash( 'sha256', $table_name . "\0" . microtime( true ) . "\0" . mt_rand() ), 0, 16 ); - - $this->record_check_metadata( $table_name, $check_constraints, $temporary ); - $create_sql = $this->mysql_create_table_statement( $table_name, $table_name, $temporary ); + $backup_table = '__wp_duckdb_rebuild_' . substr( hash( 'sha256', $context . "\0" . $table_name . "\0" . microtime( true ) . "\0" . mt_rand() ), 0, 16 ); + $create_sql = $this->mysql_create_table_statement_from_metadata_plan( + $table_name, + $table_name, + $column_metadata_rows, + $primary_key_columns, + $secondary_indexes, + $check_constraints, + $foreign_key_constraints, + $temporary, + $auto_increment_override + ); $this->execute_duckdb_query( 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $backup_table ), - 'Failed to reset DuckDB CHECK rebuild backup table' + 'Failed to reset DuckDB ' . $context . ' rebuild backup table' ); $this->execute_duckdb_query( 'CREATE TEMP TABLE ' @@ -5163,11 +5208,11 @@ function ( string $column_name ): string { . $quoted_columns . ' FROM ' . $this->connection->quote_identifier( $table_name ), - 'Failed to back up DuckDB table for CHECK rebuild' + 'Failed to back up DuckDB table for ' . $context . ' rebuild' ); $this->execute_duckdb_query( 'DROP TABLE ' . $this->connection->quote_identifier( $table_name ), - 'Failed to rebuild DuckDB CHECK table' + 'Failed to rebuild DuckDB ' . $context . ' table' ); $this->drop_auto_increment_sequences( $sequence_names ); $this->execute_create_table( $this->tokenize_and_validate( $create_sql ) ); @@ -5180,11 +5225,170 @@ function ( string $column_name ): string { . $quoted_columns . ' FROM ' . $this->connection->quote_identifier( $backup_table ), - 'Failed to restore DuckDB table rows after CHECK rebuild' + 'Failed to restore DuckDB table rows after ' . $context . ' rebuild' ); $this->execute_duckdb_query( 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $backup_table ), - 'Failed to drop DuckDB CHECK rebuild backup table' + 'Failed to drop DuckDB ' . $context . ' rebuild backup table' + ); + $this->refresh_column_key_metadata( $table_name, $temporary ); + } + + /** + * Build a MySQL CREATE TABLE statement from explicit planned metadata. + * + * @param string $table_name Resolved physical table name. + * @param string $requested_table_name Requested MySQL table name. + * @param array> $column_metadata_rows Column metadata rows. + * @param string[] $primary_key_columns Planned primary key columns. + * @param array}> $secondary_indexes Planned secondary indexes. + * @param array> $check_constraints Planned CHECK constraints. + * @param array> $foreign_key_constraints Planned FOREIGN KEY constraints. + * @param bool $temporary Whether the target is a temporary table. + * @param int|null $auto_increment_override AUTO_INCREMENT value override. + * @return string MySQL CREATE TABLE statement. + */ + private function mysql_create_table_statement_from_metadata_plan( string $table_name, string $requested_table_name, array $column_metadata_rows, array $primary_key_columns, array $secondary_indexes, array $check_constraints, array $foreign_key_constraints, bool $temporary = false, ?int $auto_increment_override = null ): string { + $metadata_by_table = $this->table_metadata_by_table( $temporary ); + $table_metadata = $metadata_by_table[ $table_name ] ?? $this->fallback_table_metadata( $table_name ); + $table_info = null === $auto_increment_override + ? $this->information_schema_table_row( $table_name, $table_metadata, $temporary ) + : array( + 'ENGINE' => $table_metadata['engine'], + 'AUTO_INCREMENT' => $auto_increment_override, + 'TABLE_COLLATION' => $table_metadata['table_collation'], + 'TABLE_COMMENT' => $table_metadata['table_comment'], + ); + $rows = array(); + $has_auto_increment = false; + + foreach ( $column_metadata_rows as $column ) { + $rows[] = $this->format_show_create_table_column( + $this->information_schema_column_row( $table_name, $column ), + $has_auto_increment + ); + } + + if ( count( $primary_key_columns ) > 0 ) { + $rows[] = $this->format_show_create_table_index( + $this->show_create_table_primary_key_group_from_columns( $primary_key_columns ) + ); + } + + foreach ( $this->show_create_table_index_groups_from_definitions( $secondary_indexes ) as $index_group ) { + $rows[] = $this->format_show_create_table_index( $index_group ); + } + + foreach ( $foreign_key_constraints as $foreign_key ) { + $rows[] = $this->format_show_create_table_foreign_key_constraint( $foreign_key ); + } + + foreach ( $check_constraints as $check_constraint ) { + $rows[] = $this->format_show_create_table_check_constraint( $check_constraint ); + } + + $sql = 'CREATE ' . ( $temporary ? 'TEMPORARY ' : '' ) . 'TABLE ' . $this->quote_mysql_identifier( $requested_table_name ) . " (\n"; + $sql .= implode( ",\n", $rows ); + $sql .= "\n)"; + $sql .= ' ENGINE=' . (string) $table_info['ENGINE']; + + $auto_increment = null === $auto_increment_override ? $table_info['AUTO_INCREMENT'] : $auto_increment_override; + if ( $has_auto_increment && null !== $auto_increment && (int) $auto_increment > 1 ) { + $sql .= ' AUTO_INCREMENT=' . (int) $auto_increment; + } + + $collation = (string) $table_info['TABLE_COLLATION']; + if ( '' === $collation ) { + $collation = 'utf8mb4_0900_ai_ci'; + } + $charset = $this->character_set_from_collation( $collation ) ?? 'utf8mb4'; + $sql .= ' DEFAULT CHARSET=' . $charset; + $sql .= ' COLLATE=' . $collation; + + if ( '' !== $table_info['TABLE_COMMENT'] ) { + $sql .= ' COMMENT=' . $this->quote_mysql_utf8_string_literal( (string) $table_info['TABLE_COMMENT'] ); + } + + return $sql; + } + + /** + * Build a SHOW CREATE index group from primary key columns. + * + * @param string[] $columns Primary key columns. + * @return array{name:string,non_unique:int,index_type:string,index_comment:string,columns:array} + */ + private function show_create_table_primary_key_group_from_columns( array $columns ): array { + return array( + 'name' => 'PRIMARY', + 'non_unique' => 0, + 'index_type' => 'BTREE', + 'index_comment' => '', + 'columns' => array_map( + function ( string $column ): array { + return array( + 'name' => $column, + 'sub_part' => null, + 'collation' => 'A', + ); + }, + $columns + ), + ); + } + + /** + * Build SHOW CREATE index groups from secondary index definitions. + * + * @param array}> $index_definitions Index definitions. + * @return array}> + */ + private function show_create_table_index_groups_from_definitions( array $index_definitions ): array { + $groups = array(); + foreach ( $index_definitions as $index_definition ) { + $groups[] = array( + 'name' => $index_definition['index_name'], + 'non_unique' => $index_definition['unique'] ? 0 : 1, + 'index_type' => 'BTREE', + 'index_comment' => '', + 'columns' => array_map( + function ( array $column ): array { + return array( + 'name' => $column['name'], + 'sub_part' => $column['sub_part'], + 'collation' => 'A', + ); + }, + $index_definition['columns'] + ), + ); + } + + usort( + $groups, + function ( array $left, array $right ): int { + if ( $left['non_unique'] !== $right['non_unique'] ) { + return $left['non_unique'] <=> $right['non_unique']; + } + return strcmp( $left['name'], $right['name'] ); + } + ); + + return $groups; + } + + /** + * Return physical primary key columns in key order. + * + * @param string $table_name Table name. + * @return string[] Primary key columns. + */ + private function primary_key_columns_for_table( string $table_name ): array { + return array_map( + function ( array $row ): string { + return (string) $row[4]; + }, + $this->primary_key_index_rows( $table_name ) ); } @@ -5223,14 +5427,14 @@ function () use ( $table_name, $next_value, $temporary ): WP_DuckDB_Result_State * @return WP_DuckDB_Result_Statement */ private function execute_alter_table_drop_index( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + if ( $this->is_alter_table_drop_primary_key_action( $tokens ) ) { + return $this->execute_alter_table_drop_primary_key( $table_name, $temporary ); + } + $index = 0; $this->expect_token( $tokens, $index, WP_MySQL_Lexer::DROP_SYMBOL, 'Expected DROP in ALTER TABLE action.' ); ++$index; - if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::PRIMARY_SYMBOL === $tokens[ $index ]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP PRIMARY KEY requires a table rebuild.' ); - } - if ( ! isset( $tokens[ $index ] ) || ( WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[ $index ]->id && WP_MySQL_Lexer::KEY_SYMBOL !== $tokens[ $index ]->id ) @@ -5239,6 +5443,10 @@ private function execute_alter_table_drop_index( string $table_name, array $toke } ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::PRIMARY_SYMBOL === $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP INDEX PRIMARY and DROP KEY PRIMARY require quoted PRIMARY.' ); + } + $index_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; if ( count( $tokens ) !== $index ) { @@ -5254,6 +5462,76 @@ function () use ( $table_name, $index_name, $temporary ): WP_DuckDB_Result_State ); } + /** + * Check whether ALTER TABLE ... DROP targets the primary key. + * + * @param WP_Parser_Token[] $tokens Action tokens starting at DROP. + * @return bool Whether this is DROP PRIMARY KEY or DROP INDEX/KEY PRIMARY. + */ + private function is_alter_table_drop_primary_key_action( array $tokens ): bool { + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return false; + } + + if ( WP_MySQL_Lexer::PRIMARY_SYMBOL === $tokens[1]->id ) { + return isset( $tokens[2] ) + && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[2]->id + && 3 === count( $tokens ); + } + + if ( WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[1]->id && WP_MySQL_Lexer::KEY_SYMBOL !== $tokens[1]->id ) { + return false; + } + + return isset( $tokens[2] ) + && WP_MySQL_Lexer::PRIMARY_SYMBOL !== $tokens[2]->id + && 0 === strcasecmp( $this->identifier_value( $tokens[2] ), 'PRIMARY' ) + && 3 === count( $tokens ); + } + + /** + * Execute ALTER TABLE ... DROP PRIMARY KEY. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_drop_primary_key( string $table_name, bool $temporary = false ): WP_DuckDB_Result_Statement { + $this->assert_alter_table_drop_primary_key_supported( $table_name, $temporary ); + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $temporary ): WP_DuckDB_Result_Statement { + $this->rebuild_table_from_metadata_plan( + $table_name, + $this->table_column_metadata_rows( $table_name, $temporary ), + array(), + $this->secondary_index_definitions_for_table( $table_name, $temporary ), + $this->check_constraint_metadata_rows( $table_name, $temporary ), + $this->show_create_table_foreign_key_groups( $table_name, $temporary ), + $this->table_auto_increment_value( $table_name, $temporary ), + 'DROP PRIMARY KEY', + $temporary + ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + + /** + * Validate ALTER TABLE ... DROP PRIMARY KEY before mutation. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + */ + private function assert_alter_table_drop_primary_key_supported( string $table_name, bool $temporary = false ): void { + $this->assert_no_active_transaction_for_table_rebuild( 'DROP PRIMARY KEY' ); + if ( count( $this->primary_key_index_rows( $table_name ) ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( "Unknown index 'PRIMARY' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'DROP PRIMARY KEY', $temporary ); + } + /** * Check whether an ALTER TABLE DROP action targets an index/key. * @@ -9040,6 +9318,36 @@ private function translate_tokens_to_duckdb_sql( continue; } + if ( + $this->is_empty_function_call( $tokens, $index, 'NOW' ) + || $this->is_empty_function_call( $tokens, $index, 'CURRENT_TIMESTAMP' ) + ) { + $pieces[] = "strftime(current_timestamp, '%Y-%m-%d %H:%M:%S')"; + $index += 2; + continue; + } + + if ( + $this->is_empty_function_call( $tokens, $index, 'CURDATE' ) + || $this->is_empty_function_call( $tokens, $index, 'UTC_DATE' ) + ) { + $pieces[] = "strftime(current_timestamp, '%Y-%m-%d')"; + $index += 2; + continue; + } + + if ( $this->is_empty_function_call( $tokens, $index, 'UTC_TIME' ) ) { + $pieces[] = "strftime(current_timestamp, '%H:%M:%S')"; + $index += 2; + continue; + } + + if ( $this->is_empty_function_call( $tokens, $index, 'UTC_TIMESTAMP' ) ) { + $pieces[] = "strftime(current_timestamp, '%Y-%m-%d %H:%M:%S')"; + $index += 2; + continue; + } + if ( $this->is_empty_function_call( $tokens, $index, 'RAND' ) ) { $pieces[] = 'random()'; $index += 2; @@ -10992,7 +11300,15 @@ private function execute_auto_increment_write( string $table_name, string $sql, ? null : $this->explicit_auto_increment_value_for_write( $tokens, $table_index, $metadata['column_name'] ); $before = null === $sequence_name ? null : $this->sequence_currval( $sequence_name ); - $result = $this->execute_duckdb_query( $sql, $context ); + if ( + null !== $metadata + && null !== $explicit_insert_id + && ! $this->auto_increment_column_has_visible_unique_key( $table_reference['table_name'], $metadata['column_name'] ) + && $this->auto_increment_value_exists( $table_reference['table_name'], $metadata['column_name'], $explicit_insert_id ) + ) { + throw new WP_DuckDB_Driver_Exception( 'UNIQUE constraint failed: ' . $table_reference['table_name'] . '.' . $metadata['column_name'] ); + } + $result = $this->execute_duckdb_query( $sql, $context ); if ( null !== $sequence_name && $result->rowCount() > 0 ) { $after = $this->sequence_currval( $sequence_name ); @@ -11006,6 +11322,46 @@ private function execute_auto_increment_write( string $table_name, string $sql, return $result; } + /** + * Check whether an AUTO_INCREMENT column still has a visible primary or unique key. + * + * @param string $table_name Table name. + * @param string $column_name AUTO_INCREMENT column name. + * @return bool Whether a visible unique key exists. + */ + private function auto_increment_column_has_visible_unique_key( string $table_name, string $column_name ): bool { + foreach ( $this->unique_key_column_sets( $table_name ) as $column_set ) { + if ( array( $column_name ) === $column_set ) { + return true; + } + } + + return false; + } + + /** + * Check whether an explicit AUTO_INCREMENT value already exists. + * + * @param string $table_name Table name. + * @param string $column_name AUTO_INCREMENT column name. + * @param int $value Explicit AUTO_INCREMENT value. + * @return bool Whether the value exists. + */ + private function auto_increment_value_exists( string $table_name, string $column_name, int $value ): bool { + $stmt = $this->execute_duckdb_query( + 'SELECT 1 FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' WHERE ' + . $this->connection->quote_identifier( $column_name ) + . ' = ' + . $value + . ' LIMIT 1', + 'Failed to inspect DuckDB AUTO_INCREMENT value' + ); + + return false !== $stmt->fetchColumn(); + } + /** * Find metadata for a table's recorded AUTO_INCREMENT column. * @@ -11968,59 +12324,16 @@ private function rebuild_empty_auto_increment_table( string $table_name, bool $t * @param bool $temporary Whether the target is a temporary table. */ private function rebuild_auto_increment_table_with_next_value( string $table_name, int $next_value, bool $temporary = false ): void { - $create_sql = $this->mysql_create_table_statement( $table_name, $table_name, $temporary, $next_value ); - $sequence_names = $this->auto_increment_sequences_for_table( $table_name, $temporary ); - $metadata_rows = $this->table_column_metadata_rows( $table_name, $temporary ); - $column_names = array_map( - function ( array $column ): string { - return (string) $column['column_name']; - }, - $metadata_rows - ); - $quoted_columns = implode( - ', ', - array_map( - function ( string $column_name ): string { - return $this->connection->quote_identifier( $column_name ); - }, - $column_names - ) - ); - $backup_table = '__wp_duckdb_auto_increment_backup_' . substr( hash( 'sha256', $table_name . "\0" . microtime( true ) . "\0" . mt_rand() ), 0, 16 ); - - $this->execute_duckdb_query( - 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $backup_table ), - 'Failed to reset DuckDB AUTO_INCREMENT rebuild backup table' - ); - $this->execute_duckdb_query( - 'CREATE TEMP TABLE ' - . $this->connection->quote_identifier( $backup_table ) - . ' AS SELECT ' - . $quoted_columns - . ' FROM ' - . $this->connection->quote_identifier( $table_name ), - 'Failed to back up DuckDB table for AUTO_INCREMENT rebuild' - ); - $this->execute_duckdb_query( - 'DROP TABLE ' . $this->connection->quote_identifier( $table_name ), - 'Failed to rebuild DuckDB AUTO_INCREMENT table' - ); - $this->drop_auto_increment_sequences( $sequence_names ); - $this->execute_create_table( $this->tokenize_and_validate( $create_sql ) ); - $this->execute_duckdb_query( - 'INSERT INTO ' - . $this->connection->quote_identifier( $table_name ) - . ' (' - . $quoted_columns - . ') SELECT ' - . $quoted_columns - . ' FROM ' - . $this->connection->quote_identifier( $backup_table ), - 'Failed to restore DuckDB table rows after AUTO_INCREMENT rebuild' - ); - $this->execute_duckdb_query( - 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $backup_table ), - 'Failed to drop DuckDB AUTO_INCREMENT rebuild backup table' + $this->rebuild_table_from_metadata_plan( + $table_name, + $this->table_column_metadata_rows( $table_name, $temporary ), + $this->primary_key_columns_for_table( $table_name ), + $this->secondary_index_definitions_for_table( $table_name, $temporary ), + $this->check_constraint_metadata_rows( $table_name, $temporary ), + $this->show_create_table_foreign_key_groups( $table_name, $temporary ), + $next_value, + 'AUTO_INCREMENT', + $temporary ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index b9b8ad692..c39cea833 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -18,6 +18,25 @@ public function test_common_scalar_functions_match_sqlite(): void { $this->assertParityRows( "SELECT SUBSTR('abcdef', 2, 3) AS short_substr, SUBSTRING('abcdef', 2, 3) AS long_substring" ); } + public function test_select_date_time_literal_functions_match_sqlite(): void { + foreach ( + array( + 'SELECT LENGTH(NOW()) AS value_length, SUBSTR(NOW(), 5, 1) AS date_sep, SUBSTR(NOW(), 14, 1) AS time_sep', + 'SELECT LENGTH(CURRENT_TIMESTAMP()) AS value_length, SUBSTR(CURRENT_TIMESTAMP(), 5, 1) AS date_sep, SUBSTR(CURRENT_TIMESTAMP(), 14, 1) AS time_sep', + 'SELECT LENGTH(CURDATE()) AS value_length, SUBSTR(CURDATE(), 5, 1) AS date_sep', + 'SELECT LENGTH(UTC_DATE()) AS value_length, SUBSTR(UTC_DATE(), 5, 1) AS date_sep', + 'SELECT LENGTH(UTC_TIME()) AS value_length, SUBSTR(UTC_TIME(), 3, 1) AS hour_sep', + 'SELECT LENGTH(UTC_TIMESTAMP()) AS value_length, SUBSTR(UTC_TIMESTAMP(), 5, 1) AS date_sep, SUBSTR(UTC_TIMESTAMP(), 14, 1) AS time_sep', + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_select_unseeded_rand_range_matches_sqlite(): void { + $this->assertParityRows( 'SELECT CAST(RAND() >= 0 AND RAND() < 1 AS SIGNED) AS rand_in_range' ); + } + public function test_select_cast_convert_binary_expressions_match_sqlite(): void { foreach ( array( @@ -2198,6 +2217,198 @@ public function test_drop_index_lifecycle_matches_sqlite(): void { $this->assertParityRows( 'SELECT name FROM lifecycle_idx ORDER BY id' ); } + public function test_alter_table_drop_primary_key_lifecycle_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE ddl_drop_pk ( + id INT NOT NULL, + name VARCHAR(20) NOT NULL, + amount INT, + PRIMARY KEY (id), + UNIQUE KEY name_unique (name), + KEY amount_idx (amount), + CONSTRAINT amount_positive CHECK (amount > 0) + )', + "INSERT INTO ddl_drop_pk (id, name, amount) VALUES (1, 'a', 10), (2, 'b', 20)", + 'ALTER TABLE ddl_drop_pk DROP PRIMARY KEY', + "INSERT INTO ddl_drop_pk (id, name, amount) VALUES (1, 'c', 30)", + ) + ); + + $this->assertParityRows( 'SELECT id, name, amount FROM ddl_drop_pk ORDER BY id, name' ); + $this->assertParityRows( 'SHOW COLUMNS FROM ddl_drop_pk' ); + $this->assertParityRows( 'SHOW CREATE TABLE ddl_drop_pk' ); + $this->assertParityRows( + "SELECT index_name, column_name, non_unique + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'ddl_drop_pk' + ORDER BY index_name, seq_in_index" + ); + $this->assertParityRows( + "SELECT constraint_name, constraint_type + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'ddl_drop_pk' + ORDER BY constraint_name" + ); + $this->assertParityRows( + "SELECT constraint_name, column_name, referenced_table_name + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' AND table_name = 'ddl_drop_pk' + ORDER BY constraint_name, ordinal_position" + ); + $this->assertParityErrorContains( + "INSERT INTO ddl_drop_pk (id, name, amount) VALUES (3, 'a', 40)", + 'constraint' + ); + $this->assertParityErrorContains( + "INSERT INTO ddl_drop_pk (id, name, amount) VALUES (4, 'd', 0)", + 'CHECK constraint failed' + ); + } + + public function test_alter_table_drop_primary_index_quoted_aliases_match_sqlite(): void { + foreach ( array( 'DROP INDEX `PRIMARY`', 'DROP KEY `PRIMARY`' ) as $drop_action ) { + $table_name = 'ddl_drop_pk_alias_' . strtolower( str_replace( array( 'DROP ', ' `PRIMARY`' ), '', $drop_action ) ); + $this->runParitySetup( + array( + "CREATE TABLE {$table_name} ( + id INT NOT NULL, + name VARCHAR(20), + PRIMARY KEY (id), + KEY name_idx (name) + )", + "INSERT INTO {$table_name} (id, name) VALUES (1, 'a'), (2, 'b')", + "ALTER TABLE {$table_name} {$drop_action}", + "INSERT INTO {$table_name} (id, name) VALUES (1, 'duplicate')", + ) + ); + + $this->assertParityRows( "SELECT id, name FROM {$table_name} ORDER BY id, name" ); + $this->assertParityRows( "SHOW COLUMNS FROM {$table_name}" ); + $this->assertParityRowColumns( + "SHOW INDEX FROM {$table_name}", + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRows( "SHOW CREATE TABLE {$table_name}" ); + } + } + + public function test_alter_table_drop_primary_index_unquoted_aliases_reject_without_mutation_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE ddl_drop_pk_unquoted (id INT PRIMARY KEY, name VARCHAR(20), KEY name_idx (name))', + "INSERT INTO ddl_drop_pk_unquoted (id, name) VALUES (1, 'a')", + ) + ); + + foreach ( array( 'DROP INDEX PRIMARY', 'DROP KEY PRIMARY' ) as $drop_action ) { + $this->assertParityErrorContains( + 'ALTER TABLE ddl_drop_pk_unquoted ' . $drop_action, + 'parse' + ); + $this->assertParityRows( 'SELECT id, name FROM ddl_drop_pk_unquoted ORDER BY id' ); + $this->assertParityRows( 'SHOW COLUMNS FROM ddl_drop_pk_unquoted' ); + $this->assertParityRowColumns( + 'SHOW INDEX FROM ddl_drop_pk_unquoted', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRows( 'SHOW CREATE TABLE ddl_drop_pk_unquoted' ); + } + } + + public function test_alter_table_drop_composite_primary_key_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE ddl_drop_composite_pk ( + site_id INT NOT NULL, + object_id INT NOT NULL, + name VARCHAR(20), + PRIMARY KEY (site_id, object_id), + KEY name_idx (name) + )', + "INSERT INTO ddl_drop_composite_pk (site_id, object_id, name) VALUES (1, 10, 'a'), (1, 11, 'b')", + 'ALTER TABLE ddl_drop_composite_pk DROP PRIMARY KEY', + "INSERT INTO ddl_drop_composite_pk (site_id, object_id, name) VALUES (1, 10, 'duplicate')", + ) + ); + + $this->assertParityRows( 'SELECT site_id, object_id, name FROM ddl_drop_composite_pk ORDER BY site_id, object_id, name' ); + $this->assertParityRows( 'SHOW COLUMNS FROM ddl_drop_composite_pk' ); + $this->assertParityRowColumns( + 'SHOW INDEX FROM ddl_drop_composite_pk', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRows( 'SHOW CREATE TABLE ddl_drop_composite_pk' ); + $this->assertParityRows( + "SELECT constraint_name, constraint_type + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'ddl_drop_composite_pk' + ORDER BY constraint_name" + ); + } + + public function test_alter_table_drop_auto_increment_primary_key_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE ddl_drop_pk_ai ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(20) NOT NULL, + KEY name_idx (name) + )', + "INSERT INTO ddl_drop_pk_ai (name) VALUES ('a'), ('b')", + 'ALTER TABLE ddl_drop_pk_ai DROP PRIMARY KEY', + "INSERT INTO ddl_drop_pk_ai (name) VALUES ('generated_after_drop')", + ) + ); + + $this->assertParityErrorContains( + "INSERT INTO ddl_drop_pk_ai (id, name) VALUES (1, 'explicit_duplicate')", + 'UNIQUE constraint failed' + ); + $this->assertParityRows( 'SELECT id, name FROM ddl_drop_pk_ai ORDER BY id, name' ); + $this->assertParityRows( 'SHOW COLUMNS FROM ddl_drop_pk_ai' ); + $this->assertParityRowColumns( + 'SHOW INDEX FROM ddl_drop_pk_ai', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRows( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'ddl_drop_pk_ai'" + ); + $this->assertParityRows( 'SHOW CREATE TABLE ddl_drop_pk_ai' ); + } + + public function test_alter_table_drop_primary_key_temporary_shadow_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE ddl_drop_pk_shadow (id INT PRIMARY KEY, name VARCHAR(20))', + "INSERT INTO ddl_drop_pk_shadow (id, name) VALUES (1, 'persistent')", + 'CREATE TEMPORARY TABLE ddl_drop_pk_shadow (id INT PRIMARY KEY, name VARCHAR(20), KEY name_idx (name))', + "INSERT INTO ddl_drop_pk_shadow (id, name) VALUES (2, 'temporary')", + 'ALTER TABLE ddl_drop_pk_shadow DROP PRIMARY KEY', + "INSERT INTO ddl_drop_pk_shadow (id, name) VALUES (2, 'temporary_duplicate')", + ) + ); + + $this->assertParityRows( 'SELECT id, name FROM ddl_drop_pk_shadow ORDER BY id, name' ); + $this->assertParityRows( 'SHOW COLUMNS FROM ddl_drop_pk_shadow' ); + $this->assertParityRowColumns( + 'SHOW INDEX FROM ddl_drop_pk_shadow', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRows( 'SHOW CREATE TABLE ddl_drop_pk_shadow' ); + + $this->runParitySetup( array( 'DROP TEMPORARY TABLE ddl_drop_pk_shadow' ) ); + $this->assertParityRows( 'SELECT id, name FROM ddl_drop_pk_shadow ORDER BY id' ); + $this->assertParityRows( 'SHOW COLUMNS FROM ddl_drop_pk_shadow' ); + $this->assertParityRowColumns( + 'SHOW INDEX FROM ddl_drop_pk_shadow', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRows( 'SHOW CREATE TABLE ddl_drop_pk_shadow' ); + } + private function createJoinedUpdateGapDrivers(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid $sqlite_driver = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index fbf30788e..c9afd7c2d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -5918,7 +5918,7 @@ public function test_drop_table_multiple_if_exists_and_lifecycle_protections(): 'DROP TABLE information_schema.tables' => "Access denied for user 'duckdb'@'%' to database 'information_schema'", 'TRUNCATE TABLE __wp_duckdb_column_metadata' => 'Internal DuckDB metadata tables cannot be modified', 'DROP INDEX `PRIMARY` ON protected_target' => 'Dropping PRIMARY requires a table rebuild', - 'ALTER TABLE protected_target DROP PRIMARY KEY' => 'DROP PRIMARY KEY requires a table rebuild', + 'ALTER TABLE protected_target DROP PRIMARY KEY' => "Unknown index 'PRIMARY'", ) as $sql => $message ) { try { @@ -6627,6 +6627,219 @@ public function test_alter_table_add_drop_check_rejects_active_transactions_befo $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_tx' ) ); } + public function test_alter_table_drop_primary_key_preserves_rows_metadata_and_secondary_enforcement(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE drop_pk_direct ( + id INT NOT NULL, + name VARCHAR(20) NOT NULL, + amount INT, + PRIMARY KEY (id), + UNIQUE KEY name_unique (name), + KEY amount_idx (amount), + CONSTRAINT amount_positive CHECK (amount > 0) + )' + ); + $driver->query( "INSERT INTO drop_pk_direct (id, name, amount) VALUES (1, 'a', 10), (2, 'b', 20)" ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE drop_pk_direct DROP PRIMARY KEY' )->rowCount() ); + $driver->query( "INSERT INTO drop_pk_direct (id, name, amount) VALUES (1, 'c', 30)" ); + + try { + $driver->query( "INSERT INTO drop_pk_direct (id, name, amount) VALUES (3, 'a', 40)" ); + $this->fail( 'Expected secondary UNIQUE index to remain enforced after DROP PRIMARY KEY.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + + try { + $driver->query( "INSERT INTO drop_pk_direct (id, name, amount) VALUES (4, 'd', 0)" ); + $this->fail( 'Expected CHECK constraint to remain enforced after DROP PRIMARY KEY.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'a', + 'amount' => 10, + ), + array( + 'id' => 1, + 'name' => 'c', + 'amount' => 30, + ), + array( + 'id' => 2, + 'name' => 'b', + 'amount' => 20, + ), + ), + $driver->query( 'SELECT id, name, amount FROM drop_pk_direct ORDER BY id, name' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertNotContains( 'PRIMARY', array_column( $driver->query( 'SHOW INDEX FROM drop_pk_direct' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) ); + $this->assertSame( + array( + 'id' => '', + 'name' => 'UNI', + 'amount' => 'MUL', + ), + array_column( $driver->query( 'SHOW COLUMNS FROM drop_pk_direct' )->fetchAll( PDO::FETCH_ASSOC ), 'Key', 'Field' ) + ); + + $create_sql = $driver->query( 'SHOW CREATE TABLE drop_pk_direct' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + $this->assertStringNotContainsString( 'PRIMARY KEY', $create_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `name_unique` (`name`)', $create_sql ); + $this->assertStringContainsString( 'KEY `amount_idx` (`amount`)', $create_sql ); + $this->assertStringContainsString( 'CONSTRAINT `amount_positive` CHECK (amount > 0)', $create_sql ); + + $this->assertSame( + array(), + $driver->query( + "SELECT * + FROM information_schema.statistics + WHERE table_schema = 'wp' + AND table_name = 'drop_pk_direct' + AND index_name = 'PRIMARY'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + foreach ( array( 'table_constraints', 'key_column_usage' ) as $table ) { + $this->assertSame( + array(), + $driver->query( + "SELECT * + FROM information_schema.{$table} + WHERE table_schema = 'wp' + AND table_name = 'drop_pk_direct' + AND constraint_name = 'PRIMARY'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + } + + public function test_alter_table_drop_primary_key_rejects_active_transaction_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE drop_pk_tx (id INT PRIMARY KEY, name VARCHAR(20), KEY name_idx (name))' ); + $driver->query( "INSERT INTO drop_pk_tx (id, name) VALUES (1, 'a')" ); + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_pk_tx' ); + + $driver->query( 'BEGIN' ); + try { + $driver->query( 'ALTER TABLE drop_pk_tx DROP PRIMARY KEY' ); + $this->fail( 'Expected active transaction DROP PRIMARY KEY rebuild rejection.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'DROP PRIMARY KEY cannot run inside an active DuckDB transaction', $e->getMessage() ); + } + $driver->query( 'ROLLBACK' ); + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_pk_tx' ) ); + } + + public function test_alter_table_drop_primary_key_rejects_multi_action_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE drop_pk_multi (id INT PRIMARY KEY, name VARCHAR(20))' ); + $driver->query( "INSERT INTO drop_pk_multi (id, name) VALUES (1, 'a')" ); + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_pk_multi' ); + + foreach ( + array( + 'ALTER TABLE drop_pk_multi DROP PRIMARY KEY, ADD COLUMN should_not_exist INT', + 'ALTER TABLE drop_pk_multi ADD COLUMN should_not_exist INT, DROP PRIMARY KEY', + ) as $sql + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected multi-action DROP PRIMARY KEY rejection for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'DROP PRIMARY KEY cannot be combined with other ALTER TABLE actions', $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_pk_multi' ) ); + } + } + + public function test_alter_table_drop_primary_key_rejects_referenced_parent_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE drop_pk_parent_guard (id INT PRIMARY KEY, name VARCHAR(20))' ); + $driver->query( 'CREATE TABLE drop_pk_child_guard (id INT PRIMARY KEY, parent_id INT, CONSTRAINT child_parent_fk FOREIGN KEY (parent_id) REFERENCES drop_pk_parent_guard (id))' ); + $driver->query( "INSERT INTO drop_pk_parent_guard (id, name) VALUES (1, 'parent')" ); + $driver->query( 'INSERT INTO drop_pk_child_guard (id, parent_id) VALUES (10, 1)' ); + + $before = array( + 'parent' => $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_pk_parent_guard' ), + 'child' => $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_pk_child_guard' ), + 'referential_constraints' => $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + ); + + try { + $driver->query( 'ALTER TABLE drop_pk_parent_guard DROP PRIMARY KEY' ); + $this->fail( 'Expected referenced parent DROP PRIMARY KEY rebuild to be rejected before mutation.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'DROP PRIMARY KEY on table', $e->getMessage() ); + $this->assertStringContainsString( 'referenced by FOREIGN KEY', $e->getMessage() ); + } + + $this->assertSame( + $before, + array( + 'parent' => $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_pk_parent_guard' ), + 'child' => $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_pk_child_guard' ), + 'referential_constraints' => $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + ) + ); + + try { + $driver->query( 'INSERT INTO drop_pk_child_guard (id, parent_id) VALUES (11, 2)' ); + $this->fail( 'Expected FK enforcement to remain intact after rejected DROP PRIMARY KEY.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + } + public function test_unsupported_alter_table_constraint_actions_throw_before_mutation(): void { $this->requireDuckDBRuntime(); From 7e4463f558210c11ab5a82abf41f844db592826a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 03:31:20 +0000 Subject: [PATCH 056/317] Add DuckDB ALTER foreign key lifecycle parity --- .../src/duckdb/class-wp-duckdb-driver.php | 480 +++++++++++++++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 439 +++++++++++++++- 2 files changed, 880 insertions(+), 39 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index ab4977568..412a0db04 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -4521,6 +4521,11 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen } if ( WP_MySQL_Lexer::DROP_SYMBOL === $action[0]->id ) { + if ( $this->is_alter_table_drop_foreign_key_constraint_action( $table_name, $action, $temporary ) ) { + $result = $this->execute_alter_table_drop_foreign_key_constraint( $table_name, $action, $temporary ); + continue; + } + if ( $this->is_alter_table_drop_check_constraint_action( $action ) ) { $result = $this->execute_alter_table_drop_check_constraint( $table_name, $action, $temporary ); continue; @@ -4555,6 +4560,11 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen continue; } + if ( $this->is_alter_table_add_foreign_key_constraint_action( $alter_item ) ) { + $result = $this->execute_alter_table_add_foreign_key_constraint( $table_name, $alter_item, $temporary ); + continue; + } + $result = $this->is_create_table_index_item( $alter_item ) ? $this->execute_alter_table_add_index( $table_name, $alter_item, $temporary ) : $this->execute_alter_table_add_column( $table_name, $alter_item, $temporary ); @@ -4579,6 +4589,9 @@ private function validate_alter_table_rebuild_action_combination( string $table_ if ( $this->is_alter_table_check_rebuild_action( $table_name, $action, $temporary ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD/DROP CHECK cannot be combined with other ALTER TABLE actions.' ); } + if ( $this->is_alter_table_foreign_key_rebuild_action( $table_name, $action, $temporary ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD/DROP FOREIGN KEY cannot be combined with other ALTER TABLE actions.' ); + } if ( $this->is_alter_table_drop_primary_key_action( $action ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP PRIMARY KEY cannot be combined with other ALTER TABLE actions.' ); } @@ -4617,6 +4630,34 @@ private function is_alter_table_check_rebuild_action( string $table_name, array return true; } + /** + * Check whether an ALTER action requires a FOREIGN KEY table rebuild. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @param bool $temporary Whether the target is a temporary table. + * @return bool Whether the action rebuilds FOREIGN KEY constraints. + */ + private function is_alter_table_foreign_key_rebuild_action( string $table_name, array $tokens, bool $temporary = false ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::ADD_SYMBOL === $tokens[0]->id ) { + return $this->is_alter_table_add_foreign_key_constraint_action( array_slice( $tokens, 1 ) ); + } + + if ( WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return false; + } + + if ( $this->is_alter_table_drop_foreign_key_constraint_action( $table_name, $tokens, $temporary ) ) { + return true; + } + + return false; + } + /** * Validate ALTER TABLE CHECK/FOREIGN KEY/CONSTRAINT actions before mutation. * @@ -4625,15 +4666,17 @@ private function is_alter_table_check_rebuild_action( string $table_name, array * @param bool $temporary Whether the target is a temporary table. */ private function validate_alter_table_constraint_actions( string $table_name, array $actions, bool $temporary = false ): void { - $check_names = array(); - $check_names_loaded = false; + $check_names = array(); + $check_names_loaded = false; + $foreign_key_names = array(); + $foreign_key_names_loaded = false; foreach ( $actions as $action ) { if ( ! isset( $action[0] ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Empty action.' ); } - $this->validate_alter_table_constraint_action( $table_name, $action, $check_names, $check_names_loaded, $temporary ); + $this->validate_alter_table_constraint_action( $table_name, $action, $check_names, $check_names_loaded, $foreign_key_names, $foreign_key_names_loaded, $temporary ); } } @@ -4670,27 +4713,62 @@ private function ensure_alter_table_check_constraint_name_map( string $table_nam $check_names_loaded = true; } + /** + * Return recorded FOREIGN KEY constraint names keyed lowercase. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + * @return array FOREIGN KEY names keyed lowercase. + */ + private function foreign_key_constraint_name_map( string $table_name, bool $temporary = false ): array { + $names = array(); + foreach ( $this->foreign_key_metadata_rows( $table_name, $temporary ) as $foreign_key ) { + $names[ strtolower( (string) $foreign_key['constraint_name'] ) ] = true; + } + + return $names; + } + + /** + * Load recorded FOREIGN KEY constraint names once for ALTER TABLE preflight. + * + * @param string $table_name Table name. + * @param array $foreign_key_names FOREIGN KEY names keyed lowercase. + * @param bool $foreign_key_names_loaded Whether the map has already been loaded. + * @param bool $temporary Whether the target is a temporary table. + */ + private function ensure_alter_table_foreign_key_constraint_name_map( string $table_name, array &$foreign_key_names, bool &$foreign_key_names_loaded, bool $temporary = false ): void { + if ( $foreign_key_names_loaded ) { + return; + } + + $foreign_key_names = $this->foreign_key_constraint_name_map( $table_name, $temporary ); + $foreign_key_names_loaded = true; + } + /** * Validate ALTER TABLE CHECK/FOREIGN KEY/CONSTRAINT actions before mutation. * - * @param string $table_name Table name. - * @param WP_Parser_Token[] $tokens ALTER action tokens. - * @param array $check_names Existing and planned CHECK names, keyed lowercase. - * @param bool $check_names_loaded Whether CHECK names have been loaded. - * @param bool $temporary Whether the target is a temporary table. + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @param array $check_names Existing and planned CHECK names, keyed lowercase. + * @param bool $check_names_loaded Whether CHECK names have been loaded. + * @param array $foreign_key_names Existing and planned FOREIGN KEY names, keyed lowercase. + * @param bool $foreign_key_names_loaded Whether FOREIGN KEY names have been loaded. + * @param bool $temporary Whether the target is a temporary table. */ - private function validate_alter_table_constraint_action( string $table_name, array $tokens, array &$check_names, bool &$check_names_loaded, bool $temporary = false ): void { + private function validate_alter_table_constraint_action( string $table_name, array $tokens, array &$check_names, bool &$check_names_loaded, array &$foreign_key_names, bool &$foreign_key_names_loaded, bool $temporary = false ): void { if ( ! isset( $tokens[0] ) ) { return; } if ( WP_MySQL_Lexer::ADD_SYMBOL === $tokens[0]->id ) { - $this->validate_alter_table_add_constraint_action( $table_name, array_slice( $tokens, 1 ), $check_names, $check_names_loaded, $temporary ); + $this->validate_alter_table_add_constraint_action( $table_name, array_slice( $tokens, 1 ), $check_names, $check_names_loaded, $foreign_key_names, $foreign_key_names_loaded, $temporary ); return; } if ( WP_MySQL_Lexer::DROP_SYMBOL === $tokens[0]->id ) { - $this->validate_alter_table_drop_constraint_action( $table_name, $tokens, $check_names, $check_names_loaded, $temporary ); + $this->validate_alter_table_drop_constraint_action( $table_name, $tokens, $check_names, $check_names_loaded, $foreign_key_names, $foreign_key_names_loaded, $temporary ); return; } @@ -4709,9 +4787,11 @@ private function validate_alter_table_constraint_action( string $table_name, arr * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. * @param array $check_names Existing and planned CHECK names, keyed lowercase. * @param bool $check_names_loaded Whether CHECK names have been loaded. + * @param array $foreign_key_names Existing and planned FOREIGN KEY names, keyed lowercase. + * @param bool $foreign_key_names_loaded Whether FOREIGN KEY names have been loaded. * @param bool $temporary Whether the target is a temporary table. */ - private function validate_alter_table_add_constraint_action( string $table_name, array $tokens, array &$check_names, bool &$check_names_loaded, bool $temporary = false ): void { + private function validate_alter_table_add_constraint_action( string $table_name, array $tokens, array &$check_names, bool &$check_names_loaded, array &$foreign_key_names, bool &$foreign_key_names_loaded, bool $temporary = false ): void { if ( ! isset( $tokens[0] ) ) { return; } @@ -4721,22 +4801,28 @@ private function validate_alter_table_add_constraint_action( string $table_name, return; } - $items = $this->alter_table_add_items_for_constraint_detection( $tokens ); - $contains_check = false; + $items = $this->alter_table_add_items_for_constraint_detection( $tokens ); + $contains_check = false; + $contains_foreign_key = false; foreach ( $items as $item ) { if ( $this->is_create_table_check_constraint( $item ) ) { $contains_check = true; - break; + } + if ( $this->is_create_table_foreign_key_constraint( $item ) ) { + $contains_foreign_key = true; } } - if ( $contains_check ) { + if ( $contains_check || $contains_foreign_key ) { $parenthesized_end = count( $tokens ); if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id ) { list( , $parenthesized_end ) = $this->collect_parenthesized_items( $tokens, 1 ); } - if ( 1 !== count( $items ) || count( $tokens ) !== $parenthesized_end ) { + if ( 1 !== count( $items ) || count( $tokens ) !== $parenthesized_end || ( $contains_check && $contains_foreign_key ) ) { + if ( $contains_foreign_key && ! $contains_check ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD FOREIGN KEY constraint is supported.' ); + } throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD CHECK constraint is supported.' ); } } @@ -4751,7 +4837,11 @@ private function validate_alter_table_add_constraint_action( string $table_name, } if ( $this->is_create_table_foreign_key_constraint( $item ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD FOREIGN KEY is not supported.' ); + $this->ensure_alter_table_foreign_key_constraint_name_map( $table_name, $foreign_key_names, $foreign_key_names_loaded, $temporary ); + $foreign_key = $this->translate_table_foreign_key_constraint( $table_name, $item, $foreign_key_names ); + $foreign_key_metadata = $foreign_key['metadata']; + $this->assert_alter_table_foreign_key_rebuild_supported( $table_name, $foreign_key_metadata, 'ADD/DROP FOREIGN KEY', $temporary, true ); + continue; } if ( isset( $item[0] ) && WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $item[0]->id ) { @@ -4803,9 +4893,11 @@ private function alter_table_add_items_for_constraint_detection( array $tokens ) * @param WP_Parser_Token[] $tokens ALTER action tokens starting at DROP. * @param array $check_names Existing and planned CHECK names, keyed lowercase. * @param bool $check_names_loaded Whether CHECK names have been loaded. + * @param array $foreign_key_names Existing and planned FOREIGN KEY names, keyed lowercase. + * @param bool $foreign_key_names_loaded Whether FOREIGN KEY names have been loaded. * @param bool $temporary Whether the target is a temporary table. */ - private function validate_alter_table_drop_constraint_action( string $table_name, array $tokens, array &$check_names, bool &$check_names_loaded, bool $temporary = false ): void { + private function validate_alter_table_drop_constraint_action( string $table_name, array $tokens, array &$check_names, bool &$check_names_loaded, array &$foreign_key_names, bool &$foreign_key_names_loaded, bool $temporary = false ): void { if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[1]->id ) { return; } @@ -4831,14 +4923,26 @@ private function validate_alter_table_drop_constraint_action( string $table_name if ( WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[1]->id ) { $constraint_name = $this->parse_alter_table_drop_check_constraint_name( $tokens, WP_MySQL_Lexer::CONSTRAINT_SYMBOL ); $constraint_type = $this->resolve_alter_table_drop_constraint_type( $table_name, $constraint_name, $temporary ); + if ( 'CHECK' === $constraint_type ) { + $this->assert_no_active_transaction_for_table_rebuild( 'ADD/DROP CHECK' ); + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD/DROP CHECK', $temporary ); + $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); + unset( $check_names[ strtolower( $constraint_name ) ] ); + return; + } + if ( 'FOREIGN KEY' === $constraint_type ) { + $foreign_key = $this->resolve_foreign_key_metadata_group( $table_name, $constraint_name, $temporary ); + if ( null === $foreign_key ) { + throw new WP_DuckDB_Driver_Exception( "Unknown FOREIGN KEY constraint '{$constraint_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + $this->assert_alter_table_foreign_key_rebuild_supported( $table_name, $foreign_key, 'ADD/DROP FOREIGN KEY', $temporary, false ); + $this->ensure_alter_table_foreign_key_constraint_name_map( $table_name, $foreign_key_names, $foreign_key_names_loaded, $temporary ); + unset( $foreign_key_names[ strtolower( $constraint_name ) ] ); + return; + } if ( 'CHECK' !== $constraint_type ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP CONSTRAINT currently supports CHECK constraints only.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP CONSTRAINT currently supports CHECK and FOREIGN KEY constraints only.' ); } - $this->assert_no_active_transaction_for_table_rebuild( 'ADD/DROP CHECK' ); - $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD/DROP CHECK', $temporary ); - $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); - unset( $check_names[ strtolower( $constraint_name ) ] ); - return; } if ( @@ -4846,7 +4950,14 @@ private function validate_alter_table_drop_constraint_action( string $table_name && isset( $tokens[2] ) && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[2]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP FOREIGN KEY is not supported.' ); + $constraint_name = $this->parse_alter_table_drop_foreign_key_constraint_name( $tokens ); + $foreign_key = $this->resolve_foreign_key_metadata_group( $table_name, $constraint_name, $temporary ); + if ( null === $foreign_key ) { + return; + } + $this->assert_alter_table_foreign_key_rebuild_supported( $table_name, $foreign_key, 'ADD/DROP FOREIGN KEY', $temporary, false ); + $this->ensure_alter_table_foreign_key_constraint_name_map( $table_name, $foreign_key_names, $foreign_key_names_loaded, $temporary ); + unset( $foreign_key_names[ strtolower( (string) $foreign_key['constraint_name'] ) ] ); } } @@ -4936,6 +5047,26 @@ private function is_alter_table_add_check_constraint_action( array $tokens ): bo return false; } + /** + * Check whether ALTER TABLE ... ADD targets a FOREIGN KEY constraint. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @return bool Whether this is an ADD FOREIGN KEY action. + */ + private function is_alter_table_add_foreign_key_constraint_action( array $tokens ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + foreach ( $this->alter_table_add_items_for_constraint_detection( $tokens ) as $item ) { + if ( $this->is_create_table_foreign_key_constraint( $item ) ) { + return true; + } + } + + return false; + } + /** * Execute ALTER TABLE ... ADD CHECK. * @@ -4983,6 +5114,56 @@ private function single_alter_table_add_check_constraint_item( array $tokens ): return $items[0]; } + /** + * Execute ALTER TABLE ... ADD FOREIGN KEY. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_add_foreign_key_constraint( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $item = $this->single_alter_table_add_foreign_key_constraint_item( $tokens ); + $foreign_key_names = $this->foreign_key_constraint_name_map( $table_name, $temporary ); + $foreign_key = $this->translate_table_foreign_key_constraint( $table_name, $item, $foreign_key_names ); + $foreign_key = $foreign_key['metadata']; + $this->assert_alter_table_foreign_key_rebuild_supported( $table_name, $foreign_key, 'ADD/DROP FOREIGN KEY', $temporary, true ); + + $metadata = $this->show_create_table_foreign_key_groups( $table_name, $temporary ); + $metadata[] = $foreign_key; + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $metadata, $temporary ): WP_DuckDB_Result_Statement { + $this->rebuild_table_with_foreign_key_constraints( $table_name, $metadata, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + + /** + * Return the single FOREIGN KEY item from a supported ALTER TABLE ... ADD FOREIGN KEY action. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @return WP_Parser_Token[] FOREIGN KEY constraint tokens. + */ + private function single_alter_table_add_foreign_key_constraint_item( array $tokens ): array { + if ( ! isset( $tokens[0] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD FOREIGN KEY requires a FOREIGN KEY constraint.' ); + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[0]->id ) { + return $tokens; + } + + list( $items, $index ) = $this->collect_parenthesized_items( $tokens, 1 ); + if ( 1 !== count( $items ) || count( $tokens ) !== $index || ! $this->is_create_table_foreign_key_constraint( $items[0] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD FOREIGN KEY constraint is supported.' ); + } + + return $items[0]; + } + /** * Check whether ALTER TABLE ... DROP targets a CHECK constraint. * @@ -4997,6 +5178,35 @@ private function is_alter_table_drop_check_constraint_action( array $tokens ): b ); } + /** + * Check whether ALTER TABLE ... DROP targets a FOREIGN KEY constraint. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Action tokens starting at DROP. + * @param bool $temporary Whether the target is a temporary table. + * @return bool Whether this is a DROP FOREIGN KEY/CONSTRAINT action. + */ + private function is_alter_table_drop_foreign_key_constraint_action( string $table_name, array $tokens, bool $temporary = false ): bool { + if ( ! isset( $tokens[1] ) ) { + return false; + } + + if ( + WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[1]->id + && isset( $tokens[2] ) + && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[2]->id + ) { + return true; + } + + if ( WP_MySQL_Lexer::CONSTRAINT_SYMBOL !== $tokens[1]->id ) { + return false; + } + + $constraint_name = $this->parse_alter_table_drop_check_constraint_name( $tokens, WP_MySQL_Lexer::CONSTRAINT_SYMBOL ); + return 'FOREIGN KEY' === $this->resolve_alter_table_drop_constraint_type( $table_name, $constraint_name, $temporary ); + } + /** * Execute ALTER TABLE ... DROP CHECK or DROP CONSTRAINT for CHECK constraints. * @@ -5033,6 +5243,44 @@ function () use ( $table_name, $metadata, $temporary ): WP_DuckDB_Result_Stateme ); } + /** + * Execute ALTER TABLE ... DROP FOREIGN KEY or DROP CONSTRAINT for FOREIGN KEY constraints. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens starting at DROP. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_drop_foreign_key_constraint( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $constraint_name = WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[1]->id + ? $this->parse_alter_table_drop_check_constraint_name( $tokens, WP_MySQL_Lexer::CONSTRAINT_SYMBOL ) + : $this->parse_alter_table_drop_foreign_key_constraint_name( $tokens ); + $foreign_key = $this->resolve_foreign_key_metadata_group( $table_name, $constraint_name, $temporary ); + if ( null === $foreign_key ) { + if ( WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[1]->id ) { + return $this->empty_ddl_result(); + } + throw new WP_DuckDB_Driver_Exception( "Unknown FOREIGN KEY constraint '{$constraint_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + + $metadata = array_values( + array_filter( + $this->show_create_table_foreign_key_groups( $table_name, $temporary ), + function ( array $constraint ) use ( $foreign_key ): bool { + return 0 !== strcasecmp( (string) $constraint['constraint_name'], (string) $foreign_key['constraint_name'] ); + } + ) + ); + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $metadata, $temporary ): WP_DuckDB_Result_Statement { + $this->rebuild_table_with_foreign_key_constraints( $table_name, $metadata, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + /** * Resolve a recorded CHECK constraint row by name. * @@ -5075,6 +5323,31 @@ private function parse_alter_table_drop_check_constraint_name( array $tokens, in return $constraint_name; } + /** + * Parse ALTER TABLE ... DROP FOREIGN KEY name. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens starting at DROP. + * @return string Constraint name. + */ + private function parse_alter_table_drop_foreign_key_constraint_name( array $tokens ): string { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::DROP_SYMBOL, 'Expected DROP in ALTER TABLE action.' ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::FOREIGN_SYMBOL, 'Expected FOREIGN KEY in ALTER TABLE DROP action.' ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::KEY_SYMBOL, 'Expected FOREIGN KEY in ALTER TABLE DROP action.' ); + ++$index; + + $constraint_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP FOREIGN KEY options are not supported.' ); + } + + return $constraint_name; + } + /** * Resolve the type of a generic ALTER TABLE ... DROP CONSTRAINT target. * @@ -5154,6 +5427,161 @@ private function rebuild_table_with_check_constraints( string $table_name, array ); } + /** + * Rebuild a table with a new FOREIGN KEY constraint metadata set. + * + * @param string $table_name Table name. + * @param array> $foreign_key_constraints New FOREIGN KEY metadata rows. + * @param bool $temporary Whether the target is a temporary table. + */ + private function rebuild_table_with_foreign_key_constraints( string $table_name, array $foreign_key_constraints, bool $temporary = false ): void { + $this->rebuild_table_from_metadata_plan( + $table_name, + $this->table_column_metadata_rows( $table_name, $temporary ), + $this->primary_key_columns_for_table( $table_name ), + $this->secondary_index_definitions_for_table( $table_name, $temporary ), + $this->check_constraint_metadata_rows( $table_name, $temporary ), + $foreign_key_constraints, + $this->table_auto_increment_value( $table_name, $temporary ), + 'FOREIGN KEY', + $temporary + ); + } + + /** + * Resolve a recorded FOREIGN KEY metadata group by name. + * + * @param string $table_name Table name. + * @param string $constraint_name Constraint name. + * @param bool $temporary Whether the target is a temporary table. + * @return array|null FOREIGN KEY metadata group, or null when not found. + */ + private function resolve_foreign_key_metadata_group( string $table_name, string $constraint_name, bool $temporary = false ): ?array { + foreach ( $this->show_create_table_foreign_key_groups( $table_name, $temporary ) as $foreign_key ) { + if ( 0 === strcasecmp( (string) $foreign_key['constraint_name'], $constraint_name ) ) { + return $foreign_key; + } + } + + return null; + } + + /** + * Validate an ALTER TABLE FOREIGN KEY rebuild before mutation. + * + * @param string $table_name Table name. + * @param array $foreign_key FOREIGN KEY metadata group. + * @param string $operation Operation label for the error message. + * @param bool $temporary Whether the target is a temporary table. + * @param bool $validate_parent_rows Whether to validate the referenced parent and existing child rows. + */ + private function assert_alter_table_foreign_key_rebuild_supported( string $table_name, array &$foreign_key, string $operation, bool $temporary = false, bool $validate_parent_rows = true ): void { + if ( $temporary ) { + throw new WP_DuckDB_Driver_Exception( "Unsupported ALTER TABLE statement in DuckDB driver. {$operation} on temporary tables is not supported." ); + } + + $this->assert_no_active_transaction_for_table_rebuild( $operation ); + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, $operation, $temporary ); + $this->assert_alter_table_foreign_key_columns_supported( $table_name, $foreign_key, $temporary ); + if ( $validate_parent_rows ) { + $this->assert_alter_table_foreign_key_parent_supported( $table_name, $foreign_key ); + $this->assert_alter_table_foreign_key_existing_rows_valid( $table_name, $foreign_key ); + } + } + + /** + * Validate child-side FOREIGN KEY columns before mutation. + * + * @param string $table_name Table name. + * @param array $foreign_key FOREIGN KEY metadata group. + * @param bool $temporary Whether the target is a temporary table. + */ + private function assert_alter_table_foreign_key_columns_supported( string $table_name, array $foreign_key, bool $temporary = false ): void { + if ( count( $foreign_key['columns'] ) !== 1 || count( $foreign_key['referenced_columns'] ) !== 1 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only single-column foreign keys are supported.' ); + } + + $child_column = (string) $foreign_key['columns'][0]; + foreach ( $this->table_column_metadata_rows( $table_name, $temporary ) as $column ) { + if ( 0 === strcasecmp( (string) $column['column_name'], $child_column ) ) { + return; + } + } + + throw new WP_DuckDB_Driver_Exception( "Unknown column '{$child_column}' on table '{$this->database}.{$table_name}' in FOREIGN KEY constraint." ); + } + + /** + * Validate parent-side FOREIGN KEY support before mutation. + * + * @param string $table_name Child table name. + * @param array $foreign_key FOREIGN KEY metadata group. + */ + private function assert_alter_table_foreign_key_parent_supported( string $table_name, array &$foreign_key ): void { + $referenced_table_name = (string) $foreign_key['referenced_table_name']; + $resolved_parent_name = $this->resolve_user_table_name( $referenced_table_name ); + if ( null === $resolved_parent_name ) { + throw new WP_DuckDB_Driver_Exception( "Unknown referenced table '{$this->database}.{$referenced_table_name}' in FOREIGN KEY constraint." ); + } + + if ( 0 === strcasecmp( $resolved_parent_name, $table_name ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Self-referencing ADD/DROP FOREIGN KEY is not supported.' ); + } + + $referenced_column = (string) $foreign_key['referenced_columns'][0]; + $column_found = false; + foreach ( $this->table_column_metadata_rows( $resolved_parent_name, false ) as $column ) { + if ( 0 === strcasecmp( (string) $column['column_name'], $referenced_column ) ) { + $column_found = true; + break; + } + } + + if ( ! $column_found ) { + throw new WP_DuckDB_Driver_Exception( "Unknown referenced column '{$referenced_column}' on table '{$this->database}.{$resolved_parent_name}' in FOREIGN KEY constraint." ); + } + + $primary_key_columns = $this->primary_key_columns_for_table( $resolved_parent_name ); + if ( 1 !== count( $primary_key_columns ) || 0 !== strcasecmp( $primary_key_columns[0], $referenced_column ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD FOREIGN KEY currently requires a single-column referenced PRIMARY KEY.' ); + } + + $foreign_key['referenced_table_name'] = $resolved_parent_name; + } + + /** + * Validate existing child rows before adding a FOREIGN KEY. + * + * @param string $table_name Child table name. + * @param array $foreign_key FOREIGN KEY metadata group. + */ + private function assert_alter_table_foreign_key_existing_rows_valid( string $table_name, array $foreign_key ): void { + $child_column = (string) $foreign_key['columns'][0]; + $parent_table_name = (string) $foreign_key['referenced_table_name']; + $parent_column = (string) $foreign_key['referenced_columns'][0]; + + $stmt = $this->execute_duckdb_query( + 'SELECT 1 FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' AS child LEFT JOIN ' + . $this->connection->quote_identifier( $parent_table_name ) + . ' AS parent ON child.' + . $this->connection->quote_identifier( $child_column ) + . ' = parent.' + . $this->connection->quote_identifier( $parent_column ) + . ' WHERE child.' + . $this->connection->quote_identifier( $child_column ) + . ' IS NOT NULL AND parent.' + . $this->connection->quote_identifier( $parent_column ) + . ' IS NULL LIMIT 1', + 'Failed to validate existing DuckDB FOREIGN KEY rows' + ); + + if ( false !== $stmt->fetch( PDO::FETCH_NUM ) ) { + throw new WP_DuckDB_Driver_Exception( "Cannot add FOREIGN KEY '{$foreign_key['constraint_name']}' on table '{$this->database}.{$table_name}' because existing rows violate the constraint." ); + } + } + /** * Rebuild a table from explicit MySQL-facing metadata. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index c9afd7c2d..dcf6f051e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -6840,6 +6840,394 @@ public function test_alter_table_drop_primary_key_rejects_referenced_parent_befo } } + public function test_alter_table_add_foreign_key_constraints_rebuilds_table_and_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE alter_fk_parent (id INT PRIMARY KEY)' ); + $driver->query( 'INSERT INTO alter_fk_parent (id) VALUES (1), (2)' ); + $driver->query( + 'CREATE TABLE alter_fk_child_named ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + parent_id INT, + amount INT, + label VARCHAR(20), + CONSTRAINT amount_positive CHECK (amount > 0), + UNIQUE KEY label_unique (label), + KEY parent_idx (parent_id) + )' + ); + $driver->query( "INSERT INTO alter_fk_child_named (parent_id, amount, label) VALUES (1, 10, 'a'), (2, 20, 'b')" ); + $driver->query( 'CREATE TABLE alter_fk_child_generated (id INT, parent_id INT, KEY parent_idx (parent_id))' ); + $driver->query( 'INSERT INTO alter_fk_child_generated (id, parent_id) VALUES (10, 1)' ); + + $this->assertSame( + 0, + $driver->query( 'ALTER TABLE alter_fk_child_named ADD CONSTRAINT fk_child_parent FOREIGN KEY (parent_id) REFERENCES alter_fk_parent (id) ON DELETE RESTRICT ON UPDATE NO ACTION' )->rowCount() + ); + $this->assertSame( + 0, + $driver->query( 'ALTER TABLE alter_fk_child_generated ADD FOREIGN KEY (parent_id) REFERENCES alter_fk_parent (id)' )->rowCount() + ); + + $this->assertSame( + array( + array( + 'TABLE_NAME' => 'alter_fk_child_generated', + 'CONSTRAINT_NAME' => 'alter_fk_child_generated_ibfk_1', + 'CONSTRAINT_TYPE' => 'FOREIGN KEY', + 'ENFORCED' => 'YES', + ), + array( + 'TABLE_NAME' => 'alter_fk_child_named', + 'CONSTRAINT_NAME' => 'amount_positive', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'YES', + ), + array( + 'TABLE_NAME' => 'alter_fk_child_named', + 'CONSTRAINT_NAME' => 'fk_child_parent', + 'CONSTRAINT_TYPE' => 'FOREIGN KEY', + 'ENFORCED' => 'YES', + ), + array( + 'TABLE_NAME' => 'alter_fk_child_named', + 'CONSTRAINT_NAME' => 'PRIMARY', + 'CONSTRAINT_TYPE' => 'PRIMARY KEY', + 'ENFORCED' => 'YES', + ), + array( + 'TABLE_NAME' => 'alter_fk_child_named', + 'CONSTRAINT_NAME' => 'label_unique', + 'CONSTRAINT_TYPE' => 'UNIQUE', + 'ENFORCED' => 'YES', + ), + ), + $driver->query( + "SELECT TABLE_NAME, CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' + AND table_name IN ('alter_fk_child_named', 'alter_fk_child_generated') + ORDER BY table_name, constraint_type, constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'alter_fk_child_generated_ibfk_1', + 'TABLE_NAME' => 'alter_fk_child_generated', + 'REFERENCED_TABLE_NAME' => 'alter_fk_parent', + 'UPDATE_RULE' => 'NO ACTION', + 'DELETE_RULE' => 'NO ACTION', + ), + array( + 'CONSTRAINT_NAME' => 'fk_child_parent', + 'TABLE_NAME' => 'alter_fk_child_named', + 'REFERENCED_TABLE_NAME' => 'alter_fk_parent', + 'UPDATE_RULE' => 'NO ACTION', + 'DELETE_RULE' => 'RESTRICT', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME, UPDATE_RULE, DELETE_RULE + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' + AND table_name IN ('alter_fk_child_named', 'alter_fk_child_generated') + ORDER BY table_name, constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'alter_fk_child_generated_ibfk_1', + 'TABLE_NAME' => 'alter_fk_child_generated', + 'COLUMN_NAME' => 'parent_id', + 'REFERENCED_TABLE_NAME' => 'alter_fk_parent', + 'REFERENCED_COLUMN_NAME' => 'id', + ), + array( + 'CONSTRAINT_NAME' => 'fk_child_parent', + 'TABLE_NAME' => 'alter_fk_child_named', + 'COLUMN_NAME' => 'parent_id', + 'REFERENCED_TABLE_NAME' => 'alter_fk_parent', + 'REFERENCED_COLUMN_NAME' => 'id', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name IN ('alter_fk_child_named', 'alter_fk_child_generated') + AND referenced_table_name IS NOT NULL + ORDER BY table_name, constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $named_create = $driver->query( 'SHOW CREATE TABLE alter_fk_child_named' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + $this->assertStringContainsString( 'CONSTRAINT `fk_child_parent` FOREIGN KEY (`parent_id`) REFERENCES `alter_fk_parent` (`id`) ON DELETE RESTRICT', $named_create ); + $this->assertStringContainsString( 'CONSTRAINT `amount_positive` CHECK (amount > 0)', $named_create ); + $this->assertStringContainsString( 'UNIQUE KEY `label_unique` (`label`)', $named_create ); + $this->assertStringContainsString( 'KEY `parent_idx` (`parent_id`)', $named_create ); + $this->assertStringContainsString( + 'CONSTRAINT `alter_fk_child_generated_ibfk_1` FOREIGN KEY (`parent_id`) REFERENCES `alter_fk_parent` (`id`)', + $driver->query( 'SHOW CREATE TABLE alter_fk_child_generated' )->fetch( PDO::FETCH_ASSOC )['Create Table'] + ); + + $driver->query( "INSERT INTO alter_fk_child_named (parent_id, amount, label) VALUES (1, 30, 'c')" ); + $this->assertSame( + array( + array( + 'id' => 1, + 'parent_id' => 1, + 'amount' => 10, + 'label' => 'a', + ), + array( + 'id' => 2, + 'parent_id' => 2, + 'amount' => 20, + 'label' => 'b', + ), + array( + 'id' => 3, + 'parent_id' => 1, + 'amount' => 30, + 'label' => 'c', + ), + ), + $driver->query( 'SELECT id, parent_id, amount, label FROM alter_fk_child_named ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + try { + $driver->query( "INSERT INTO alter_fk_child_named (parent_id, amount, label) VALUES (404, 40, 'blocked')" ); + $this->fail( 'Expected added FOREIGN KEY to reject missing parent rows.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + + try { + $driver->query( 'DELETE FROM alter_fk_parent WHERE id = 1' ); + $this->fail( 'Expected added FOREIGN KEY to reject referenced parent deletes.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB DELETE', $e->getMessage() ); + } + } + + public function test_alter_table_drop_foreign_key_constraints_rebuilds_table_and_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE alter_fk_drop_parent (id INT PRIMARY KEY)' ); + $driver->query( 'INSERT INTO alter_fk_drop_parent (id) VALUES (1)' ); + $driver->query( + 'CREATE TABLE alter_fk_drop_by_key ( + id INT, + parent_id INT, + amount INT, + CONSTRAINT fk_drop_parent FOREIGN KEY (parent_id) REFERENCES alter_fk_drop_parent (id), + CONSTRAINT amount_positive CHECK (amount > 0), + KEY parent_idx (parent_id) + )' + ); + $driver->query( + 'CREATE TABLE alter_fk_drop_by_constraint ( + id INT, + parent_id INT, + CONSTRAINT fk_constraint_parent FOREIGN KEY (parent_id) REFERENCES alter_fk_drop_parent (id) + )' + ); + $driver->query( 'INSERT INTO alter_fk_drop_by_key (id, parent_id, amount) VALUES (1, 1, 10)' ); + $driver->query( 'INSERT INTO alter_fk_drop_by_constraint (id, parent_id) VALUES (2, 1)' ); + + $before = $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_drop_by_key' ); + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_fk_drop_by_key DROP FOREIGN KEY missing_fk' )->rowCount() ); + $this->assertSame( $before, $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_drop_by_key' ) ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_fk_drop_by_key DROP FOREIGN KEY fk_drop_parent' )->rowCount() ); + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_fk_drop_by_constraint DROP CONSTRAINT fk_constraint_parent' )->rowCount() ); + + $this->assertSame( + array(), + $driver->query( + "SELECT CONSTRAINT_NAME + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' + AND table_name IN ('alter_fk_drop_by_key', 'alter_fk_drop_by_constraint')" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array(), + $driver->query( + "SELECT CONSTRAINT_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name IN ('alter_fk_drop_by_key', 'alter_fk_drop_by_constraint') + AND referenced_table_name IS NOT NULL" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $key_create = $driver->query( 'SHOW CREATE TABLE alter_fk_drop_by_key' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + $this->assertStringNotContainsString( 'fk_drop_parent', $key_create ); + $this->assertStringContainsString( 'CONSTRAINT `amount_positive` CHECK (amount > 0)', $key_create ); + $this->assertStringContainsString( 'KEY `parent_idx` (`parent_id`)', $key_create ); + $this->assertStringNotContainsString( + 'fk_constraint_parent', + $driver->query( 'SHOW CREATE TABLE alter_fk_drop_by_constraint' )->fetch( PDO::FETCH_ASSOC )['Create Table'] + ); + + $driver->query( 'INSERT INTO alter_fk_drop_by_key (id, parent_id, amount) VALUES (3, 404, 20)' ); + $driver->query( 'INSERT INTO alter_fk_drop_by_constraint (id, parent_id) VALUES (4, 404)' ); + $this->assertSame( + array( + array( + 'id' => 1, + 'parent_id' => 1, + 'amount' => 10, + ), + array( + 'id' => 3, + 'parent_id' => 404, + 'amount' => 20, + ), + ), + $driver->query( 'SELECT id, parent_id, amount FROM alter_fk_drop_by_key ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_alter_table_add_foreign_key_rejects_existing_row_violations_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE alter_fk_validate_parent (id INT PRIMARY KEY)' ); + $driver->query( 'INSERT INTO alter_fk_validate_parent (id) VALUES (1)' ); + $driver->query( 'CREATE TABLE alter_fk_validate_child (id INT, parent_id INT, KEY parent_idx (parent_id))' ); + $driver->query( 'INSERT INTO alter_fk_validate_child (id, parent_id) VALUES (1, 1), (2, 404)' ); + + $before = $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_validate_child' ); + try { + $driver->query( 'ALTER TABLE alter_fk_validate_child ADD CONSTRAINT fk_validate_parent FOREIGN KEY (parent_id) REFERENCES alter_fk_validate_parent (id)' ); + $this->fail( 'Expected ADD FOREIGN KEY to reject existing orphan rows before mutation.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'existing rows violate the constraint', $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_validate_child' ) ); + $driver->query( 'INSERT INTO alter_fk_validate_child (id, parent_id) VALUES (3, 405)' ); + } + + public function test_alter_table_foreign_key_limitations_reject_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE alter_fk_limit_parent (id INT PRIMARY KEY, other_id INT, code INT, UNIQUE KEY code_u (code))' ); + $driver->query( 'INSERT INTO alter_fk_limit_parent (id, other_id, code) VALUES (1, 10, 100)' ); + $driver->query( 'CREATE TABLE alter_fk_limit_child (id INT PRIMARY KEY, parent_id INT, other_id INT, code INT)' ); + $driver->query( 'INSERT INTO alter_fk_limit_child (id, parent_id, other_id, code) VALUES (1, 1, 10, 100)' ); + $driver->query( 'CREATE TEMPORARY TABLE alter_fk_limit_temp (id INT, parent_id INT)' ); + $driver->query( 'INSERT INTO alter_fk_limit_temp (id, parent_id) VALUES (1, 1)' ); + + foreach ( + array( + 'ALTER TABLE alter_fk_limit_child ADD CONSTRAINT fk_multi FOREIGN KEY (parent_id, other_id) REFERENCES alter_fk_limit_parent (id, other_id)' => 'Only single-column foreign keys are supported', + 'ALTER TABLE alter_fk_limit_child ADD CONSTRAINT fk_schema FOREIGN KEY (parent_id) REFERENCES wp.alter_fk_limit_parent (id)' => 'Schema-qualified references are not supported', + 'ALTER TABLE alter_fk_limit_child ADD CONSTRAINT fk_cascade FOREIGN KEY (parent_id) REFERENCES alter_fk_limit_parent (id) ON DELETE CASCADE' => 'ON DELETE CASCADE is not supported', + 'ALTER TABLE alter_fk_limit_child ADD CONSTRAINT fk_unique_gap FOREIGN KEY (code) REFERENCES alter_fk_limit_parent (code)' => 'single-column referenced PRIMARY KEY', + 'ALTER TABLE alter_fk_limit_temp ADD CONSTRAINT fk_temp FOREIGN KEY (parent_id) REFERENCES alter_fk_limit_parent (id)' => 'temporary tables is not supported', + 'ALTER TABLE alter_fk_limit_child ADD COLUMN parent_ref INT REFERENCES alter_fk_limit_parent (id)' => 'Inline REFERENCES constraints are only supported in CREATE TABLE', + 'ALTER TABLE alter_fk_limit_child ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT fk_multi_action FOREIGN KEY (parent_id) REFERENCES alter_fk_limit_parent (id)' => 'ADD/DROP FOREIGN KEY cannot be combined with other ALTER TABLE actions', + ) as $sql => $message + ) { + $before_child = $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_limit_child' ); + $before_temp = $driver->query( 'SELECT id, parent_id FROM alter_fk_limit_temp ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + + try { + $driver->query( $sql ); + $this->fail( 'Expected unsupported ALTER TABLE FOREIGN KEY form to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + + $this->assertSame( $before_child, $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_limit_child' ) ); + $this->assertSame( $before_temp, $driver->query( 'SELECT id, parent_id FROM alter_fk_limit_temp ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) ); + } + + $driver->query( 'BEGIN' ); + try { + $driver->query( 'ALTER TABLE alter_fk_limit_child ADD CONSTRAINT fk_tx FOREIGN KEY (parent_id) REFERENCES alter_fk_limit_parent (id)' ); + $this->fail( 'Expected active transaction ADD FOREIGN KEY rebuild rejection.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'ADD/DROP FOREIGN KEY cannot run inside an active DuckDB transaction', $e->getMessage() ); + } + $driver->query( 'ROLLBACK' ); + + $driver->query( + 'CREATE TABLE alter_fk_referenced_child ( + id INT PRIMARY KEY, + parent_id INT, + CONSTRAINT fk_referenced_parent FOREIGN KEY (parent_id) REFERENCES alter_fk_limit_parent (id) + )' + ); + $driver->query( 'CREATE TABLE alter_fk_referenced_grandchild (id INT, child_id INT, CONSTRAINT fk_grandchild FOREIGN KEY (child_id) REFERENCES alter_fk_referenced_child (id))' ); + $driver->query( 'INSERT INTO alter_fk_referenced_child (id, parent_id) VALUES (1, 1)' ); + $driver->query( 'INSERT INTO alter_fk_referenced_grandchild (id, child_id) VALUES (1, 1)' ); + $before = $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_referenced_child' ); + + try { + $driver->query( 'ALTER TABLE alter_fk_referenced_child DROP FOREIGN KEY fk_referenced_parent' ); + $this->fail( 'Expected DROP FOREIGN KEY on referenced parent table to reject before mutation.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'referenced by FOREIGN KEY', $e->getMessage() ); + } + $this->assertSame( $before, $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_referenced_child' ) ); + } + + public function test_alter_table_drop_foreign_key_missing_and_generic_missing_constraint_distinction(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE alter_fk_missing_parent (id INT PRIMARY KEY)' ); + $driver->query( 'CREATE TABLE alter_fk_missing_child (id INT, parent_id INT, CONSTRAINT fk_missing_parent FOREIGN KEY (parent_id) REFERENCES alter_fk_missing_parent (id))' ); + $driver->query( 'INSERT INTO alter_fk_missing_parent (id) VALUES (1)' ); + $driver->query( 'INSERT INTO alter_fk_missing_child (id, parent_id) VALUES (1, 1)' ); + + $before = $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_missing_child' ); + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_fk_missing_child DROP FOREIGN KEY missing_fk' )->rowCount() ); + $this->assertSame( $before, $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_missing_child' ) ); + + try { + $driver->query( 'ALTER TABLE alter_fk_missing_child DROP CONSTRAINT missing_fk' ); + $this->fail( 'Expected generic DROP CONSTRAINT missing name to reject.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "Unknown constraint 'missing_fk'", $e->getMessage() ); + } + $this->assertSame( $before, $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_missing_child' ) ); + } + public function test_unsupported_alter_table_constraint_actions_throw_before_mutation(): void { $this->requireDuckDBRuntime(); @@ -6864,15 +7252,10 @@ public function test_unsupported_alter_table_constraint_actions_throw_before_mut foreach ( array( - 'ALTER TABLE alter_constraint_guard ADD FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD FOREIGN KEY is not supported', - 'ALTER TABLE alter_constraint_guard ADD CONSTRAINT added_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD FOREIGN KEY is not supported', 'ALTER TABLE alter_constraint_guard ADD CONSTRAINT added_unique UNIQUE KEY (id)' => 'ADD CONSTRAINT is not supported', 'ALTER TABLE alter_constraint_guard ADD COLUMN score INT CHECK (score >= 0)' => 'Inline CHECK constraints are only supported in CREATE TABLE', 'ALTER TABLE alter_constraint_guard ADD COLUMN parent_ref INT REFERENCES alter_parent (id)' => 'Inline REFERENCES constraints are only supported in CREATE TABLE', - 'ALTER TABLE alter_constraint_guard DROP CONSTRAINT id_unique' => 'DROP CONSTRAINT currently supports CHECK constraints only', - 'ALTER TABLE alter_constraint_guard DROP CONSTRAINT existing_fk' => 'DROP CONSTRAINT currently supports CHECK constraints only', - 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY existing_fk' => 'DROP FOREIGN KEY is not supported', - 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY' => 'DROP FOREIGN KEY is not supported', + 'ALTER TABLE alter_constraint_guard DROP CONSTRAINT id_unique' => 'DROP CONSTRAINT currently supports CHECK and FOREIGN KEY constraints only', ) as $sql => $message ) { try { @@ -6891,15 +7274,16 @@ public function test_unsupported_alter_table_constraint_actions_throw_before_mut foreach ( array( - 'ALTER TABLE alter_constraint_guard DROP CHECK', - 'ALTER TABLE alter_constraint_guard DROP CONSTRAINT', - ) as $sql + 'ALTER TABLE alter_constraint_guard DROP CHECK' => 'DuckDB driver could not parse MySQL statement', + 'ALTER TABLE alter_constraint_guard DROP CONSTRAINT' => 'DuckDB driver could not parse MySQL statement', + 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY' => 'Expected a MySQL identifier', + ) as $sql => $message ) { try { $driver->query( $sql ); $this->fail( 'Expected malformed ALTER TABLE constraint action to reject SQL: ' . $sql ); } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( 'DuckDB driver could not parse MySQL statement', $e->getMessage() ); + $this->assertStringContainsString( $message, $e->getMessage() ); } $this->assertSame( @@ -6935,10 +7319,10 @@ public function test_unsupported_alter_table_constraint_actions_in_multi_action_ foreach ( array( 'ALTER TABLE alter_constraint_guard ADD CHECK (id >= 0), ADD CONSTRAINT added_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD/DROP CHECK cannot be combined with other ALTER TABLE actions', - 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY existing_fk, ADD COLUMN should_not_exist INT DEFAULT 2' => 'DROP FOREIGN KEY is not supported', - 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT added_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD FOREIGN KEY is not supported', + 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY existing_fk, ADD COLUMN should_not_exist INT DEFAULT 2' => 'ADD/DROP FOREIGN KEY cannot be combined with other ALTER TABLE actions', + 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT added_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD/DROP FOREIGN KEY cannot be combined with other ALTER TABLE actions', 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT added_unique UNIQUE KEY (id)' => 'ADD CONSTRAINT is not supported', - 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, DROP CONSTRAINT existing_fk' => 'DROP CONSTRAINT currently supports CHECK constraints only', + 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, DROP CONSTRAINT existing_fk' => 'ADD/DROP FOREIGN KEY cannot be combined with other ALTER TABLE actions', 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD COLUMN inline_check INT CHECK (inline_check >= 0)' => 'Inline CHECK constraints are only supported in CREATE TABLE', 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD COLUMN inline_parent INT REFERENCES alter_parent (id)' => 'Inline REFERENCES constraints are only supported in CREATE TABLE', ) as $sql => $message @@ -7063,6 +7447,35 @@ public function test_unsupported_alter_table_add_not_null_without_default_on_non $driver->query( 'ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL' ); } + private function alter_table_foreign_key_lifecycle_snapshot( WP_DuckDB_Driver $driver, string $table_name ): array { + return array( + 'columns' => $driver->query( 'SHOW COLUMNS FROM ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), + 'rows' => $driver->query( 'SELECT * FROM ' . $table_name . ' ORDER BY 1' )->fetchAll( PDO::FETCH_ASSOC ), + 'indexes' => $driver->query( 'SHOW INDEX FROM ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), + 'table_constraints' => $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = '{$table_name}' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'referential_constraints' => $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME, UPDATE_RULE, DELETE_RULE + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' AND table_name = '{$table_name}' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'key_column_usage' => $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name = '{$table_name}' + AND referenced_table_name IS NOT NULL + ORDER BY constraint_name, ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ), + 'show_create' => $driver->query( 'SHOW CREATE TABLE ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), + ); + } + private function alter_table_constraint_guard_snapshot( WP_DuckDB_Driver $driver ): array { return array( 'columns' => array_column( $driver->query( 'SHOW COLUMNS FROM alter_constraint_guard' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ), From 966882cf2ac2c31be10bd3c3b9395d00e5062cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 03:36:06 +0000 Subject: [PATCH 057/317] Preserve DuckDB found rows result metadata --- .../src/duckdb/class-wp-duckdb-driver.php | 10 +- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 122 +++++++++++++++++- 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 412a0db04..a976aa119 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1071,16 +1071,18 @@ private function record_found_rows_from_result( WP_DuckDB_Result_Statement $stat return $statement; } - $columns = array(); + $columns = array(); + $column_meta = array(); for ( $index = 0; $index < $statement->columnCount(); ++$index ) { - $meta = $statement->getColumnMeta( $index ); - $columns[] = is_array( $meta ) && isset( $meta['name'] ) ? (string) $meta['name'] : (string) $index; + $meta = $statement->getColumnMeta( $index ); + $column_meta[] = is_array( $meta ) ? $meta : array(); + $columns[] = is_array( $meta ) && isset( $meta['name'] ) ? (string) $meta['name'] : (string) $index; } $rows = $statement->fetchAll( PDO::FETCH_NUM ); $this->found_rows = count( $rows ); - return new WP_DuckDB_Result_Statement( $columns, $rows, $statement->rowCount() ); + return new WP_DuckDB_Result_Statement( $columns, $rows, $statement->rowCount(), $column_meta ); } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index dcf6f051e..bd5f256c3 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -6,6 +6,69 @@ * @group duckdb */ class WP_DuckDB_Driver_Tests extends WP_DuckDB_TestCase { + public function test_record_found_rows_from_result_preserves_column_metadata(): void { + $driver = ( new ReflectionClass( WP_DuckDB_Driver::class ) )->newInstanceWithoutConstructor(); + $source = new WP_DuckDB_Result_Statement( + array( 'post_id', 'post_title' ), + array( + array( 1, 'Hello' ), + array( 2, 'World' ), + ), + 0, + array( + array( + 'name' => 'post_id', + 'table' => 'p', + 'mysqli:orgname' => 'ID', + 'mysqli:orgtable' => 'wp_posts', + 'mysqli:db' => 'wordpress_test', + 'len' => 20, + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 8, + 'mysqli:custom_key' => 'preserved', + ), + array( + 'name' => 'post_title', + 'table' => 'p', + 'mysqli:orgname' => 'post_title', + 'mysqli:orgtable' => 'wp_posts', + 'mysqli:db' => 'wordpress_test', + 'len' => 764, + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 253, + ), + ) + ); + + $method = new ReflectionMethod( WP_DuckDB_Driver::class, 'record_found_rows_from_result' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + $result = $method->invoke( $driver, $source ); + + $found_rows = new ReflectionProperty( WP_DuckDB_Driver::class, 'found_rows' ); + if ( PHP_VERSION_ID < 80100 ) { + $found_rows->setAccessible( true ); + } + + $this->assertSame( 2, $found_rows->getValue( $driver ) ); + $this->assertSame( + array( + array( + 'post_id' => 1, + 'post_title' => 'Hello', + ), + array( + 'post_id' => 2, + 'post_title' => 'World', + ), + ), + $result->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( $source->getColumnMeta( 0 ), $result->getColumnMeta( 0 ) ); + $this->assertSame( $source->getColumnMeta( 1 ), $result->getColumnMeta( 1 ) ); + } + public function test_select_mysql_functions_are_emulated(): void { $this->requireDuckDBRuntime(); @@ -855,6 +918,7 @@ public function test_builtin_system_variables_are_emulated(): void { $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); $read = $driver->query( 'SELECT @@version, @@version_comment' ); + $this->assertSame( 0, $read->rowCount() ); $this->assertSame( array( 'name' => '@@version' ), $read->getColumnMeta( 0 ) ); $this->assertSame( array( 'name' => '@@version_comment' ), $read->getColumnMeta( 1 ) ); $this->assertSame( @@ -864,6 +928,11 @@ public function test_builtin_system_variables_are_emulated(): void { ), $read->fetch( PDO::FETCH_ASSOC ) ); + $this->assertFalse( $read->fetch( PDO::FETCH_ASSOC ) ); + $this->assertSame( + array( array( 'found_rows' => 1 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); $this->assertSame( array(), $driver->get_last_duckdb_queries() ); } @@ -1830,9 +1899,26 @@ public function test_sql_calc_found_rows_and_found_rows_are_emulated(): void { ('katherine')" ); - $rows = $driver->query( + $result = $driver->query( 'SELECT SQL_CALC_FOUND_ROWS ID, user_login FROM wp_found_rows_users ORDER BY ID LIMIT 2' - )->fetchAll( PDO::FETCH_ASSOC ); + ); + + $this->assertSame( 0, $result->rowCount() ); + $id_meta = $result->getColumnMeta( 0 ); + $this->assertSame( 'ID', $id_meta['name'] ); + $this->assertSame( 'ID', $id_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_found_rows_users', $id_meta['mysqli:orgtable'] ); + $this->assertSame( 20, $id_meta['len'] ); + $this->assertSame( 8, $id_meta['mysqli:type'] ); + + $login_meta = $result->getColumnMeta( 1 ); + $this->assertSame( 'user_login', $login_meta['name'] ); + $this->assertSame( 'user_login', $login_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_found_rows_users', $login_meta['mysqli:orgtable'] ); + $this->assertSame( 240, $login_meta['len'] ); + $this->assertSame( 253, $login_meta['mysqli:type'] ); + + $rows = $result->fetchAll( PDO::FETCH_ASSOC ); $this->assertSame( array( @@ -1869,9 +1955,23 @@ public function test_found_rows_state_tracks_selects_and_result_counts(): void { $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) ); - $driver->query( 'SELECT id, label FROM wp_found_rows_state ORDER BY id' ); + $plain = $driver->query( 'SELECT id, label FROM wp_found_rows_state ORDER BY id LIMIT 2' ); + $this->assertSame( 0, $plain->rowCount() ); $this->assertSame( - array( array( 'found_rows' => 3 ) ), + array( + array( + 'id' => 1, + 'label' => 'one', + ), + array( + 'id' => 2, + 'label' => 'two', + ), + ), + $plain->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'found_rows' => 2 ) ), $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) ); $this->assertSame( @@ -4353,7 +4453,13 @@ public function test_show_table_status_exposes_mysql_shaped_table_metadata(): vo VALUES ('siteurl', 'https://example.test'), ('home', 'https://example.test')" ); - $rows = $driver->query( 'SHOW TABLE STATUS FROM wp' )->fetchAll( PDO::FETCH_ASSOC ); + $status = $driver->query( 'SHOW TABLE STATUS FROM wp' ); + $this->assertSame( 0, $status->rowCount() ); + $this->assertSame( array( 'name' => 'Name' ), $status->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => 'Engine' ), $status->getColumnMeta( 1 ) ); + $this->assertSame( array( 'name' => 'Comment' ), $status->getColumnMeta( 17 ) ); + + $rows = $status->fetchAll( PDO::FETCH_ASSOC ); $this->assertSame( array( @@ -4417,6 +4523,12 @@ public function test_show_table_status_exposes_mysql_shaped_table_metadata(): vo $internal = $driver->query( "SHOW TABLE STATUS LIKE '__wp_duckdb_%'" )->fetchAll( PDO::FETCH_ASSOC ); $this->assertSame( array(), $internal ); + + $driver->query( 'SHOW TABLE STATUS FROM wp' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( + array( array( 'found_rows' => 2 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); } public function test_check_table_returns_mysql_shaped_status_rows(): void { From fa675eb0c931395e895b57dc94fc30a902ba0627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 03:49:18 +0000 Subject: [PATCH 058/317] Add DuckDB DROP COLUMN key rebuild parity --- .../src/duckdb/class-wp-duckdb-driver.php | 256 +++++++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 459 +++++++++++++++++- 2 files changed, 699 insertions(+), 16 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index a976aa119..5f223c887 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -4597,6 +4597,9 @@ private function validate_alter_table_rebuild_action_combination( string $table_ if ( $this->is_alter_table_drop_primary_key_action( $action ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP PRIMARY KEY cannot be combined with other ALTER TABLE actions.' ); } + if ( $this->is_alter_table_drop_column_rebuild_action( $table_name, $action, $temporary ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN requiring a table rebuild cannot be combined with other ALTER TABLE actions.' ); + } } } @@ -5645,6 +5648,7 @@ function ( string $column_name ): string { 'Failed to rebuild DuckDB ' . $context . ' table' ); $this->drop_auto_increment_sequences( $sequence_names ); + $this->delete_index_metadata_for_table( $table_name, $temporary ); $this->execute_create_table( $this->tokenize_and_validate( $create_sql ) ); $this->execute_duckdb_query( 'INSERT INTO ' @@ -6009,8 +6013,20 @@ private function execute_alter_table_drop_column( string $table_name, array $tok } $resolved_column_name = $this->assert_alter_table_drop_column_supported( $table_name, $column_name, $temporary ); - $index_definitions = $this->secondary_index_definitions_for_table( $table_name, $temporary ); - $rebuilt_indexes = $this->secondary_index_definitions_after_column_drop( $table_name, $resolved_column_name, $index_definitions, $temporary ); + if ( $this->alter_table_drop_column_requires_rebuild( $table_name, $resolved_column_name, $temporary ) ) { + $this->assert_alter_table_drop_column_rebuild_supported( $table_name, $resolved_column_name, $temporary ); + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $resolved_column_name, $temporary ): WP_DuckDB_Result_Statement { + $this->execute_alter_table_drop_column_rebuild( $table_name, $resolved_column_name, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + + $index_definitions = $this->secondary_index_definitions_for_table( $table_name, $temporary ); + $rebuilt_indexes = $this->secondary_index_definitions_after_column_drop( $table_name, $resolved_column_name, $index_definitions, $temporary ); $callback = function () use ( $table_name, $resolved_column_name, $index_definitions, $rebuilt_indexes, $temporary ): WP_DuckDB_Result_Statement { return $this->execute_alter_table_drop_column_change( $table_name, $resolved_column_name, $index_definitions, $rebuilt_indexes, $temporary ); @@ -6119,18 +6135,226 @@ private function assert_alter_table_drop_column_supported( string $table_name, s throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN cannot remove the last column.' ); } + return $resolved_column_name; + } + + /** + * Check whether ALTER TABLE ... DROP COLUMN needs a metadata-plan rebuild. + * + * @param string $table_name Table name. + * @param string $column_name Resolved column name. + * @param bool $temporary Whether the target is a temporary table. + * @return bool Whether the column requires a table rebuild. + */ + private function alter_table_drop_column_requires_rebuild( string $table_name, string $column_name, bool $temporary = false ): bool { foreach ( $this->primary_key_index_rows( $table_name ) as $index_row ) { - if ( 0 === strcasecmp( (string) $index_row[4], $resolved_column_name ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN on a primary key column requires a table rebuild.' ); + if ( 0 === strcasecmp( (string) $index_row[4], $column_name ) ) { + return true; } } $auto_increment = $this->auto_increment_metadata_for_table( $table_name, $temporary ); - if ( null !== $auto_increment && 0 === strcasecmp( $auto_increment['column_name'], $resolved_column_name ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN on an AUTO_INCREMENT column requires a table rebuild.' ); + if ( null !== $auto_increment && 0 === strcasecmp( $auto_increment['column_name'], $column_name ) ) { + return true; } - return $resolved_column_name; + return false; + } + + /** + * Check whether an ALTER TABLE action drops a column that requires rebuild. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @param bool $temporary Whether the target is a temporary table. + * @return bool Whether the action needs a drop-column rebuild. + */ + private function is_alter_table_drop_column_rebuild_action( string $table_name, array $tokens, bool $temporary = false ): bool { + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return false; + } + + if ( + WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[1]->id + || WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[1]->id + || WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[1]->id + || $this->is_alter_table_drop_index_action( $tokens ) + ) { + return false; + } + + $index = WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[1]->id ? 2 : 1; + if ( ! isset( $tokens[ $index ] ) ) { + return false; + } + + $column_name = $this->identifier_value( $tokens[ $index ] ); + $resolved_column_name = $this->assert_alter_table_drop_column_supported( $table_name, $column_name, $temporary ); + + return $this->alter_table_drop_column_requires_rebuild( $table_name, $resolved_column_name, $temporary ); + } + + /** + * Validate ALTER TABLE ... DROP COLUMN rebuild before mutation. + * + * @param string $table_name Table name. + * @param string $column_name Resolved column name. + * @param bool $temporary Whether the target is a temporary table. + */ + private function assert_alter_table_drop_column_rebuild_supported( string $table_name, string $column_name, bool $temporary = false ): void { + $this->assert_no_active_transaction_for_table_rebuild( 'DROP COLUMN' ); + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'DROP COLUMN', $temporary ); + + foreach ( $this->check_constraint_metadata_rows( $table_name, $temporary ) as $check_constraint ) { + if ( $this->check_constraint_references_column( $check_constraint, $column_name ) ) { + throw new WP_DuckDB_Driver_Exception( "Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN cannot remove column '{$column_name}' because CHECK constraint '{$check_constraint['constraint_name']}' references it." ); + } + } + + foreach ( $this->show_create_table_foreign_key_groups( $table_name, $temporary ) as $foreign_key ) { + if ( $this->foreign_key_constraint_references_column( $foreign_key, $table_name, $column_name ) ) { + throw new WP_DuckDB_Driver_Exception( "Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN cannot remove column '{$column_name}' because FOREIGN KEY constraint '{$foreign_key['constraint_name']}' references it." ); + } + } + } + + /** + * Rebuild a table for ALTER TABLE ... DROP COLUMN. + * + * @param string $table_name Table name. + * @param string $column_name Resolved column name. + * @param bool $temporary Whether the target is a temporary table. + */ + private function execute_alter_table_drop_column_rebuild( string $table_name, string $column_name, bool $temporary = false ): void { + $column_metadata_rows = $this->column_metadata_rows_after_column_drop( + $this->table_column_metadata_rows( $table_name, $temporary ), + $column_name + ); + $primary_key_columns = $this->primary_key_columns_after_column_drop( $table_name, $column_name ); + $secondary_indexes = $this->secondary_index_definitions_after_column_drop( + $table_name, + $column_name, + $this->secondary_index_definitions_for_table( $table_name, $temporary ), + $temporary + ); + $auto_increment = $this->metadata_rows_have_auto_increment( $column_metadata_rows ) + ? $this->table_auto_increment_value( $table_name, $temporary ) + : null; + + $this->rebuild_table_from_metadata_plan( + $table_name, + $column_metadata_rows, + $primary_key_columns, + $secondary_indexes, + $this->check_constraint_metadata_rows( $table_name, $temporary ), + $this->show_create_table_foreign_key_groups( $table_name, $temporary ), + $auto_increment, + 'DROP COLUMN', + $temporary + ); + } + + /** + * Return column metadata rows after dropping one column. + * + * @param array> $metadata Current metadata rows. + * @param string $column_name Dropped column name. + * @return array> + */ + private function column_metadata_rows_after_column_drop( array $metadata, string $column_name ): array { + return array_values( + array_filter( + $metadata, + function ( array $column ) use ( $column_name ): bool { + return 0 !== strcasecmp( (string) $column['column_name'], $column_name ); + } + ) + ); + } + + /** + * Return primary key columns after dropping one column. + * + * @param string $table_name Table name. + * @param string $column_name Dropped column name. + * @return string[] Primary key columns. + */ + private function primary_key_columns_after_column_drop( string $table_name, string $column_name ): array { + return array_values( + array_filter( + $this->primary_key_columns_for_table( $table_name ), + function ( string $primary_key_column ) use ( $column_name ): bool { + return 0 !== strcasecmp( $primary_key_column, $column_name ); + } + ) + ); + } + + /** + * Check whether planned metadata still has an AUTO_INCREMENT column. + * + * @param array> $metadata Column metadata rows. + * @return bool Whether AUTO_INCREMENT remains. + */ + private function metadata_rows_have_auto_increment( array $metadata ): bool { + foreach ( $metadata as $column ) { + if ( 'auto_increment' === $column['extra'] ) { + return true; + } + } + + return false; + } + + /** + * Check whether a CHECK constraint references a column. + * + * @param array $check_constraint CHECK metadata row. + * @param string $column_name Column name. + * @return bool Whether the CHECK expression mentions the column. + */ + private function check_constraint_references_column( array $check_constraint, string $column_name ): bool { + $clause = (string) $check_constraint['check_clause']; + + $quoted_identifier = '`' . str_replace( '`', '``', $column_name ) . '`'; + if ( false !== stripos( $clause, $quoted_identifier ) ) { + return true; + } + + $without_strings = preg_replace( "/'([^'\\\\]|\\\\.|'')*'/", "''", $clause ); + if ( null === $without_strings ) { + $without_strings = $clause; + } + + return 1 === preg_match( '/(? $foreign_key FOREIGN KEY metadata group. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return bool Whether the FOREIGN KEY mentions the column. + */ + private function foreign_key_constraint_references_column( array $foreign_key, string $table_name, string $column_name ): bool { + foreach ( $foreign_key['columns'] as $foreign_key_column ) { + if ( 0 === strcasecmp( (string) $foreign_key_column, $column_name ) ) { + return true; + } + } + + if ( 0 !== strcasecmp( (string) $foreign_key['referenced_table_name'], $table_name ) ) { + return false; + } + + foreach ( $foreign_key['referenced_columns'] as $referenced_column ) { + if ( 0 === strcasecmp( (string) $referenced_column, $column_name ) ) { + return true; + } + } + + return false; } /** @@ -12651,6 +12875,24 @@ private function delete_index_metadata( string $table_name, string $index_name, ); } + /** + * Delete all secondary index metadata rows for a table. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + */ + private function delete_index_metadata_for_table( string $table_name, bool $temporary = false ): void { + $this->ensure_index_metadata_table( $temporary ); + + $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ), + 'Failed to delete DuckDB table index metadata' + ); + } + /** * Delete all stored lifecycle metadata for a table. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index bd5f256c3..0dfee4f96 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -5580,6 +5580,294 @@ function ( array $row ): array { ); } + public function test_alter_table_drop_primary_key_column_rebuilds_and_cleans_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE drop_col_pk_rebuild ( + id INT NOT NULL, + code VARCHAR(20) NOT NULL, + amount INT, + note VARCHAR(20), + PRIMARY KEY (id), + UNIQUE KEY code_unique (code), + KEY amount_idx (amount), + CONSTRAINT amount_positive CHECK (amount > 0) + )' + ); + $driver->query( "INSERT INTO drop_col_pk_rebuild (id, code, amount, note) VALUES (1, 'a', 10, 'first'), (2, 'b', 20, 'second')" ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE drop_col_pk_rebuild DROP COLUMN id' )->rowCount() ); + $driver->query( "INSERT INTO drop_col_pk_rebuild (code, amount, note) VALUES ('c', 30, 'third')" ); + + try { + $driver->query( "INSERT INTO drop_col_pk_rebuild (code, amount, note) VALUES ('a', 40, 'duplicate')" ); + $this->fail( 'Expected surviving UNIQUE index to remain enforced after DROP COLUMN rebuild.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + + try { + $driver->query( "INSERT INTO drop_col_pk_rebuild (code, amount, note) VALUES ('d', 0, 'invalid')" ); + $this->fail( 'Expected surviving CHECK constraint to remain enforced after DROP COLUMN rebuild.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + $this->assertSame( + array( + array( + 'code' => 'a', + 'amount' => 10, + 'note' => 'first', + ), + array( + 'code' => 'b', + 'amount' => 20, + 'note' => 'second', + ), + array( + 'code' => 'c', + 'amount' => 30, + 'note' => 'third', + ), + ), + $driver->query( 'SELECT code, amount, note FROM drop_col_pk_rebuild ORDER BY code' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + 'code' => 'UNI', + 'amount' => 'MUL', + 'note' => '', + ), + array_column( $driver->query( 'SHOW COLUMNS FROM drop_col_pk_rebuild' )->fetchAll( PDO::FETCH_ASSOC ), 'Key', 'Field' ) + ); + $this->assertSame( + array( + array( + 'COLUMN_NAME' => 'code', + 'ORDINAL_POSITION' => 1, + 'EXTRA' => '', + ), + array( + 'COLUMN_NAME' => 'amount', + 'ORDINAL_POSITION' => 2, + 'EXTRA' => '', + ), + array( + 'COLUMN_NAME' => 'note', + 'ORDINAL_POSITION' => 3, + 'EXTRA' => '', + ), + ), + $driver->query( + "SELECT COLUMN_NAME, ORDINAL_POSITION, EXTRA + FROM information_schema.columns + WHERE table_schema = 'wp' AND table_name = 'drop_col_pk_rebuild' + ORDER BY ORDINAL_POSITION" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create_sql = $driver->query( 'SHOW CREATE TABLE drop_col_pk_rebuild' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + $this->assertStringNotContainsString( '`id`', $create_sql ); + $this->assertStringNotContainsString( 'PRIMARY KEY', $create_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `code_unique` (`code`)', $create_sql ); + $this->assertStringContainsString( 'KEY `amount_idx` (`amount`)', $create_sql ); + $this->assertStringContainsString( 'CONSTRAINT `amount_positive` CHECK (amount > 0)', $create_sql ); + $this->assertSame( + array(), + $driver->query( + "SELECT index_name + FROM information_schema.statistics + WHERE table_schema = 'wp' + AND table_name = 'drop_col_pk_rebuild' + AND index_name = 'PRIMARY'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array(), + $driver->query( + "SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_schema = 'wp' + AND table_name = 'drop_col_pk_rebuild' + AND constraint_name = 'PRIMARY'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array(), + $driver->query( + "SELECT constraint_name + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name = 'drop_col_pk_rebuild' + AND constraint_name = 'PRIMARY'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_alter_table_drop_composite_primary_key_member_shrinks_keys(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE drop_col_composite_pk ( + site_id INT NOT NULL, + option_id INT NOT NULL, + name VARCHAR(20), + payload INT, + PRIMARY KEY (site_id, option_id), + KEY site_name (site_id, name), + KEY only_site (site_id), + KEY payload_idx (payload) + )' + ); + $driver->query( "INSERT INTO drop_col_composite_pk (site_id, option_id, name, payload) VALUES (1, 10, 'a', 100), (2, 20, 'b', 200)" ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE drop_col_composite_pk DROP site_id' )->rowCount() ); + + $index_columns = array(); + foreach ( $driver->query( 'SHOW INDEX FROM drop_col_composite_pk' )->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $index_columns[ $row['Key_name'] ][] = $row['Column_name']; + } + $this->assertSame( array( 'option_id' ), $index_columns['PRIMARY'] ); + $this->assertSame( array( 'name' ), $index_columns['site_name'] ); + $this->assertSame( array( 'payload' ), $index_columns['payload_idx'] ); + $this->assertArrayNotHasKey( 'only_site', $index_columns ); + $this->assertSame( + array(), + $driver->query( + "SELECT index_name + FROM information_schema.statistics + WHERE table_schema = 'wp' + AND table_name = 'drop_col_composite_pk' + AND index_name = 'only_site'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + try { + $driver->query( "INSERT INTO drop_col_composite_pk (option_id, name, payload) VALUES (10, 'duplicate', 300)" ); + $this->fail( 'Expected shrunken PRIMARY KEY to remain enforced.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + + $this->assertSame( + array( + array( + 'option_id' => 10, + 'name' => 'a', + 'payload' => 100, + ), + array( + 'option_id' => 20, + 'name' => 'b', + 'payload' => 200, + ), + ), + $driver->query( 'SELECT option_id, name, payload FROM drop_col_composite_pk ORDER BY option_id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create_sql = $driver->query( 'SHOW CREATE TABLE drop_col_composite_pk' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + $this->assertStringContainsString( 'PRIMARY KEY (`option_id`)', $create_sql ); + $this->assertStringContainsString( 'KEY `site_name` (`name`)', $create_sql ); + $this->assertStringNotContainsString( 'KEY `only_site`', $create_sql ); + } + + public function test_alter_table_drop_auto_increment_primary_key_column_removes_sequence_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE drop_col_ai_rebuild ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + slug VARCHAR(20) NOT NULL, + payload INT, + PRIMARY KEY (id), + UNIQUE KEY slug_unique (slug), + KEY payload_idx (payload) + ) AUTO_INCREMENT=50' + ); + $driver->query( "INSERT INTO drop_col_ai_rebuild (slug, payload) VALUES ('a', 100), ('b', 200)" ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE drop_col_ai_rebuild DROP COLUMN id' )->rowCount() ); + $this->assertNotEmpty( + array_filter( + $driver->get_last_duckdb_queries(), + function ( string $sql ): bool { + return false !== strpos( $sql, 'DROP SEQUENCE IF EXISTS' ); + } + ) + ); + $driver->query( "INSERT INTO drop_col_ai_rebuild (slug, payload) VALUES ('c', 300)" ); + + $this->assertSame( + array( + array( + 'slug' => 'a', + 'payload' => 100, + ), + array( + 'slug' => 'b', + 'payload' => 200, + ), + array( + 'slug' => 'c', + 'payload' => 300, + ), + ), + $driver->query( 'SELECT slug, payload FROM drop_col_ai_rebuild ORDER BY slug' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertNull( + $driver->query( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'drop_col_ai_rebuild'" + )->fetch( PDO::FETCH_ASSOC )['AUTO_INCREMENT'] + ); + $this->assertSame( + array( + array( + 'COLUMN_NAME' => 'slug', + 'EXTRA' => '', + ), + array( + 'COLUMN_NAME' => 'payload', + 'EXTRA' => '', + ), + ), + $driver->query( + "SELECT COLUMN_NAME, EXTRA + FROM information_schema.columns + WHERE table_schema = 'wp' AND table_name = 'drop_col_ai_rebuild' + ORDER BY ORDINAL_POSITION" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create_sql = $driver->query( 'SHOW CREATE TABLE drop_col_ai_rebuild' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + $this->assertStringNotContainsString( '`id`', $create_sql ); + $this->assertStringNotContainsString( 'AUTO_INCREMENT', $create_sql ); + $this->assertStringNotContainsString( 'PRIMARY KEY', $create_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `slug_unique` (`slug`)', $create_sql ); + $this->assertStringContainsString( 'KEY `payload_idx` (`payload`)', $create_sql ); + } + public function test_alter_table_drop_multiple_and_mixed_columns(): void { $this->requireDuckDBRuntime(); @@ -5656,6 +5944,51 @@ public function test_alter_table_drop_column_targets_temporary_table(): void { ); } + public function test_alter_table_drop_primary_key_column_rebuild_targets_temporary_table(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE shadow_drop_pk (id INT NOT NULL, keep_col VARCHAR(20), PRIMARY KEY (id))' ); + $driver->query( "INSERT INTO shadow_drop_pk (id, keep_col) VALUES (1, 'persistent')" ); + $driver->query( 'CREATE TEMPORARY TABLE shadow_drop_pk (id INT NOT NULL, keep_col VARCHAR(20), temp_col VARCHAR(20), PRIMARY KEY (id))' ); + $driver->query( "INSERT INTO shadow_drop_pk (id, keep_col, temp_col) VALUES (10, 'temporary', 'temp')" ); + + $driver->query( 'ALTER TABLE shadow_drop_pk DROP COLUMN id' ); + $this->assertSame( + array( 'keep_col', 'temp_col' ), + array_column( $driver->query( 'SHOW COLUMNS FROM shadow_drop_pk' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( + array( + 'keep_col' => 'temporary', + 'temp_col' => 'temp', + ), + ), + $driver->query( 'SELECT keep_col, temp_col FROM shadow_drop_pk' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'DROP TEMPORARY TABLE shadow_drop_pk' ); + $this->assertSame( + array( 'id', 'keep_col' ), + array_column( $driver->query( 'SHOW COLUMNS FROM shadow_drop_pk' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'keep_col' => 'persistent', + ), + ), + $driver->query( 'SELECT id, keep_col FROM shadow_drop_pk' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_alter_table_change_column_renames_with_type_default_and_metadata(): void { $this->requireDuckDBRuntime(); @@ -5929,7 +6262,7 @@ public function test_alter_table_change_modify_rejects_protected_definitions(): } } - public function test_alter_table_drop_column_rejects_protected_columns(): void { + public function test_alter_table_drop_column_rejects_last_column(): void { $this->requireDuckDBRuntime(); $driver = new WP_DuckDB_Driver( @@ -5938,23 +6271,131 @@ public function test_alter_table_drop_column_rejects_protected_columns(): void { 'database' => 'wp', ) ); - $driver->query( 'CREATE TABLE drop_col_pk (id INT NOT NULL, keep_col INT, PRIMARY KEY (id))' ); - $driver->query( 'CREATE TABLE drop_col_auto (id BIGINT NOT NULL AUTO_INCREMENT, keep_col INT, KEY id_idx (id))' ); $driver->query( 'CREATE TABLE drop_col_last (only_col INT)' ); + try { + $driver->query( 'ALTER TABLE drop_col_last DROP COLUMN only_col' ); + $this->fail( 'Expected DROP COLUMN last-column rejection.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'DROP COLUMN cannot remove the last column', $e->getMessage() ); + } + } + + public function test_alter_table_drop_primary_key_column_rejects_check_references_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE drop_col_check_guard ( + id INT NOT NULL, + name VARCHAR(20), + PRIMARY KEY (id), + CONSTRAINT id_positive CHECK (id > 0) + )' + ); + $driver->query( "INSERT INTO drop_col_check_guard (id, name) VALUES (1, 'a')" ); + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_col_check_guard' ); + try { + $driver->query( 'ALTER TABLE drop_col_check_guard DROP COLUMN id' ); + $this->fail( 'Expected DROP COLUMN CHECK reference rejection.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "CHECK constraint 'id_positive' references it", $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_col_check_guard' ) ); + } + + public function test_alter_table_drop_primary_key_column_rejects_foreign_key_references_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE drop_col_fk_parent (id INT PRIMARY KEY)' ); + $driver->query( + 'CREATE TABLE drop_col_fk_child ( + id INT NOT NULL, + parent_id INT NOT NULL, + name VARCHAR(20), + PRIMARY KEY (id, parent_id), + CONSTRAINT child_parent_fk FOREIGN KEY (parent_id) REFERENCES drop_col_fk_parent (id) + )' + ); + $driver->query( 'INSERT INTO drop_col_fk_parent (id) VALUES (1)' ); + $driver->query( "INSERT INTO drop_col_fk_child (id, parent_id, name) VALUES (10, 1, 'a')" ); + + $before = $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'drop_col_fk_child' ); + try { + $driver->query( 'ALTER TABLE drop_col_fk_child DROP COLUMN parent_id' ); + $this->fail( 'Expected DROP COLUMN FOREIGN KEY reference rejection.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "FOREIGN KEY constraint 'child_parent_fk' references it", $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'drop_col_fk_child' ) ); + } + + public function test_alter_table_drop_primary_key_column_rejects_active_transaction_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE drop_col_pk_tx (id INT PRIMARY KEY, name VARCHAR(20))' ); + $driver->query( "INSERT INTO drop_col_pk_tx (id, name) VALUES (1, 'a')" ); + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_col_pk_tx' ); + $driver->query( 'BEGIN' ); + try { + $driver->query( 'ALTER TABLE drop_col_pk_tx DROP COLUMN id' ); + $this->fail( 'Expected active transaction DROP COLUMN rebuild rejection.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'DROP COLUMN cannot run inside an active DuckDB transaction', $e->getMessage() ); + } + $driver->query( 'ROLLBACK' ); + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_col_pk_tx' ) ); + } + + public function test_alter_table_drop_primary_key_column_rejects_multi_action_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE drop_col_pk_multi_guard (id INT PRIMARY KEY, name VARCHAR(20))' ); + $driver->query( "INSERT INTO drop_col_pk_multi_guard (id, name) VALUES (1, 'a')" ); + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_col_pk_multi_guard' ); foreach ( array( - 'ALTER TABLE drop_col_pk DROP COLUMN id' => 'primary key column requires a table rebuild', - 'ALTER TABLE drop_col_auto DROP COLUMN id' => 'AUTO_INCREMENT column requires a table rebuild', - 'ALTER TABLE drop_col_last DROP COLUMN only_col' => 'DROP COLUMN cannot remove the last column', - ) as $sql => $message + 'ALTER TABLE drop_col_pk_multi_guard DROP COLUMN id, ADD COLUMN should_not_exist INT', + 'ALTER TABLE drop_col_pk_multi_guard ADD COLUMN should_not_exist INT, DROP COLUMN id', + ) as $sql ) { try { $driver->query( $sql ); - $this->fail( 'Expected DROP COLUMN protection to reject SQL: ' . $sql ); + $this->fail( 'Expected multi-action DROP COLUMN rebuild rejection for SQL: ' . $sql ); } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( $message, $e->getMessage() ); + $this->assertStringContainsString( 'DROP COLUMN requiring a table rebuild cannot be combined with other ALTER TABLE actions', $e->getMessage() ); } + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'drop_col_pk_multi_guard' ) ); } } From 96276593c42c913108021585f8aa431c15516703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 03:55:49 +0000 Subject: [PATCH 059/317] Prove unsafe joined DELETE rejections do not mutate --- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 0dfee4f96..75731c79f 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -2500,6 +2500,28 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { $driver->query( 'CREATE TABLE t1 (id INT)' ); $driver->query( 'CREATE TABLE t2 (id INT)' ); $driver->query( 'CREATE TABLE has_rowid (rowid INT, id INT)' ); + $driver->query( 'INSERT INTO t1 VALUES (1), (2)' ); + $driver->query( 'INSERT INTO t2 VALUES (1), (3)' ); + $driver->query( 'INSERT INTO has_rowid VALUES (101, 1), (102, 2)' ); + + $expected_t1_rows = array( + array( 'id' => 1 ), + array( 'id' => 2 ), + ); + $expected_t2_rows = array( + array( 'id' => 1 ), + array( 'id' => 3 ), + ); + $expected_has_rowid_rows = array( + array( + 'rowid' => 101, + 'id' => 1, + ), + array( + 'rowid' => 102, + 'id' => 2, + ), + ); foreach ( array( @@ -2571,6 +2593,22 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { } catch ( WP_DuckDB_Driver_Exception $e ) { $this->assertStringContainsString( $rejection['message'], $e->getMessage() ); } + + $this->assertSame( + $expected_t1_rows, + $driver->query( 'SELECT id FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'Rejected multi-table DELETE mutated t1 for SQL: ' . $rejection['sql'] + ); + $this->assertSame( + $expected_t2_rows, + $driver->query( 'SELECT id FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'Rejected multi-table DELETE mutated t2 for SQL: ' . $rejection['sql'] + ); + $this->assertSame( + $expected_has_rowid_rows, + $driver->query( 'SELECT rowid, id FROM has_rowid ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'Rejected multi-table DELETE mutated has_rowid for SQL: ' . $rejection['sql'] + ); } } From ebb0dddee0b40930a3c2f8c1e56b3defa1341307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 03:57:31 +0000 Subject: [PATCH 060/317] Fix DuckDB affected row result classification --- .../src/duckdb/class-wp-duckdb-connection.php | 83 +++++- .../class-wp-duckdb-prepared-statement.php | 11 +- .../duckdb/WP_DuckDB_Connection_Tests.php | 281 +++++++++++++++++- 3 files changed, 366 insertions(+), 9 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php index fa20dd75e..fc01beb82 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php @@ -72,7 +72,7 @@ public function query( string $sql, array $params = array() ): WP_DuckDB_Result_ } try { - return $this->create_statement_from_result( $this->duckdb->query( $sql ) ); + return $this->create_statement_from_result( $this->duckdb->query( $sql ), $sql ); } catch ( Throwable $e ) { throw new WP_DuckDB_Driver_Exception( 'DuckDB query failed: ' . $e->getMessage(), 0, $e ); } @@ -92,7 +92,7 @@ public function prepare( string $sql ): WP_DuckDB_Prepared_Statement { } try { - return new WP_DuckDB_Prepared_Statement( $this, $this->duckdb->preparedStatement( $sql ) ); + return new WP_DuckDB_Prepared_Statement( $this, $this->duckdb->preparedStatement( $sql ), $sql ); } catch ( Throwable $e ) { throw new WP_DuckDB_Driver_Exception( 'Failed to prepare DuckDB query: ' . $e->getMessage(), 0, $e ); } @@ -178,10 +178,11 @@ public function inTransaction(): bool { // phpcs:ignore WordPress.NamingConventi /** * Create a statement wrapper from a DuckDB PHP ResultSet. * - * @param object $result DuckDB PHP ResultSet. + * @param object $result DuckDB PHP ResultSet. + * @param string|null $sql SQL query that produced the result. * @return WP_DuckDB_Result_Statement */ - public function create_statement_from_result( $result ): WP_DuckDB_Result_Statement { + public function create_statement_from_result( $result, ?string $sql = null ): WP_DuckDB_Result_Statement { $columns = iterator_to_array( $result->columnNames() ); $columns = array_values( $columns ); $rows = array(); @@ -191,17 +192,87 @@ public function create_statement_from_result( $result ): WP_DuckDB_Result_Statem } $affected_rows = 0; - if ( array( 'Count' ) === $columns ) { + if ( array( 'Count' ) === $columns && $this->is_affected_row_statement( $sql ) ) { $affected_rows = isset( $rows[0][0] ) ? (int) $rows[0][0] : 0; return new WP_DuckDB_Result_Statement( array(), array(), $affected_rows ); } - if ( array( 'Success' ) === $columns ) { + if ( array( 'Count' ) === $columns && $this->is_success_statement( $sql ) ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + if ( array( 'Success' ) === $columns && $this->is_success_statement( $sql ) ) { return new WP_DuckDB_Result_Statement( array(), array(), 0 ); } return new WP_DuckDB_Result_Statement( $columns, $rows, $affected_rows ); } + /** + * Check whether SQL is expected to produce a DuckDB affected-row Count result. + * + * @param string|null $sql SQL query. + * @return bool + */ + private function is_affected_row_statement( ?string $sql ): bool { + return in_array( + $this->get_statement_verb( $sql ), + array( 'insert', 'update', 'delete', 'replace' ), + true + ); + } + + /** + * Check whether SQL is expected to produce a DuckDB command-success result. + * + * @param string|null $sql SQL query. + * @return bool + */ + private function is_success_statement( ?string $sql ): bool { + return in_array( + $this->get_statement_verb( $sql ), + array( + 'alter', + 'attach', + 'begin', + 'checkpoint', + 'commit', + 'create', + 'detach', + 'drop', + 'reset', + 'rollback', + 'set', + 'truncate', + 'use', + 'vacuum', + ), + true + ); + } + + /** + * Get the first SQL statement verb after leading comments. + * + * @param string|null $sql SQL query. + * @return string|null + */ + private function get_statement_verb( ?string $sql ): ?string { + if ( null === $sql ) { + return null; + } + + if ( + 1 !== preg_match( + '/^\s*(?:(?:\/\*.*?\*\/|--[^\r\n]*|#[^\r\n]*)\s*)*([a-z]+)/is', + $sql, + $matches + ) + ) { + return null; + } + + return strtolower( $matches[1] ); + } + /** * Quote a value for SQL. * diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-prepared-statement.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-prepared-statement.php index f26ded0a0..83e44d05e 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-prepared-statement.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-prepared-statement.php @@ -14,13 +14,20 @@ class WP_DuckDB_Prepared_Statement { */ private $statement; + /** + * @var string + */ + private $sql; + /** * @param WP_DuckDB_Connection $connection DuckDB connection. * @param object $statement Native DuckDB PHP prepared statement. + * @param string $sql SQL query. */ - public function __construct( WP_DuckDB_Connection $connection, $statement ) { + public function __construct( WP_DuckDB_Connection $connection, $statement, string $sql ) { $this->connection = $connection; $this->statement = $statement; + $this->sql = $sql; } /** @@ -37,7 +44,7 @@ public function execute( $params = null ): WP_DuckDB_Result_Statement { $parameter = is_string( $key ) ? ltrim( $key, ':$' ) : $key + 1; $this->statement->bindParam( $parameter, $value ); } - return $this->connection->create_statement_from_result( $this->statement->execute() ); + return $this->connection->create_statement_from_result( $this->statement->execute(), $this->sql ); } catch ( Throwable $e ) { throw new WP_DuckDB_Driver_Exception( 'DuckDB prepared statement failed: ' . $e->getMessage(), 0, $e ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index f23a64f93..c47d147c7 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -136,6 +136,119 @@ public function test_result_statement_cursor_and_metadata_methods_match_pdo_shap $this->assertFalse( $stmt->fetch() ); } + public function test_result_classification_keeps_select_count_and_success_columns_fetchable(): void { + $connection = new WP_DuckDB_Connection( array( 'duckdb' => new stdClass() ) ); + + $count = $connection->create_statement_from_result( + $this->createDuckDBResult( array( 'Count' ), array( array( 123 ) ) ), + 'SELECT 123 AS Count' + ); + + $this->assertSame( 1, $count->columnCount() ); + $this->assertSame( 0, $count->rowCount() ); + $this->assertSame( array( 'Count' => 123 ), $count->fetch( PDO::FETCH_ASSOC ) ); + + $success = $connection->create_statement_from_result( + $this->createDuckDBResult( array( 'Success' ), array( array( 1 ) ) ), + 'SELECT 1 AS Success' + ); + + $this->assertSame( 1, $success->columnCount() ); + $this->assertSame( 0, $success->rowCount() ); + $this->assertSame( array( 'Success' => 1 ), $success->fetch( PDO::FETCH_ASSOC ) ); + } + + public function test_result_classification_maps_raw_dml_count_to_affected_rows(): void { + $connection = new WP_DuckDB_Connection( array( 'duckdb' => new stdClass() ) ); + + $sql_statements = array( + 'INSERT INTO items VALUES (1)', + 'UPDATE items SET name = ? WHERE id = ?', + 'DELETE FROM items WHERE id = ?', + "INSERT OR REPLACE INTO items VALUES (1, 'one')", + ); + + foreach ( $sql_statements as $sql ) { + $stmt = $connection->create_statement_from_result( + $this->createDuckDBResult( array( 'Count' ), array( array( 2 ) ) ), + $sql + ); + + $this->assertSame( 0, $stmt->columnCount(), 'Column count mismatch for SQL: ' . $sql ); + $this->assertSame( 2, $stmt->rowCount(), 'Row count mismatch for SQL: ' . $sql ); + $this->assertFalse( $stmt->fetch(), 'Fetch mismatch for SQL: ' . $sql ); + } + } + + public function test_result_classification_maps_raw_command_results_to_empty_statement(): void { + $connection = new WP_DuckDB_Connection( array( 'duckdb' => new stdClass() ) ); + + $results = array( + array( + 'sql' => 'CREATE TABLE items (id INTEGER)', + 'columns' => array( 'Count' ), + 'rows' => array(), + ), + array( + 'sql' => 'DROP TABLE items', + 'columns' => array( 'Success' ), + 'rows' => array(), + ), + array( + 'sql' => 'COMMIT', + 'columns' => array( 'Success' ), + 'rows' => array(), + ), + ); + + foreach ( $results as $result ) { + $stmt = $connection->create_statement_from_result( + $this->createDuckDBResult( $result['columns'], $result['rows'] ), + $result['sql'] + ); + + $this->assertSame( 0, $stmt->columnCount(), 'Column count mismatch for SQL: ' . $result['sql'] ); + $this->assertSame( 0, $stmt->rowCount(), 'Row count mismatch for SQL: ' . $result['sql'] ); + $this->assertFalse( $stmt->fetch(), 'Fetch mismatch for SQL: ' . $result['sql'] ); + } + } + + public function test_prepared_statement_passes_sql_context_to_result_classification(): void { + $connection = new WP_DuckDB_Connection( array( 'duckdb' => new stdClass() ) ); + + $count = new WP_DuckDB_Prepared_Statement( + $connection, + $this->createDuckDBPreparedStatement( array( 'Count' ), array( array( 123 ) ) ), + 'SELECT ? AS Count' + ); + $count_result = $count->execute( array( 123 ) ); + + $this->assertSame( 1, $count_result->columnCount() ); + $this->assertSame( 0, $count_result->rowCount() ); + $this->assertSame( array( 'Count' => 123 ), $count_result->fetch( PDO::FETCH_ASSOC ) ); + + $insert = new WP_DuckDB_Prepared_Statement( + $connection, + $this->createDuckDBPreparedStatement( array( 'Count' ), array( array( 1 ) ) ), + 'INSERT INTO items VALUES (?)' + ); + $insert_result = $insert->execute( array( 1 ) ); + + $this->assertSame( 0, $insert_result->columnCount() ); + $this->assertSame( 1, $insert_result->rowCount() ); + + $success = new WP_DuckDB_Prepared_Statement( + $connection, + $this->createDuckDBPreparedStatement( array( 'Success' ), array( array( 1 ) ) ), + 'SELECT ? AS Success' + ); + $success_result = $success->execute( array( 1 ) ); + + $this->assertSame( 1, $success_result->columnCount() ); + $this->assertSame( 0, $success_result->rowCount() ); + $this->assertSame( array( 'Success' => 1 ), $success_result->fetch( PDO::FETCH_ASSOC ) ); + } + public function test_in_memory_connection_executes_query(): void { $this->requireDuckDBRuntime(); @@ -153,6 +266,70 @@ public function test_in_memory_connection_executes_query(): void { $this->assertFalse( $stmt->fetch() ); } + public function test_select_count_alias_is_fetchable_result_set(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $stmt = $connection->query( 'SELECT 123 AS Count' ); + + $this->assertSame( 1, $stmt->columnCount() ); + $this->assertSame( 0, $stmt->rowCount() ); + $this->assertSame( array( 'Count' => 123 ), $stmt->fetch( PDO::FETCH_ASSOC ) ); + $this->assertFalse( $stmt->fetch() ); + } + + public function test_select_success_alias_is_fetchable_result_set(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $stmt = $connection->query( 'SELECT 1 AS Success' ); + + $this->assertSame( 1, $stmt->columnCount() ); + $this->assertSame( 0, $stmt->rowCount() ); + $this->assertSame( array( 'Success' => 1 ), $stmt->fetch( PDO::FETCH_ASSOC ) ); + $this->assertFalse( $stmt->fetch() ); + } + + public function test_table_select_count_alias_is_fetchable_result_set(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->query( 'CREATE TABLE count_alias_items (id INTEGER, label VARCHAR)' ); + $connection->query( "INSERT INTO count_alias_items VALUES (1, 'one'), (2, 'two')" ); + + $stmt = $connection->query( 'SELECT id AS Count FROM count_alias_items ORDER BY id' ); + + $this->assertSame( 1, $stmt->columnCount() ); + $this->assertSame( 0, $stmt->rowCount() ); + $this->assertSame( + array( + array( 'Count' => 1 ), + array( 'Count' => 2 ), + ), + $stmt->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_table_select_success_alias_is_fetchable_result_set(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->query( 'CREATE TABLE success_alias_items (id INTEGER, label VARCHAR)' ); + $connection->query( "INSERT INTO success_alias_items VALUES (1, 'one'), (2, 'two')" ); + + $stmt = $connection->query( 'SELECT id AS Success FROM success_alias_items ORDER BY id' ); + + $this->assertSame( 1, $stmt->columnCount() ); + $this->assertSame( 0, $stmt->rowCount() ); + $this->assertSame( + array( + array( 'Success' => 1 ), + array( 'Success' => 2 ), + ), + $stmt->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_insert_result_reports_affected_rows(): void { $this->requireDuckDBRuntime(); @@ -165,17 +342,76 @@ public function test_insert_result_reports_affected_rows(): void { $this->assertFalse( $stmt->fetch() ); } + public function test_command_success_result_is_empty_statement(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $stmt = $connection->query( 'CREATE TABLE success_result_items (id INTEGER)' ); + + $this->assertSame( 0, $stmt->columnCount() ); + $this->assertSame( 0, $stmt->rowCount() ); + $this->assertFalse( $stmt->fetch() ); + } + + public function test_write_results_report_affected_rows(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->query( 'CREATE TABLE write_counts (id INTEGER PRIMARY KEY, label VARCHAR)' ); + + $insert = $connection->query( "INSERT INTO write_counts VALUES (1, 'one'), (2, 'two')" ); + $this->assertSame( 0, $insert->columnCount() ); + $this->assertSame( 2, $insert->rowCount() ); + + $update = $connection->query( "UPDATE write_counts SET label = 'updated' WHERE id = 1" ); + $this->assertSame( 0, $update->columnCount() ); + $this->assertSame( 1, $update->rowCount() ); + + $replace = $connection->query( "INSERT OR REPLACE INTO write_counts VALUES (2, 'replaced')" ); + $this->assertSame( 0, $replace->columnCount() ); + $this->assertSame( 1, $replace->rowCount() ); + + $delete = $connection->query( 'DELETE FROM write_counts WHERE id IN (1, 2)' ); + $this->assertSame( 0, $delete->columnCount() ); + $this->assertSame( 2, $delete->rowCount() ); + } + public function test_prepared_statement_binds_positional_parameters(): void { $this->requireDuckDBRuntime(); $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); $connection->query( 'CREATE TABLE t (id INTEGER, label VARCHAR)' ); - $connection->prepare( 'INSERT INTO t VALUES (?, ?)' )->execute( array( 1, 'first' ) ); + $insert = $connection->prepare( 'INSERT INTO t VALUES (?, ?)' )->execute( array( 1, 'first' ) ); + $this->assertSame( 1, $insert->rowCount() ); $stmt = $connection->prepare( 'SELECT label FROM t WHERE id = ?' )->execute( array( 1 ) ); $this->assertSame( 'first', $stmt->fetchColumn() ); } + public function test_prepared_select_count_alias_is_fetchable_result_set(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $stmt = $connection->prepare( 'SELECT ? AS Count' )->execute( array( 123 ) ); + + $this->assertSame( 1, $stmt->columnCount() ); + $this->assertSame( 0, $stmt->rowCount() ); + $this->assertSame( array( 'Count' => 123 ), $stmt->fetch( PDO::FETCH_ASSOC ) ); + $this->assertFalse( $stmt->fetch() ); + } + + public function test_prepared_select_success_alias_is_fetchable_result_set(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $stmt = $connection->prepare( 'SELECT ? AS Success' )->execute( array( 1 ) ); + + $this->assertSame( 1, $stmt->columnCount() ); + $this->assertSame( 0, $stmt->rowCount() ); + $this->assertSame( array( 'Success' => 1 ), $stmt->fetch( PDO::FETCH_ASSOC ) ); + $this->assertFalse( $stmt->fetch() ); + } + public function test_transactions_commit_and_rollback(): void { $this->requireDuckDBRuntime(); @@ -253,4 +489,47 @@ public function test_quote_identifier_uses_duckdb_double_quotes(): void { $this->assertSame( '"table""name"', $duckdb->quote_identifier( 'table"name' ) ); } + + private function createDuckDBResult( array $columns, array $rows ) { + return new class( $columns, $rows ) { + private $columns; + private $rows; + + public function __construct( array $columns, array $rows ) { + $this->columns = $columns; + $this->rows = $rows; + } + + public function columnNames(): ArrayIterator { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return new ArrayIterator( $this->columns ); + } + + public function rows( bool $assoc ): array { + $rows = array(); + foreach ( $this->rows as $row ) { + $rows[] = array_combine( $this->columns, $row ); + } + return $rows; + } + }; + } + + private function createDuckDBPreparedStatement( array $columns, array $rows ) { + $result = $this->createDuckDBResult( $columns, $rows ); + + return new class( $result ) { + private $result; + + public function __construct( $result ) { + $this->result = $result; + } + + public function bindParam( $parameter, $value ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + } + + public function execute() { + return $this->result; + } + }; + } } From a0beed696a2c01b1cff3a29cac66f53c14d7e364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 04:07:51 +0000 Subject: [PATCH 061/317] Add DuckDB ADD PRIMARY KEY parity --- .../src/duckdb/class-wp-duckdb-driver.php | 267 ++++++++++++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 303 ++++++++++++++++++ 2 files changed, 568 insertions(+), 2 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 5f223c887..7a5dd558b 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -4567,6 +4567,11 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen continue; } + if ( $this->is_alter_table_add_primary_key_item( $alter_item ) ) { + $result = $this->execute_alter_table_add_primary_key( $table_name, $alter_item, $temporary ); + continue; + } + $result = $this->is_create_table_index_item( $alter_item ) ? $this->execute_alter_table_add_index( $table_name, $alter_item, $temporary ) : $this->execute_alter_table_add_column( $table_name, $alter_item, $temporary ); @@ -4594,6 +4599,9 @@ private function validate_alter_table_rebuild_action_combination( string $table_ if ( $this->is_alter_table_foreign_key_rebuild_action( $table_name, $action, $temporary ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD/DROP FOREIGN KEY cannot be combined with other ALTER TABLE actions.' ); } + if ( $this->is_alter_table_add_primary_key_action( $action ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD PRIMARY KEY cannot be combined with other ALTER TABLE actions.' ); + } if ( $this->is_alter_table_drop_primary_key_action( $action ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP PRIMARY KEY cannot be combined with other ALTER TABLE actions.' ); } @@ -4809,6 +4817,7 @@ private function validate_alter_table_add_constraint_action( string $table_name, $items = $this->alter_table_add_items_for_constraint_detection( $tokens ); $contains_check = false; $contains_foreign_key = false; + $contains_primary_key = false; foreach ( $items as $item ) { if ( $this->is_create_table_check_constraint( $item ) ) { $contains_check = true; @@ -4816,18 +4825,29 @@ private function validate_alter_table_add_constraint_action( string $table_name, if ( $this->is_create_table_foreign_key_constraint( $item ) ) { $contains_foreign_key = true; } + if ( $this->is_table_primary_key_item( $item ) ) { + $contains_primary_key = true; + } } - if ( $contains_check || $contains_foreign_key ) { + if ( $contains_check || $contains_foreign_key || $contains_primary_key ) { $parenthesized_end = count( $tokens ); if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id ) { list( , $parenthesized_end ) = $this->collect_parenthesized_items( $tokens, 1 ); } - if ( 1 !== count( $items ) || count( $tokens ) !== $parenthesized_end || ( $contains_check && $contains_foreign_key ) ) { + if ( + 1 !== count( $items ) + || count( $tokens ) !== $parenthesized_end + || ( $contains_check && $contains_foreign_key ) + || ( $contains_primary_key && ( $contains_check || $contains_foreign_key ) ) + ) { if ( $contains_foreign_key && ! $contains_check ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD FOREIGN KEY constraint is supported.' ); } + if ( $contains_primary_key && ! $contains_check && ! $contains_foreign_key ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD PRIMARY KEY constraint is supported.' ); + } throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD CHECK constraint is supported.' ); } } @@ -4849,6 +4869,12 @@ private function validate_alter_table_add_constraint_action( string $table_name, continue; } + if ( $this->is_table_primary_key_item( $item ) ) { + $primary_key_columns = $this->alter_table_primary_key_columns( $item ); + $this->assert_alter_table_add_primary_key_supported( $table_name, $primary_key_columns, $temporary ); + continue; + } + if ( isset( $item[0] ) && WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $item[0]->id ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD CONSTRAINT is not supported.' ); } @@ -4891,6 +4917,63 @@ private function alter_table_add_items_for_constraint_detection( array $tokens ) return $items; } + /** + * Check whether an ALTER TABLE action adds a PRIMARY KEY. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @return bool Whether this is ADD PRIMARY KEY. + */ + private function is_alter_table_add_primary_key_action( array $tokens ): bool { + return isset( $tokens[0] ) + && WP_MySQL_Lexer::ADD_SYMBOL === $tokens[0]->id + && $this->is_alter_table_add_primary_key_item( array_slice( $tokens, 1 ) ); + } + + /** + * Check whether ADD action tokens contain a supported PRIMARY KEY item. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @return bool Whether this is ADD PRIMARY KEY. + */ + private function is_alter_table_add_primary_key_item( array $tokens ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + if ( $this->is_table_primary_key_item( $tokens ) ) { + return true; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[0]->id ) { + return false; + } + + list( $items, $index ) = $this->collect_parenthesized_items( $tokens, 1 ); + return 1 === count( $items ) + && count( $tokens ) === $index + && $this->is_table_primary_key_item( $items[0] ); + } + + /** + * Check whether tokens start with a table-level PRIMARY KEY item. + * + * @param WP_Parser_Token[] $tokens Item tokens. + * @return bool Whether this is a table-level PRIMARY KEY. + */ + private function is_table_primary_key_item( array $tokens ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::PRIMARY_SYMBOL === $tokens[0]->id ) { + return true; + } + + return isset( $tokens[2] ) + && WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::PRIMARY_SYMBOL === $tokens[2]->id; + } + /** * Validate ALTER TABLE ... DROP constraint actions before DROP COLUMN fallback. * @@ -5146,6 +5229,186 @@ function () use ( $table_name, $metadata, $temporary ): WP_DuckDB_Result_Stateme ); } + /** + * Execute ALTER TABLE ... ADD PRIMARY KEY. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_add_primary_key( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $item = $this->single_alter_table_add_primary_key_item( $tokens ); + $primary_key_columns = $this->alter_table_primary_key_columns( $item ); + $this->assert_alter_table_add_primary_key_supported( $table_name, $primary_key_columns, $temporary ); + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $primary_key_columns, $temporary ): WP_DuckDB_Result_Statement { + $this->rebuild_table_from_metadata_plan( + $table_name, + $this->column_metadata_rows_with_primary_key_not_null( + $this->table_column_metadata_rows( $table_name, $temporary ), + $primary_key_columns + ), + $primary_key_columns, + $this->secondary_index_definitions_for_table( $table_name, $temporary ), + $this->check_constraint_metadata_rows( $table_name, $temporary ), + $this->show_create_table_foreign_key_groups( $table_name, $temporary ), + $this->table_auto_increment_value( $table_name, $temporary ), + 'ADD PRIMARY KEY', + $temporary + ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + + /** + * Return the single PRIMARY KEY item from a supported ALTER TABLE ... ADD PRIMARY KEY action. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @return WP_Parser_Token[] PRIMARY KEY constraint tokens. + */ + private function single_alter_table_add_primary_key_item( array $tokens ): array { + if ( ! isset( $tokens[0] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD PRIMARY KEY requires a PRIMARY KEY constraint.' ); + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[0]->id ) { + return $tokens; + } + + list( $items, $index ) = $this->collect_parenthesized_items( $tokens, 1 ); + if ( 1 !== count( $items ) || count( $tokens ) !== $index || ! $this->is_table_primary_key_item( $items[0] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD PRIMARY KEY constraint is supported.' ); + } + + return $items[0]; + } + + /** + * Read ALTER TABLE ADD PRIMARY KEY columns, accepting optional CONSTRAINT name. + * + * @param WP_Parser_Token[] $tokens PRIMARY KEY item tokens. + * @return string[] Primary key column names. + */ + private function alter_table_primary_key_columns( array $tokens ): array { + if ( isset( $tokens[0] ) && WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[0]->id ) { + $this->identifier_value( $tokens[1] ?? null ); + $tokens = array_slice( $tokens, 2 ); + } + + return $this->table_primary_key_columns( $tokens ); + } + + /** + * Mark planned PRIMARY KEY columns as NOT NULL in MySQL-facing metadata. + * + * @param array> $metadata Column metadata rows. + * @param string[] $primary_key_columns Primary key columns. + * @return array> Updated column metadata rows. + */ + private function column_metadata_rows_with_primary_key_not_null( array $metadata, array $primary_key_columns ): array { + $primary_key_map = array(); + foreach ( $primary_key_columns as $column_name ) { + $primary_key_map[ strtolower( $column_name ) ] = true; + } + + foreach ( $metadata as &$column ) { + if ( isset( $primary_key_map[ strtolower( (string) $column['column_name'] ) ] ) ) { + $column['is_nullable'] = 'NO'; + } + } + unset( $column ); + + return $metadata; + } + + /** + * Validate ALTER TABLE ... ADD PRIMARY KEY before mutation. + * + * @param string $table_name Table name. + * @param string[] $primary_key_columns Planned primary key columns. + * @param bool $temporary Whether the target is a temporary table. + */ + private function assert_alter_table_add_primary_key_supported( string $table_name, array $primary_key_columns, bool $temporary = false ): void { + $this->assert_no_active_transaction_for_table_rebuild( 'ADD PRIMARY KEY' ); + if ( count( $primary_key_columns ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported PRIMARY KEY constraint in DuckDB driver.' ); + } + if ( count( $this->primary_key_index_rows( $table_name ) ) > 0 ) { + throw new WP_DuckDB_Driver_Exception( "Duplicate primary key on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD PRIMARY KEY', $temporary ); + + $metadata_by_column = array(); + foreach ( $this->table_column_metadata_rows( $table_name, $temporary ) as $column ) { + $metadata_by_column[ strtolower( (string) $column['column_name'] ) ] = $column; + } + + $seen_columns = array(); + foreach ( $primary_key_columns as $column_name ) { + $normalized_column_name = strtolower( $column_name ); + if ( isset( $seen_columns[ $normalized_column_name ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Duplicate column name '{$column_name}' in PRIMARY KEY constraint." ); + } + $seen_columns[ $normalized_column_name ] = true; + + if ( ! isset( $metadata_by_column[ $normalized_column_name ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Unknown column '{$column_name}' on table '{$this->database}.{$table_name}' in PRIMARY KEY constraint." ); + } + } + + $this->assert_alter_table_add_primary_key_existing_rows_valid( $table_name, $primary_key_columns ); + } + + /** + * Validate existing rows before adding a PRIMARY KEY. + * + * @param string $table_name Table name. + * @param string[] $primary_key_columns Planned primary key columns. + */ + private function assert_alter_table_add_primary_key_existing_rows_valid( string $table_name, array $primary_key_columns ): void { + $null_predicates = array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ) . ' IS NULL'; + }, + $primary_key_columns + ); + + $stmt = $this->execute_duckdb_query( + 'SELECT 1 FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' WHERE ' + . implode( ' OR ', $null_predicates ) + . ' LIMIT 1', + 'Failed to validate existing DuckDB PRIMARY KEY rows' + ); + if ( false !== $stmt->fetch( PDO::FETCH_NUM ) ) { + throw new WP_DuckDB_Driver_Exception( "Cannot add PRIMARY KEY on table '{$this->database}.{$table_name}' because existing rows contain NULL values." ); + } + + $quoted_columns = array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ); + }, + $primary_key_columns + ); + + $stmt = $this->execute_duckdb_query( + 'SELECT 1 FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' GROUP BY ' + . implode( ', ', $quoted_columns ) + . ' HAVING COUNT(*) > 1 LIMIT 1', + 'Failed to validate existing DuckDB PRIMARY KEY rows' + ); + if ( false !== $stmt->fetch( PDO::FETCH_NUM ) ) { + throw new WP_DuckDB_Driver_Exception( "Cannot add PRIMARY KEY on table '{$this->database}.{$table_name}' because existing rows contain duplicate key values." ); + } + } + /** * Return the single FOREIGN KEY item from a supported ALTER TABLE ... ADD FOREIGN KEY action. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 75731c79f..b704b325e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -7218,6 +7218,309 @@ public function test_alter_table_add_drop_check_rejects_active_transactions_befo $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'alter_check_tx' ) ); } + public function test_alter_table_add_primary_key_rebuilds_table_metadata_and_enforcement(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE add_pk_direct ( + id INT, + name VARCHAR(20) NOT NULL, + amount INT, + UNIQUE KEY name_unique (name), + KEY amount_idx (amount), + CONSTRAINT amount_positive CHECK (amount > 0) + )' + ); + $driver->query( "INSERT INTO add_pk_direct (id, name, amount) VALUES (1, 'a', 10), (2, 'b', 20)" ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE add_pk_direct ADD PRIMARY KEY (id)' )->rowCount() ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'a', + 'amount' => 10, + ), + array( + 'id' => 2, + 'name' => 'b', + 'amount' => 20, + ), + ), + $driver->query( 'SELECT id, name, amount FROM add_pk_direct ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $columns = $driver->query( 'SHOW COLUMNS FROM add_pk_direct' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( 'PRI', array_column( $columns, 'Key', 'Field' )['id'] ); + $this->assertSame( 'NO', array_column( $columns, 'Null', 'Field' )['id'] ); + $this->assertSame( 'UNI', array_column( $columns, 'Key', 'Field' )['name'] ); + $this->assertSame( 'MUL', array_column( $columns, 'Key', 'Field' )['amount'] ); + + $index_rows = $driver->query( 'SHOW INDEX FROM add_pk_direct' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertContains( 'PRIMARY', array_column( $index_rows, 'Key_name' ) ); + $this->assertContains( 'name_unique', array_column( $index_rows, 'Key_name' ) ); + $this->assertContains( 'amount_idx', array_column( $index_rows, 'Key_name' ) ); + + $create_sql = $driver->query( 'SHOW CREATE TABLE add_pk_direct' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + $this->assertStringContainsString( 'PRIMARY KEY (`id`)', $create_sql ); + $this->assertStringContainsString( 'UNIQUE KEY `name_unique` (`name`)', $create_sql ); + $this->assertStringContainsString( 'KEY `amount_idx` (`amount`)', $create_sql ); + $this->assertStringContainsString( 'CONSTRAINT `amount_positive` CHECK (amount > 0)', $create_sql ); + + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'PRIMARY', + 'CONSTRAINT_TYPE' => 'PRIMARY KEY', + 'ENFORCED' => 'YES', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' + AND table_name = 'add_pk_direct' + AND constraint_name = 'PRIMARY'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'PRIMARY', + 'COLUMN_NAME' => 'id', + 'ORDINAL_POSITION' => 1, + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, COLUMN_NAME, ORDINAL_POSITION + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name = 'add_pk_direct' + AND constraint_name = 'PRIMARY'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'INDEX_NAME' => 'PRIMARY', + 'COLUMN_NAME' => 'id', + 'NON_UNIQUE' => 0, + ), + ), + $driver->query( + "SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE + FROM information_schema.statistics + WHERE table_schema = 'wp' + AND table_name = 'add_pk_direct' + AND index_name = 'PRIMARY'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + foreach ( + array( + "INSERT INTO add_pk_direct (id, name, amount) VALUES (1, 'duplicate', 30)", + "INSERT INTO add_pk_direct (id, name, amount) VALUES (NULL, 'null-id', 30)", + "UPDATE add_pk_direct SET id = 1 WHERE name = 'b'", + "UPDATE add_pk_direct SET id = NULL WHERE name = 'b'", + "INSERT INTO add_pk_direct (id, name, amount) VALUES (3, 'a', 30)", + "INSERT INTO add_pk_direct (id, name, amount) VALUES (4, 'd', 0)", + ) as $sql + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected added PRIMARY KEY, secondary UNIQUE, or CHECK enforcement to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB', $e->getMessage() ); + } + } + } + + public function test_alter_table_add_composite_primary_key_supports_named_constraints(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE add_pk_composite (site_id INT NOT NULL, option_id INT NOT NULL, label VARCHAR(20))' ); + $driver->query( "INSERT INTO add_pk_composite (site_id, option_id, label) VALUES (1, 1, 'a'), (1, 2, 'b')" ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE add_pk_composite ADD CONSTRAINT ignored_name PRIMARY KEY (site_id, option_id)' )->rowCount() ); + + $this->assertSame( + array( + array( + 'Key_name' => 'PRIMARY', + 'Seq_in_index' => 1, + 'Column_name' => 'site_id', + ), + array( + 'Key_name' => 'PRIMARY', + 'Seq_in_index' => 2, + 'Column_name' => 'option_id', + ), + ), + array_map( + function ( array $row ): array { + return array( + 'Key_name' => $row['Key_name'], + 'Seq_in_index' => $row['Seq_in_index'], + 'Column_name' => $row['Column_name'], + ); + }, + $driver->query( 'SHOW INDEX FROM add_pk_composite' )->fetchAll( PDO::FETCH_ASSOC ) + ) + ); + $this->assertStringContainsString( + 'PRIMARY KEY (`site_id`, `option_id`)', + $driver->query( 'SHOW CREATE TABLE add_pk_composite' )->fetch( PDO::FETCH_ASSOC )['Create Table'] + ); + + try { + $driver->query( "INSERT INTO add_pk_composite (site_id, option_id, label) VALUES (1, 1, 'duplicate')" ); + $this->fail( 'Expected composite PRIMARY KEY to reject duplicate rows.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + } + + public function test_alter_table_add_primary_key_rejects_invalid_existing_state_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE add_pk_duplicate (id INT, name VARCHAR(20), KEY name_idx (name))' ); + $driver->query( "INSERT INTO add_pk_duplicate (id, name) VALUES (1, 'a'), (1, 'b')" ); + $driver->query( 'CREATE TABLE add_pk_null (id INT, name VARCHAR(20), KEY name_idx (name))' ); + $driver->query( "INSERT INTO add_pk_null (id, name) VALUES (1, 'a'), (NULL, 'b')" ); + $driver->query( 'CREATE TABLE add_pk_existing (id INT PRIMARY KEY, name VARCHAR(20))' ); + $driver->query( "INSERT INTO add_pk_existing (id, name) VALUES (1, 'a')" ); + + foreach ( + array( + 'add_pk_duplicate' => array( + 'sql' => 'ALTER TABLE add_pk_duplicate ADD PRIMARY KEY (id)', + 'message' => 'duplicate key values', + ), + 'add_pk_null' => array( + 'sql' => 'ALTER TABLE add_pk_null ADD PRIMARY KEY (id)', + 'message' => 'NULL values', + ), + 'add_pk_existing' => array( + 'sql' => 'ALTER TABLE add_pk_existing ADD PRIMARY KEY (name)', + 'message' => 'Duplicate primary key', + ), + 'add_pk_missing' => array( + 'sql' => 'ALTER TABLE add_pk_duplicate ADD PRIMARY KEY (missing_id)', + 'message' => "Unknown column 'missing_id'", + ), + ) as $table_name => $case + ) { + $snapshot_table = 'add_pk_missing' === $table_name ? 'add_pk_duplicate' : $table_name; + $before = $this->alter_table_check_lifecycle_snapshot( $driver, $snapshot_table ); + + try { + $driver->query( $case['sql'] ); + $this->fail( 'Expected invalid ADD PRIMARY KEY state to reject SQL: ' . $case['sql'] ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $case['message'], $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, $snapshot_table ) ); + } + } + + public function test_alter_table_add_primary_key_rejects_active_transaction_and_multi_action_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE add_pk_guard (id INT NOT NULL, name VARCHAR(20))' ); + $driver->query( "INSERT INTO add_pk_guard (id, name) VALUES (1, 'a')" ); + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'add_pk_guard' ); + + $driver->query( 'BEGIN' ); + try { + $driver->query( 'ALTER TABLE add_pk_guard ADD PRIMARY KEY (id)' ); + $this->fail( 'Expected active transaction ADD PRIMARY KEY rebuild rejection.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'ADD PRIMARY KEY cannot run inside an active DuckDB transaction', $e->getMessage() ); + } + $driver->query( 'ROLLBACK' ); + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'add_pk_guard' ) ); + + foreach ( + array( + 'ALTER TABLE add_pk_guard ADD PRIMARY KEY (id), ADD COLUMN should_not_exist INT', + 'ALTER TABLE add_pk_guard ADD COLUMN should_not_exist INT, ADD PRIMARY KEY (id)', + ) as $sql + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected multi-action ADD PRIMARY KEY rejection for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'ADD PRIMARY KEY cannot be combined with other ALTER TABLE actions', $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'add_pk_guard' ) ); + } + } + + public function test_alter_table_add_primary_key_targets_temporary_shadow_table(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE add_pk_shadow (id INT, label VARCHAR(20))' ); + $driver->query( "INSERT INTO add_pk_shadow (id, label) VALUES (1, 'persistent')" ); + $driver->query( 'CREATE TEMPORARY TABLE add_pk_shadow (id INT, label VARCHAR(20))' ); + $driver->query( "INSERT INTO add_pk_shadow (id, label) VALUES (2, 'temporary')" ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE add_pk_shadow ADD PRIMARY KEY (id)' )->rowCount() ); + $this->assertContains( 'PRIMARY', array_column( $driver->query( 'SHOW INDEX FROM add_pk_shadow' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) ); + + try { + $driver->query( "INSERT INTO add_pk_shadow (id, label) VALUES (2, 'duplicate-temp')" ); + $this->fail( 'Expected temporary shadow table PRIMARY KEY to reject duplicate rows.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + + $driver->query( 'DROP TEMPORARY TABLE add_pk_shadow' ); + $this->assertNotContains( 'PRIMARY', array_column( $driver->query( 'SHOW INDEX FROM add_pk_shadow' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) ); + $this->assertSame( + array( + array( + 'id' => 1, + 'label' => 'persistent', + ), + ), + $driver->query( 'SELECT id, label FROM add_pk_shadow ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_alter_table_drop_primary_key_preserves_rows_metadata_and_secondary_enforcement(): void { $this->requireDuckDBRuntime(); From b1b3807e9ee154b1ec06ea3f8accf0ead18730cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 04:15:01 +0000 Subject: [PATCH 062/317] Harden DuckDB savepoint limitation coverage --- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 106 ++++++++++++++---- 1 file changed, 86 insertions(+), 20 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index b704b325e..093454928 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -649,35 +649,98 @@ public function test_sql_transaction_statements_update_connection_state_and_quer public function test_savepoint_sql_is_rejected_without_mutating_active_transaction(): void { $this->requireDuckDBRuntime(); - $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); - $driver->query( 'CREATE TABLE tx_savepoint_state (id INT)' ); - - $driver->query( 'BEGIN' ); - $driver->query( 'INSERT INTO tx_savepoint_state (id) VALUES (1)' ); - foreach ( array( - 'SAVEPOINT sp1', - 'ROLLBACK TO sp1', - 'ROLLBACK TO SAVEPOINT sp1', - 'RELEASE SAVEPOINT sp1', - ) as $sql + array( + 'sql' => 'SAVEPOINT sp1', + 'message' => 'Unsupported DuckDB MySQL-emulation statement: SAVEPOINT.', + 'finish' => 'COMMIT', + ), + array( + 'sql' => 'ROLLBACK TO sp1', + 'message' => 'Unsupported ROLLBACK statement in DuckDB driver. Only ROLLBACK [WORK] is supported.', + 'finish' => 'ROLLBACK', + ), + array( + 'sql' => 'ROLLBACK TO SAVEPOINT sp1', + 'message' => 'Unsupported ROLLBACK statement in DuckDB driver. Only ROLLBACK [WORK] is supported.', + 'finish' => 'COMMIT', + ), + array( + 'sql' => 'ROLLBACK WORK TO sp1', + 'message' => 'Unsupported ROLLBACK statement in DuckDB driver. Only ROLLBACK [WORK] is supported.', + 'finish' => 'ROLLBACK', + ), + array( + 'sql' => 'ROLLBACK WORK TO SAVEPOINT sp1', + 'message' => 'Unsupported ROLLBACK statement in DuckDB driver. Only ROLLBACK [WORK] is supported.', + 'finish' => 'COMMIT', + ), + array( + 'sql' => 'RELEASE SAVEPOINT sp1', + 'message' => 'Unsupported DuckDB MySQL-emulation statement: RELEASE.', + 'finish' => 'ROLLBACK', + ), + ) as $case ) { - $this->assertDriverQueryRejected( $driver, $sql ); + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE tx_savepoint_state (id INT)' ); + + $driver->query( 'BEGIN' ); + $driver->query( 'INSERT INTO tx_savepoint_state (id) VALUES (1)' ); + + $this->assertDriverQueryRejected( $driver, $case['sql'], $case['message'] ); $this->assertSame( array(), $driver->get_last_duckdb_queries() ); $this->assertTrue( $driver->get_connection()->inTransaction() ); $this->assertSame( array( array( 'id' => 1 ) ), $driver->query( 'SELECT id FROM tx_savepoint_state ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) ); - } - $driver->query( 'ROLLBACK' ); - $this->assertFalse( $driver->get_connection()->inTransaction() ); - $this->assertSame( - array(), - $driver->query( 'SELECT id FROM tx_savepoint_state ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) - ); + $driver->query( 'INSERT INTO tx_savepoint_state (id) VALUES (2)' ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( + array( + array( 'id' => 1 ), + array( 'id' => 2 ), + ), + $driver->query( 'SELECT id FROM tx_savepoint_state ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertDriverQueryRejected( + $driver, + 'ALTER TABLE tx_savepoint_state ADD PRIMARY KEY (id)', + 'ADD PRIMARY KEY cannot run inside an active DuckDB transaction' + ); + foreach ( $driver->get_last_duckdb_queries() as $duckdb_sql ) { + $this->assertStringNotContainsString( 'ALTER TABLE', $duckdb_sql ); + $this->assertStringNotContainsString( 'ADD PRIMARY KEY', $duckdb_sql ); + $this->assertStringNotContainsString( 'COMMIT', $duckdb_sql ); + $this->assertStringNotContainsString( 'ROLLBACK', $duckdb_sql ); + } + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( '', $driver->query( 'DESCRIBE tx_savepoint_state' )->fetch( PDO::FETCH_ASSOC )['Key'] ); + $driver->query( 'INSERT INTO tx_savepoint_state (id) VALUES (3)' ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + + $driver->query( $case['finish'] ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + if ( 'COMMIT' === $case['finish'] ) { + $this->assertSame( + array( + array( 'id' => 1 ), + array( 'id' => 2 ), + array( 'id' => 3 ), + ), + $driver->query( 'SELECT id FROM tx_savepoint_state ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } else { + $this->assertSame( + array(), + $driver->query( 'SELECT id FROM tx_savepoint_state ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + } } public function test_session_boolean_variables_are_emulated(): void { @@ -8435,12 +8498,15 @@ private function alter_table_referenced_parent_snapshot( WP_DuckDB_Driver $drive ); } - private function assertDriverQueryRejected( WP_DuckDB_Driver $driver, string $sql ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + private function assertDriverQueryRejected( WP_DuckDB_Driver $driver, string $sql, string $message_substring = '' ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid try { $driver->query( $sql ); $this->fail( 'Expected DuckDB driver rejection for SQL: ' . $sql ); } catch ( WP_DuckDB_Driver_Exception $e ) { $this->assertNotSame( '', $e->getMessage() ); + if ( '' !== $message_substring ) { + $this->assertStringContainsString( $message_substring, $e->getMessage() ); + } } } From 7501ac1936f3cec04ccb8056ca0e4f81eb1ed767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 04:15:08 +0000 Subject: [PATCH 063/317] Add DuckDB date expression parity --- .../src/duckdb/class-wp-duckdb-driver.php | 155 ++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 7 + 2 files changed, 162 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 7a5dd558b..e014e1136 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -10277,6 +10277,22 @@ private function translate_tokens_to_duckdb_sql( continue; } + $date_time_function = $this->translate_date_time_function_call( + $tokens, + $index, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + if ( null !== $date_time_function ) { + $pieces[] = $date_time_function; + continue; + } + $binary_comparison = $this->translate_binary_comparison_predicate( $tokens, $index, @@ -11480,6 +11496,145 @@ private function translate_token_to_duckdb_sql( WP_Parser_Token $token ): string return $token->get_bytes(); } + /** + * Translate deterministic MySQL date/time scalar functions. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current index, advanced on match. + * @return string|null DuckDB SQL, or null when no supported function starts here. + */ + private function translate_date_time_function_call( + array $tokens, + int &$index, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints + ): ?string { + if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id ) { + return null; + } + + $name = strtoupper( $tokens[ $index ]->get_value() ); + if ( ! in_array( $name, array( 'DATE', 'DATEDIFF', 'DATE_ADD', 'DATE_SUB' ), true ) ) { + return null; + } + + $end_index = $this->skip_balanced_parentheses( $tokens, $index + 1 ); + $body = array_slice( $tokens, $index + 2, $end_index - $index - 3 ); + $items = $this->split_top_level_comma_items( $body ); + + if ( 'DATE' === $name ) { + if ( 1 !== count( $items ) || count( $items[0] ) === 0 ) { + return null; + } + + $index = $end_index - 1; + return 'strftime(CAST((' + . $this->translate_tokens_to_duckdb_sql( + $items[0], + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ) + . ") AS TIMESTAMP), '%Y-%m-%d')"; + } + + if ( 'DATEDIFF' === $name ) { + if ( 2 !== count( $items ) || count( $items[0] ) === 0 || count( $items[1] ) === 0 ) { + return null; + } + + $start_sql = $this->translate_tokens_to_duckdb_sql( + $items[0], + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + $end_sql = $this->translate_tokens_to_duckdb_sql( + $items[1], + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + + $index = $end_index - 1; + return 'CAST(CAST((' . $start_sql . ') AS DATE) - CAST((' . $end_sql . ') AS DATE) AS BIGINT)'; + } + + if ( 2 !== count( $items ) || count( $items[0] ) === 0 || count( $items[1] ) < 3 ) { + return null; + } + + $interval_tokens = $items[1]; + if ( WP_MySQL_Lexer::INTERVAL_SYMBOL !== $interval_tokens[0]->id ) { + return null; + } + + $unit_token = $interval_tokens[ count( $interval_tokens ) - 1 ]; + $unit = strtoupper( $unit_token->get_value() ); + if ( ! in_array( $unit, array( 'SECOND', 'MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR' ), true ) ) { + return null; + } + + $value_tokens = array_slice( $interval_tokens, 1, -1 ); + if ( count( $value_tokens ) === 0 ) { + return null; + } + + $date_sql = $this->translate_tokens_to_duckdb_sql( + $items[0], + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + $value_sql = $this->translate_tokens_to_duckdb_sql( + $value_tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + $sign = 'DATE_SUB' === $name ? '-' : '+'; + if ( 'WEEK' === $unit ) { + $unit = 'DAY'; + $value_sql = '7 * (' . $value_sql . ')'; + } + + $index = $end_index - 1; + return 'strftime(CAST((' + . $date_sql + . ') AS TIMESTAMP) ' + . $sign + . ' CAST((' + . $value_sql + . ') AS BIGINT) * INTERVAL 1 ' + . $unit + . ", '%Y-%m-%d %H:%M:%S')"; + } + /** * Translate MySQL CAST(expr AS type) expressions. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index c39cea833..869dd6ddd 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -27,6 +27,13 @@ public function test_select_date_time_literal_functions_match_sqlite(): void { 'SELECT LENGTH(UTC_DATE()) AS value_length, SUBSTR(UTC_DATE(), 5, 1) AS date_sep', 'SELECT LENGTH(UTC_TIME()) AS value_length, SUBSTR(UTC_TIME(), 3, 1) AS hour_sep', 'SELECT LENGTH(UTC_TIMESTAMP()) AS value_length, SUBSTR(UTC_TIMESTAMP(), 5, 1) AS date_sep, SUBSTR(UTC_TIMESTAMP(), 14, 1) AS time_sep', + "SELECT DATE('2008-01-02 13:29:17') AS value_date", + "SELECT DATEDIFF('2008-01-09 13:29:17', '2008-01-02 00:00:00') AS day_delta", + "SELECT DATE_ADD('2008-01-02 13:29:17', INTERVAL 1 SECOND) AS shifted", + "SELECT DATE_ADD('2008-01-02 13:29:17', INTERVAL 2 WEEK) AS shifted", + "SELECT DATE_SUB('2008-01-02 13:29:17', INTERVAL 1 MONTH) AS shifted", + "SELECT DATE(DATE_ADD('2008-01-02 13:29:17', INTERVAL 1 DAY)) AS nested_date", + "SELECT DATE_ADD('2008-01-02 13:29:17', INTERVAL 1 HOUR) AS shifted ORDER BY shifted", ) as $sql ) { $this->assertParityRows( $sql ); From 4a479bc5f75324ca6c58760d65536e1482dc00b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 04:20:49 +0000 Subject: [PATCH 064/317] Add DuckDB wildcard result metadata parity --- .../src/duckdb/class-wp-duckdb-driver.php | 64 ++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 255 ++++++++++++++++++ 2 files changed, 312 insertions(+), 7 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index e014e1136..04285c582 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -586,22 +586,31 @@ private function simple_select_column_metadata( array $tokens ): ?array { } foreach ( $columns as $column ) { - if ( - null !== $column['qualifier'] - && 0 !== strcasecmp( $column['qualifier'], $table['alias'] ) - && 0 !== strcasecmp( $column['qualifier'], $table['table_name'] ) - ) { + if ( ! $this->simple_select_column_qualifier_matches_table( $column['qualifier'], $table ) ) { return null; } } + $metadata_rows = $this->table_column_metadata_rows( $table['table_name'], $table['temporary'] ); $metadata_by_column = array(); - foreach ( $this->table_column_metadata_rows( $table['table_name'], $table['temporary'] ) as $metadata ) { + foreach ( $metadata_rows as $metadata ) { $metadata_by_column[ strtolower( (string) $metadata['column_name'] ) ] = $metadata; } $column_meta = array(); foreach ( $columns as $column ) { + if ( $column['wildcard'] ) { + foreach ( $metadata_rows as $metadata ) { + $column_meta[] = $this->mysql_result_column_metadata( + $table['table_name'], + $table['alias'], + $metadata, + (string) $metadata['column_name'] + ); + } + continue; + } + $key = strtolower( $column['column_name'] ); if ( ! isset( $metadata_by_column[ $key ] ) ) { return null; @@ -618,6 +627,19 @@ private function simple_select_column_metadata( array $tokens ): ?array { return $column_meta; } + /** + * Check whether a SELECT-list qualifier belongs to the one supported table. + * + * @param string|null $qualifier Optional column qualifier. + * @param array{table_name:string,alias:string,temporary:bool} $table Parsed table reference. + * @return bool Whether the qualifier matches the table reference. + */ + private function simple_select_column_qualifier_matches_table( ?string $qualifier, array $table ): bool { + return null === $qualifier + || 0 === strcasecmp( $qualifier, $table['alias'] ) + || 0 === strcasecmp( $qualifier, $table['table_name'] ); + } + /** * Find the end of a simple SELECT FROM clause. * @@ -734,7 +756,7 @@ private function parse_simple_select_table_reference( array $tokens ): ?array { * Parse one SELECT list item for bounded result metadata. * * @param WP_Parser_Token[] $tokens SELECT item tokens. - * @return array{name:string,column_name:string,qualifier:string|null}|null Column reference, or null when unsupported. + * @return array{name:string,column_name:string,qualifier:string|null,wildcard:bool}|null Column reference, or null when unsupported. */ private function parse_simple_select_column_reference( array $tokens ): ?array { $count = count( $tokens ); @@ -742,6 +764,33 @@ private function parse_simple_select_column_reference( array $tokens ): ?array { return null; } + if ( 1 === $count && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[0]->id ) { + return array( + 'name' => '*', + 'column_name' => '*', + 'qualifier' => null, + 'wildcard' => true, + ); + } + + if ( + 3 === $count + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[2]->id + ) { + $qualifier = $this->metadata_identifier_value( $tokens[0] ); + if ( null === $qualifier ) { + return null; + } + + return array( + 'name' => '*', + 'column_name' => '*', + 'qualifier' => $qualifier, + 'wildcard' => true, + ); + } + $index = 0; $qualifier = null; $name = $this->metadata_identifier_value( $tokens[ $index ] ?? null ); @@ -786,6 +835,7 @@ private function parse_simple_select_column_reference( array $tokens ): ?array { 'name' => $alias, 'column_name' => $name, 'qualifier' => $qualifier, + 'wildcard' => false, ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 093454928..9d7cd3e24 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -2790,6 +2790,261 @@ public function test_simple_select_result_metadata_uses_recorded_table_columns() $this->assertFalse( $update->getColumnMeta( 0 ) ); } + public function test_simple_select_wildcard_result_metadata_uses_recorded_table_columns(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wordpress_test', + ) + ); + $driver->query( + "CREATE TABLE `wp_wildcard_posts` ( + `ID` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + `post_title` VARCHAR(191) NOT NULL DEFAULT '', + `post_content` LONGTEXT, + PRIMARY KEY (`ID`) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wp_wildcard_posts (post_title, post_content) VALUES + ('Hello', 'First post'), + ('World', 'Second post')" + ); + + $result = $driver->query( 'SELECT * FROM wp_wildcard_posts ORDER BY ID LIMIT 1' ); + + $this->assertSame( 3, $result->columnCount() ); + $this->assertSame( + array( + array( + 'ID' => 1, + 'post_title' => 'Hello', + 'post_content' => 'First post', + ), + ), + $result->fetchAll( PDO::FETCH_ASSOC ) + ); + + $id_meta = $result->getColumnMeta( 0 ); + $this->assertSame( 'ID', $id_meta['name'] ); + $this->assertSame( 'wp_wildcard_posts', $id_meta['table'] ); + $this->assertSame( 'ID', $id_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_wildcard_posts', $id_meta['mysqli:orgtable'] ); + $this->assertSame( 'wordpress_test', $id_meta['mysqli:db'] ); + $this->assertSame( 20, $id_meta['len'] ); + $this->assertSame( 63, $id_meta['mysqli:charsetnr'] ); + $this->assertSame( 8, $id_meta['mysqli:type'] ); + + $title_meta = $result->getColumnMeta( 1 ); + $this->assertSame( 'post_title', $title_meta['name'] ); + $this->assertSame( 'wp_wildcard_posts', $title_meta['table'] ); + $this->assertSame( 'post_title', $title_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_wildcard_posts', $title_meta['mysqli:orgtable'] ); + $this->assertSame( 764, $title_meta['len'] ); + $this->assertSame( 255, $title_meta['mysqli:charsetnr'] ); + $this->assertSame( 253, $title_meta['mysqli:type'] ); + + $content_meta = $result->getColumnMeta( 2 ); + $this->assertSame( 'post_content', $content_meta['name'] ); + $this->assertSame( 'post_content', $content_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_wildcard_posts', $content_meta['mysqli:orgtable'] ); + $this->assertSame( 4294967295, $content_meta['len'] ); + $this->assertSame( 255, $content_meta['mysqli:charsetnr'] ); + $this->assertSame( 252, $content_meta['mysqli:type'] ); + } + + public function test_simple_select_alias_wildcard_result_metadata_uses_alias_table(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wordpress_test', + ) + ); + $driver->query( + "CREATE TABLE wp_alias_wildcard_posts ( + ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + post_title VARCHAR(191) NOT NULL DEFAULT '', + PRIMARY KEY (ID) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( "INSERT INTO wp_alias_wildcard_posts (post_title) VALUES ('Hello')" ); + + $result = $driver->query( 'SELECT p.* FROM wp_alias_wildcard_posts AS p WHERE p.ID = 1' ); + + $this->assertSame( + array( + array( + 'ID' => 1, + 'post_title' => 'Hello', + ), + ), + $result->fetchAll( PDO::FETCH_ASSOC ) + ); + + $id_meta = $result->getColumnMeta( 0 ); + $this->assertSame( 'ID', $id_meta['name'] ); + $this->assertSame( 'p', $id_meta['table'] ); + $this->assertSame( 'ID', $id_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_alias_wildcard_posts', $id_meta['mysqli:orgtable'] ); + + $title_meta = $result->getColumnMeta( 1 ); + $this->assertSame( 'post_title', $title_meta['name'] ); + $this->assertSame( 'p', $title_meta['table'] ); + $this->assertSame( 'post_title', $title_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_alias_wildcard_posts', $title_meta['mysqli:orgtable'] ); + } + + public function test_simple_select_wildcard_metadata_uses_temporary_shadow_table(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_shadow_wildcard_posts ( + persistent_id BIGINT(20) UNSIGNED NOT NULL, + persistent_label VARCHAR(191) NOT NULL DEFAULT '' + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "CREATE TEMPORARY TABLE wp_shadow_wildcard_posts ( + temp_id INT NOT NULL, + temp_label VARCHAR(20) NOT NULL DEFAULT '' + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( "INSERT INTO wp_shadow_wildcard_posts (temp_id, temp_label) VALUES (7, 'temporary')" ); + + $result = $driver->query( 'SELECT * FROM wp_shadow_wildcard_posts' ); + + $this->assertSame( + array( + array( + 'temp_id' => 7, + 'temp_label' => 'temporary', + ), + ), + $result->fetchAll( PDO::FETCH_ASSOC ) + ); + + $id_meta = $result->getColumnMeta( 0 ); + $this->assertSame( 'temp_id', $id_meta['name'] ); + $this->assertSame( 'temp_id', $id_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_shadow_wildcard_posts', $id_meta['mysqli:orgtable'] ); + $this->assertSame( 11, $id_meta['len'] ); + $this->assertSame( 3, $id_meta['mysqli:type'] ); + + $label_meta = $result->getColumnMeta( 1 ); + $this->assertSame( 'temp_label', $label_meta['name'] ); + $this->assertSame( 'temp_label', $label_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_shadow_wildcard_posts', $label_meta['mysqli:orgtable'] ); + $this->assertSame( 80, $label_meta['len'] ); + $this->assertSame( 253, $label_meta['mysqli:type'] ); + } + + public function test_sql_calc_found_rows_wildcard_result_metadata_is_preserved(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_found_rows_wildcard_posts ( + ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + post_title VARCHAR(191) NOT NULL DEFAULT '', + PRIMARY KEY (ID) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wp_found_rows_wildcard_posts (post_title) VALUES + ('Hello'), + ('World')" + ); + + $result = $driver->query( 'SELECT SQL_CALC_FOUND_ROWS * FROM wp_found_rows_wildcard_posts ORDER BY ID LIMIT 1' ); + + $id_meta = $result->getColumnMeta( 0 ); + $this->assertSame( 'ID', $id_meta['name'] ); + $this->assertSame( 'ID', $id_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_found_rows_wildcard_posts', $id_meta['mysqli:orgtable'] ); + + $title_meta = $result->getColumnMeta( 1 ); + $this->assertSame( 'post_title', $title_meta['name'] ); + $this->assertSame( 'post_title', $title_meta['mysqli:orgname'] ); + $this->assertSame( 'wp_found_rows_wildcard_posts', $title_meta['mysqli:orgtable'] ); + + $this->assertSame( + array( + array( + 'ID' => 1, + 'post_title' => 'Hello', + ), + ), + $result->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'found_rows' => 2 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_non_simple_wildcard_selects_do_not_infer_origin_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_join_wildcard_posts ( + ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + post_title VARCHAR(191) NOT NULL DEFAULT '', + PRIMARY KEY (ID) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "CREATE TABLE wp_join_wildcard_meta ( + post_id BIGINT(20) UNSIGNED NOT NULL, + meta_key VARCHAR(191) NOT NULL DEFAULT '' + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( "INSERT INTO wp_join_wildcard_posts (post_title) VALUES ('Hello')" ); + $driver->query( "INSERT INTO wp_join_wildcard_meta (post_id, meta_key) VALUES (1, '_edit_lock')" ); + + $join = $driver->query( + 'SELECT p.*, m.meta_key FROM wp_join_wildcard_posts AS p JOIN wp_join_wildcard_meta AS m ON p.ID = m.post_id' + ); + + $this->assertSame( + array( + array( + 'ID' => 1, + 'post_title' => 'Hello', + 'meta_key' => '_edit_lock', + ), + ), + $join->fetchAll( PDO::FETCH_ASSOC ) + ); + + $join_meta = $join->getColumnMeta( 0 ); + $this->assertSame( 'ID', $join_meta['name'] ); + $this->assertArrayNotHasKey( 'mysqli:orgname', $join_meta ); + $this->assertArrayNotHasKey( 'mysqli:orgtable', $join_meta ); + + $expression = $driver->query( 'SELECT *, CONCAT(post_title, post_title) AS doubled FROM wp_join_wildcard_posts' ); + $this->assertSame( + array( + array( + 'ID' => 1, + 'post_title' => 'Hello', + 'doubled' => 'HelloHello', + ), + ), + $expression->fetchAll( PDO::FETCH_ASSOC ) + ); + + $expression_meta = $expression->getColumnMeta( 0 ); + $this->assertSame( 'ID', $expression_meta['name'] ); + $this->assertArrayNotHasKey( 'mysqli:orgname', $expression_meta ); + $this->assertArrayNotHasKey( 'mysqli:orgtable', $expression_meta ); + } + public function test_regexp_predicates_are_emulated(): void { $this->requireDuckDBRuntime(); From 3e6e87663687b8b05b86bacb5c4a76c7359b4bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 04:27:46 +0000 Subject: [PATCH 065/317] Normalize DuckDB error surface parity --- .../class-wp-duckdb-driver-exception.php | 11 ++ .../duckdb/WP_DuckDB_Connection_Tests.php | 135 ++++++++++++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 61 ++++++++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 15 +- 4 files changed, 221 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver-exception.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver-exception.php index 45148b9a3..7e2253eb6 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver-exception.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver-exception.php @@ -4,4 +4,15 @@ * Exception thrown by the DuckDB adapter. */ class WP_DuckDB_Driver_Exception extends RuntimeException { + /** + * Constructor. + * + * @param string $message The exception message. + * @param int|string $code The exception code. + * @param Throwable|null $previous The previous throwable used for the exception chaining. + */ + public function __construct( string $message = '', $code = 0, ?Throwable $previous = null ) { + parent::__construct( $message, 0, $previous ); + $this->code = ( 0 === $code && null !== $previous ) ? 'HY000' : $code; + } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index c47d147c7..dd17c20ac 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -249,6 +249,130 @@ public function test_prepared_statement_passes_sql_context_to_result_classificat $this->assertSame( array( 'Success' => 1 ), $success_result->fetch( PDO::FETCH_ASSOC ) ); } + public function test_query_failure_wraps_native_exception_with_stable_surface(): void { + $previous = new RuntimeException( 'native syntax failure', 123 ); + $connection = new WP_DuckDB_Connection( + array( + 'duckdb' => new class( $previous ) { + private $previous; + + public function __construct( Throwable $previous ) { + $this->previous = $previous; + } + + public function query( string $sql ) { + throw $this->previous; + } + }, + ) + ); + + $this->assertDuckDBDriverExceptionSurface( + 'DuckDB query failed: native syntax failure', + $previous, + function () use ( $connection ): void { + $connection->query( 'SELECT BROKEN' ); + } + ); + } + + public function test_prepare_failure_wraps_native_exception_with_stable_surface(): void { + $previous = new RuntimeException( 'native prepare failure', 456 ); + $connection = new WP_DuckDB_Connection( + array( + 'duckdb' => new class( $previous ) { + private $previous; + + public function __construct( Throwable $previous ) { + $this->previous = $previous; + } + + public function preparedStatement( string $sql ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + throw $this->previous; + } + }, + ) + ); + + $this->assertDuckDBDriverExceptionSurface( + 'Failed to prepare DuckDB query: native prepare failure', + $previous, + function () use ( $connection ): void { + $connection->prepare( 'SELECT ?' ); + } + ); + } + + public function test_prepared_statement_execute_failure_wraps_native_exception_with_stable_surface(): void { + $previous = new RuntimeException( 'native execute failure', 789 ); + $connection = new WP_DuckDB_Connection( array( 'duckdb' => new stdClass() ) ); + $statement = new WP_DuckDB_Prepared_Statement( + $connection, + new class( $previous ) { + private $previous; + + public function __construct( Throwable $previous ) { + $this->previous = $previous; + } + + public function bindParam( $parameter, $value ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + } + + public function execute() { + throw $this->previous; + } + }, + 'SELECT ?' + ); + + $this->assertDuckDBDriverExceptionSurface( + 'DuckDB prepared statement failed: native execute failure', + $previous, + function () use ( $statement ): void { + $statement->execute( array( 1 ) ); + } + ); + } + + public function test_prepared_statement_bind_failure_wraps_native_exception_with_stable_surface(): void { + $previous = new RuntimeException( 'native bind failure', 901 ); + $connection = new WP_DuckDB_Connection( array( 'duckdb' => new stdClass() ) ); + $statement = new WP_DuckDB_Prepared_Statement( + $connection, + new class( $previous ) { + private $previous; + + public function __construct( Throwable $previous ) { + $this->previous = $previous; + } + + public function bindParam( $parameter, $value ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + throw $this->previous; + } + + public function execute() { + return null; + } + }, + 'SELECT ?' + ); + + $this->assertDuckDBDriverExceptionSurface( + 'DuckDB prepared statement failed: native bind failure', + $previous, + function () use ( $statement ): void { + $statement->execute( array( 1 ) ); + } + ); + } + + public function test_successful_statement_error_info_remains_clear_after_failure_characterization(): void { + $stmt = new WP_DuckDB_Result_Statement( array(), array() ); + + $this->assertSame( '00000', $stmt->errorCode() ); + $this->assertSame( array( '00000', null, null ), $stmt->errorInfo() ); + } + public function test_in_memory_connection_executes_query(): void { $this->requireDuckDBRuntime(); @@ -532,4 +656,15 @@ public function execute() { } }; } + + private function assertDuckDBDriverExceptionSurface( string $message_prefix, Throwable $previous, callable $callback ): void { + try { + $callback(); + $this->fail( 'Expected WP_DuckDB_Driver_Exception.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringStartsWith( $message_prefix, $e->getMessage() ); + $this->assertSame( 'HY000', $e->getCode() ); + $this->assertSame( $previous, $e->getPrevious() ); + } + } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 9d7cd3e24..31e87b5da 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -69,6 +69,67 @@ public function test_record_found_rows_from_result_preserves_column_metadata(): $this->assertSame( $source->getColumnMeta( 1 ), $result->getColumnMeta( 1 ) ); } + public function test_translated_driver_execution_failure_preserves_context_and_previous_chain(): void { + $native_failure = new RuntimeException( 'native create failure', 321 ); + $connection = new WP_DuckDB_Connection( + array( + 'duckdb' => new class( $native_failure ) { + private $native_failure; + + public function __construct( Throwable $native_failure ) { + $this->native_failure = $native_failure; + } + + public function query( string $sql ) { + if ( 0 === strpos( $sql, 'CREATE TABLE "broken"' ) ) { + throw $this->native_failure; + } + + return $this->create_native_result( array( 'Count' ), array() ); + } + + private function create_native_result( array $columns, array $rows ) { + return new class( $columns, $rows ) { + private $columns; + private $rows; + + public function __construct( array $columns, array $rows ) { + $this->columns = $columns; + $this->rows = $rows; + } + + public function columnNames(): ArrayIterator { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return new ArrayIterator( $this->columns ); + } + + public function rows( bool $assoc ): array { + $rows = array(); + foreach ( $this->rows as $row ) { + $rows[] = array_combine( $this->columns, $row ); + } + return $rows; + } + }; + } + }, + ) + ); + $driver = new WP_DuckDB_Driver( array( 'connection' => $connection ) ); + + try { + $driver->query( 'CREATE TABLE broken (id INT)' ); + $this->fail( 'Expected WP_DuckDB_Driver_Exception.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertSame( 'HY000', $e->getCode() ); + $this->assertStringStartsWith( 'Failed to create DuckDB table: DuckDB query failed:', $e->getMessage() ); + $this->assertSame( 1, substr_count( $e->getMessage(), 'Failed to create DuckDB table:' ) ); + $this->assertInstanceOf( WP_DuckDB_Driver_Exception::class, $e->getPrevious() ); + $this->assertSame( 'HY000', $e->getPrevious()->getCode() ); + $this->assertStringStartsWith( 'DuckDB query failed: native create failure', $e->getPrevious()->getMessage() ); + $this->assertSame( $native_failure, $e->getPrevious()->getPrevious() ); + } + } + public function test_select_mysql_functions_are_emulated(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index 4a141e7df..fd83eb7a8 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -154,6 +154,9 @@ public function test_duckdb_wpdb_insert_id_tracks_driver_insert_id(): void { $this->assertSame( 102, $result['insert_id_after_replace'] ); $this->assertFalse( $result['failed_insert_return'] ); $this->assertSame( 0, $result['insert_id_after_failed_insert'] ); + $this->assertSame( 'Synthetic insert failure.', $result['last_error_after_failed_insert'] ); + $this->assertSame( 0, $result['successful_select_return'] ); + $this->assertSame( '', $result['last_error_after_success'] ); } public function test_duckdb_wpdb_flush_clears_statement_metadata(): void { @@ -463,7 +466,11 @@ public function query( string $sql ): WP_DuckDB_Result_Statement { } if ( false !== strpos( $sql, 'BROKEN' ) ) { - throw new RuntimeException( 'Synthetic insert failure.' ); + throw new WP_DuckDB_Driver_Exception( + 'Synthetic insert failure.', + 'HY000', + new RuntimeException( 'Native synthetic insert failure.' ) + ); } if ( 0 === stripos( trim( $sql ), 'replace' ) ) { @@ -493,6 +500,9 @@ public function get_insert_id(): int { $replace_insert_id = $db->insert_id; $failed_insert = $db->query( 'INSERT INTO t VALUES (BROKEN)' ); $failed_insert_id = $db->insert_id; +$failed_last_error = $db->last_error; +$successful_select = $db->query( 'SELECT 1 AS ok' ); +$last_error_after_success = $db->last_error; echo json_encode( array( @@ -503,6 +513,9 @@ public function get_insert_id(): int { 'insert_id_after_replace' => $replace_insert_id, 'failed_insert_return' => $failed_insert, 'insert_id_after_failed_insert' => $failed_insert_id, + 'last_error_after_failed_insert' => $failed_last_error, + 'successful_select_return' => $successful_select, + 'last_error_after_success' => $last_error_after_success, ) ); PHP; From 7060d4ca845b943b86885b1241086636cb1c7e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 04:43:55 +0000 Subject: [PATCH 066/317] Add DuckDB qualified metadata and key rebuild parity --- .../src/duckdb/class-wp-duckdb-driver.php | 384 ++++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 70 ++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 240 +++++++++++ 3 files changed, 654 insertions(+), 40 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 04285c582..70c6c6eb0 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -4658,9 +4658,80 @@ private function validate_alter_table_rebuild_action_combination( string $table_ if ( $this->is_alter_table_drop_column_rebuild_action( $table_name, $action, $temporary ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN requiring a table rebuild cannot be combined with other ALTER TABLE actions.' ); } + if ( $this->is_alter_table_change_modify_column_rebuild_action( $table_name, $action, $temporary ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY requiring a table rebuild cannot be combined with other ALTER TABLE actions.' ); + } } } + /** + * Check whether CHANGE/MODIFY targets a key column with physical changes. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @param bool $temporary Whether the target is a temporary table. + * @return bool Whether the action requires a CHANGE/MODIFY rebuild. + */ + private function is_alter_table_change_modify_column_rebuild_action( string $table_name, array $tokens, bool $temporary = false ): bool { + if ( ! isset( $tokens[0] ) || ( WP_MySQL_Lexer::CHANGE_SYMBOL !== $tokens[0]->id && WP_MySQL_Lexer::MODIFY_SYMBOL !== $tokens[0]->id ) ) { + return false; + } + + $index = 1; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + + $old_column_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( WP_MySQL_Lexer::CHANGE_SYMBOL === $tokens[0]->id ) { + $new_column_token = $tokens[ $index ] ?? null; + $this->identifier_value( $new_column_token ); + ++$index; + } else { + $new_column_token = $tokens[ $index - 1 ]; + } + + $definition_tokens = array_merge( array( $new_column_token ), array_slice( $tokens, $index ) ); + if ( count( $definition_tokens ) < 2 ) { + return false; + } + + list( , $sequence_sql, , $metadata ) = $this->translate_create_table_column( $table_name, $definition_tokens, false, true, $temporary, null, $this->table_default_collation( $table_name, $temporary ) ); + if ( null !== $sequence_sql || 'auto_increment' === $metadata['extra'] || 'PRI' === $metadata['column_key'] || 'UNI' === $metadata['column_key'] ) { + return false; + } + + $metadata_rows = $this->table_column_metadata_rows( $table_name, $temporary ); + $current_column = $this->resolve_alter_table_change_column_metadata( $table_name, $old_column_name, $metadata_rows ); + $current_column_name = (string) $current_column['column_name']; + $new_column_name = (string) $metadata['column_name']; + if ( 0 !== strcasecmp( $current_column_name, $new_column_name ) ) { + return false; + } + + $auto_increment = $this->auto_increment_metadata_for_table( $table_name, $temporary ); + if ( + ( null !== $auto_increment && 0 === strcasecmp( $auto_increment['column_name'], $current_column_name ) ) + || 'auto_increment' === $current_column['extra'] + ) { + return false; + } + + $physical_column = $this->physical_column_info_row( $table_name, $current_column_name ); + $type_change = $this->canonical_duckdb_type( (string) $physical_column['type'] ) !== $this->canonical_duckdb_type( (string) $metadata['_duckdb_type'] ); + $default_change = $this->column_default_changed( $current_column, $metadata ); + $nullability_change = (string) $current_column['is_nullable'] !== (string) $metadata['is_nullable']; + $has_physical_changes = $type_change || $default_change || $nullability_change; + + return $has_physical_changes && $this->alter_table_change_modify_column_targets_key( + $table_name, + $current_column_name, + $this->secondary_index_definitions_for_table( $table_name, $temporary ) + ); + } + /** * Check whether an ALTER action requires a CHECK table rebuild. * @@ -6921,7 +6992,10 @@ private function assert_alter_table_change_column_supported( string $table_name, foreach ( $this->primary_key_index_rows( $table_name ) as $index_row ) { if ( 0 === strcasecmp( (string) $index_row[4], $current_column_name ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY on a primary key column requires a table rebuild.' ); + if ( 0 !== strcasecmp( $current_column_name, $new_column_name ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY on a primary key column requires a table rebuild.' ); + } + break; } } @@ -7036,6 +7110,62 @@ function ( array $column ): string { return $rebuilt_indexes; } + /** + * Check whether a CHANGE/MODIFY target participates in a primary or secondary key. + * + * @param string $table_name Table name. + * @param string $column_name Column name. + * @param array}> $index_definitions Current secondary indexes. + * @return bool Whether the column is part of any key. + */ + private function alter_table_change_modify_column_targets_key( string $table_name, string $column_name, array $index_definitions ): bool { + foreach ( $this->primary_key_index_rows( $table_name ) as $index_row ) { + if ( 0 === strcasecmp( (string) $index_row[4], $column_name ) ) { + return true; + } + } + + foreach ( $index_definitions as $index_definition ) { + if ( $this->index_definition_contains_column( $index_definition, $column_name ) ) { + return true; + } + } + + return false; + } + + /** + * Return column metadata rows after a CHANGE/MODIFY column rebuild. + * + * @param array> $metadata_rows Current table metadata rows. + * @param string $old_column_name Old column name. + * @param array $metadata New column metadata. + * @return array> Updated metadata rows. + */ + private function column_metadata_rows_after_change_modify( array $metadata_rows, string $old_column_name, array $metadata ): array { + foreach ( $metadata_rows as &$column ) { + if ( 0 === strcasecmp( (string) $column['column_name'], $old_column_name ) ) { + $column = array_merge( + $column, + array( + 'column_name' => $metadata['column_name'], + 'column_type' => $metadata['column_type'], + 'is_nullable' => $metadata['is_nullable'], + 'column_key' => $metadata['column_key'], + 'column_default' => $metadata['column_default'], + 'extra' => $metadata['extra'], + 'collation_name' => $metadata['collation_name'], + 'comment' => $metadata['comment'], + ) + ); + break; + } + } + unset( $column ); + + return $metadata_rows; + } + /** * Replace one stored column metadata row while preserving ordinal positions. * @@ -7315,6 +7445,26 @@ private function execute_alter_table_change_or_modify_column( string $table_name ? $this->secondary_index_definitions_after_column_rename( $table_name, $current_column_name, $new_column_name, $index_definitions, $temporary ) : $index_definitions; $has_physical_changes = $rename_column || $type_change || $default_change || $nullability_change; + $targets_key_column = $this->alter_table_change_modify_column_targets_key( $table_name, $current_column_name, $index_definitions ); + + if ( ! $rename_column && $has_physical_changes && $targets_key_column ) { + $this->assert_alter_table_change_modify_column_rebuild_supported( + $table_name, + $current_column_name, + $metadata, + $type_change, + $nullability_change, + $temporary + ); + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $current_column_name, $metadata, $metadata_rows, $temporary ): WP_DuckDB_Result_Statement { + $this->execute_alter_table_change_modify_column_rebuild( $table_name, $current_column_name, $metadata, $metadata_rows, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } if ( count( $index_definitions ) > 0 && $has_physical_changes && ! $rename_column ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY on indexed columns that changes type, default, or nullability requires a table rebuild.' ); @@ -7352,6 +7502,112 @@ private function execute_alter_table_change_or_modify_column( string $table_name return $this->execute_schema_lifecycle_change( $callback ); } + /** + * Validate a CHANGE/MODIFY metadata-plan rebuild before mutation. + * + * @param string $table_name Table name. + * @param string $current_column_name Resolved current column name. + * @param array $metadata New column metadata. + * @param bool $type_change Whether the physical DuckDB type changes. + * @param bool $nullability_change Whether physical nullability changes. + * @param bool $temporary Whether the target is a temporary table. + */ + private function assert_alter_table_change_modify_column_rebuild_supported( string $table_name, string $current_column_name, array $metadata, bool $type_change, bool $nullability_change, bool $temporary = false ): void { + $this->assert_no_active_transaction_for_table_rebuild( 'CHANGE/MODIFY' ); + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'CHANGE/MODIFY', $temporary ); + + if ( $type_change ) { + $this->assert_alter_table_change_modify_column_values_convertible( $table_name, $current_column_name, (string) $metadata['_duckdb_type'] ); + } + + if ( $nullability_change && 'NO' === $metadata['is_nullable'] ) { + $this->assert_alter_table_change_modify_column_not_null_safe( $table_name, $current_column_name ); + } + } + + /** + * Reject key-column rebuilds when existing values cannot be converted. + * + * @param string $table_name Table name. + * @param string $column_name Column name. + * @param string $duck_type Target DuckDB type. + */ + private function assert_alter_table_change_modify_column_values_convertible( string $table_name, string $column_name, string $duck_type ): void { + $stmt = $this->execute_duckdb_query( + 'SELECT COUNT(*) AS invalid_count FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' WHERE ' + . $this->connection->quote_identifier( $column_name ) + . ' IS NOT NULL AND TRY_CAST(' + . $this->connection->quote_identifier( $column_name ) + . ' AS ' + . $duck_type + . ') IS NULL', + 'Failed to validate DuckDB CHANGE/MODIFY column conversion' + ); + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + $count = is_array( $row ) && isset( $row['invalid_count'] ) ? (int) $row['invalid_count'] : 0; + + if ( $count > 0 ) { + throw new WP_DuckDB_Driver_Exception( "Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY cannot rebuild column '{$column_name}' as {$duck_type} because existing rows cannot be converted." ); + } + } + + /** + * Reject NOT NULL key-column rebuilds when existing rows contain NULL. + * + * @param string $table_name Table name. + * @param string $column_name Column name. + */ + private function assert_alter_table_change_modify_column_not_null_safe( string $table_name, string $column_name ): void { + $stmt = $this->execute_duckdb_query( + 'SELECT COUNT(*) AS null_count FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' WHERE ' + . $this->connection->quote_identifier( $column_name ) + . ' IS NULL', + 'Failed to validate DuckDB CHANGE/MODIFY column nullability' + ); + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + $count = is_array( $row ) && isset( $row['null_count'] ) ? (int) $row['null_count'] : 0; + + if ( $count > 0 ) { + throw new WP_DuckDB_Driver_Exception( "Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY cannot rebuild column '{$column_name}' as NOT NULL because existing rows contain NULL values." ); + } + } + + /** + * Rebuild a table for same-name ALTER TABLE ... CHANGE/MODIFY on a key column. + * + * @param string $table_name Table name. + * @param string $current_column_name Resolved current column name. + * @param array $metadata New column metadata. + * @param array> $metadata_rows Current table metadata rows. + * @param bool $temporary Whether the target is a temporary table. + */ + private function execute_alter_table_change_modify_column_rebuild( string $table_name, string $current_column_name, array $metadata, array $metadata_rows, bool $temporary = false ): void { + $primary_key_columns = $this->primary_key_columns_for_table( $table_name ); + $column_metadata_rows = $this->column_metadata_rows_after_change_modify( $metadata_rows, $current_column_name, $metadata ); + if ( count( $primary_key_columns ) > 0 ) { + $column_metadata_rows = $this->column_metadata_rows_with_primary_key_not_null( $column_metadata_rows, $primary_key_columns ); + } + $auto_increment = $this->metadata_rows_have_auto_increment( $column_metadata_rows ) + ? $this->table_auto_increment_value( $table_name, $temporary ) + : null; + + $this->rebuild_table_from_metadata_plan( + $table_name, + $column_metadata_rows, + $primary_key_columns, + $this->secondary_index_definitions_for_table( $table_name, $temporary ), + $this->check_constraint_metadata_rows( $table_name, $temporary ), + $this->show_create_table_foreign_key_groups( $table_name, $temporary ), + $auto_increment, + 'CHANGE/MODIFY', + $temporary + ); + } + /** * Execute supported SHOW statements. * @@ -7711,6 +7967,11 @@ private function translate_static_show_where_condition( array $tokens, array $co continue; } + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id ) { + $pieces[] = 'NULL'; + continue; + } + $next_token_starts_call = isset( $tokens[ $index + 1 ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index + 1 ]->id; if ( ! $next_token_starts_call && ! $this->is_non_identifier_token( $token ) ) { $column = $column_map[ strtolower( $token->get_value() ) ] ?? null; @@ -8043,53 +8304,83 @@ private function execute_show_columns( array $tokens ): WP_DuckDB_Result_Stateme } ++$index; - $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); - ++$index; + $requested_reference = $this->parse_metadata_table_reference( $tokens, $index, true ); + $index = $requested_reference['next_index']; - $like_pattern = null; - if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $index ]->id ) { - if ( - ! isset( $tokens[ $index + 1 ] ) - || ( - WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $index + 1 ]->id - && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $index + 1 ]->id - ) - ) { - throw new WP_DuckDB_Driver_Exception( 'SHOW COLUMNS LIKE requires a string pattern in the DuckDB driver.' ); - } - $like_pattern = $tokens[ $index + 1 ]->get_value(); - $index += 2; + if ( 0 !== strcasecmp( $requested_reference['database'], $this->database ) ) { + throw new WP_DuckDB_Driver_Exception( "Table '{$requested_reference['database']}.{$requested_reference['table_name']}' doesn't exist" ); } - if ( count( $tokens ) !== $index ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW COLUMNS statement in DuckDB driver. Only optional LIKE is supported.' ); - } - - $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + $table_reference = $this->resolve_visible_user_table_reference( $requested_reference['table_name'] ); if ( null === $table_reference ) { - throw new WP_DuckDB_Driver_Exception( 'DuckDB table does not exist: ' . $table_name . '.' ); + if ( $requested_reference['database_explicit'] ) { + throw new WP_DuckDB_Driver_Exception( "Table '{$requested_reference['database']}.{$requested_reference['table_name']}' doesn't exist" ); + } + throw new WP_DuckDB_Driver_Exception( 'DuckDB table does not exist: ' . $requested_reference['table_name'] . '.' ); } if ( ! $full ) { - $rows = $this->describe_column_rows( $table_reference['table_name'], $table_reference['temporary'] ); - if ( null !== $like_pattern ) { - $rows = $this->filter_column_rows_by_like( $rows, $like_pattern ); - } - - return new WP_DuckDB_Result_Statement( + return $this->execute_static_show_metadata_statement( array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), - $rows + $this->describe_column_rows( $table_reference['table_name'], $table_reference['temporary'] ), + 'Field', + $tokens, + $index, + 'SHOW COLUMNS' ); } - $full_rows = $this->full_describe_column_rows( $table_reference['table_name'], $table_reference['temporary'] ); - if ( null !== $like_pattern ) { - $full_rows = $this->filter_column_rows_by_like( $full_rows, $like_pattern ); + return $this->execute_static_show_metadata_statement( + array( 'Field', 'Type', 'Collation', 'Null', 'Key', 'Default', 'Extra', 'Privileges', 'Comment' ), + $this->full_describe_column_rows( $table_reference['table_name'], $table_reference['temporary'] ), + 'Field', + $tokens, + $index, + 'SHOW COLUMNS' + ); + } + + /** + * Parse a metadata table reference with an optional database qualifier. + * + * SHOW COLUMNS additionally supports a trailing FROM/IN database clause. When + * both forms are present, the trailing database clause takes precedence. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Reference start index. + * @param bool $allow_database_clause Whether FROM/IN database is allowed after the table. + * @return array{database:string,table_name:string,database_explicit:bool,next_index:int} + */ + private function parse_metadata_table_reference( array $tokens, int $index, bool $allow_database_clause ): array { + $database = $this->database; + $database_explicit = false; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index ]->id ) { + $database = $table_name; + $database_explicit = true; + ++$index; + $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + } + + if ( + $allow_database_clause + && isset( $tokens[ $index ] ) + && ( WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $index ]->id ) + ) { + ++$index; + $database = $this->identifier_value( $tokens[ $index ] ?? null ); + $database_explicit = true; + ++$index; } - return new WP_DuckDB_Result_Statement( - array( 'Field', 'Type', 'Collation', 'Null', 'Key', 'Default', 'Extra', 'Privileges', 'Comment' ), - $full_rows + return array( + 'database' => $database, + 'table_name' => $table_name, + 'database_explicit' => $database_explicit, + 'next_index' => $index, ); } @@ -8481,25 +8772,38 @@ private function show_index_row( string $table_name, int $non_unique, string $ke * @return WP_DuckDB_Result_Statement */ private function execute_describe( array $tokens ): WP_DuckDB_Result_Statement { - if ( 2 !== count( $tokens ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported DESCRIBE statement in DuckDB driver. Only DESCRIBE table is supported.' ); + $reference = $this->parse_metadata_table_reference( $tokens, 1, false ); + + if ( 0 === strcasecmp( $reference['database'], 'information_schema' ) ) { + return new WP_DuckDB_Result_Statement( + array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + array() + ); + } + + if ( 0 !== strcasecmp( $reference['database'], $this->database ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DESCRIBE statement in DuckDB driver. Only the current database is supported.' ); } return new WP_DuckDB_Result_Statement( array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), - $this->describe_column_rows_for_request( $this->identifier_value( $tokens[1] ) ) + $this->describe_column_rows_for_request( $reference['table_name'], $reference['database_explicit'] ? $reference['database'] : null ) ); } /** * Build DESCRIBE rows for a requested table name, resolving temporary tables first. * - * @param string $table_name Requested table name. + * @param string $table_name Requested table name. + * @param string|null $database Explicit requested database, or null when unqualified. * @return array> */ - private function describe_column_rows_for_request( string $table_name ): array { + private function describe_column_rows_for_request( string $table_name, ?string $database = null ): array { $table_reference = $this->resolve_visible_user_table_reference( $table_name ); if ( null === $table_reference ) { + if ( null !== $database ) { + throw new WP_DuckDB_Driver_Exception( "Table '{$database}.{$table_name}' doesn't exist" ); + } throw new WP_DuckDB_Driver_Exception( 'DuckDB table does not exist: ' . $table_name . '.' ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 869dd6ddd..2828dbec2 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1296,6 +1296,40 @@ public function test_show_columns_metadata_matches_sqlite(): void { $this->assertParityRows( 'SHOW COLUMNS FROM metadata' ); } + public function test_show_columns_qualified_and_filtered_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE metadata ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '' COMMENT 'Option name', + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'yes', + UNIQUE KEY option_name (option_name), + KEY autoload (autoload) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + ) + ); + + foreach ( + array( + 'SHOW COLUMNS FROM wp.metadata', + "SHOW COLUMNS FROM `wp`.`metadata` LIKE 'option_%'", + "SHOW COLUMNS FROM information_schema.metadata FROM wp WHERE Field = 'option_name'", + "SHOW COLUMNS FROM wp.metadata WHERE Field = 'autoload'", + "SHOW COLUMNS FROM wp.metadata WHERE Type = 'longtext'", + "SHOW COLUMNS FROM wp.metadata WHERE `Null` = 'NO' AND `Key` = 'PRI'", + "SHOW COLUMNS FROM wp.metadata WHERE `Default` = 'yes'", + "SHOW COLUMNS FROM wp.metadata WHERE Extra = 'auto_increment'", + 'SHOW FULL COLUMNS FROM wp.metadata WHERE `Collation` IS NOT NULL', + "SHOW FULL FIELDS IN `wp`.`metadata` WHERE `Comment` = 'Option name'", + 'DESCRIBE wp.metadata', + "DESCRIBE `wp`.`metadata` 'option_%'", + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + public function test_alter_table_add_column_metadata_matches_sqlite(): void { $this->runParitySetup( array( @@ -1339,6 +1373,42 @@ public function test_alter_table_change_modify_metadata_matches_sqlite(): void { ); } + public function test_alter_table_change_modify_key_rebuild_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE metadata_key ( + id VARCHAR(20) NOT NULL, + code VARCHAR(20) DEFAULT '7', + payload VARCHAR(20), + PRIMARY KEY (id), + KEY code_idx (code) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO metadata_key (id, code, payload) VALUES ('1', '10', 'alpha'), ('2', '11', 'bravo')", + 'ALTER TABLE metadata_key CHANGE COLUMN id id INT NOT NULL', + "ALTER TABLE metadata_key MODIFY COLUMN code SMALLINT NOT NULL DEFAULT 42 COMMENT 'Numeric code'", + "INSERT INTO metadata_key (id, payload) VALUES (3, 'charlie')", + ) + ); + + $this->assertParityRows( 'SELECT id, code, payload FROM metadata_key ORDER BY id' ); + $this->assertParityRows( + "SELECT column_name, column_type, is_nullable, column_default, extra + FROM information_schema.columns + WHERE table_schema = 'wp' AND table_name = 'metadata_key' + ORDER BY ordinal_position" + ); + $this->assertParityRowColumns( + 'SHOW INDEX FROM metadata_key', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRows( + "SELECT index_name AS INDEX_NAME, column_name AS COLUMN_NAME, non_unique AS NON_UNIQUE, sub_part AS SUB_PART + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'metadata_key' + ORDER BY CASE WHEN index_name = 'PRIMARY' THEN 0 ELSE 1 END, index_name, seq_in_index" + ); + } + public function test_information_schema_columns_metadata_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 31e87b5da..7477c7de7 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -5607,10 +5607,22 @@ public function test_temporary_table_takes_precedence_over_persistent_table(): v array( 'b' ), array_column( $driver->query( 'SHOW COLUMNS FROM t' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) ); + $this->assertSame( + array( 'b' ), + array_column( $driver->query( 'SHOW COLUMNS FROM wp.t' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $this->assertSame( + array( 'b' ), + array_column( $driver->query( 'SHOW COLUMNS FROM information_schema.t FROM wp' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); $this->assertSame( array( 'b' ), array_column( $driver->query( 'DESCRIBE t' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) ); + $this->assertSame( + array( 'b' ), + array_column( $driver->query( 'DESCRIBE wp.t' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); $this->assertSame( array( 'ib' ), array_column( $driver->query( 'SHOW INDEX FROM t' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) @@ -5653,12 +5665,45 @@ public function test_temporary_table_takes_precedence_over_persistent_table(): v array( 'a' ), array_column( $driver->query( 'SHOW COLUMNS FROM t' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) ); + $this->assertSame( + array( 'a' ), + array_column( $driver->query( 'SHOW COLUMNS FROM wp.t' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); $this->assertSame( array( array( 'a' => 1 ) ), $driver->query( 'SELECT * FROM t' )->fetchAll( PDO::FETCH_ASSOC ) ); } + public function test_qualified_describe_does_not_expose_information_schema_tables(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE columns (user_col INT)' ); + + $this->assertSame( + array(), + $driver->query( 'DESCRIBE information_schema.columns' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + try { + $driver->query( 'SHOW COLUMNS FROM information_schema.columns' ); + $this->fail( 'Expected information_schema SHOW COLUMNS to be rejected.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "Table 'information_schema.columns' doesn't exist", $e->getMessage() ); + } + + $this->assertSame( + array( 'user_col' ), + array_column( $driver->query( 'SHOW COLUMNS FROM columns' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + } + public function test_temporary_tables_are_connection_scoped(): void { $this->requireDuckDBRuntime(); @@ -6650,6 +6695,201 @@ public function test_alter_table_change_column_targets_temporary_shadow_table(): ); } + public function test_alter_table_change_modify_rebuilds_same_name_key_columns(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE change_key_rebuild ( + id VARCHAR(20) NOT NULL, + code VARCHAR(20) DEFAULT '7', + payload VARCHAR(20), + PRIMARY KEY (id), + UNIQUE KEY code_unique (code) + )" + ); + $driver->query( "INSERT INTO change_key_rebuild (id, code, payload) VALUES ('1', '10', 'alpha'), ('2', '11', 'bravo')" ); + + $driver->query( 'ALTER TABLE change_key_rebuild CHANGE COLUMN id id INT NOT NULL' ); + $driver->query( "ALTER TABLE change_key_rebuild MODIFY COLUMN code SMALLINT NOT NULL DEFAULT 42 COMMENT 'Numeric code'" ); + $driver->query( "INSERT INTO change_key_rebuild (id, payload) VALUES (3, 'charlie')" ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'code' => 10, + 'payload' => 'alpha', + ), + array( + 'id' => 2, + 'code' => 11, + 'payload' => 'bravo', + ), + array( + 'id' => 3, + 'code' => 42, + 'payload' => 'charlie', + ), + ), + $driver->query( 'SELECT id, code, payload FROM change_key_rebuild ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $columns = array_column( $driver->query( 'SHOW FULL COLUMNS FROM change_key_rebuild' )->fetchAll( PDO::FETCH_ASSOC ), null, 'Field' ); + $this->assertSame( 'int', $columns['id']['Type'] ); + $this->assertSame( 'NO', $columns['id']['Null'] ); + $this->assertSame( 'PRI', $columns['id']['Key'] ); + $this->assertSame( 'smallint', $columns['code']['Type'] ); + $this->assertSame( 'NO', $columns['code']['Null'] ); + $this->assertSame( '42', $columns['code']['Default'] ); + $this->assertSame( 'UNI', $columns['code']['Key'] ); + $this->assertSame( 'Numeric code', $columns['code']['Comment'] ); + + $index_rows = array_map( + function ( array $row ): array { + return array( + 'Key_name' => $row['Key_name'], + 'Seq_in_index' => (int) $row['Seq_in_index'], + 'Column_name' => $row['Column_name'], + 'Non_unique' => (int) $row['Non_unique'], + ); + }, + $driver->query( 'SHOW INDEX FROM change_key_rebuild' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'Key_name' => 'PRIMARY', + 'Seq_in_index' => 1, + 'Column_name' => 'id', + 'Non_unique' => 0, + ), + array( + 'Key_name' => 'code_unique', + 'Seq_in_index' => 1, + 'Column_name' => 'code', + 'Non_unique' => 0, + ), + ), + $index_rows + ); + + $statistics_rows = array_map( + function ( array $row ): array { + return array( + 'index_name' => $row['index_name'] ?? $row['INDEX_NAME'], + 'column_name' => $row['column_name'] ?? $row['COLUMN_NAME'], + 'non_unique' => (int) ( $row['non_unique'] ?? $row['NON_UNIQUE'] ), + 'nullable' => $row['nullable'] ?? $row['NULLABLE'], + ); + }, + $driver->query( + "SELECT index_name AS index_name, column_name AS column_name, non_unique AS non_unique, nullable AS nullable + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'change_key_rebuild' + ORDER BY CASE WHEN index_name = 'PRIMARY' THEN 0 ELSE 1 END, index_name, seq_in_index" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'index_name' => 'PRIMARY', + 'column_name' => 'id', + 'non_unique' => 0, + 'nullable' => '', + ), + array( + 'index_name' => 'code_unique', + 'column_name' => 'code', + 'non_unique' => 0, + 'nullable' => '', + ), + ), + $statistics_rows + ); + + try { + $driver->query( "INSERT INTO change_key_rebuild (id, code, payload) VALUES (4, 10, 'duplicate')" ); + $this->fail( 'Expected rebuilt UNIQUE index to remain enforced.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + } + + public function test_alter_table_change_modify_key_rebuild_rejects_unsafe_conversion_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE change_key_bad_cast ( + code VARCHAR(20), + payload INT, + KEY code_idx (code) + )' + ); + $driver->query( "INSERT INTO change_key_bad_cast (code, payload) VALUES ('abc', 1)" ); + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'change_key_bad_cast' ); + try { + $driver->query( 'ALTER TABLE change_key_bad_cast MODIFY COLUMN code INT NOT NULL' ); + $this->fail( 'Expected unsafe key-column conversion rejection.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'existing rows cannot be converted', $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'change_key_bad_cast' ) ); + } + + public function test_alter_table_change_modify_key_rebuild_rejects_transaction_and_multi_action_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE change_key_guard (id VARCHAR(20) NOT NULL, payload INT, PRIMARY KEY (id))' ); + $driver->query( "INSERT INTO change_key_guard (id, payload) VALUES ('1', 1)" ); + + $before = $this->alter_table_check_lifecycle_snapshot( $driver, 'change_key_guard' ); + $driver->query( 'BEGIN' ); + try { + $driver->query( 'ALTER TABLE change_key_guard CHANGE COLUMN id id INT NOT NULL' ); + $this->fail( 'Expected active transaction CHANGE/MODIFY rebuild rejection.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHANGE/MODIFY cannot run inside an active DuckDB transaction', $e->getMessage() ); + } + $driver->query( 'ROLLBACK' ); + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'change_key_guard' ) ); + + foreach ( + array( + 'ALTER TABLE change_key_guard CHANGE COLUMN id id INT NOT NULL, ADD COLUMN should_not_exist INT', + 'ALTER TABLE change_key_guard ADD COLUMN should_not_exist INT, CHANGE COLUMN id id INT NOT NULL', + ) as $sql + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected multi-action CHANGE/MODIFY rebuild rejection for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHANGE/MODIFY requiring a table rebuild cannot be combined with other ALTER TABLE actions', $e->getMessage() ); + } + + $this->assertSame( $before, $this->alter_table_check_lifecycle_snapshot( $driver, 'change_key_guard' ) ); + } + } + public function test_alter_table_change_modify_rejects_protected_definitions(): void { $this->requireDuckDBRuntime(); From ebd88b03c50ce43ac3fbf452fc634293435f9847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 04:53:51 +0000 Subject: [PATCH 067/317] Add DuckDB joined delete USING and fetch group parity --- .../src/duckdb/class-wp-duckdb-driver.php | 114 +++++++++-- .../class-wp-duckdb-result-statement.php | 186 +++++++++++++++--- .../duckdb/WP_DuckDB_Connection_Tests.php | 91 +++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 19 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 80 +++++++- 5 files changed, 447 insertions(+), 43 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 70c6c6eb0..03ad86591 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1760,7 +1760,7 @@ private function execute_update( array $tokens ): WP_DuckDB_Result_Statement { * Parse supported joined UPDATE shapes. * * @param WP_Parser_Token[] $tokens MySQL tokens. - * @return array{target:array{alias:string,explicit_alias:bool,table_name:string,requested_table_name:string},sources:array,join_predicates:array>,update_tokens:array,where_tokens:array}|null Parsed shape, or null for single-table UPDATE. + * @return array{target:array{alias:string,explicit_alias:bool,table_name:string,requested_table_name:string},sources:array,join_predicates:array|string>,update_tokens:array,where_tokens:array}|null Parsed shape, or null for single-table UPDATE. */ private function parse_joined_update_shape( array $tokens ): ?array { if ( ! isset( $tokens[1] ) ) { @@ -1820,7 +1820,7 @@ private function parse_joined_update_shape( array $tokens ): ?array { /** * Execute a parsed joined UPDATE. * - * @param array{target:array{alias:string,explicit_alias:bool,table_name:string,requested_table_name:string},sources:array,join_predicates:array>,update_tokens:array,where_tokens:array} $shape Parsed shape. + * @param array{target:array{alias:string,explicit_alias:bool,table_name:string,requested_table_name:string},sources:array,join_predicates:array|string>,update_tokens:array,where_tokens:array} $shape Parsed shape. * @return WP_DuckDB_Result_Statement */ private function execute_joined_update( array $shape ): WP_DuckDB_Result_Statement { @@ -1848,7 +1848,7 @@ private function execute_joined_update( array $shape ): WP_DuckDB_Result_Stateme $where_clauses[] = $this->translate_tokens_to_duckdb_sql( $shape['where_tokens'] ); } foreach ( $shape['join_predicates'] as $predicate ) { - $where_clauses[] = $this->translate_tokens_to_duckdb_sql( $predicate ); + $where_clauses[] = $this->joined_dml_predicate_sql( $predicate ); } if ( count( $where_clauses ) > 0 ) { $sql .= ' WHERE (' . implode( ') AND (', $where_clauses ) . ')'; @@ -1898,7 +1898,7 @@ private function execute_delete( array $tokens ): WP_DuckDB_Result_Statement { * Parse supported multi-table DELETE shapes. * * @param WP_Parser_Token[] $tokens MySQL tokens. - * @return array{targets:array,from_sql:string,join_predicates:array>,where_tokens:array,temp_table:string}|null Parsed shape, or null for single-table DELETE. + * @return array{targets:array,from_sql:string,join_predicates:array|string>,where_tokens:array,temp_table:string}|null Parsed shape, or null for single-table DELETE. */ private function parse_multi_table_delete_shape( array $tokens ): ?array { if ( ! isset( $tokens[1] ) ) { @@ -1980,7 +1980,7 @@ private function parse_multi_table_delete_shape( array $tokens ): ?array { /** * Execute a parsed multi-table DELETE. * - * @param array{targets:array,from_sql:string,join_predicates:array>,where_tokens:array,temp_table:string} $shape Parsed shape. + * @param array{targets:array,from_sql:string,join_predicates:array|string>,where_tokens:array,temp_table:string} $shape Parsed shape. * @return WP_DuckDB_Result_Statement */ private function execute_multi_table_delete( array $shape ): WP_DuckDB_Result_Statement { @@ -2010,7 +2010,7 @@ function () use ( $shape ): WP_DuckDB_Result_Statement { $where_clauses[] = $this->translate_tokens_to_duckdb_sql( $shape['where_tokens'] ); } foreach ( $shape['join_predicates'] as $predicate ) { - $where_clauses[] = $this->translate_tokens_to_duckdb_sql( $predicate ); + $where_clauses[] = $this->joined_dml_predicate_sql( $predicate ); } if ( count( $where_clauses ) > 0 ) { $sql .= ' WHERE (' . implode( ') AND (', $where_clauses ) . ')'; @@ -2047,6 +2047,20 @@ function () use ( $shape ): WP_DuckDB_Result_Statement { ); } + /** + * Translate a joined DML predicate into DuckDB SQL. + * + * @param array|string $predicate Predicate token list or generated SQL. + * @return string DuckDB SQL. + */ + private function joined_dml_predicate_sql( $predicate ): string { + if ( is_string( $predicate ) ) { + return $predicate; + } + + return $this->translate_tokens_to_duckdb_sql( $predicate ); + } + /** * Parse a multi-table DELETE target alias list. * @@ -2075,7 +2089,7 @@ private function parse_multi_delete_target_aliases( array $tokens ): array { * Parse comma-separated table references for a bounded multi-table DELETE. * * @param WP_Parser_Token[] $tokens Table reference tokens. - * @return array{sql:string,by_alias:array,join_predicates:array>} SQL and references keyed by lowercase alias. + * @return array{sql:string,by_alias:array,join_predicates:array|string>} SQL and references keyed by lowercase alias. */ private function parse_multi_delete_table_references( array $tokens ): array { if ( $this->contains_top_level_join_token( $tokens ) ) { @@ -2112,7 +2126,7 @@ private function parse_multi_delete_table_references( array $tokens ): array { * Parse joined table references for a bounded multi-table DELETE. * * @param WP_Parser_Token[] $tokens Table reference tokens. - * @return array{sql:string,by_alias:array,join_predicates:array>} SQL and references keyed by lowercase alias. + * @return array{sql:string,by_alias:array,join_predicates:array|string>} SQL and references keyed by lowercase alias. */ private function parse_joined_multi_delete_table_references( array $tokens ): array { $joined_references = $this->parse_joined_update_table_references( $tokens, 'DELETE', false ); @@ -2213,7 +2227,7 @@ private function parse_multi_delete_table_reference( array $tokens ): array { * @param WP_Parser_Token[] $tokens Table reference tokens. * @param string $statement Statement name for diagnostics. * @param bool $first_factor_must_be_base Whether the first table factor must be a base table. - * @return array{target:array{alias:string,explicit_alias:bool,sql:string,table_name:string|null,requested_table_name:string,temporary:bool},sources:array,join_predicates:array>} + * @return array{target:array{alias:string,explicit_alias:bool,sql:string,table_name:string|null,requested_table_name:string,temporary:bool},sources:array,join_predicates:array|string>} */ private function parse_joined_update_table_references( array $tokens, string $statement = 'UPDATE', bool $first_factor_must_be_base = true ): array { $items = $this->split_top_level_comma_items( $tokens ); @@ -2227,11 +2241,11 @@ private function parse_joined_update_table_references( array $tokens, string $st throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Derived tables cannot be ' . ( 'UPDATE' === $statement ? 'updated' : 'deleted' ) . '.' ); } - $this->parse_joined_update_join_chain( $target_item, $target_factor['next_index'], $sources, $join_predicates, $statement ); + $this->parse_joined_update_join_chain( $target_item, $target_factor['next_index'], $target, $sources, $join_predicates, $statement ); foreach ( $items as $item ) { $source = $this->parse_joined_update_table_factor( $item, 0, true, false, $statement ); $sources[] = $source['reference']; - $this->parse_joined_update_join_chain( $item, $source['next_index'], $sources, $join_predicates, $statement ); + $this->parse_joined_update_join_chain( $item, $source['next_index'], $source['reference'], $sources, $join_predicates, $statement ); } return array( @@ -2361,11 +2375,12 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo * * @param WP_Parser_Token[] $tokens Table reference tokens. * @param int $index Current index. + * @param array $left_reference Left table reference for the current join. * @param array $sources Source references. - * @param array $join_predicates Join predicate token lists. + * @param array $join_predicates Join predicate token lists or generated SQL strings. * @param string $statement Statement name for diagnostics. */ - private function parse_joined_update_join_chain( array $tokens, int $index, array &$sources, array &$join_predicates, string $statement = 'UPDATE' ): void { + private function parse_joined_update_join_chain( array $tokens, int $index, array $left_reference, array &$sources, array &$join_predicates, string $statement = 'UPDATE' ): void { while ( $index < count( $tokens ) ) { if ( WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $index ]->id ) { ++$index; @@ -2383,7 +2398,17 @@ private function parse_joined_update_join_chain( array $tokens, int $index, arra $index = $source['next_index']; if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::USING_SYMBOL === $tokens[ $index ]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. JOIN ... USING is not supported.' ); + if ( 'DELETE' !== $statement ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. JOIN ... USING is not supported.' ); + } + + $using = $this->parse_joined_delete_using_predicates( $tokens, $index, $left_reference, $source['reference'] ); + foreach ( $using['predicates'] as $predicate ) { + $join_predicates[] = $predicate; + } + $index = $using['next_index']; + $left_reference = $source['reference']; + continue; } $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ON_SYMBOL, 'Expected ON in joined ' . $statement . ' statement.' ); @@ -2394,7 +2419,68 @@ private function parse_joined_update_join_chain( array $tokens, int $index, arra } $join_predicates[] = array_slice( $tokens, $index, $predicate_end - $index ); $index = $predicate_end; + $left_reference = $source['reference']; + } + } + + /** + * Parse DELETE JOIN ... USING columns into explicit equality predicates. + * + * @param WP_Parser_Token[] $tokens Table reference tokens. + * @param int $index Index at USING. + * @param array $left_reference Left table reference. + * @param array $right_reference Right table reference. + * @return array{predicates:array,next_index:int} Generated predicates and next token index. + */ + private function parse_joined_delete_using_predicates( array $tokens, int $index, array $left_reference, array $right_reference ): array { + if ( null === $left_reference['table_name'] || null === $right_reference['table_name'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. JOIN ... USING requires base table references.' ); + } + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::USING_SYMBOL, 'Expected USING in joined DELETE statement.' ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Unsupported DELETE statement in DuckDB driver. JOIN ... USING requires a column list.' ); + + $close_index = $this->skip_balanced_parentheses( $tokens, $index ) - 1; + $column_tokens = array_slice( $tokens, $index + 1, $close_index - $index - 1 ); + if ( count( $column_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. JOIN ... USING requires a column list.' ); } + + $seen = array(); + $predicates = array(); + foreach ( $this->split_top_level_comma_items( $column_tokens ) as $item ) { + if ( 1 !== count( $item ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. JOIN ... USING supports only column names.' ); + } + + $column = $this->identifier_value( $item[0] ); + $key = strtolower( $column ); + if ( isset( $seen[ $key ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Duplicate JOIN ... USING column '{$column}' in DuckDB driver." ); + } + $seen[ $key ] = true; + + if ( + ! $this->table_has_column( $left_reference['table_name'], $column, $left_reference['temporary'] ) + || ! $this->table_has_column( $right_reference['table_name'], $column, $right_reference['temporary'] ) + ) { + throw new WP_DuckDB_Driver_Exception( "Unknown JOIN ... USING column '{$column}' in DuckDB driver." ); + } + + $predicates[] = $this->connection->quote_identifier( $left_reference['alias'] ) + . '.' + . $this->connection->quote_identifier( $column ) + . ' = ' + . $this->connection->quote_identifier( $right_reference['alias'] ) + . '.' + . $this->connection->quote_identifier( $column ); + } + + return array( + 'predicates' => $predicates, + 'next_index' => $close_index + 1, + ); } /** diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php index faf25e9a4..0cef41dfd 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php @@ -159,6 +159,10 @@ public function fetchAll( $mode = null, ...$args ): array { // phpcs:ignore Word } $fetch_args = null === $mode ? $this->default_fetch_args : $args; + if ( $this->is_keyed_fetch_all_mode( (int) $fetch_mode ) ) { + return $this->fetch_all_keyed( (int) $fetch_mode, $fetch_args ); + } + if ( PDO::FETCH_COLUMN === $fetch_mode ) { $column = isset( $fetch_args[0] ) ? (int) $fetch_args[0] : 0; $this->assert_valid_column_index( $column ); @@ -171,16 +175,7 @@ public function fetchAll( $mode = null, ...$args ): array { // phpcs:ignore Word } if ( PDO::FETCH_KEY_PAIR === $fetch_mode ) { - $this->assert_valid_column_index( 0 ); - $this->assert_valid_column_index( 1 ); - - $rows = array(); - while ( isset( $this->rows[ $this->cursor ] ) ) { - $row = $this->rows[ $this->cursor ]; - ++$this->cursor; - $rows[ $row[0] ] = $row[1]; - } - return $rows; + return $this->fetch_all_key_pair(); } $rows = array(); @@ -308,15 +303,28 @@ private function format_row( array $row, int $mode, array $args = array() ) { $args = $this->default_fetch_args; } + return $this->format_row_with_columns( $row, $this->columns, $mode, $args ); + } + + /** + * Format a row for a fetch mode using a specific column list. + * + * @param array $row Numeric row. + * @param string[] $columns Column names keyed by column offset. + * @param int $mode Fetch mode. + * @param array $args Fetch mode arguments. + * @return mixed + */ + private function format_row_with_columns( array $row, array $columns, int $mode, array $args = array() ) { switch ( $mode ) { case PDO::FETCH_ASSOC: - return $this->assoc_row( $row ); + return $this->assoc_row( $row, $columns ); case PDO::FETCH_NAMED: - return $this->named_row( $row ); + return $this->named_row( $row, $columns ); case PDO::FETCH_NUM: - return $row; + return array_values( $row ); case PDO::FETCH_OBJ: - return (object) $this->assoc_row( $row ); + return (object) $this->assoc_row( $row, $columns ); case PDO::FETCH_COLUMN: $column = isset( $args[0] ) ? (int) $args[0] : 0; $this->assert_valid_column_index( $column ); @@ -324,28 +332,145 @@ private function format_row( array $row, int $mode, array $args = array() ) { case PDO::FETCH_CLASS: $class = isset( $args[0] ) ? $args[0] : 'stdClass'; $constructor_args = isset( $args[1] ) && is_array( $args[1] ) ? $args[1] : array(); - return $this->class_row( $row, $class, $constructor_args ); + return $this->class_row( $row, $class, $constructor_args, $columns ); case PDO::FETCH_FUNC: if ( ! isset( $args[0] ) || ! is_callable( $args[0] ) ) { throw new TypeError( 'PDO::FETCH_FUNC requires a callable fetch argument.' ); } - return call_user_func_array( $args[0], $row ); + return call_user_func_array( $args[0], array_values( $row ) ); case PDO::FETCH_BOTH: default: - return $this->both_row( $row ); + return $this->both_row( $row, $columns ); } } + /** + * Check whether a fetchAll mode uses the PDO keyed-result modifiers. + * + * @param int $mode Fetch mode. + * @return bool + */ + private function is_keyed_fetch_all_mode( int $mode ): bool { + return ( PDO::FETCH_GROUP === ( $mode & PDO::FETCH_GROUP ) ) + || ( PDO::FETCH_UNIQUE === ( $mode & PDO::FETCH_UNIQUE ) ); + } + + /** + * Fetch all rows keyed by a result column. + * + * @param int $mode Fetch mode. + * @param array $args Fetch mode arguments. + * @return array + */ + private function fetch_all_keyed( int $mode, array $args ): array { + $unique = PDO::FETCH_UNIQUE === ( $mode & PDO::FETCH_UNIQUE ); + $base_mode = $mode & ~( PDO::FETCH_GROUP | PDO::FETCH_UNIQUE ); + + if ( 0 === $base_mode ) { + $base_mode = $this->default_fetch_mode; + } + $base_mode = $base_mode & ~( PDO::FETCH_GROUP | PDO::FETCH_UNIQUE ); + if ( 0 === $base_mode ) { + $base_mode = PDO::FETCH_BOTH; + } + + if ( PDO::FETCH_KEY_PAIR === $base_mode ) { + return $this->fetch_all_key_pair(); + } + + if ( PDO::FETCH_COLUMN === $base_mode ) { + return $this->fetch_all_keyed_column( $unique, $args ); + } + + $this->assert_valid_column_index( 0 ); + + $rows = array(); + while ( isset( $this->rows[ $this->cursor ] ) ) { + $row = $this->rows[ $this->cursor ]; + ++$this->cursor; + + $key = $row[0]; + $columns = $this->columns; + unset( $row[0], $columns[0] ); + + $value = $this->format_row_with_columns( $row, $columns, $base_mode, $args ); + if ( $unique ) { + $rows[ $key ] = $value; + } else { + $rows[ $key ][] = $value; + } + } + return $rows; + } + + /** + * Fetch keyed rows for PDO::FETCH_COLUMN combinations. + * + * @param bool $unique Whether the mode uses PDO::FETCH_UNIQUE. + * @param array $args Fetch mode arguments. + * @return array + */ + private function fetch_all_keyed_column( bool $unique, array $args ): array { + if ( $unique ) { + $key_column = 0; + $value_column = isset( $args[0] ) ? (int) $args[0] : 1; + } else { + $key_column = isset( $args[0] ) ? (int) $args[0] : 0; + $value_column = isset( $args[0] ) ? 0 : 1; + } + + $this->assert_valid_column_index( $key_column ); + $this->assert_valid_column_index( $value_column ); + + $rows = array(); + while ( isset( $this->rows[ $this->cursor ] ) ) { + $row = $this->rows[ $this->cursor ]; + ++$this->cursor; + + $key = $row[ $key_column ]; + $value = $row[ $value_column ]; + if ( $unique ) { + $rows[ $key ] = $value; + } else { + $rows[ $key ][] = $value; + } + } + return $rows; + } + + /** + * Fetch all rows as key-value pairs. + * + * @return array + */ + private function fetch_all_key_pair(): array { + $this->assert_valid_column_index( 0 ); + $this->assert_valid_column_index( 1 ); + + $rows = array(); + while ( isset( $this->rows[ $this->cursor ] ) ) { + $row = $this->rows[ $this->cursor ]; + ++$this->cursor; + $rows[ $row[0] ] = $row[1]; + } + return $rows; + } + /** * Build an associative row. * * @param array $row Numeric row. + * @param string[]|null $columns Column names keyed by column offset. * @return array */ - private function assoc_row( array $row ): array { + private function assoc_row( array $row, $columns = null ): array { + if ( null === $columns ) { + $columns = $this->columns; + } + $assoc = array(); - foreach ( $this->columns as $index => $name ) { - $assoc[ $name ] = $row[ $index ] ?? null; + foreach ( $columns as $index => $name ) { + $assoc[ $name ] = array_key_exists( $index, $row ) ? $row[ $index ] : null; } return $assoc; } @@ -354,12 +479,17 @@ private function assoc_row( array $row ): array { * Build a PDO::FETCH_NAMED row. * * @param array $row Numeric row. + * @param string[]|null $columns Column names keyed by column offset. * @return array */ - private function named_row( array $row ): array { + private function named_row( array $row, $columns = null ): array { + if ( null === $columns ) { + $columns = $this->columns; + } + $named = array(); - foreach ( $this->columns as $index => $name ) { - $value = $row[ $index ] ?? null; + foreach ( $columns as $index => $name ) { + $value = array_key_exists( $index, $row ) ? $row[ $index ] : null; if ( ! array_key_exists( $name, $named ) ) { $named[ $name ] = $value; } elseif ( is_array( $named[ $name ] ) ) { @@ -375,10 +505,11 @@ private function named_row( array $row ): array { * Build a PDO::FETCH_BOTH row. * * @param array $row Numeric row. + * @param string[]|null $columns Column names keyed by column offset. * @return array */ - private function both_row( array $row ): array { - $both = $this->assoc_row( $row ); + private function both_row( array $row, $columns = null ): array { + $both = $this->assoc_row( $row, $columns ); foreach ( $row as $index => $value ) { $both[ $index ] = $value; } @@ -391,11 +522,12 @@ private function both_row( array $row ): array { * @param array $row Numeric row. * @param string $class Class name. * @param array $constructor_args Constructor arguments. + * @param string[]|null $columns Column names keyed by column offset. * @return object */ - private function class_row( array $row, string $class, array $constructor_args ): object { + private function class_row( array $row, string $class, array $constructor_args, $columns = null ): object { $object = new $class( ...$constructor_args ); - foreach ( $this->assoc_row( $row ) as $name => $value ) { + foreach ( $this->assoc_row( $row, $columns ) as $name => $value ) { $object->$name = $value; } return $object; diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index dd17c20ac..75943717c 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -97,6 +97,97 @@ function ( $first, $second ) { ); } + public function test_result_statement_fetch_all_group_and_unique_modes_match_pdo_shape(): void { + $stmt = new WP_DuckDB_Result_Statement( + array( 'kind', 'name', 'visits' ), + array( + array( 'core', 'Ada', 1 ), + array( 'plugin', 'Grace', 2 ), + array( 'core', 'Linus', 3 ), + ) + ); + + $this->assertSame( + array( + 'core' => array( + array( + 'name' => 'Ada', + 'visits' => 1, + ), + array( + 'name' => 'Linus', + 'visits' => 3, + ), + ), + 'plugin' => array( + array( + 'name' => 'Grace', + 'visits' => 2, + ), + ), + ), + $stmt->fetchAll( PDO::FETCH_GROUP | PDO::FETCH_ASSOC ) + ); + $this->assertFalse( $stmt->fetch() ); + + $stmt = new WP_DuckDB_Result_Statement( + array( 'kind', 'name', 'visits' ), + array( + array( 'core', 'Ada', 1 ), + array( 'plugin', 'Grace', 2 ), + array( 'core', 'Linus', 3 ), + ) + ); + + $this->assertSame( + array( + 'core' => array( + 'name' => 'Linus', + 'visits' => 3, + ), + 'plugin' => array( + 'name' => 'Grace', + 'visits' => 2, + ), + ), + $stmt->fetchAll( PDO::FETCH_UNIQUE | PDO::FETCH_ASSOC ) + ); + + $stmt = new WP_DuckDB_Result_Statement( + array( 'kind', 'name' ), + array( + array( 'core', 'Ada' ), + array( 'plugin', 'Grace' ), + array( 'core', 'Linus' ), + ) + ); + + $this->assertSame( + array( + 'core' => array( 'Ada', 'Linus' ), + 'plugin' => array( 'Grace' ), + ), + $stmt->fetchAll( PDO::FETCH_GROUP | PDO::FETCH_COLUMN ) + ); + + $stmt = new WP_DuckDB_Result_Statement( + array( 'kind', 'name' ), + array( + array( 'core', 'Ada' ), + array( 'plugin', 'Grace' ), + array( 'core', 'Linus' ), + ) + ); + + $this->assertSame( + array( + 'core' => 'Linus', + 'plugin' => 'Grace', + ), + $stmt->fetchAll( PDO::FETCH_UNIQUE | PDO::FETCH_COLUMN ) + ); + } + public function test_result_statement_cursor_and_metadata_methods_match_pdo_shape(): void { $stmt = new WP_DuckDB_Result_Statement( array( 'id', 'name' ), diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 2828dbec2..5e20826f5 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1000,6 +1000,25 @@ public function test_multi_table_delete_using_and_single_target_match_sqlite(): $this->assertParityRows( 'SELECT id, note FROM t2 ORDER BY id' ); } + public function test_joined_delete_using_columns_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20))', + 'CREATE TABLE t2 (id INT, flag VARCHAR(20), note VARCHAR(20))', + "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')", + "INSERT INTO t2 VALUES (1, 'drop', 'x'), (3, 'drop', 'z'), (4, 'keep', 'other')", + ) + ); + + $this->assertParityRowCount( + "DELETE a FROM t1 a + JOIN t2 b USING (id) + WHERE b.flag = 'drop'" + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, flag FROM t2 ORDER BY id' ); + } + public function test_single_target_joined_delete_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 7477c7de7..ebce7763b 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -2428,6 +2428,82 @@ public function test_multi_table_delete_using_form_and_single_target_are_rewritt ); } + public function test_joined_delete_rewrites_join_using_columns(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, flag VARCHAR(20), note VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'drop', 'x'), (3, 'drop', 'z'), (4, 'keep', 'other')" ); + + $single_target_delete = $driver->query( + "DELETE a FROM t1 a + JOIN t2 b USING (id) + WHERE b.flag = 'drop'" + ); + + $this->assertSame( 2, $single_target_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'b', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'flag' => 'drop', + ), + array( + 'id' => 3, + 'flag' => 'drop', + ), + array( + 'id' => 4, + 'flag' => 'keep', + ), + ), + $driver->query( 'SELECT id, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, flag VARCHAR(20), note VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'drop', 'x'), (3, 'drop', 'z'), (4, 'keep', 'other')" ); + + $multi_target_delete = $driver->query( + "DELETE a, b FROM t1 a + JOIN t2 b USING (id) + WHERE b.flag = 'drop'" + ); + + $this->assertSame( 4, $multi_target_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'b', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 4, + 'flag' => 'keep', + ), + ), + $driver->query( 'SELECT id, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_single_target_joined_delete_rewrites_join_using_and_alias_forms(): void { $this->requireDuckDBRuntime(); @@ -2670,8 +2746,8 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { 'message' => 'Only comma joins and INNER JOIN ... ON are supported', ), array( - 'sql' => 'DELETE a, b FROM t1 a JOIN t2 b USING (id)', - 'message' => 'JOIN ... USING is not supported', + 'sql' => 'DELETE a, b FROM t1 a JOIN t2 b USING (missing_id)', + 'message' => "Unknown JOIN ... USING column 'missing_id'", ), array( 'sql' => 'DELETE t1 FROM t1 a JOIN t2 b ON a.id = b.id', From 8e23ad9f6db5577519be5a4f69c89b1a5099d80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 05:07:28 +0000 Subject: [PATCH 068/317] Add DuckDB seeded RAND select parity --- .../src/duckdb/class-wp-duckdb-driver.php | 439 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 28 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 129 +++++ 3 files changed, 589 insertions(+), 7 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 03ad86591..d6739e639 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -464,6 +464,8 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $has_sql_calc_found_rows = $this->has_top_level_sql_calc_found_rows( $tokens ); $tokens = $this->normalize_select_helper_tokens( $tokens ); $column_meta = $this->simple_select_column_metadata( $tokens ); + $seeded_rand_expressions = $this->parse_seeded_rand_select_expressions( $tokens ); + $seeded_rand_rewrites = $this->seeded_rand_rewrite_map( $seeded_rand_expressions ); $rewrite_information_schema_tables = $this->uses_information_schema_tables( $tokens ); $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); @@ -502,7 +504,8 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_table_constraints, $rewrite_information_schema_key_column_usage, $rewrite_information_schema_referential_constraints, - $rewrite_information_schema_check_constraints + $rewrite_information_schema_check_constraints, + $seeded_rand_rewrites ); if ( $has_sql_calc_found_rows ) { @@ -515,10 +518,12 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_table_constraints, $rewrite_information_schema_key_column_usage, $rewrite_information_schema_referential_constraints, - $rewrite_information_schema_check_constraints + $rewrite_information_schema_check_constraints, + $seeded_rand_rewrites ); $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); - return $this->apply_result_column_metadata( $result, $column_meta ); + $result = $this->apply_result_column_metadata( $result, $column_meta ); + return $this->apply_seeded_rand_select_expressions( $result, $seeded_rand_expressions ); } catch ( Throwable $e ) { $this->found_rows = 0; throw $e; @@ -527,7 +532,8 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); $this->found_rows = $sql; - return $this->apply_result_column_metadata( $result, $column_meta ); + $result = $this->apply_result_column_metadata( $result, $column_meta ); + return $this->apply_seeded_rand_select_expressions( $result, $seeded_rand_expressions ); } /** @@ -545,6 +551,385 @@ private function apply_result_column_metadata( WP_DuckDB_Result_Statement $resul return $result; } + /** + * Parse top-level SELECT-list RAND(seed) expressions supported by the DuckDB driver. + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @return array + */ + private function parse_seeded_rand_select_expressions( array $tokens ): array { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return array(); + } + + $ranges = $this->split_top_level_select_item_ranges( + $tokens, + 1, + $this->top_level_select_list_end( $tokens ) + ); + + $expressions = array(); + $has_wildcard = false; + foreach ( $ranges as $column_index => $range ) { + if ( $this->select_item_is_wildcard( $range['tokens'] ) ) { + $has_wildcard = true; + continue; + } + + $expression = $this->parse_seeded_rand_select_item( $range['tokens'], $range['start'], $column_index ); + if ( null !== $expression ) { + $expressions[] = $expression; + } + } + + if ( $has_wildcard && count( $expressions ) > 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() with SELECT-list wildcards is not supported by the DuckDB driver.' ); + } + + return $expressions; + } + + /** + * Index seeded RAND rewrites by absolute token offset. + * + * @param array $expressions Seeded RAND expressions. + * @return array + */ + private function seeded_rand_rewrite_map( array $expressions ): array { + $rewrites = array(); + foreach ( $expressions as $expression ) { + $rewrites[ $expression['start'] ] = $expression; + } + return $rewrites; + } + + /** + * Find the token offset where the top-level SELECT list ends. + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @return int End offset, exclusive. + */ + private function top_level_select_list_end( array $tokens ): int { + $depth = 0; + for ( $index = 1; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + continue; + } + if ( 0 === $depth && $this->is_select_list_boundary_token( $tokens[ $index ] ) ) { + return $index; + } + } + + return count( $tokens ); + } + + /** + * Check whether a top-level token ends the SELECT list. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether this token starts a later SELECT clause. + */ + private function is_select_list_boundary_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::FROM_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + ), + true + ); + } + + /** + * Split a top-level SELECT list into item token ranges. + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @param int $start Start offset, inclusive. + * @param int $end End offset, exclusive. + * @return array}> + */ + private function split_top_level_select_item_ranges( array $tokens, int $start, int $end ): array { + $ranges = array(); + $item_start = $start; + $depth = 0; + + for ( $index = $start; $index < $end; ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + if ( $depth < 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unbalanced parentheses in DuckDB driver statement.' ); + } + continue; + } + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + if ( $item_start === $index ) { + throw new WP_DuckDB_Driver_Exception( 'Empty comma-separated item in DuckDB driver statement.' ); + } + $ranges[] = array( + 'start' => $item_start, + 'end' => $index, + 'tokens' => array_slice( $tokens, $item_start, $index - $item_start ), + ); + $item_start = $index + 1; + } + } + + if ( 0 !== $depth ) { + throw new WP_DuckDB_Driver_Exception( 'Unbalanced parentheses in DuckDB driver statement.' ); + } + if ( $item_start < $end ) { + $ranges[] = array( + 'start' => $item_start, + 'end' => $end, + 'tokens' => array_slice( $tokens, $item_start, $end - $item_start ), + ); + } + + return $ranges; + } + + /** + * Parse one supported RAND(seed) SELECT-list item. + * + * @param WP_Parser_Token[] $tokens SELECT item tokens. + * @param int $absolute_start Absolute start offset in the full SELECT token stream. + * @param int $column_index Result column index. + * @return array{column:int,start:int,end:int,seed:int,replacement:string}|null Parsed expression, or null for non-RAND items. + */ + private function parse_seeded_rand_select_item( array $tokens, int $absolute_start, int $column_index ): ?array { + if ( + ! isset( $tokens[0], $tokens[1] ) + || $this->is_non_identifier_token( $tokens[0] ) + || 0 !== strcasecmp( $tokens[0]->get_value(), 'RAND' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $close_index = $this->matching_parenthesis_index( $tokens, 1 ); + if ( null === $close_index || 2 === $close_index ) { + return null; + } + + $seed_tokens = array_slice( $tokens, 2, $close_index - 2 ); + if ( count( $this->split_top_level_comma_items( $seed_tokens ) ) !== 1 ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB SELECT supports exactly one seed argument.' ); + } + + $seed = $this->parse_seeded_rand_literal_seed( $seed_tokens ); + if ( null === $seed ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB SELECT supports only literal numeric, string, or NULL seeds.' ); + } + + $has_alias = $this->validate_seeded_rand_select_item_tail( $tokens, $close_index + 1 ); + $replacement = '0.0'; + if ( ! $has_alias ) { + $replacement .= ' AS ' . $this->connection->quote_identifier( + $this->concatenate_token_bytes( array_slice( $tokens, 0, $close_index + 1 ) ) + ); + } + + return array( + 'column' => $column_index, + 'start' => $absolute_start, + 'end' => $absolute_start + $close_index, + 'seed' => $seed, + 'replacement' => $replacement, + ); + } + + /** + * Find a matching closing parenthesis in a token array. + * + * @param WP_Parser_Token[] $tokens Tokens. + * @param int $open_index Opening parenthesis offset. + * @return int|null Closing parenthesis offset, or null when unbalanced. + */ + private function matching_parenthesis_index( array $tokens, int $open_index ): ?int { + $depth = 0; + for ( $index = $open_index; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + if ( 0 === $depth ) { + return $index; + } + } + } + + return null; + } + + /** + * Validate the alias tail after a supported RAND(seed) expression. + * + * @param WP_Parser_Token[] $tokens Tokens. + * @param int $index First tail token offset. + * @return bool Whether the expression has an alias. + */ + private function validate_seeded_rand_select_item_tail( array $tokens, int $index ): bool { + if ( $index >= count( $tokens ) ) { + return false; + } + + if ( WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + if ( ! isset( $tokens[ $index + 1 ] ) || count( $tokens ) !== $index + 2 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported seeded RAND() SELECT alias in DuckDB driver.' ); + } + $this->identifier_value( $tokens[ $index + 1 ] ); + return true; + } + + if ( count( $tokens ) !== $index + 1 ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB SELECT must be a standalone SELECT-list expression.' ); + } + + $this->identifier_value( $tokens[ $index ] ); + return true; + } + + /** + * Parse a supported seeded RAND literal seed. + * + * @param WP_Parser_Token[] $tokens Seed tokens. + * @return int|null Normalized seed, or null when unsupported. + */ + private function parse_seeded_rand_literal_seed( array $tokens ): ?int { + if ( 1 === count( $tokens ) ) { + $token = $tokens[0]; + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id || WP_MySQL_Lexer::NULL2_SYMBOL === $token->id ) { + return $this->normalize_seeded_rand_seed( null ); + } + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $this->normalize_seeded_rand_seed( $token->get_value() ); + } + if ( $this->is_number_token( $token ) ) { + return $this->normalize_seeded_rand_seed( $this->number_token_value( $token ) ); + } + } + + if ( + 2 === count( $tokens ) + && $this->is_sign_token( $tokens[0] ) + && $this->is_number_token( $tokens[1] ) + ) { + return $this->normalize_seeded_rand_seed( $this->signed_number_token_value( $tokens[0], $tokens[1] ) ); + } + + return null; + } + + /** + * Normalize a RAND(seed) value using MySQL/SQLite-driver coercion. + * + * @param int|float|string|null $seed Seed value. + * @return int Normalized seed. + */ + private function normalize_seeded_rand_seed( $seed ): int { + if ( null === $seed ) { + return 0; + } + if ( is_int( $seed ) ) { + return $seed; + } + + return (int) fmod( round( (float) $seed, 0, PHP_ROUND_HALF_EVEN ), 0x100000000 ); + } + + /** + * Check whether a SELECT-list item expands to one or more wildcard columns. + * + * @param WP_Parser_Token[] $tokens SELECT item tokens. + * @return bool Whether the item is * or qualifier.*. + */ + private function select_item_is_wildcard( array $tokens ): bool { + if ( 1 === count( $tokens ) && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[0]->id ) { + return true; + } + + return 3 === count( $tokens ) + && ! $this->is_non_identifier_token( $tokens[0] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[2]->id; + } + + /** + * Apply MySQL's seeded RAND(N) sequence to materialized SELECT rows. + * + * @param WP_DuckDB_Result_Statement $result Result statement. + * @param array $expressions Seeded RAND expressions. + * @return WP_DuckDB_Result_Statement Result with seeded RAND columns replaced. + */ + private function apply_seeded_rand_select_expressions( WP_DuckDB_Result_Statement $result, array $expressions ): WP_DuckDB_Result_Statement { + if ( count( $expressions ) === 0 ) { + return $result; + } + + $columns = array(); + $column_meta = array(); + for ( $index = 0; $index < $result->columnCount(); ++$index ) { + $meta = $result->getColumnMeta( $index ); + $column_meta[] = is_array( $meta ) ? $meta : array(); + $columns[] = is_array( $meta ) && isset( $meta['name'] ) ? (string) $meta['name'] : (string) $index; + } + + $rows = $result->fetchAll( PDO::FETCH_NUM ); + $state = array(); + foreach ( $rows as &$row ) { + foreach ( $expressions as $expression ) { + if ( ! array_key_exists( $expression['column'], $row ) ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() result column was not found in DuckDB SELECT result.' ); + } + $row[ $expression['column'] ] = $this->next_seeded_rand_value( $expression['seed'], $state ); + } + } + unset( $row ); + + return new WP_DuckDB_Result_Statement( $columns, $rows, $result->rowCount(), $column_meta ); + } + + /** + * Advance MySQL's deterministic RAND(N) LCG. + * + * @param int $seed Normalized seed. + * @param array $state Per-statement LCG state. + * @return float A value in [0, 1). + */ + private function next_seeded_rand_value( int $seed, array &$state ): float { + $max_value = 0x3FFFFFFF; + + if ( ! array_key_exists( 'last_seed', $state ) || $seed !== $state['last_seed'] ) { + $seed_u32 = $seed & 0xFFFFFFFF; + $state['seed1'] = ( ( $seed_u32 * 0x10001 + 55555555 ) & 0xFFFFFFFF ) % $max_value; + $state['seed2'] = ( ( $seed_u32 * 0x10000001 ) & 0xFFFFFFFF ) % $max_value; + $state['last_seed'] = $seed; + } + + $state['seed1'] = ( $state['seed1'] * 3 + $state['seed2'] ) % $max_value; + $state['seed2'] = ( $state['seed1'] + $state['seed2'] + 33 ) % $max_value; + + return (float) $state['seed1'] / (float) $max_value; + } + /** * Derive bounded result metadata for SELECT column_list FROM single_table. * @@ -1090,7 +1475,8 @@ private function count_select_rows( bool $rewrite_information_schema_table_constraints, bool $rewrite_information_schema_key_column_usage, bool $rewrite_information_schema_referential_constraints, - bool $rewrite_information_schema_check_constraints + bool $rewrite_information_schema_check_constraints, + array $seeded_rand_rewrites = array() ): int { $sql = $this->translate_tokens_to_duckdb_sql( $tokens, @@ -1100,7 +1486,8 @@ private function count_select_rows( $rewrite_information_schema_table_constraints, $rewrite_information_schema_key_column_usage, $rewrite_information_schema_referential_constraints, - $rewrite_information_schema_check_constraints + $rewrite_information_schema_check_constraints, + $seeded_rand_rewrites ); return (int) $this->execute_duckdb_query( @@ -10570,7 +10957,8 @@ private function translate_tokens_to_duckdb_sql( bool $rewrite_information_schema_table_constraints = false, bool $rewrite_information_schema_key_column_usage = false, bool $rewrite_information_schema_referential_constraints = false, - bool $rewrite_information_schema_check_constraints = false + bool $rewrite_information_schema_check_constraints = false, + array $seeded_rand_rewrites = array() ): string { $pieces = array(); @@ -10711,6 +11099,12 @@ private function translate_tokens_to_duckdb_sql( continue; } + $seeded_rand_function = $this->translate_seeded_rand_function_call( $tokens, $index, $seeded_rand_rewrites ); + if ( null !== $seeded_rand_function ) { + $pieces[] = $seeded_rand_function; + continue; + } + if ( $this->is_empty_function_call( $tokens, $index, 'UNIX_TIMESTAMP' ) ) { $pieces[] = 'CAST(epoch(current_timestamp) AS BIGINT)'; $index += 2; @@ -12716,6 +13110,37 @@ private function is_empty_function_call( array $tokens, int $index, string $name && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index + 2 ]->id; } + /** + * Translate a pre-approved seeded RAND(seed) call. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current token index, advanced on match. + * @param array $seeded_rand_rewrites Allowed rewrites. + * @return string|null Replacement SQL, or null when the current token is not RAND(seed). + */ + private function translate_seeded_rand_function_call( array $tokens, int &$index, array $seeded_rand_rewrites ): ?string { + if ( + ! isset( $tokens[ $index + 2 ] ) + || $this->is_non_identifier_token( $tokens[ $index ] ) + || 0 !== strcasecmp( $tokens[ $index ]->get_value(), 'RAND' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + return null; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index + 2 ]->id ) { + return null; + } + + if ( isset( $seeded_rand_rewrites[ $index ] ) ) { + $rewrite = $seeded_rand_rewrites[ $index ]; + $index = $rewrite['end']; + return $rewrite['replacement']; + } + + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB driver is supported only as a top-level SELECT expression with a literal seed.' ); + } + /** * Join SQL pieces with conservative whitespace. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 5e20826f5..18cc55654 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -44,6 +44,34 @@ public function test_select_unseeded_rand_range_matches_sqlite(): void { $this->assertParityRows( 'SELECT CAST(RAND() >= 0 AND RAND() < 1 AS SIGNED) AS rand_in_range' ); } + public function test_select_seeded_rand_literals_match_sqlite(): void { + foreach ( + array( + 'SELECT RAND(0) AS r', + 'SELECT RAND(1) AS r', + 'SELECT RAND(5) AS r', + 'SELECT RAND(NULL) AS r', + "SELECT RAND('5') AS r", + "SELECT RAND('3.9') AS r", + 'SELECT RAND(3.9) AS r', + 'SELECT RAND(-1) AS r', + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_select_seeded_rand_multi_row_sequence_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE seeded_rand_rows (id INT)', + 'INSERT INTO seeded_rand_rows (id) VALUES (1), (2), (3)', + ) + ); + + $this->assertParityRows( 'SELECT id, RAND(3) AS r FROM seeded_rand_rows ORDER BY id' ); + } + public function test_select_cast_convert_binary_expressions_match_sqlite(): void { foreach ( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index ebce7763b..a41db4e72 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -152,6 +152,135 @@ public function test_select_mysql_functions_are_emulated(): void { $this->assertLessThan( time() + 60, $row['unix_time'] ); } + public function test_select_seeded_rand_literals_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $cases = array( + 'SELECT RAND(0) AS r' => 0.15522042769494, + 'SELECT RAND(1) AS r' => 0.40540353712198, + 'SELECT RAND(5) AS r' => 0.40613597483014, + 'SELECT RAND(NULL) AS r' => 0.15522042769494, + "SELECT RAND('5') AS r" => 0.40613597483014, + "SELECT RAND('3.9') AS r" => 0.15595286540310, + "SELECT RAND('abc') AS r" => 0.15522042769494, + 'SELECT RAND(3.1) AS r' => 0.90576975597606, + 'SELECT RAND(3.9) AS r' => 0.15595286540310, + 'SELECT RAND(-1) AS r' => 0.90503732199318, + ); + + foreach ( $cases as $sql => $expected ) { + $row = $driver->query( $sql )->fetch( PDO::FETCH_ASSOC ); + $this->assertEqualsWithDelta( $expected, (float) $row['r'], 1e-12, $sql ); + } + } + + public function test_select_seeded_rand_without_alias_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $row = $driver->query( 'SELECT RAND(1)' )->fetch( PDO::FETCH_ASSOC ); + + $this->assertArrayHasKey( 'RAND(1)', $row ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $row['RAND(1)'], 1e-12 ); + } + + public function test_select_seeded_rand_sequence_advances_per_statement(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE seeded_rand_rows (id INT)' ); + $driver->query( 'INSERT INTO seeded_rand_rows (id) VALUES (1), (2), (3)' ); + + $first = $driver->query( 'SELECT id, RAND(3) AS r FROM seeded_rand_rows ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertCount( 3, $first ); + $this->assertEqualsWithDelta( 0.90576975597606, (float) $first[0]['r'], 1e-12 ); + $this->assertEqualsWithDelta( 0.37307905813035, (float) $first[1]['r'], 1e-12 ); + $this->assertEqualsWithDelta( 0.14808605345719, (float) $first[2]['r'], 1e-12 ); + + $second = $driver->query( 'SELECT id, RAND(3) AS r FROM seeded_rand_rows ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( $first, $second ); + } + + public function test_select_seeded_rand_call_sites_share_statement_state(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $row = $driver->query( 'SELECT RAND(1) AS a, RAND(1) AS b' )->fetch( PDO::FETCH_ASSOC ); + + $this->assertEqualsWithDelta( 0.40540353712198, (float) $row['a'], 1e-12 ); + $this->assertEqualsWithDelta( 0.87161418038571, (float) $row['b'], 1e-12 ); + } + + public function test_select_seeded_and_unseeded_rand_are_independent(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $run1 = $driver->query( 'SELECT RAND(1) AS seeded, RAND() AS unseeded' )->fetch( PDO::FETCH_ASSOC ); + $run2 = $driver->query( 'SELECT RAND(1) AS seeded, RAND() AS unseeded' )->fetch( PDO::FETCH_ASSOC ); + + $this->assertSame( (float) $run1['seeded'], (float) $run2['seeded'] ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $run1['seeded'], 1e-12 ); + foreach ( array( $run1['unseeded'], $run2['unseeded'] ) as $value ) { + $this->assertGreaterThanOrEqual( 0.0, (float) $value ); + $this->assertLessThan( 1.0, (float) $value ); + } + } + + public function test_select_seeded_rand_unsupported_shapes_are_rejected(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t (id INT, value DOUBLE)' ); + $driver->query( 'INSERT INTO t (id, value) VALUES (1, 0.0)' ); + + try { + $driver->query( 'SELECT RAND(CAST(1 AS SIGNED)) AS r' ); + $this->fail( 'Expected unsupported non-literal seeded RAND() shape to fail.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'literal numeric, string, or NULL seeds', $e->getMessage() ); + } + + try { + $driver->query( 'SELECT CAST(RAND(1) AS DOUBLE) AS r' ); + $this->fail( 'Expected nested seeded RAND() shape to fail.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'top-level SELECT expression', $e->getMessage() ); + } + + $unsupported_contexts = array( + array( + 'sql' => 'SELECT *, RAND(1) AS r FROM t', + 'message' => 'SELECT-list wildcards', + ), + array( + 'sql' => 'SELECT RAND(1) AS r FROM t ORDER BY RAND(1)', + 'message' => 'top-level SELECT expression', + ), + array( + 'sql' => 'SELECT id FROM t WHERE RAND(1) < 1', + 'message' => 'top-level SELECT expression', + ), + array( + 'sql' => 'UPDATE t SET value = RAND(1)', + 'message' => 'top-level SELECT expression', + ), + array( + 'sql' => 'INSERT INTO t (value) VALUES (RAND(1))', + 'message' => 'top-level SELECT expression', + ), + ); + + foreach ( $unsupported_contexts as $case ) { + try { + $driver->query( $case['sql'] ); + $this->fail( 'Expected unsupported seeded RAND() context to fail: ' . $case['sql'] ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $case['message'], $e->getMessage(), $case['sql'] ); + } + } + } + public function test_date_format_function_is_emulated(): void { $this->requireDuckDBRuntime(); From 7af1db198d5c653f72f462e3e49c8c47329f434e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 05:35:05 +0000 Subject: [PATCH 069/317] Add DuckDB temporal write coercion parity --- .../src/duckdb/class-wp-duckdb-driver.php | 713 ++++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 496 ++++++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 46 ++ .../duckdb/WP_DuckDB_Runtime_Gate_Tests.php | 76 ++ 4 files changed, 1278 insertions(+), 53 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index d6739e639..a3544ea19 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -79,6 +79,13 @@ class WP_DuckDB_Driver { WP_MySQL_Lexer::LONGBLOB_SYMBOL => 'BLOB', ); + const TEMPORAL_IMPLICIT_DEFAULT_MAP = array( + 'date' => '0000-00-00', + 'time' => '00:00:00', + 'datetime' => '0000-00-00 00:00:00', + 'timestamp' => '0000-00-00 00:00:00', + ); + /** * @var WP_Parser_Grammar|null */ @@ -2050,9 +2057,7 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_auto_increment_write( $this->identifier_value( $tokens[ $index ] ?? null ), - $ignore - ? $this->translate_insert_ignore_tokens_to_duckdb_sql( $tokens, $index ) - : $this->translate_tokens_to_duckdb_sql( $tokens ), + $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $index, $ignore ), 'Failed to execute DuckDB INSERT', $tokens, $index @@ -4714,7 +4719,8 @@ private function translate_update_assignment_tokens_to_duckdb_sql( array $tokens 'is_string' ); - $items = array(); + $metadata_map = $this->write_column_metadata_map( $reference['table_name'], $reference['temporary'] ?? false ); + $items = array(); foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { if ( isset( $item[0], $item[1] ) @@ -4723,7 +4729,32 @@ private function translate_update_assignment_tokens_to_duckdb_sql( array $tokens ) { $item = array_slice( $item, 2 ); } - $items[] = $this->translate_tokens_to_duckdb_sql( $item ); + + $equals_index = $this->find_top_level_token_index( $item, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); + if ( null === $equals_index ) { + $items[] = $this->translate_tokens_to_duckdb_sql( $item ); + continue; + } + + $left_tokens = array_slice( $item, 0, $equals_index ); + $right_tokens = array_slice( $item, $equals_index + 1 ); + if ( count( $left_tokens ) !== 1 || count( $right_tokens ) === 0 ) { + $items[] = $this->translate_tokens_to_duckdb_sql( $item ); + continue; + } + + $column_name = $this->identifier_value( $left_tokens[0] ); + $value_sql = $this->translate_tokens_to_duckdb_sql( $right_tokens ); + if ( isset( $metadata_map[ strtolower( $column_name ) ] ) ) { + $value_sql = $this->coerce_write_value_for_column_sql( + $metadata_map[ strtolower( $column_name ) ], + $right_tokens, + $value_sql, + true + ); + } + + $items[] = $this->translate_tokens_to_duckdb_sql( $left_tokens ) . ' = ' . $value_sql; } return implode( ', ', $items ); @@ -4757,8 +4788,9 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array } } - $items = array(); - $target_index = null; + $items = array(); + $metadata_maps = array(); + $target_index = null; foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { $equals_index = $this->find_top_level_token_index( $item, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); if ( null === $equals_index ) { @@ -4822,9 +4854,26 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE statement modifying multiple tables is not supported.' ); } - $items[] = $this->connection->quote_identifier( $column ) - . ' = ' - . $this->translate_tokens_to_duckdb_sql( $right_tokens ); + if ( ! isset( $metadata_maps[ $assignment_target_index ] ) ) { + $assignment_target = $references[ $assignment_target_index ]; + $metadata_maps[ $assignment_target_index ] = $this->write_column_metadata_map( + $assignment_target['table_name'], + $assignment_target['temporary'] ?? false + ); + } + + $value_sql = $this->translate_tokens_to_duckdb_sql( $right_tokens ); + $metadata_map = $metadata_maps[ $assignment_target_index ]; + if ( isset( $metadata_map[ strtolower( $column ) ] ) ) { + $value_sql = $this->coerce_write_value_for_column_sql( + $metadata_map[ strtolower( $column ) ], + $right_tokens, + $value_sql, + true + ); + } + + $items[] = $this->connection->quote_identifier( $column ) . ' = ' . $value_sql; } if ( null === $target_index ) { @@ -11627,6 +11676,10 @@ private function translate_replace_tokens_to_duckdb_sql( array $tokens ): string ++$index; } + if ( null === $this->find_insert_select_index( $tokens, $index ) ) { + return $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $index, false, 'INSERT OR REPLACE' ); + } + return 'INSERT OR REPLACE INTO ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $index ) ); } @@ -11642,6 +11695,10 @@ private function translate_replace_tokens_to_duckdb_insert_sql( array $tokens ): ++$index; } + if ( null === $this->find_insert_select_index( $tokens, $index ) ) { + return $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $index, false, 'INSERT' ); + } + return 'INSERT INTO ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $index ) ); } @@ -11653,7 +11710,7 @@ private function translate_replace_tokens_to_duckdb_insert_sql( array $tokens ): * @return string DuckDB SQL. */ private function translate_insert_ignore_tokens_to_duckdb_sql( array $tokens, int $table_index ): string { - return 'INSERT OR IGNORE INTO ' . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $table_index ) ); + return $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $table_index, true ); } /** @@ -11671,6 +11728,54 @@ private function translate_insert_select_tokens_to_duckdb_sql( array $tokens, in . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $table_index ) ); } + /** + * Translate MySQL INSERT/REPLACE ... VALUES to DuckDB with temporal write coercion. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param bool $ignore Whether INSERT IGNORE was used. + * @param string $verb DuckDB INSERT verb. + * @param int|null $end_index Optional token index where VALUES input ends. + * @return string DuckDB SQL. + */ + private function translate_insert_values_tokens_to_duckdb_sql( array $tokens, int $table_index, bool $ignore, string $verb = 'INSERT', ?int $end_index = null ): string { + $shape = $this->parse_insert_values_write_shape( $tokens, $table_index, $end_index, true ); + if ( ! $shape['requires_coercion'] ) { + return $verb + . ( $ignore ? ' OR IGNORE' : '' ) + . ' INTO ' + . $this->translate_tokens_to_duckdb_sql( + array_slice( + $tokens, + $table_index, + (null === $end_index ? count( $tokens ) : $end_index) - $table_index + ) + ); + } + + $row_sql = array(); + foreach ( $shape['ordered_rows'] as $row ) { + $row_sql[] = '(' . implode( ', ', $row ) . ')'; + } + + return $verb + . ( $ignore ? ' OR IGNORE' : '' ) + . ' INTO ' + . $this->connection->quote_identifier( $shape['table_name'] ) + . ' (' + . implode( + ', ', + array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ); + }, + $shape['columns'] + ) + ) + . ') VALUES ' + . implode( ', ', $row_sql ); + } + /** * Translate MySQL INSERT ... SET to DuckDB INSERT ... VALUES. * @@ -11681,7 +11786,26 @@ private function translate_insert_select_tokens_to_duckdb_sql( array $tokens, in * @return string DuckDB SQL. */ private function translate_insert_set_tokens_to_duckdb_sql( array $tokens, int $table_index, int $set_index, bool $ignore ): string { - list( $columns, $values ) = $this->parse_insert_set_assignments( array_slice( $tokens, $set_index + 1 ) ); + $assignments = $this->parse_insert_set_assignments( array_slice( $tokens, $set_index + 1 ) ); + $table_name = $this->identifier_value( $tokens[ $table_index ] ?? null ); + $reference = $this->resolve_write_table_reference( $table_name ); + $metadata_map = $this->write_column_metadata_map( $reference['table_name'], $reference['temporary'] ); + $columns = array(); + $values = array(); + + foreach ( $assignments as $assignment ) { + $columns[] = $assignment['column_sql']; + $value_sql = $assignment['value_sql']; + if ( isset( $metadata_map[ strtolower( $assignment['column_name'] ) ] ) ) { + $value_sql = $this->coerce_write_value_for_column_sql( + $metadata_map[ strtolower( $assignment['column_name'] ) ], + $assignment['value_tokens'], + $value_sql, + false + ); + } + $values[] = $value_sql; + } return 'INSERT ' . ( $ignore ? 'OR IGNORE ' : '' ) @@ -11698,16 +11822,15 @@ private function translate_insert_set_tokens_to_duckdb_sql( array $tokens, int $ * Parse INSERT ... SET assignments. * * @param WP_Parser_Token[] $tokens Assignment-list tokens after SET. - * @return array{0:string[],1:string[]} + * @return array,value_sql:string}> */ private function parse_insert_set_assignments( array $tokens ): array { - $columns = array(); - $values = array(); - $index = 0; + $assignments = array(); + $index = 0; while ( $index < count( $tokens ) ) { $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); - $columns[] = $this->translate_tokens_to_duckdb_sql( array( $tokens[ $index ] ) ); + $column_sql = $this->translate_tokens_to_duckdb_sql( array( $tokens[ $index ] ) ); ++$index; if ( @@ -11737,17 +11860,22 @@ private function parse_insert_set_assignments( array $tokens ): array { throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... SET statement in DuckDB driver. Assignment value is required for column: ' . $column_name . '.' ); } - $values[] = $this->translate_tokens_to_duckdb_sql( $value_tokens ); + $assignments[] = array( + 'column_name' => $column_name, + 'column_sql' => $column_sql, + 'value_tokens' => $value_tokens, + 'value_sql' => $this->translate_tokens_to_duckdb_sql( $value_tokens ), + ); if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { ++$index; } } - if ( count( $columns ) === 0 ) { + if ( count( $assignments ) === 0 ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... SET statement in DuckDB driver. At least one assignment is required.' ); } - return array( $columns, $values ); + return $assignments; } /** @@ -11761,14 +11889,16 @@ private function parse_insert_set_assignments( array $tokens ): array { private function translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( array $tokens, int $table_index, int $on_duplicate_index ): string { $insert_shape = $this->parse_on_duplicate_insert_shape( $tokens, $table_index, $on_duplicate_index ); $target = $this->select_on_duplicate_conflict_target( $insert_shape['table_name'], $insert_shape['values_by_column'] ); - $update_sql = $this->translate_on_duplicate_update_tokens_to_duckdb_sql( array_slice( $tokens, $on_duplicate_index + 4 ) ); + $update_sql = $this->translate_on_duplicate_update_tokens_to_duckdb_sql( + array_slice( $tokens, $on_duplicate_index + 4 ), + $this->write_column_metadata_map( $insert_shape['table_name'], $insert_shape['temporary'] ) + ); if ( '' === $update_sql ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. UPDATE list is required.' ); } - return 'INSERT INTO ' - . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $table_index, $on_duplicate_index - $table_index ) ) + return $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $table_index, false, 'INSERT', $on_duplicate_index ) . ' ON CONFLICT (' . implode( ', ', @@ -11789,11 +11919,13 @@ function ( string $column_name ): string { * @param WP_Parser_Token[] $tokens MySQL tokens. * @param int $table_index Index of the table token. * @param int $on_duplicate_index Index of the ON token. - * @return array{table_name:string,values_by_column:array} + * @return array{table_name:string,temporary:bool,values_by_column:array} */ private function parse_on_duplicate_insert_shape( array $tokens, int $table_index, int $on_duplicate_index ): array { - $table_name = $this->identifier_value( $tokens[ $table_index ] ?? null ); - $index = $table_index + 1; + $requested_table_name = $this->identifier_value( $tokens[ $table_index ] ?? null ); + $reference = $this->resolve_write_table_reference( $requested_table_name ); + $metadata_map = $this->write_column_metadata_map( $reference['table_name'], $reference['temporary'] ); + $index = $table_index + 1; $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Explicit column list is required.' ); ++$index; @@ -11830,11 +11962,21 @@ private function parse_on_duplicate_insert_shape( array $tokens, int $table_inde $values_by_column = array(); foreach ( $columns as $offset => $column_name ) { - $values_by_column[ strtolower( $column_name ) ] = $this->translate_tokens_to_duckdb_sql( $value_items[ $offset ] ); + $value_sql = $this->translate_tokens_to_duckdb_sql( $value_items[ $offset ] ); + if ( isset( $metadata_map[ strtolower( $column_name ) ] ) ) { + $value_sql = $this->coerce_write_value_for_column_sql( + $metadata_map[ strtolower( $column_name ) ], + $value_items[ $offset ], + $value_sql, + false + ); + } + $values_by_column[ strtolower( $column_name ) ] = $value_sql; } return array( - 'table_name' => $table_name, + 'table_name' => $reference['table_name'], + 'temporary' => $reference['temporary'], 'values_by_column' => $values_by_column, ); } @@ -11849,7 +11991,7 @@ private function parse_on_duplicate_insert_shape( array $tokens, int $table_inde * @param int $table_index Index of the table token. */ private function assert_insert_values_do_not_conflict_with_case_insensitive_unique_keys( array $tokens, int $table_index ): void { - $insert_shape = $this->parse_insert_values_shape( $tokens, $table_index ); + $insert_shape = $this->parse_insert_values_shape( $tokens, $table_index, true ); $case_insensitive_columns = $this->case_insensitive_column_names( $insert_shape['table_name'] ); if ( count( $case_insensitive_columns ) === 0 ) { return; @@ -11889,32 +12031,71 @@ private function assert_insert_values_do_not_conflict_with_case_insensitive_uniq /** * Parse a supported INSERT ... VALUES shape into per-row value SQL. * - * @param WP_Parser_Token[] $tokens MySQL tokens. - * @param int $table_index Index of the table token. + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param bool $coerce_for_storage Whether values should be coerced for storage. * @return array{table_name:string,rows:array>} */ - private function parse_insert_values_shape( array $tokens, int $table_index ): array { - $table_name = $this->identifier_value( $tokens[ $table_index ] ?? null ); - $index = $table_index + 1; - $columns = array(); + private function parse_insert_values_shape( array $tokens, int $table_index, bool $coerce_for_storage = false ): array { + $shape = $this->parse_insert_values_write_shape( $tokens, $table_index, null, $coerce_for_storage ); + + return array( + 'table_name' => $shape['table_name'], + 'rows' => $shape['rows'], + ); + } + + /** + * Parse a supported INSERT/REPLACE ... VALUES shape into ordered and keyed value SQL. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param int|null $end_index Optional token index where VALUES input ends. + * @param bool $coerce_for_storage Whether values should be coerced for storage. + * @return array{table_name:string,temporary:bool,columns:string[],rows:array>,ordered_rows:array,coerced_value_rows:array,temporal_validation_rows:array>>,requires_coercion:bool} + */ + private function parse_insert_values_write_shape( array $tokens, int $table_index, ?int $end_index = null, bool $coerce_for_storage = false ): array { + if ( null !== $end_index ) { + $tokens = array_slice( $tokens, 0, $end_index ); + } + + $requested_table_name = $this->identifier_value( $tokens[ $table_index ] ?? null ); + $reference = $this->resolve_write_table_reference( $requested_table_name ); + $table_name = $reference['table_name']; + $temporary = $reference['temporary']; + $index = $table_index + 1; + $columns = array(); + $metadata_map = $this->write_column_metadata_map( $table_name, $temporary ); if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { list( $column_items, $index ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); foreach ( $column_items as $column_tokens ) { if ( 1 !== count( $column_tokens ) ) { return array( - 'table_name' => $table_name, - 'rows' => array(), + 'table_name' => $table_name, + 'temporary' => $temporary, + 'columns' => array(), + 'rows' => array(), + 'ordered_rows' => array(), + 'coerced_value_rows' => array(), + 'temporal_validation_rows' => array(), + 'requires_coercion' => false, ); } $columns[] = $this->identifier_value( $column_tokens[0] ); } } else { - foreach ( $this->table_column_metadata_rows( $table_name ) as $row ) { + foreach ( $this->table_column_metadata_rows( $table_name, $temporary ) as $row ) { if ( ! array_key_exists( 'column_name', $row ) ) { return array( - 'table_name' => $table_name, - 'rows' => array(), + 'table_name' => $table_name, + 'temporary' => $temporary, + 'columns' => array(), + 'rows' => array(), + 'ordered_rows' => array(), + 'coerced_value_rows' => array(), + 'temporal_validation_rows' => array(), + 'requires_coercion' => false, ); } $columns[] = (string) $row['column_name']; @@ -11923,27 +12104,73 @@ private function parse_insert_values_shape( array $tokens, int $table_index ): a if ( count( $columns ) === 0 || ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $index ]->id ) { return array( - 'table_name' => $table_name, - 'rows' => array(), + 'table_name' => $table_name, + 'temporary' => $temporary, + 'columns' => array(), + 'rows' => array(), + 'ordered_rows' => array(), + 'coerced_value_rows' => array(), + 'temporal_validation_rows' => array(), + 'requires_coercion' => false, ); } ++$index; - $rows = array(); + $rows = array(); + $ordered_rows = array(); + $coerced_value_rows = array(); + $temporal_validation_rows = array(); + $requires_coercion = false; while ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { list( $value_items, $index ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); if ( count( $columns ) !== count( $value_items ) ) { return array( - 'table_name' => $table_name, - 'rows' => array(), + 'table_name' => $table_name, + 'temporary' => $temporary, + 'columns' => array(), + 'rows' => array(), + 'ordered_rows' => array(), + 'coerced_value_rows' => array(), + 'temporal_validation_rows' => array(), + 'requires_coercion' => false, ); } - $values_by_column = array(); + $values_by_column = array(); + $ordered_row = array(); + $coerced_values = array(); + $validation_values = array(); foreach ( $columns as $offset => $column_name ) { - $values_by_column[ strtolower( $column_name ) ] = $this->translate_tokens_to_duckdb_sql( $value_items[ $offset ] ); + $value_sql = $this->translate_tokens_to_duckdb_sql( $value_items[ $offset ] ); + if ( $coerce_for_storage && isset( $metadata_map[ strtolower( $column_name ) ] ) ) { + $validation = $this->temporal_write_validation_for_column( + $metadata_map[ strtolower( $column_name ) ], + $value_items[ $offset ], + $value_sql + ); + if ( null !== $validation ) { + $validation_values[] = $validation; + } + + $coerced_sql = $this->coerce_write_value_for_column_sql( + $metadata_map[ strtolower( $column_name ) ], + $value_items[ $offset ], + $value_sql, + false + ); + if ( $coerced_sql !== $value_sql ) { + $requires_coercion = true; + $value_sql = $coerced_sql; + $coerced_values[] = $coerced_sql; + } + } + $values_by_column[ strtolower( $column_name ) ] = $value_sql; + $ordered_row[] = $value_sql; } - $rows[] = $values_by_column; + $rows[] = $values_by_column; + $ordered_rows[] = $ordered_row; + $coerced_value_rows[] = $coerced_values; + $temporal_validation_rows[] = $validation_values; if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { ++$index; @@ -11953,11 +12180,299 @@ private function parse_insert_values_shape( array $tokens, int $table_index ): a } return array( - 'table_name' => $table_name, - 'rows' => $rows, + 'table_name' => $table_name, + 'temporary' => $temporary, + 'columns' => $columns, + 'rows' => $rows, + 'ordered_rows' => $ordered_rows, + 'coerced_value_rows' => $coerced_value_rows, + 'temporal_validation_rows' => $temporal_validation_rows, + 'requires_coercion' => $requires_coercion, + ); + } + + /** + * Resolve a DML write target, preferring visible temporary tables. + * + * @param string $requested_table_name Requested table name. + * @return array{table_name:string,temporary:bool} + */ + private function resolve_write_table_reference( string $requested_table_name ): array { + $reference = $this->resolve_visible_user_table_reference( $requested_table_name ); + if ( null !== $reference ) { + return $reference; + } + + return array( + 'table_name' => $requested_table_name, + 'temporary' => false, + ); + } + + /** + * Build a lowercase column metadata map for a write target. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + * @return array> + */ + private function write_column_metadata_map( string $table_name, bool $temporary = false ): array { + $map = array(); + foreach ( $this->table_column_metadata_rows( $table_name, $temporary ) as $row ) { + if ( array_key_exists( 'column_name', $row ) ) { + $map[ strtolower( (string) $row['column_name'] ) ] = $row; + } + } + + return $map; + } + + /** + * Coerce a target-column value for temporal storage when needed. + * + * @param array $metadata Column metadata. + * @param WP_Parser_Token[] $value_tokens RHS value tokens. + * @param string $value_sql Translated RHS SQL. + * @param bool $coalesce_non_strict_not_null Whether normal UPDATE should coalesce NULL to an implicit default. + * @return string Value SQL. + */ + private function coerce_write_value_for_column_sql( array $metadata, array $value_tokens, string $value_sql, bool $coalesce_non_strict_not_null ): string { + $data_type = $this->mysql_column_data_type( $metadata ); + if ( ! $this->is_temporal_write_data_type( $data_type ) || $this->is_default_value_tokens( $value_tokens ) ) { + return $value_sql; + } + + $value_sql = $this->coerce_temporal_write_value_sql( + $data_type, + $value_sql, + $this->write_value_display_sql( $value_tokens, $value_sql ) + ); + + if ( + $coalesce_non_strict_not_null + && ! $this->is_strict_sql_mode_active() + && isset( $metadata['is_nullable'] ) + && 'NO' === strtoupper( (string) $metadata['is_nullable'] ) + ) { + $implicit_default = $this->temporal_implicit_default( $data_type ); + if ( null !== $implicit_default ) { + $value_sql = 'COALESCE(' . $value_sql . ', ' . $this->connection->quote( $implicit_default ) . ')'; + } + } + + return $value_sql; + } + + /** + * Build a non-throwing strict temporal validation expression for a target column. + * + * @param array $metadata Column metadata. + * @param WP_Parser_Token[] $value_tokens RHS value tokens. + * @param string $value_sql Translated RHS SQL. + * @return array{data_type:string,invalid_display_sql:string}|null Validation metadata, or null when not needed. + */ + private function temporal_write_validation_for_column( array $metadata, array $value_tokens, string $value_sql ): ?array { + $data_type = $this->mysql_column_data_type( $metadata ); + if ( + ! $this->is_strict_sql_mode_active() + || ! $this->is_temporal_write_data_type( $data_type ) + || $this->is_default_value_tokens( $value_tokens ) + ) { + return null; + } + + $display_sql = $this->write_value_display_sql( $value_tokens, $value_sql ); + + return array( + 'data_type' => $data_type, + 'invalid_display_sql' => $this->temporal_invalid_write_display_sql( $data_type, $value_sql, $display_sql ), ); } + /** + * Read a MySQL-facing column data type from metadata. + * + * @param array $metadata Column metadata. + * @return string MySQL data type. + */ + private function mysql_column_data_type( array $metadata ): string { + return $this->data_type_from_column_type( strtolower( (string) ( $metadata['column_type'] ?? '' ) ) ); + } + + /** + * Check whether a data type needs temporal write coercion. + * + * @param string $data_type MySQL data type. + * @return bool Whether the type is temporal. + */ + private function is_temporal_write_data_type( string $data_type ): bool { + return in_array( $data_type, array( 'date', 'time', 'datetime', 'timestamp' ), true ); + } + + /** + * Return the temporal implicit default for non-strict invalid writes. + * + * @param string $data_type MySQL data type. + * @return string|null Implicit default, or null for unsupported types. + */ + private function temporal_implicit_default( string $data_type ): ?string { + return self::TEMPORAL_IMPLICIT_DEFAULT_MAP[ $data_type ] ?? null; + } + + /** + * Check whether a value token list is exactly DEFAULT. + * + * @param WP_Parser_Token[] $tokens Value tokens. + * @return bool Whether the value is DEFAULT. + */ + private function is_default_value_tokens( array $tokens ): bool { + return 1 === count( $tokens ) && WP_MySQL_Lexer::DEFAULT_SYMBOL === $tokens[0]->id; + } + + /** + * Build a string SQL expression used for temporal checks and error messages. + * + * @param WP_Parser_Token[] $value_tokens Value tokens. + * @param string $value_sql Translated value SQL. + * @return string String SQL expression. + */ + private function write_value_display_sql( array $value_tokens, string $value_sql ): string { + if ( 1 === count( $value_tokens ) ) { + $token = $value_tokens[0]; + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $this->connection->quote( $token->get_value() ); + } + if ( $this->is_number_token( $token ) ) { + return $this->connection->quote( $token->get_bytes() ); + } + if ( WP_MySQL_Lexer::TRUE_SYMBOL === $token->id ) { + return "'1'"; + } + if ( WP_MySQL_Lexer::FALSE_SYMBOL === $token->id ) { + return "'0'"; + } + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id || WP_MySQL_Lexer::NULL2_SYMBOL === $token->id ) { + return 'NULL'; + } + } + + if ( + 2 === count( $value_tokens ) + && ( WP_MySQL_Lexer::MINUS_OPERATOR === $value_tokens[0]->id || WP_MySQL_Lexer::PLUS_OPERATOR === $value_tokens[0]->id ) + && $this->is_number_token( $value_tokens[1] ) + ) { + return $this->connection->quote( $value_tokens[0]->get_bytes() . $value_tokens[1]->get_bytes() ); + } + + return 'CAST((' . $value_sql . ') AS VARCHAR)'; + } + + /** + * Build a DuckDB CASE expression that emulates SQLite/MySQL temporal saving. + * + * @param string $data_type MySQL temporal data type. + * @param string $value_sql Translated value SQL. + * @param string $display_sql String SQL expression for checks and messages. + * @return string Coerced value SQL. + */ + private function coerce_temporal_write_value_sql( string $data_type, string $value_sql, string $display_sql ): string { + $is_strict_mode = $this->is_strict_sql_mode_active(); + $reject_zero_date = $this->is_sql_mode_active( 'NO_ZERO_DATE' ) && $is_strict_mode ? '1' : '0'; + $reject_zero_in_date = $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ? '1' : '0'; + $implicit_default = $this->temporal_implicit_default( $data_type ); + $fallback = $is_strict_mode + ? "error('Incorrect " . $data_type . " value: ''' || " . $display_sql . " || '''')" + : ( null === $implicit_default ? 'NULL' : $this->connection->quote( $implicit_default ) ); + $zero_date_value = 'date' === $data_type ? "'0000-00-00'" : "'0000-00-00 00:00:00'"; + $formatted_value = $this->temporal_try_cast_formatted_sql( $data_type, $display_sql ); + $canonical_shape_test = $this->temporal_canonical_shape_sql( $data_type, $display_sql ); + + return 'CASE' + . ' WHEN (' . $value_sql . ') IS NULL THEN NULL' + . ' WHEN ' . $display_sql . " IN ('0000-00-00', '0000-00-00 00:00:00') AND NOT " . $reject_zero_date . ' THEN ' . $zero_date_value + . ' WHEN substr(' . $display_sql . ", 1, 4) <> '0000' AND (substr(" . $display_sql . ", 6, 2) = '00' OR substr(" . $display_sql . ", 9, 2) = '00') AND NOT " . $reject_zero_in_date . ' THEN ' . $display_sql + . ' WHEN ' . $canonical_shape_test . ' AND ' . $formatted_value . ' IS NOT NULL THEN ' . $formatted_value + . ' ELSE ' . $fallback + . ' END'; + } + + /** + * Build a CASE expression that returns the invalid display value or NULL. + * + * @param string $data_type MySQL temporal data type. + * @param string $value_sql Translated value SQL. + * @param string $display_sql String SQL expression for checks and messages. + * @return string Invalid display SQL. + */ + private function temporal_invalid_write_display_sql( string $data_type, string $value_sql, string $display_sql ): string { + $reject_zero_date = $this->is_sql_mode_active( 'NO_ZERO_DATE' ) ? '1' : '0'; + $reject_zero_in_date = $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ? '1' : '0'; + $formatted_value = $this->temporal_try_cast_formatted_sql( $data_type, $display_sql ); + $canonical_shape_test = $this->temporal_canonical_shape_sql( $data_type, $display_sql ); + + return 'CASE' + . ' WHEN (' . $value_sql . ') IS NULL THEN NULL' + . ' WHEN ' . $display_sql . " IN ('0000-00-00', '0000-00-00 00:00:00') AND NOT " . $reject_zero_date . ' THEN NULL' + . ' WHEN substr(' . $display_sql . ", 1, 4) <> '0000' AND (substr(" . $display_sql . ", 6, 2) = '00' OR substr(" . $display_sql . ", 9, 2) = '00') AND NOT " . $reject_zero_in_date . ' THEN NULL' + . ' WHEN ' . $canonical_shape_test . ' AND ' . $formatted_value . ' IS NOT NULL THEN NULL' + . ' ELSE ' . $display_sql + . ' END'; + } + + /** + * Build formatted TRY_CAST SQL for a temporal value. + * + * @param string $data_type MySQL temporal data type. + * @param string $display_sql String SQL expression. + * @return string Formatted TRY_CAST expression. + */ + private function temporal_try_cast_formatted_sql( string $data_type, string $display_sql ): string { + if ( 'date' === $data_type ) { + return "strftime(TRY_CAST(" . $display_sql . " AS DATE), '%Y-%m-%d')"; + } + if ( 'time' === $data_type ) { + return 'substr(CAST(TRY_CAST(' . $display_sql . ' AS TIME) AS VARCHAR), 1, 8)'; + } + + return "strftime(TRY_CAST(" . $display_sql . " AS TIMESTAMP), '%Y-%m-%d %H:%M:%S')"; + } + + /** + * Build a conservative canonical temporal shape check. + * + * @param string $data_type MySQL temporal data type. + * @param string $display_sql String SQL expression. + * @return string Boolean SQL expression. + */ + private function temporal_canonical_shape_sql( string $data_type, string $display_sql ): string { + if ( 'time' === $data_type ) { + $pattern = '^[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?$'; + } else { + $pattern = '^[0-9]{4}-[0-9]{2}-[0-9]{2}([ T][0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?)?$'; + } + + return 'regexp_full_match(' . $display_sql . ', ' . $this->connection->quote( $pattern ) . ')'; + } + + /** + * Check whether a SQL mode is active. + * + * @param string $mode SQL mode. + * @return bool Whether the mode is active. + */ + private function is_sql_mode_active( string $mode ): bool { + return in_array( strtoupper( $mode ), $this->active_sql_modes, true ); + } + + /** + * Check whether strict SQL mode is active. + * + * @return bool Whether strict mode is active. + */ + private function is_strict_sql_mode_active(): bool { + return $this->is_sql_mode_active( 'STRICT_TRANS_TABLES' ) || $this->is_sql_mode_active( 'STRICT_ALL_TABLES' ); + } + /** * Execute REPLACE ... VALUES when DuckDB's native INSERT OR REPLACE is insufficient. * @@ -11972,7 +12487,7 @@ private function parse_insert_values_shape( array $tokens, int $table_index ): a * @return WP_DuckDB_Result_Statement|null Statement when emulated, null when native DuckDB can be used. */ private function execute_replace_values_with_manual_conflict_handling( array $tokens, int $table_index ): ?WP_DuckDB_Result_Statement { - $replace_shape = $this->parse_insert_values_shape( $tokens, $table_index ); + $replace_shape = $this->parse_insert_values_write_shape( $tokens, $table_index, null, true ); $case_insensitive_columns = $this->case_insensitive_column_names( $replace_shape['table_name'] ); if ( count( $replace_shape['rows'] ) === 0 ) { return null; @@ -11983,6 +12498,8 @@ private function execute_replace_values_with_manual_conflict_handling( array $to return null; } + $this->validate_insert_values_write_shape( $replace_shape, 'Failed to validate DuckDB REPLACE values' ); + $started_transaction = false; if ( ! $this->connection->inTransaction() ) { $this->connection->beginTransaction(); @@ -12031,6 +12548,37 @@ private function execute_replace_values_with_manual_conflict_handling( array $to } } + /** + * Evaluate coerced INSERT/REPLACE VALUES expressions before side-effecting emulation. + * + * @param array{temporal_validation_rows:array>>} $shape Parsed VALUES shape. + * @param string $context Failure context. + */ + private function validate_insert_values_write_shape( array $shape, string $context ): void { + if ( empty( $shape['temporal_validation_rows'] ) ) { + return; + } + + foreach ( $shape['temporal_validation_rows'] as $row ) { + if ( count( $row ) === 0 ) { + continue; + } + + foreach ( $row as $validation ) { + $stmt = $this->execute_duckdb_query( + 'SELECT (' . $validation['invalid_display_sql'] . ') AS invalid_display', + $context + ); + $result = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( false !== $result && null !== $result['invalid_display'] ) { + throw new WP_DuckDB_Driver_Exception( + "Incorrect " . $validation['data_type'] . " value: '" . (string) $result['invalid_display'] . "'" + ); + } + } + } + } + /** * Check whether any unique key includes a case-insensitive column. * @@ -12242,10 +12790,69 @@ private function case_insensitive_column_names( string $table_name ): array { /** * Translate an ODKU update list, rewriting MySQL VALUES(col) references. * - * @param WP_Parser_Token[] $tokens Update-list tokens after ON DUPLICATE KEY UPDATE. + * @param WP_Parser_Token[] $tokens Update-list tokens after ON DUPLICATE KEY UPDATE. + * @param array> $metadata_map Target column metadata keyed by lowercase column name. + * @return string DuckDB SQL. + */ + private function translate_on_duplicate_update_tokens_to_duckdb_sql( array $tokens, array $metadata_map ): string { + $items = array(); + foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { + $equals_index = $this->find_top_level_token_index( $item, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); + if ( null === $equals_index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. UPDATE assignment is required.' ); + } + + $left_tokens = array_slice( $item, 0, $equals_index ); + $right_tokens = array_slice( $item, $equals_index + 1 ); + if ( count( $right_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. UPDATE assignment value is required.' ); + } + + $column_name = $this->assignment_column_name( $left_tokens ); + $value_sql = $this->translate_on_duplicate_value_tokens_to_duckdb_sql( $right_tokens ); + if ( isset( $metadata_map[ strtolower( $column_name ) ] ) ) { + $value_sql = $this->coerce_write_value_for_column_sql( + $metadata_map[ strtolower( $column_name ) ], + $right_tokens, + $value_sql, + false + ); + } + + $items[] = $this->translate_tokens_to_duckdb_sql( $left_tokens ) . ' = ' . $value_sql; + } + + return implode( ', ', $items ); + } + + /** + * Resolve an assignment target column from unqualified or qualified LHS tokens. + * + * @param WP_Parser_Token[] $left_tokens Assignment LHS tokens. + * @return string Column name. + */ + private function assignment_column_name( array $left_tokens ): string { + if ( 1 === count( $left_tokens ) ) { + return $this->identifier_value( $left_tokens[0] ); + } + + if ( + 3 === count( $left_tokens ) + && WP_MySQL_Lexer::DOT_SYMBOL === $left_tokens[1]->id + ) { + return $this->identifier_value( $left_tokens[2] ); + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Only simple column assignments are supported.' ); + } + + /** + * Translate an ODKU RHS expression, rewriting MySQL VALUES(col) references. + * + * @param WP_Parser_Token[] $tokens RHS tokens. * @return string DuckDB SQL. */ - private function translate_on_duplicate_update_tokens_to_duckdb_sql( array $tokens ): string { + private function translate_on_duplicate_value_tokens_to_duckdb_sql( array $tokens ): string { $pieces = array(); for ( $index = 0; $index < count( $tokens ); ++$index ) { diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 18cc55654..2247ea56e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1245,6 +1245,465 @@ public function test_on_duplicate_key_update_values_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_temporal_insert_values_and_set_writes_match_sqlite(): void { + $this->create_temporal_write_table( 'temporal_writes' ); + + $this->assertParityRowCount( + "INSERT INTO temporal_writes (id, d, tm, dt, ts, payload) VALUES + (1, '2025-10-23', '18:30:00', '2025-10-23 18:30:00', '2025-10-23 18:30:00', 'canonical'), + (2, '2025-10-23 18:30:00.123456', '18:30:00.123456', '2025-10-23', '2025-10-23', 'normalized'), + (3, NULL, NULL, NULL, NULL, 'nullable')" + ); + $this->assertParityRowCount( + "INSERT INTO temporal_writes SET + id = 4, + d = '2025-11-01 01:02:03.123456', + tm = '18:30:00.123456', + dt = '2025-11-01', + ts = '2025-11-01 01:02:03.123456', + payload = 'insert-set'" + ); + $this->assertParityRowCount( + "INSERT IGNORE INTO temporal_writes (id, d, tm, dt, ts, payload) VALUES + (5, '2025-12-01 05:06:07.123456', '18:30:00.123456', '2025-12-01', '2025-12-01 05:06:07.123456', 'insert-ignore')" + ); + + $this->assertParityRows( $this->temporal_select_sql( 'temporal_writes' ) ); + } + + public function test_temporal_strict_write_errors_match_sqlite(): void { + $this->create_temporal_write_table( + 'temporal_strict', + 'NULL', + array( + 'UNIQUE KEY payload_unique (payload)', + ) + ); + $this->runParitySetup( + array( + "INSERT INTO temporal_strict (id, d, tm, dt, ts, payload) VALUES + (1, '2025-01-01', '18:30:00', '2025-01-01 12:00:00', '2025-01-01 12:00:00', 'original')", + 'CREATE TABLE temporal_strict_source (id INT, dt_text VARCHAR(40), ts_text VARCHAR(40))', + "INSERT INTO temporal_strict_source VALUES (1, 'bad', 'bad')", + ) + ); + + $stable_selects = array( + $this->temporal_select_sql( 'temporal_strict' ), + 'SELECT id, dt_text, ts_text FROM temporal_strict_source ORDER BY id', + ); + + foreach ( + array( + array( + 'sql' => "INSERT INTO temporal_strict (id, d, payload) VALUES (2, 'bad', 'insert-bad-date')", + 'needle' => "Incorrect date value: 'bad'", + ), + array( + 'sql' => "INSERT INTO temporal_strict (id, d, payload) VALUES (2, TRUE, 'insert-true-date')", + 'needle' => "Incorrect date value: '1'", + ), + array( + 'sql' => "INSERT INTO temporal_strict (id, dt, payload) VALUES (2, 0, 'insert-zero-datetime')", + 'needle' => "Incorrect datetime value: '0'", + ), + array( + 'sql' => "INSERT INTO temporal_strict (id, ts, payload) VALUES (2, TRUE, 'insert-true-timestamp')", + 'needle' => "Incorrect timestamp value: '1'", + ), + array( + 'sql' => "INSERT INTO temporal_strict (id, d, payload) VALUES (2, '0000-00-00', 'insert-zero-date')", + 'needle' => "Incorrect date value: '0000-00-00'", + ), + array( + 'sql' => "INSERT INTO temporal_strict (id, dt, payload) VALUES (2, '0000-00-00 00:00:00', 'insert-zero-datetime')", + 'needle' => "Incorrect datetime value: '0000-00-00 00:00:00'", + ), + array( + 'sql' => "INSERT INTO temporal_strict (id, ts, payload) VALUES (2, '2020-01-00 00:00:00', 'insert-zero-in-timestamp')", + 'needle' => "Incorrect timestamp value: '2020-01-00 00:00:00'", + ), + array( + 'sql' => "INSERT IGNORE INTO temporal_strict (id, d, payload) VALUES (2, 'bad', 'ignore-bad-date')", + 'needle' => "Incorrect date value: 'bad'", + ), + array( + 'sql' => "INSERT INTO temporal_strict SET id = 2, dt = 'bad', payload = 'set-bad-datetime'", + 'needle' => "Incorrect datetime value: 'bad'", + ), + array( + 'sql' => "INSERT INTO temporal_strict SET id = 2, d = '0000-00-00', payload = 'set-zero-date'", + 'needle' => "Incorrect date value: '0000-00-00'", + ), + array( + 'sql' => "REPLACE INTO temporal_strict (id, d, payload) VALUES (1, 'bad', 'replace-bad-date')", + 'needle' => "Incorrect date value: 'bad'", + ), + array( + 'sql' => "UPDATE temporal_strict SET ts = 'bad' WHERE id = 1", + 'needle' => "Incorrect timestamp value: 'bad'", + ), + array( + 'sql' => 'UPDATE temporal_strict t + JOIN temporal_strict_source s ON s.id = t.id + SET t.dt = s.dt_text + WHERE t.id = 1', + 'needle' => "Incorrect datetime value: 'bad'", + ), + array( + 'sql' => "INSERT INTO temporal_strict (id, d, payload) VALUES (2, 'bad', 'odku-insert') + ON DUPLICATE KEY UPDATE payload = 'unexpected'", + 'needle' => "Incorrect date value: 'bad'", + ), + array( + 'sql' => "INSERT INTO temporal_strict (id, d, dt, payload) VALUES (2, '2025-01-02', '2025-01-02', 'original') + ON DUPLICATE KEY UPDATE dt = 'bad'", + 'needle' => "Incorrect datetime value: 'bad'", + ), + ) as $case + ) { + $this->assert_temporal_error_leaves_rows( $case['sql'], $case['needle'], $stable_selects ); + } + } + + public function test_temporal_zero_date_sql_modes_match_sqlite(): void { + $this->create_temporal_write_table( 'temporal_modes_default' ); + $this->runParitySetup( + array( + "INSERT INTO temporal_modes_default (id, d, tm, dt, ts, payload) VALUES + (1, '2025-01-01', '18:30:00', '2025-01-01', '2025-01-01', 'stable')", + ) + ); + $this->assert_temporal_error_leaves_rows( + "INSERT INTO temporal_modes_default (id, d, payload) VALUES (2, '0000-00-00', 'default-zero-date')", + "Incorrect date value: '0000-00-00'", + array( $this->temporal_select_sql( 'temporal_modes_default' ) ) + ); + $this->assert_temporal_error_leaves_rows( + "UPDATE temporal_modes_default SET dt = '2020-00-15 00:00:00' WHERE id = 1", + "Incorrect datetime value: '2020-00-15 00:00:00'", + array( $this->temporal_select_sql( 'temporal_modes_default' ) ) + ); + + $this->assertParityRowCount( "SET sql_mode = 'STRICT_TRANS_TABLES'" ); + $this->create_temporal_write_table( 'temporal_modes_strict' ); + $this->assertParityRowCount( + "INSERT INTO temporal_modes_strict (id, d, tm, dt, ts, payload) VALUES + (1, '0000-00-00', '18:30:00', '0000-00-00 00:00:00', '2020-01-00 00:00:00', 'strict-insert'), + (2, '2025-01-01', '18:30:00', '2025-01-01', '2025-01-01', 'strict-update')" + ); + $this->assertParityRowCount( + "UPDATE temporal_modes_strict + SET d = '2020-00-15', dt = '2020-01-00 00:00:00', ts = '0000-00-00 00:00:00' + WHERE id = 2" + ); + $this->assertParityRows( $this->temporal_select_sql( 'temporal_modes_strict' ) ); + + $this->assertParityRowCount( "SET sql_mode = 'NO_ZERO_DATE'" ); + $this->create_temporal_write_table( 'temporal_modes_no_zero_date' ); + $this->assertParityRowCount( + "INSERT INTO temporal_modes_no_zero_date (id, d, tm, dt, ts, payload) VALUES + (1, '0000-00-00', '18:30:00', '0000-00-00 00:00:00', '0000-00-00 00:00:00', 'no-zero-date-insert'), + (2, '2025-01-01', '18:30:00', '2025-01-01', '2025-01-01', 'no-zero-date-update')" + ); + $this->assertParityRowCount( + "UPDATE temporal_modes_no_zero_date + SET d = '0000-00-00', dt = '0000-00-00 00:00:00', ts = '0000-00-00 00:00:00' + WHERE id = 2" + ); + $this->assertParityRows( $this->temporal_select_sql( 'temporal_modes_no_zero_date' ) ); + + $this->assertParityRowCount( "SET sql_mode = 'NO_ZERO_IN_DATE'" ); + $this->create_temporal_write_table( 'temporal_modes_no_zero_in_date' ); + $this->assertParityRowCount( + "INSERT INTO temporal_modes_no_zero_in_date (id, d, tm, dt, ts, payload) VALUES + (1, '2020-00-15', '18:30:00', '2020-01-00 00:00:00', '2020-00-15 00:00:00', 'no-zero-in-date-insert'), + (2, '2025-01-01', '18:30:00', '2025-01-01', '2025-01-01', 'no-zero-in-date-update')" + ); + $this->assertParityRowCount( + "UPDATE temporal_modes_no_zero_in_date + SET d = '2020-01-00', dt = '2020-00-15 00:00:00', ts = '2020-01-00 00:00:00' + WHERE id = 2" + ); + $this->assertParityRows( $this->temporal_select_sql( 'temporal_modes_no_zero_in_date' ) ); + + $this->assertParityRowCount( "SET sql_mode = ''" ); + $this->create_temporal_write_table( 'temporal_modes_empty' ); + $this->assertParityRowCount( + "INSERT INTO temporal_modes_empty (id, d, tm, dt, ts, payload) VALUES + (1, '0000-00-00', '18:30:00', '2020-00-15 00:00:00', '2020-01-00 00:00:00', 'empty-mode-insert'), + (2, '2025-01-01', '18:30:00', '2025-01-01', '2025-01-01', 'empty-mode-update')" + ); + $this->assertParityRowCount( + "UPDATE temporal_modes_empty + SET d = '2020-00-15', dt = '0000-00-00 00:00:00', ts = '2020-01-00 00:00:00' + WHERE id = 2" + ); + $this->assertParityRows( $this->temporal_select_sql( 'temporal_modes_empty' ) ); + } + + public function test_temporal_non_strict_implicit_defaults_match_sqlite(): void { + $this->assertParityRowCount( "SET SESSION sql_mode = ''" ); + $this->create_temporal_write_table( + 'temporal_non_strict', + 'NOT NULL', + array( + 'UNIQUE KEY payload_unique (payload)', + ) + ); + + $this->assertParityRowCount( + "INSERT INTO temporal_non_strict (id, d, tm, dt, ts, payload) VALUES + (1, 'bad', '18:30:00', 'bad', 'bad', 'invalid-strings'), + (2, TRUE, '18:30:00.123456', FALSE, 0, 'scalars')" + ); + $this->assertParityRowCount( + "INSERT INTO temporal_non_strict SET + id = 3, + d = 'bad', + tm = '18:30:00', + dt = FALSE, + ts = TRUE, + payload = 'insert-set'" + ); + $this->assertParityRowCount( + "INSERT INTO temporal_non_strict (id, d, tm, dt, ts, payload) VALUES + (4, '2025-02-01', '18:30:00', '2025-02-01', '2025-02-01', 'normal-update')" + ); + $this->assertParityRowCount( 'UPDATE temporal_non_strict SET d = NULL, dt = NULL, ts = NULL WHERE id = 4' ); + + $this->assertParityRowCount( + "INSERT INTO temporal_non_strict (id, d, tm, dt, ts, payload) VALUES + (5, '2025-03-01', '18:30:00', '2025-03-01', '2025-03-01', 'odku-target')" + ); + $this->assertParityRowCount( + "INSERT INTO temporal_non_strict (id, d, tm, dt, ts, payload) VALUES + (6, '2025-03-02', '18:30:00', '2025-03-02', '2025-03-02', 'odku-target') + ON DUPLICATE KEY UPDATE d = 'bad', dt = TRUE, ts = 0" + ); + $this->assert_temporal_error_leaves_rows( + "INSERT INTO temporal_non_strict (id, d, tm, dt, ts, payload) VALUES + (7, '2025-03-03', '18:30:00', '2025-03-03', '2025-03-03', 'odku-target') + ON DUPLICATE KEY UPDATE d = NULL", + 'NOT NULL', + array( $this->temporal_select_sql( 'temporal_non_strict' ) ) + ); + + $this->assertParityRows( $this->temporal_select_sql( 'temporal_non_strict' ) ); + } + + public function test_temporal_replace_and_odku_conflict_values_are_coerced(): void { + $this->runParitySetup( + array( + 'CREATE TABLE temporal_replace_conflict ( + id INT, + d DATE NOT NULL, + dt DATETIME NULL, + ts TIMESTAMP NULL, + payload VARCHAR(40), + UNIQUE KEY d_unique (d) + )', + "INSERT INTO temporal_replace_conflict (id, d, dt, ts, payload) VALUES + (1, '2025-10-23', '2025-10-23 09:00:00', '2025-10-23 09:00:00', 'original')", + ) + ); + $this->assertParityRowCount( + "REPLACE INTO temporal_replace_conflict (id, d, dt, ts, payload) VALUES + (2, '2025-10-23 18:30:00', '2025-10-24', '2025-10-24 01:02:03.123456', 'replaced')" + ); + $this->assertParityRows( 'SELECT id, d, dt, ts, payload FROM temporal_replace_conflict ORDER BY id' ); + + $this->runParitySetup( + array( + 'CREATE TABLE temporal_replace_error ( + id INT, + d DATE NOT NULL, + payload VARCHAR(40) UNIQUE + )', + "INSERT INTO temporal_replace_error (id, d, payload) VALUES (1, '2025-10-23', 'stable')", + ) + ); + $this->assert_temporal_error_leaves_rows( + "REPLACE INTO temporal_replace_error (id, d, payload) VALUES (2, 'bad', 'stable')", + "Incorrect date value: 'bad'", + array( 'SELECT id, d, payload FROM temporal_replace_error ORDER BY id' ) + ); + + $this->runParitySetup( + array( + 'CREATE TABLE temporal_odku_conflict ( + id INT PRIMARY KEY, + d DATE NOT NULL, + dt DATETIME NULL, + ts TIMESTAMP NULL, + payload VARCHAR(40), + UNIQUE KEY d_unique (d) + )', + "INSERT INTO temporal_odku_conflict (id, d, dt, ts, payload) VALUES + (1, '2025-10-23', '2025-10-23 09:00:00', '2025-10-23 09:00:00', 'original')", + ) + ); + $this->assertParityRowCount( + "INSERT INTO temporal_odku_conflict (id, d, dt, ts, payload) VALUES + (2, '2025-10-23 18:30:00', '2025-10-24', '2025-10-24 01:02:03.123456', 'incoming') + ON DUPLICATE KEY UPDATE d = VALUES(d), dt = VALUES(dt), ts = VALUES(ts), payload = 'updated'" + ); + $this->assertParityRows( 'SELECT id, d, dt, ts, payload FROM temporal_odku_conflict ORDER BY id' ); + $this->assert_temporal_error_leaves_rows( + "INSERT INTO temporal_odku_conflict (id, d, dt, ts, payload) VALUES + (3, '2025-10-23', '2025-10-26', '2025-10-26', 'bad-update') + ON DUPLICATE KEY UPDATE ts = 'bad'", + "Incorrect timestamp value: 'bad'", + array( 'SELECT id, d, dt, ts, payload FROM temporal_odku_conflict ORDER BY id' ) + ); + } + + public function test_temporal_odku_date_unique_conflict_uses_coerced_insert_value(): void { + $this->runParitySetup( + array( + 'CREATE TABLE temporal_odku_date_conflict ( + id INT PRIMARY KEY, + d DATE UNIQUE, + payload VARCHAR(20) + )', + "INSERT INTO temporal_odku_date_conflict (id, d, payload) VALUES (1, '2025-10-23', 'old')", + ) + ); + + $this->assertParityRowCount( + "INSERT INTO temporal_odku_date_conflict (id, d, payload) VALUES (2, '2025-10-23 18:30:00', 'new') + ON DUPLICATE KEY UPDATE payload = VALUES(payload)" + ); + $this->assertParityRows( 'SELECT id, d, payload FROM temporal_odku_date_conflict ORDER BY id' ); + } + + public function test_temporal_replace_manual_conflicts_use_storage_coerced_values(): void { + $this->runParitySetup( + array( + 'CREATE TABLE temporal_replace_manual_conflict ( + id INT UNIQUE, + d DATE UNIQUE, + payload VARCHAR(40) + )', + "INSERT INTO temporal_replace_manual_conflict (id, d, payload) VALUES + (1, '2025-10-23', 'old-date'), + (2, '2025-10-24', 'old-id')", + ) + ); + + $this->assertParityRowCount( + "REPLACE INTO temporal_replace_manual_conflict (id, d, payload) VALUES + (2, '2025-10-23 18:30:00', 'new')" + ); + $this->assertParityRows( 'SELECT id, d, payload FROM temporal_replace_manual_conflict ORDER BY id' ); + } + + public function test_temporal_replace_manual_strict_error_preserves_caller_transaction(): void { + $this->runParitySetup( + array( + 'CREATE TABLE temporal_replace_manual_tx ( + id INT UNIQUE, + d DATE, + payload VARCHAR(40), + UNIQUE KEY payload_key (payload) + )', + "INSERT INTO temporal_replace_manual_tx (id, d, payload) VALUES (1, '2025-10-23', 'old')", + 'START TRANSACTION', + ) + ); + + $this->assert_temporal_error_leaves_rows( + "REPLACE INTO temporal_replace_manual_tx (id, d, payload) VALUES (1, 'not-a-date', 'new')", + "Incorrect date value: 'not-a-date'", + array( 'SELECT id, d, payload FROM temporal_replace_manual_tx ORDER BY id' ) + ); + $this->runParitySetup( array( 'ROLLBACK' ) ); + } + + public function test_temporal_joined_update_writes_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE temporal_join_posts ( + id INT, + d DATE NULL, + tm TIME NULL, + dt DATETIME NULL, + ts TIMESTAMP NULL, + payload VARCHAR(40) + )', + 'CREATE TABLE temporal_join_updates ( + post_id INT, + d_text VARCHAR(40), + tm_text VARCHAR(40), + dt_text VARCHAR(40), + ts_text VARCHAR(40), + flag VARCHAR(20) + )', + "INSERT INTO temporal_join_posts (id, d, tm, dt, ts, payload) VALUES + (1, '2025-01-01', '18:30:00', '2025-01-01', '2025-01-01', 'target-first-old'), + (2, '2025-01-02', '18:30:00', '2025-01-02', '2025-01-02', 'comma-old'), + (3, '2025-01-03', '18:30:00', '2025-01-03', '2025-01-03', 'non-first-old'), + (4, '2025-01-04', '18:30:00', '2025-01-04', '2025-01-04', 'invalid-old'), + (5, '2025-01-05', '18:30:00', '2025-01-05', '2025-01-05', 'derived-old')", + "INSERT INTO temporal_join_updates VALUES + (1, '2025-10-23 18:30:00.123456', '18:30:00.123456', '2025-10-23', '2025-10-23 18:30:00.123456', 'target-first'), + (2, '2025-10-24', '18:30:00', '2025-10-24', '2025-10-24 01:02:03.123456', 'comma'), + (3, '2025-10-25 09:08:07.123456', '18:30:00', '2025-10-25', '2025-10-25', 'non-first'), + (4, 'bad', '18:30:00', 'bad', 'bad', 'invalid'), + (5, '2025-10-26', '18:30:00', '2025-10-26 04:05:06.123456', '2025-10-26', 'derived')", + ) + ); + + $this->assertParityRowCount( + "UPDATE temporal_join_posts p + JOIN temporal_join_updates u ON u.post_id = p.id + SET p.d = u.d_text, p.tm = u.tm_text, p.dt = u.dt_text, p.ts = u.ts_text, p.payload = 'target-first' + WHERE u.flag = 'target-first'" + ); + $this->assertParityRowCount( + "UPDATE temporal_join_posts p, temporal_join_updates u + SET p.dt = u.dt_text, p.ts = u.ts_text, p.payload = 'comma' + WHERE p.id = u.post_id AND u.flag = 'comma'" + ); + $this->assertParityRowCount( + "UPDATE temporal_join_updates u + JOIN temporal_join_posts p ON p.id = u.post_id + SET p.d = u.d_text, p.payload = 'non-first' + WHERE u.flag = 'non-first'" + ); + $this->assertParityRowCount( + "UPDATE temporal_join_posts p + JOIN ( + SELECT post_id, dt_text + FROM temporal_join_updates + WHERE flag = 'derived' + ) u ON u.post_id = p.id + SET p.dt = u.dt_text, p.payload = 'derived'" + ); + $this->assertParityRows( $this->temporal_join_posts_select_sql() ); + + $stable_selects = array( + $this->temporal_join_posts_select_sql(), + 'SELECT post_id, d_text, tm_text, dt_text, ts_text, flag FROM temporal_join_updates ORDER BY post_id', + ); + $this->assert_temporal_error_leaves_rows( + "UPDATE temporal_join_posts p + JOIN temporal_join_updates u ON u.post_id = p.id + SET p.dt = u.dt_text + WHERE u.flag = 'invalid'", + "Incorrect datetime value: 'bad'", + $stable_selects + ); + $this->assert_temporal_error_leaves_rows( + "UPDATE temporal_join_posts p + JOIN temporal_join_updates u ON u.post_id = p.id + SET p.dt = '2025-12-01', u.dt_text = '2025-12-01' + WHERE p.id = 1", + 'UPDATE statement modifying multiple tables', + $stable_selects + ); + } + public function test_case_insensitive_unique_conflicts_match_sqlite(): void { $this->runParitySetup( array( @@ -2533,6 +2992,43 @@ public function test_alter_table_drop_primary_key_temporary_shadow_matches_sqlit $this->assertParityRows( 'SHOW CREATE TABLE ddl_drop_pk_shadow' ); } + private function create_temporal_write_table( string $table_name, string $nullability = 'NULL', array $extra_definitions = array() ): void { + $definitions = array_merge( + array( + 'id INT PRIMARY KEY', + 'd DATE ' . $nullability, + 'tm TIME ' . $nullability, + 'dt DATETIME ' . $nullability, + 'ts TIMESTAMP ' . $nullability, + 'payload VARCHAR(40)', + ), + $extra_definitions + ); + + $this->runParitySetup( + array( + 'CREATE TABLE ' . $table_name . " (\n\t" + . implode( ",\n\t", $definitions ) + . "\n)", + ) + ); + } + + private function temporal_select_sql( string $table_name ): string { + return 'SELECT id, d, tm, dt, ts, payload FROM ' . $table_name . ' ORDER BY id'; + } + + private function temporal_join_posts_select_sql(): string { + return 'SELECT id, d, tm, dt, ts, payload FROM temporal_join_posts ORDER BY id'; + } + + private function assert_temporal_error_leaves_rows( string $sql, string $needle, array $select_queries ): void { + $this->assertParityErrorContains( $sql, $needle ); + foreach ( $select_queries as $select_query ) { + $this->assertParityRows( $select_query ); + } + } + private function createJoinedUpdateGapDrivers(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid $sqlite_driver = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index a41db4e72..1ba53f32d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -933,6 +933,52 @@ public function test_savepoint_sql_is_rejected_without_mutating_active_transacti } } + public function test_savepoint_sql_is_rejected_without_starting_transaction_or_mutating_table(): void { + $this->requireDuckDBRuntime(); + + $cases = array( + array( + 'sql' => 'SAVEPOINT sp1', + 'message' => 'Unsupported DuckDB MySQL-emulation statement: SAVEPOINT.', + ), + array( + 'sql' => 'ROLLBACK TO sp1', + 'message' => 'Unsupported ROLLBACK statement in DuckDB driver. Only ROLLBACK [WORK] is supported.', + ), + array( + 'sql' => 'ROLLBACK TO SAVEPOINT sp1', + 'message' => 'Unsupported ROLLBACK statement in DuckDB driver. Only ROLLBACK [WORK] is supported.', + ), + array( + 'sql' => 'ROLLBACK WORK TO sp1', + 'message' => 'Unsupported ROLLBACK statement in DuckDB driver. Only ROLLBACK [WORK] is supported.', + ), + array( + 'sql' => 'ROLLBACK WORK TO SAVEPOINT sp1', + 'message' => 'Unsupported ROLLBACK statement in DuckDB driver. Only ROLLBACK [WORK] is supported.', + ), + array( + 'sql' => 'RELEASE SAVEPOINT sp1', + 'message' => 'Unsupported DuckDB MySQL-emulation statement: RELEASE.', + ), + ); + + foreach ( $cases as $case ) { + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE savepoint_reject_state (id INT)' ); + $driver->query( 'INSERT INTO savepoint_reject_state (id) VALUES (1)' ); + + $this->assertFalse( $driver->get_connection()->inTransaction() ); + $this->assertDriverQueryRejected( $driver, $case['sql'], $case['message'] ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + $this->assertSame( + array( array( 'id' => 1 ) ), + $driver->query( 'SELECT id FROM savepoint_reject_state ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + } + public function test_session_boolean_variables_are_emulated(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Runtime_Gate_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Runtime_Gate_Tests.php index 0db9f52fd..e98c5b92c 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Runtime_Gate_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Runtime_Gate_Tests.php @@ -4,6 +4,17 @@ * @group duckdb-runtime */ class WP_DuckDB_Runtime_Gate_Tests extends PHPUnit\Framework\TestCase { + public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); + + foreach ( array( 'WP_DUCKDB_AUTOLOAD', 'DUCKDB_PHP_AUTOLOAD' ) as $environment_key ) { + $autoload = getenv( $environment_key ); + if ( is_string( $autoload ) && '' !== $autoload && file_exists( $autoload ) ) { + require_once $autoload; + } + } + } + public function test_runtime_gate_does_not_require_duckdb_for_default_sqlite_path(): void { $this->assertTrue( class_exists( WP_SQLite_Connection::class ) ); $this->assertTrue( class_exists( WP_DuckDB_Runtime::class ) ); @@ -33,4 +44,69 @@ public function test_connection_constructor_throws_clear_exception_when_runtime_ $this->expectException( WP_DuckDB_Driver_Exception::class ); new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); } + + public function test_native_duckdb_rejects_savepoints_without_corrupting_transaction(): void { + $this->requireDuckDBRuntime(); + + $client_class = WP_DuckDB_Runtime::CLIENT_CLASS; + $cases = array( + 'SAVEPOINT sp1', + 'ROLLBACK TO sp1', + 'ROLLBACK TO SAVEPOINT sp1', + 'ROLLBACK WORK TO sp1', + 'ROLLBACK WORK TO SAVEPOINT sp1', + 'RELEASE SAVEPOINT sp1', + ); + + foreach ( $cases as $sql ) { + $duckdb = $client_class::create(); + $duckdb->query( 'CREATE TABLE native_savepoint_state (id INTEGER)' ); + $duckdb->query( 'BEGIN TRANSACTION' ); + $duckdb->query( 'INSERT INTO native_savepoint_state VALUES (1)' ); + + try { + $duckdb->query( $sql ); + $this->fail( 'Expected native DuckDB to reject SQL: ' . $sql ); + } catch ( Throwable $e ) { + $message = $e->getMessage(); + $this->assertTrue( + false !== stripos( $message, 'syntax error' ) || false !== stripos( $message, 'Parser Error' ), + 'Unexpected native DuckDB savepoint error for ' . $sql . ': ' . $message + ); + } + + $this->assertSame( + array( array( 'id' => 1 ) ), + $this->fetchNativeIdRows( $duckdb->query( 'SELECT id FROM native_savepoint_state ORDER BY id' ) ) + ); + $duckdb->query( 'INSERT INTO native_savepoint_state VALUES (2)' ); + $duckdb->query( 'COMMIT' ); + $this->assertSame( + array( + array( 'id' => 1 ), + array( 'id' => 2 ), + ), + $this->fetchNativeIdRows( $duckdb->query( 'SELECT id FROM native_savepoint_state ORDER BY id' ) ) + ); + } + } + + private function requireDuckDBRuntime(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + if ( '1' !== getenv( 'WP_DUCKDB_TESTS' ) ) { + $this->markTestSkipped( 'DuckDB tests require WP_DUCKDB_TESTS=1.' ); + } + + $reason = WP_DuckDB_Runtime::get_unavailable_reason( true ); + if ( null !== $reason ) { + $this->markTestSkipped( $reason ); + } + } + + private function fetchNativeIdRows( $result ): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $rows = array(); + foreach ( $result->rows( true ) as $row ) { + $rows[] = array( 'id' => (int) $row['id'] ); + } + return $rows; + } } From 9793f97b5708d814343d3648a8de4086fa31250a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 05:43:14 +0000 Subject: [PATCH 070/317] Normalize DuckDB SQL mode parsing --- .../src/duckdb/class-wp-duckdb-driver.php | 41 +++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 82 +++++++++++++++++++ 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index a3544ea19..0df733f77 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -50,6 +50,17 @@ class WP_DuckDB_Driver { 'version_comment' => true, ); + const SQL_MODE_ALIASES = array( + 'TRADITIONAL' => array( + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'NO_ZERO_IN_DATE', + 'NO_ZERO_DATE', + 'ERROR_FOR_DIVISION_BY_ZERO', + 'NO_ENGINE_SUBSTITUTION', + ), + ); + const DATA_TYPE_MAP = array( WP_MySQL_Lexer::BOOL_SYMBOL => 'BOOLEAN', WP_MySQL_Lexer::BOOLEAN_SYMBOL => 'BOOLEAN', @@ -3527,7 +3538,7 @@ private function execute_set_session_system_variable_assignment( array $tokens, $value = $this->normalize_set_session_system_variable_value_tokens( $name, array_slice( $tokens, $index ) ); if ( 'sql_mode' === $name ) { - $this->active_sql_modes = '' === $value ? array() : explode( ',', (string) $value ); + $this->active_sql_modes = $this->normalize_sql_modes( (string) $value ); } else { $this->session_system_variables[ $name ] = $value; } @@ -3777,7 +3788,7 @@ private function normalize_set_sql_mode_value( WP_Parser_Token $token ): string } if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { - return strtoupper( $token->get_value() ); + return implode( ',', $this->normalize_sql_modes( $token->get_value() ) ); } if ( 'default' === strtolower( $token->get_value() ) ) { @@ -3785,12 +3796,36 @@ private function normalize_set_sql_mode_value( WP_Parser_Token $token ): string } if ( ! $this->is_non_identifier_token( $token ) ) { - return strtoupper( $token->get_value() ); + return implode( ',', $this->normalize_sql_modes( $token->get_value() ) ); } throw $this->new_unsupported_set_session_system_variable_value_exception( 'sql_mode' ); } + /** + * Normalize a comma-separated SQL mode string. + * + * @param string $value SQL mode value. + * @return string[] Normalized modes. + */ + private function normalize_sql_modes( string $value ): array { + $modes = array(); + foreach ( explode( ',', strtoupper( $value ) ) as $mode ) { + $mode = trim( $mode ); + if ( '' === $mode ) { + continue; + } + + foreach ( self::SQL_MODE_ALIASES[ $mode ] ?? array( $mode ) as $expanded_mode ) { + if ( ! in_array( $expanded_mode, $modes, true ) ) { + $modes[] = $expanded_mode; + } + } + } + + return $modes; + } + /** * Build an unsupported SET value exception. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 2247ea56e..0eae4ea4a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1442,6 +1442,65 @@ public function test_temporal_zero_date_sql_modes_match_sqlite(): void { $this->assertParityRows( $this->temporal_select_sql( 'temporal_modes_empty' ) ); } + public function test_temporal_comma_space_sql_mode_rejects_zero_dates_in_duckdb(): void { + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( "SET SESSION sql_mode = 'STRICT_TRANS_TABLES, NO_ZERO_DATE, NO_ZERO_IN_DATE'" ); + $row = $driver->query( 'SELECT @@SESSION.sql_mode' )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( 'STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE', $row['@@SESSION.sql_mode'] ); + + $this->create_duckdb_temporal_mode_table( $driver, 'temporal_comma_space_modes' ); + $this->assert_duckdb_error_contains( + $driver, + "INSERT INTO temporal_comma_space_modes (id, d, payload) VALUES (1, '0000-00-00', 'zero-date')", + "Incorrect date value: '0000-00-00'" + ); + $this->assert_duckdb_error_contains( + $driver, + "INSERT INTO temporal_comma_space_modes (id, dt, payload) VALUES (1, '2020-00-15 00:00:00', 'zero-in-date')", + "Incorrect datetime value: '2020-00-15 00:00:00'" + ); + + $row = $driver->query( 'SELECT COUNT(*) AS row_count FROM temporal_comma_space_modes' )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( 0, (int) $row['row_count'] ); + } + + public function test_temporal_traditional_sql_mode_rejects_zero_dates_in_duckdb(): void { + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( "SET SESSION sql_mode = 'TRADITIONAL'" ); + $row = $driver->query( 'SELECT @@SESSION.sql_mode' )->fetch( PDO::FETCH_ASSOC ); + $this->assertStringContainsString( 'STRICT_TRANS_TABLES', $row['@@SESSION.sql_mode'] ); + $this->assertStringContainsString( 'STRICT_ALL_TABLES', $row['@@SESSION.sql_mode'] ); + $this->assertStringContainsString( 'NO_ZERO_DATE', $row['@@SESSION.sql_mode'] ); + $this->assertStringContainsString( 'NO_ZERO_IN_DATE', $row['@@SESSION.sql_mode'] ); + + $this->create_duckdb_temporal_mode_table( $driver, 'temporal_traditional_mode' ); + $this->assert_duckdb_error_contains( + $driver, + "INSERT INTO temporal_traditional_mode (id, d, payload) VALUES (1, '0000-00-00', 'zero-date')", + "Incorrect date value: '0000-00-00'" + ); + $this->assert_duckdb_error_contains( + $driver, + "INSERT INTO temporal_traditional_mode (id, ts, payload) VALUES (1, '2020-01-00 00:00:00', 'zero-in-date')", + "Incorrect timestamp value: '2020-01-00 00:00:00'" + ); + + $row = $driver->query( 'SELECT COUNT(*) AS row_count FROM temporal_traditional_mode' )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( 0, (int) $row['row_count'] ); + } + public function test_temporal_non_strict_implicit_defaults_match_sqlite(): void { $this->assertParityRowCount( "SET SESSION sql_mode = ''" ); $this->create_temporal_write_table( @@ -3029,6 +3088,29 @@ private function assert_temporal_error_leaves_rows( string $sql, string $needle, } } + private function create_duckdb_temporal_mode_table( WP_DuckDB_Driver $driver, string $table_name ): void { + $driver->query( + 'CREATE TABLE ' . $table_name . ' ( + id INT PRIMARY KEY, + d DATE NULL, + dt DATETIME NULL, + ts TIMESTAMP NULL, + payload VARCHAR(40) + )' + ); + } + + private function assert_duckdb_error_contains( WP_DuckDB_Driver $driver, string $sql, string $needle ): void { + try { + $driver->query( $sql ); + } catch ( Throwable $e ) { + $this->assertStringContainsString( $needle, $e->getMessage() ); + return; + } + + $this->fail( 'DuckDB query should have failed for SQL: ' . $sql ); + } + private function createJoinedUpdateGapDrivers(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid $sqlite_driver = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), From a362d955b9fe188bf2db0f8bb0f181001732f6d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 05:47:36 +0000 Subject: [PATCH 071/317] Support qualified DuckDB ODKU targets --- .../src/duckdb/class-wp-duckdb-driver.php | 67 ++++++++++++++----- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 18 +++++ 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 0df733f77..eedcd1433 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -11926,7 +11926,9 @@ private function translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( $target = $this->select_on_duplicate_conflict_target( $insert_shape['table_name'], $insert_shape['values_by_column'] ); $update_sql = $this->translate_on_duplicate_update_tokens_to_duckdb_sql( array_slice( $tokens, $on_duplicate_index + 4 ), - $this->write_column_metadata_map( $insert_shape['table_name'], $insert_shape['temporary'] ) + $this->write_column_metadata_map( $insert_shape['table_name'], $insert_shape['temporary'] ), + $insert_shape['table_name'], + $insert_shape['requested_table_name'] ); if ( '' === $update_sql ) { @@ -11954,7 +11956,7 @@ function ( string $column_name ): string { * @param WP_Parser_Token[] $tokens MySQL tokens. * @param int $table_index Index of the table token. * @param int $on_duplicate_index Index of the ON token. - * @return array{table_name:string,temporary:bool,values_by_column:array} + * @return array{table_name:string,requested_table_name:string,temporary:bool,values_by_column:array} */ private function parse_on_duplicate_insert_shape( array $tokens, int $table_index, int $on_duplicate_index ): array { $requested_table_name = $this->identifier_value( $tokens[ $table_index ] ?? null ); @@ -12010,9 +12012,10 @@ private function parse_on_duplicate_insert_shape( array $tokens, int $table_inde } return array( - 'table_name' => $reference['table_name'], - 'temporary' => $reference['temporary'], - 'values_by_column' => $values_by_column, + 'table_name' => $reference['table_name'], + 'requested_table_name' => $requested_table_name, + 'temporary' => $reference['temporary'], + 'values_by_column' => $values_by_column, ); } @@ -12825,11 +12828,18 @@ private function case_insensitive_column_names( string $table_name ): array { /** * Translate an ODKU update list, rewriting MySQL VALUES(col) references. * - * @param WP_Parser_Token[] $tokens Update-list tokens after ON DUPLICATE KEY UPDATE. - * @param array> $metadata_map Target column metadata keyed by lowercase column name. + * @param WP_Parser_Token[] $tokens Update-list tokens after ON DUPLICATE KEY UPDATE. + * @param array> $metadata_map Target column metadata keyed by lowercase column name. + * @param string $target_table_name Resolved target table name. + * @param string $requested_table_name Requested target table name. * @return string DuckDB SQL. */ - private function translate_on_duplicate_update_tokens_to_duckdb_sql( array $tokens, array $metadata_map ): string { + private function translate_on_duplicate_update_tokens_to_duckdb_sql( + array $tokens, + array $metadata_map, + string $target_table_name, + string $requested_table_name + ): string { $items = array(); foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { $equals_index = $this->find_top_level_token_index( $item, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); @@ -12843,8 +12853,9 @@ private function translate_on_duplicate_update_tokens_to_duckdb_sql( array $toke throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. UPDATE assignment value is required.' ); } - $column_name = $this->assignment_column_name( $left_tokens ); + $target = $this->on_duplicate_assignment_target( $left_tokens, $target_table_name, $requested_table_name ); $value_sql = $this->translate_on_duplicate_value_tokens_to_duckdb_sql( $right_tokens ); + $column_name = $target['column_name']; if ( isset( $metadata_map[ strtolower( $column_name ) ] ) ) { $value_sql = $this->coerce_write_value_for_column_sql( $metadata_map[ strtolower( $column_name ) ], @@ -12854,28 +12865,52 @@ private function translate_on_duplicate_update_tokens_to_duckdb_sql( array $toke ); } - $items[] = $this->translate_tokens_to_duckdb_sql( $left_tokens ) . ' = ' . $value_sql; + $items[] = $target['sql'] . ' = ' . $value_sql; } return implode( ', ', $items ); } /** - * Resolve an assignment target column from unqualified or qualified LHS tokens. + * Resolve an ODKU assignment target and strip target-table qualifiers. * - * @param WP_Parser_Token[] $left_tokens Assignment LHS tokens. - * @return string Column name. + * @param WP_Parser_Token[] $left_tokens Assignment LHS tokens. + * @param string $target_table_name Resolved target table name. + * @param string $requested_table_name Requested target table name. + * @return array{column_name:string,sql:string} Column name and unqualified DuckDB target SQL. */ - private function assignment_column_name( array $left_tokens ): string { + private function on_duplicate_assignment_target( + array $left_tokens, + string $target_table_name, + string $requested_table_name + ): array { if ( 1 === count( $left_tokens ) ) { - return $this->identifier_value( $left_tokens[0] ); + $column_name = $this->identifier_value( $left_tokens[0] ); + + return array( + 'column_name' => $column_name, + 'sql' => $this->translate_tokens_to_duckdb_sql( $left_tokens ), + ); } if ( 3 === count( $left_tokens ) && WP_MySQL_Lexer::DOT_SYMBOL === $left_tokens[1]->id ) { - return $this->identifier_value( $left_tokens[2] ); + $qualifier = $this->identifier_value( $left_tokens[0] ); + if ( + 0 !== strcasecmp( $qualifier, $target_table_name ) + && 0 !== strcasecmp( $qualifier, $requested_table_name ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Only target table-qualified assignments are supported.' ); + } + + $column_name = $this->identifier_value( $left_tokens[2] ); + + return array( + 'column_name' => $column_name, + 'sql' => $this->connection->quote_identifier( $column_name ), + ); } throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Only simple column assignments are supported.' ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 0eae4ea4a..3fa4a00a0 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1245,6 +1245,24 @@ public function test_on_duplicate_key_update_values_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_on_duplicate_key_update_qualified_target_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t ( + id INTEGER PRIMARY KEY, + d VARCHAR(100) NOT NULL DEFAULT \'\' + )', + "INSERT INTO t (id, d) VALUES (1, 'old')", + ) + ); + + $this->assertParityRowCount( + "INSERT INTO t (id, d) VALUES (1, 'new') + ON DUPLICATE KEY UPDATE t.d = VALUES(d)" + ); + $this->assertParityRows( 'SELECT id, d FROM t ORDER BY id' ); + } + public function test_temporal_insert_values_and_set_writes_match_sqlite(): void { $this->create_temporal_write_table( 'temporal_writes' ); From 5d8f7c38ff53ba658e88337b17490ec7527a8740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 05:56:35 +0000 Subject: [PATCH 072/317] Add DuckDB omitted temporal default parity --- .../src/duckdb/class-wp-duckdb-driver.php | 85 ++++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 62 ++++++++++++++ 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index eedcd1433..256d5b0bc 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -11827,9 +11827,11 @@ private function translate_insert_set_tokens_to_duckdb_sql( array $tokens, int $ $metadata_map = $this->write_column_metadata_map( $reference['table_name'], $reference['temporary'] ); $columns = array(); $values = array(); + $supplied = array(); foreach ( $assignments as $assignment ) { $columns[] = $assignment['column_sql']; + $supplied[] = $assignment['column_name']; $value_sql = $assignment['value_sql']; if ( isset( $metadata_map[ strtolower( $assignment['column_name'] ) ] ) ) { $value_sql = $this->coerce_write_value_for_column_sql( @@ -11842,10 +11844,15 @@ private function translate_insert_set_tokens_to_duckdb_sql( array $tokens, int $ $values[] = $value_sql; } + foreach ( $this->omitted_non_strict_temporal_default_writes( $reference['table_name'], $reference['temporary'], $supplied ) as $default_write ) { + $columns[] = $this->connection->quote_identifier( $default_write['column_name'] ); + $values[] = $default_write['value_sql']; + } + return 'INSERT ' . ( $ignore ? 'OR IGNORE ' : '' ) . 'INTO ' - . $this->translate_tokens_to_duckdb_sql( array( $tokens[ $table_index ] ) ) + . $this->connection->quote_identifier( $reference['table_name'] ) . ' (' . implode( ', ', $columns ) . ') VALUES (' @@ -12010,6 +12017,9 @@ private function parse_on_duplicate_insert_shape( array $tokens, int $table_inde } $values_by_column[ strtolower( $column_name ) ] = $value_sql; } + foreach ( $this->omitted_non_strict_temporal_default_writes( $reference['table_name'], $reference['temporary'], $columns ) as $default_write ) { + $values_by_column[ strtolower( $default_write['column_name'] ) ] = $default_write['value_sql']; + } return array( 'table_name' => $reference['table_name'], @@ -12140,6 +12150,14 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde } } + $omitted_defaults = $coerce_for_storage + ? $this->omitted_non_strict_temporal_default_writes( $table_name, $temporary, $columns ) + : array(); + $storage_columns = $columns; + foreach ( $omitted_defaults as $default_write ) { + $storage_columns[] = $default_write['column_name']; + } + if ( count( $columns ) === 0 || ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $index ]->id ) { return array( 'table_name' => $table_name, @@ -12205,6 +12223,10 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde $values_by_column[ strtolower( $column_name ) ] = $value_sql; $ordered_row[] = $value_sql; } + foreach ( $omitted_defaults as $default_write ) { + $values_by_column[ strtolower( $default_write['column_name'] ) ] = $default_write['value_sql']; + $ordered_row[] = $default_write['value_sql']; + } $rows[] = $values_by_column; $ordered_rows[] = $ordered_row; $coerced_value_rows[] = $coerced_values; @@ -12220,12 +12242,12 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde return array( 'table_name' => $table_name, 'temporary' => $temporary, - 'columns' => $columns, + 'columns' => $storage_columns, 'rows' => $rows, 'ordered_rows' => $ordered_rows, 'coerced_value_rows' => $coerced_value_rows, 'temporal_validation_rows' => $temporal_validation_rows, - 'requires_coercion' => $requires_coercion, + 'requires_coercion' => $requires_coercion || count( $omitted_defaults ) > 0, ); } @@ -12265,6 +12287,63 @@ private function write_column_metadata_map( string $table_name, bool $temporary return $map; } + /** + * Return omitted non-strict temporal default writes in table ordinal order. + * + * @param string $table_name Resolved DuckDB table name. + * @param bool $temporary Whether the target is temporary. + * @param string[] $supplied_columns MySQL-facing supplied column names. + * @return array + */ + private function omitted_non_strict_temporal_default_writes( string $table_name, bool $temporary, array $supplied_columns ): array { + if ( $this->is_strict_sql_mode_active() ) { + return array(); + } + + $supplied = array(); + foreach ( $supplied_columns as $column_name ) { + $supplied[ strtolower( $column_name ) ] = true; + } + + $writes = array(); + foreach ( $this->table_column_metadata_rows( $table_name, $temporary ) as $metadata ) { + if ( ! isset( $metadata['column_name'] ) ) { + continue; + } + + $column_name = (string) $metadata['column_name']; + if ( isset( $supplied[ strtolower( $column_name ) ] ) ) { + continue; + } + if ( + ! isset( $metadata['is_nullable'] ) + || 'NO' !== strtoupper( (string) $metadata['is_nullable'] ) + || null !== ( $metadata['column_default'] ?? null ) + || false !== stripos( (string) ( $metadata['extra'] ?? '' ), 'auto_increment' ) + ) { + continue; + } + + $data_type = $this->mysql_column_data_type( $metadata ); + if ( ! $this->is_temporal_write_data_type( $data_type ) ) { + continue; + } + + $implicit_default = $this->temporal_implicit_default( $data_type ); + if ( null === $implicit_default ) { + continue; + } + + $writes[] = array( + 'column_name' => $column_name, + 'value_sql' => $this->connection->quote( $implicit_default ), + 'data_type' => $data_type, + ); + } + + return $writes; + } + /** * Coerce a target-column value for temporal storage when needed. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 3fa4a00a0..bf25b28b6 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1569,6 +1569,48 @@ public function test_temporal_non_strict_implicit_defaults_match_sqlite(): void $this->assertParityRows( $this->temporal_select_sql( 'temporal_non_strict' ) ); } + public function test_temporal_non_strict_omitted_defaults_for_insert_values_and_set_match_sqlite(): void { + $this->create_temporal_write_table( 'temporal_omitted_defaults', 'NOT NULL' ); + + $this->assertParityRowCount( "SET sql_mode = ''" ); + $this->assertParityRowCount( "INSERT INTO temporal_omitted_defaults (id, payload) VALUES (1, 'values')" ); + $this->assertParityRowCount( "INSERT INTO temporal_omitted_defaults SET id = 2, payload = 'set'" ); + $this->assertParityRowCount( "INSERT INTO temporal_omitted_defaults (id, d, payload) VALUES (3, '2025-01-01', 'partial')" ); + + $this->assertParityRows( $this->temporal_select_sql( 'temporal_omitted_defaults' ) ); + } + + public function test_temporal_strict_omitted_defaults_still_fail_at_insert_time(): void { + $this->assertParityRowCount( "SET sql_mode = ''" ); + $this->create_temporal_write_table( 'temporal_omitted_strict_values', 'NOT NULL' ); + $this->create_temporal_write_table( 'temporal_omitted_strict_set', 'NOT NULL' ); + + $this->assertParityRowCount( "SET sql_mode = 'STRICT_TRANS_TABLES'" ); + $this->assertParityErrorContains( + 'INSERT INTO temporal_omitted_strict_values (id) VALUES (1)', + 'NOT NULL' + ); + $this->assertParityErrorContains( + 'INSERT INTO temporal_omitted_strict_set SET id = 1', + 'NOT NULL' + ); + } + + public function test_temporal_full_column_insert_order_is_unchanged_by_omitted_defaults(): void { + $this->assertParityRowCount( "SET sql_mode = ''" ); + $this->runParitySetup( + array( + 'CREATE TABLE temporal_omitted_full_order ( + id INT, + d DATE NOT NULL + )', + ) + ); + + $this->assertParityRowCount( "INSERT INTO temporal_omitted_full_order VALUES (1, '2025-01-01')" ); + $this->assertParityRows( 'SELECT id, d FROM temporal_omitted_full_order ORDER BY id' ); + } + public function test_temporal_replace_and_odku_conflict_values_are_coerced(): void { $this->runParitySetup( array( @@ -1654,6 +1696,26 @@ public function test_temporal_odku_date_unique_conflict_uses_coerced_insert_valu $this->assertParityRows( 'SELECT id, d, payload FROM temporal_odku_date_conflict ORDER BY id' ); } + public function test_temporal_odku_omitted_default_unique_conflict_matches_sqlite(): void { + $this->runParitySetup( + array( + "SET sql_mode = ''", + 'CREATE TABLE temporal_odku_omitted_conflict ( + id INT PRIMARY KEY, + d DATE NOT NULL UNIQUE, + payload VARCHAR(20) + )', + "INSERT INTO temporal_odku_omitted_conflict (id, d, payload) VALUES (1, '0000-00-00', 'old')", + ) + ); + + $this->assertParityRowCount( + "INSERT INTO temporal_odku_omitted_conflict (id, payload) VALUES (2, 'new') + ON DUPLICATE KEY UPDATE payload = VALUES(payload)" + ); + $this->assertParityRows( 'SELECT id, d, payload FROM temporal_odku_omitted_conflict ORDER BY id' ); + } + public function test_temporal_replace_manual_conflicts_use_storage_coerced_values(): void { $this->runParitySetup( array( From 691a6716f75d85071e9aec09e0166b72557faf0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 06:01:41 +0000 Subject: [PATCH 073/317] Use DuckDB temp metadata for conflict checks --- .../src/duckdb/class-wp-duckdb-driver.php | 48 +++++----- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 88 +++++++++++++++++++ 2 files changed, 115 insertions(+), 21 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 256d5b0bc..147062e02 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -11930,7 +11930,7 @@ private function parse_insert_set_assignments( array $tokens ): array { */ private function translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( array $tokens, int $table_index, int $on_duplicate_index ): string { $insert_shape = $this->parse_on_duplicate_insert_shape( $tokens, $table_index, $on_duplicate_index ); - $target = $this->select_on_duplicate_conflict_target( $insert_shape['table_name'], $insert_shape['values_by_column'] ); + $target = $this->select_on_duplicate_conflict_target( $insert_shape['table_name'], $insert_shape['temporary'], $insert_shape['values_by_column'] ); $update_sql = $this->translate_on_duplicate_update_tokens_to_duckdb_sql( array_slice( $tokens, $on_duplicate_index + 4 ), $this->write_column_metadata_map( $insert_shape['table_name'], $insert_shape['temporary'] ), @@ -12040,13 +12040,13 @@ private function parse_on_duplicate_insert_shape( array $tokens, int $table_inde */ private function assert_insert_values_do_not_conflict_with_case_insensitive_unique_keys( array $tokens, int $table_index ): void { $insert_shape = $this->parse_insert_values_shape( $tokens, $table_index, true ); - $case_insensitive_columns = $this->case_insensitive_column_names( $insert_shape['table_name'] ); + $case_insensitive_columns = $this->case_insensitive_column_names( $insert_shape['table_name'], $insert_shape['temporary'] ); if ( count( $case_insensitive_columns ) === 0 ) { return; } foreach ( $insert_shape['rows'] as $values_by_column ) { - foreach ( $this->unique_key_column_sets( $insert_shape['table_name'] ) as $column_set ) { + foreach ( $this->unique_key_column_sets( $insert_shape['table_name'], $insert_shape['temporary'] ) as $column_set ) { $has_all_values = true; $has_case_insensitive_key = false; foreach ( $column_set as $column_name ) { @@ -12064,7 +12064,7 @@ private function assert_insert_values_do_not_conflict_with_case_insensitive_uniq continue; } - if ( $this->insert_values_conflict_with_target( $insert_shape['table_name'], $column_set, $values_by_column ) ) { + if ( $this->insert_values_conflict_with_target( $insert_shape['table_name'], $insert_shape['temporary'], $column_set, $values_by_column ) ) { throw new WP_DuckDB_Driver_Exception( 'Failed to execute DuckDB INSERT: UNIQUE constraint failed: ' . $insert_shape['table_name'] @@ -12082,13 +12082,14 @@ private function assert_insert_values_do_not_conflict_with_case_insensitive_uniq * @param WP_Parser_Token[] $tokens MySQL tokens. * @param int $table_index Index of the table token. * @param bool $coerce_for_storage Whether values should be coerced for storage. - * @return array{table_name:string,rows:array>} + * @return array{table_name:string,temporary:bool,rows:array>} */ private function parse_insert_values_shape( array $tokens, int $table_index, bool $coerce_for_storage = false ): array { $shape = $this->parse_insert_values_write_shape( $tokens, $table_index, null, $coerce_for_storage ); return array( 'table_name' => $shape['table_name'], + 'temporary' => $shape['temporary'], 'rows' => $shape['rows'], ); } @@ -12605,12 +12606,12 @@ private function is_strict_sql_mode_active(): bool { */ private function execute_replace_values_with_manual_conflict_handling( array $tokens, int $table_index ): ?WP_DuckDB_Result_Statement { $replace_shape = $this->parse_insert_values_write_shape( $tokens, $table_index, null, true ); - $case_insensitive_columns = $this->case_insensitive_column_names( $replace_shape['table_name'] ); + $case_insensitive_columns = $this->case_insensitive_column_names( $replace_shape['table_name'], $replace_shape['temporary'] ); if ( count( $replace_shape['rows'] ) === 0 ) { return null; } - $unique_sets = $this->unique_key_column_sets( $replace_shape['table_name'] ); + $unique_sets = $this->unique_key_column_sets( $replace_shape['table_name'], $replace_shape['temporary'] ); if ( count( $unique_sets ) < 2 && ! $this->has_case_insensitive_unique_key( $unique_sets, $case_insensitive_columns ) ) { return null; } @@ -12754,14 +12755,15 @@ private function unique_key_conflict_predicate( array $column_set, array $values * Select the conflict target that MySQL would hit for a single inserted row. * * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. * @param array $values_by_column Inserted values keyed by lowercase column name. * @return string[] Conflict target columns. */ - private function select_on_duplicate_conflict_target( string $table_name, array $values_by_column ): array { + private function select_on_duplicate_conflict_target( string $table_name, bool $temporary, array $values_by_column ): array { $eligible_targets = array(); $matched_targets = array(); - foreach ( $this->unique_key_column_sets( $table_name ) as $column_set ) { + foreach ( $this->unique_key_column_sets( $table_name, $temporary ) as $column_set ) { $has_all_values = true; foreach ( $column_set as $column_name ) { if ( ! array_key_exists( strtolower( $column_name ), $values_by_column ) ) { @@ -12775,7 +12777,7 @@ private function select_on_duplicate_conflict_target( string $table_name, array } $eligible_targets[] = $column_set; - if ( $this->insert_values_conflict_with_target( $table_name, $column_set, $values_by_column ) ) { + if ( $this->insert_values_conflict_with_target( $table_name, $temporary, $column_set, $values_by_column ) ) { $matched_targets[] = $column_set; } } @@ -12798,9 +12800,10 @@ private function select_on_duplicate_conflict_target( string $table_name, array * Read primary and unique secondary key column sets. * * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. * @return array */ - private function unique_key_column_sets( string $table_name ): array { + private function unique_key_column_sets( string $table_name, bool $temporary = false ): array { $sets = array(); $primary = $this->execute_duckdb_query( @@ -12816,10 +12819,10 @@ function ( array $row ): string { ); } - $this->ensure_index_metadata_table(); + $this->ensure_index_metadata_table( $temporary ); $secondary = $this->execute_duckdb_query( 'SELECT index_name, column_name FROM ' - . $this->connection->quote_identifier( self::INDEX_METADATA_TABLE ) + . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) . ' AND non_unique = 0 ORDER BY index_name, seq_in_index', @@ -12843,13 +12846,14 @@ function ( array $row ): string { * Determine whether the inserted row conflicts with a unique target. * * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. * @param string[] $column_set Unique key columns. * @param array $values_by_column Inserted values keyed by lowercase column name. * @return bool Whether an existing row matches the target values. */ - private function insert_values_conflict_with_target( string $table_name, array $column_set, array $values_by_column ): bool { + private function insert_values_conflict_with_target( string $table_name, bool $temporary, array $column_set, array $values_by_column ): bool { $where = array(); - $case_insensitive_columns = $this->case_insensitive_column_names( $table_name ); + $case_insensitive_columns = $this->case_insensitive_column_names( $table_name, $temporary ); foreach ( $column_set as $column_name ) { $value_sql = $values_by_column[ strtolower( $column_name ) ]; if ( isset( $case_insensitive_columns[ strtolower( $column_name ) ] ) ) { @@ -12883,12 +12887,13 @@ private function insert_values_conflict_with_target( string $table_name, array $ * Read case-insensitive MySQL-facing text columns for a table. * * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. * @return array Lowercase column-name map. */ - private function case_insensitive_column_names( string $table_name ): array { + private function case_insensitive_column_names( string $table_name, bool $temporary = false ): array { $columns = array(); - $table_collation = $this->table_default_collation( $table_name ); - foreach ( $this->column_metadata_rows( $table_name ) as $row ) { + $table_collation = $this->table_default_collation( $table_name, $temporary ); + foreach ( $this->column_metadata_rows( $table_name, $temporary ) as $row ) { if ( ! array_key_exists( 'column_name', $row ) || ! array_key_exists( 'collation_name', $row ) ) { continue; } @@ -13996,7 +14001,7 @@ private function execute_auto_increment_write( string $table_name, string $sql, if ( null !== $metadata && null !== $explicit_insert_id - && ! $this->auto_increment_column_has_visible_unique_key( $table_reference['table_name'], $metadata['column_name'] ) + && ! $this->auto_increment_column_has_visible_unique_key( $table_reference['table_name'], $metadata['column_name'], $table_reference['temporary'] ) && $this->auto_increment_value_exists( $table_reference['table_name'], $metadata['column_name'], $explicit_insert_id ) ) { throw new WP_DuckDB_Driver_Exception( 'UNIQUE constraint failed: ' . $table_reference['table_name'] . '.' . $metadata['column_name'] ); @@ -14020,10 +14025,11 @@ private function execute_auto_increment_write( string $table_name, string $sql, * * @param string $table_name Table name. * @param string $column_name AUTO_INCREMENT column name. + * @param bool $temporary Whether the target is a temporary table. * @return bool Whether a visible unique key exists. */ - private function auto_increment_column_has_visible_unique_key( string $table_name, string $column_name ): bool { - foreach ( $this->unique_key_column_sets( $table_name ) as $column_set ) { + private function auto_increment_column_has_visible_unique_key( string $table_name, string $column_name, bool $temporary = false ): bool { + foreach ( $this->unique_key_column_sets( $table_name, $temporary ) as $column_set ) { if ( array( $column_name ) === $column_set ) { return true; } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index bf25b28b6..921fb53c5 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1696,6 +1696,44 @@ public function test_temporal_odku_date_unique_conflict_uses_coerced_insert_valu $this->assertParityRows( 'SELECT id, d, payload FROM temporal_odku_date_conflict ORDER BY id' ); } + public function test_temporary_temporal_odku_date_unique_conflict_uses_coerced_insert_value(): void { + $this->runParitySetup( + array( + 'CREATE TEMPORARY TABLE tmp_temporal_odku_date_conflict ( + id INT, + d DATE UNIQUE, + payload VARCHAR(20) + )', + "INSERT INTO tmp_temporal_odku_date_conflict (id, d, payload) VALUES (1, '2025-10-23', 'old')", + ) + ); + + $this->assertParityRowCount( + "INSERT INTO tmp_temporal_odku_date_conflict (id, d, payload) VALUES (2, '2025-10-23 18:30:00', 'new') + ON DUPLICATE KEY UPDATE payload = VALUES(payload)" + ); + $this->assertParityRows( 'SELECT id, d, payload FROM tmp_temporal_odku_date_conflict ORDER BY id' ); + } + + public function test_temporary_temporal_odku_prefers_secondary_unique_conflict_over_nonconflicting_primary(): void { + $this->runParitySetup( + array( + 'CREATE TEMPORARY TABLE tmp_temporal_odku_secondary_conflict ( + id INT PRIMARY KEY, + d DATE UNIQUE, + payload VARCHAR(20) + )', + "INSERT INTO tmp_temporal_odku_secondary_conflict (id, d, payload) VALUES (1, '2025-10-23', 'old')", + ) + ); + + $this->assertParityRowCount( + "INSERT INTO tmp_temporal_odku_secondary_conflict (id, d, payload) VALUES (2, '2025-10-23 18:30:00', 'new') + ON DUPLICATE KEY UPDATE payload = VALUES(payload)" + ); + $this->assertParityRows( 'SELECT id, d, payload FROM tmp_temporal_odku_secondary_conflict ORDER BY id' ); + } + public function test_temporal_odku_omitted_default_unique_conflict_matches_sqlite(): void { $this->runParitySetup( array( @@ -1737,6 +1775,27 @@ public function test_temporal_replace_manual_conflicts_use_storage_coerced_value $this->assertParityRows( 'SELECT id, d, payload FROM temporal_replace_manual_conflict ORDER BY id' ); } + public function test_temporary_temporal_replace_manual_conflicts_use_storage_coerced_values(): void { + $this->runParitySetup( + array( + 'CREATE TEMPORARY TABLE tmp_temporal_replace_manual_conflict ( + id INT UNIQUE, + d DATE UNIQUE, + payload VARCHAR(40) + )', + "INSERT INTO tmp_temporal_replace_manual_conflict (id, d, payload) VALUES + (1, '2025-10-23', 'old-date'), + (2, '2025-10-24', 'old-id')", + ) + ); + + $this->assertParityRowCount( + "REPLACE INTO tmp_temporal_replace_manual_conflict (id, d, payload) VALUES + (2, '2025-10-23 18:30:00', 'new')" + ); + $this->assertParityRows( 'SELECT id, d, payload FROM tmp_temporal_replace_manual_conflict ORDER BY id' ); + } + public function test_temporal_replace_manual_strict_error_preserves_caller_transaction(): void { $this->runParitySetup( array( @@ -1875,6 +1934,35 @@ public function test_case_insensitive_unique_conflicts_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, payload FROM ci_items ORDER BY id' ); } + public function test_temporary_case_insensitive_unique_conflicts_use_temp_metadata(): void { + $this->runParitySetup( + array( + "CREATE TEMPORARY TABLE tmp_ci_items ( + id INTEGER PRIMARY KEY, + name VARCHAR(20) NOT NULL DEFAULT '', + payload VARCHAR(20), + UNIQUE KEY name (name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO tmp_ci_items (id, name, payload) VALUES (1, 'first', 'a')", + ) + ); + + $this->assertParityErrorContains( + "INSERT INTO tmp_ci_items (id, name, payload) VALUES (2, 'FIRST', 'duplicate')", + 'UNIQUE constraint failed' + ); + $this->assertParityRows( 'SELECT id, name, payload FROM tmp_ci_items ORDER BY id' ); + + $this->assertParityRowCount( + "INSERT INTO tmp_ci_items (id, name, payload) VALUES (2, 'FIRST', 'updated') + ON DUPLICATE KEY UPDATE name = VALUES(name), payload = VALUES(payload)" + ); + $this->assertParityRows( 'SELECT id, name, payload FROM tmp_ci_items ORDER BY id' ); + + $this->assertParityRowCount( "REPLACE INTO tmp_ci_items (id, name, payload) VALUES (2, 'first', 'replaced')" ); + $this->assertParityRows( 'SELECT id, name, payload FROM tmp_ci_items ORDER BY id' ); + } + public function test_case_insensitive_composite_unique_conflicts_match_sqlite(): void { $this->runParitySetup( array( From 40a49a9f77c68b3f69a31c68b82f545b7c330b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 06:23:56 +0000 Subject: [PATCH 074/317] Add temporal coercion for DuckDB INSERT SELECT writes --- .../src/duckdb/class-wp-duckdb-driver.php | 403 +++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 420 ++++++++++++++++++ 2 files changed, 795 insertions(+), 28 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 147062e02..bafe0b5c4 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -2023,13 +2023,7 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { if ( null !== $this->find_on_duplicate_key_update_index( $tokens ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT statement in DuckDB driver. INSERT ... SELECT ... ON DUPLICATE KEY UPDATE is not supported.' ); } - return $this->execute_auto_increment_write( - $this->identifier_value( $tokens[ $index ] ?? null ), - $this->translate_insert_select_tokens_to_duckdb_sql( $tokens, $index, $ignore ), - 'Failed to execute DuckDB INSERT', - $tokens, - $index - ); + return $this->execute_insert_select_with_temporal_coercion( $tokens, $index, $select_index, $ignore ); } $set_index = $this->find_insert_set_index( $tokens, $index ); @@ -2087,14 +2081,9 @@ private function execute_replace( array $tokens ): WP_DuckDB_Result_Statement { ++$index; } - if ( null !== $this->find_insert_select_index( $tokens, $index ) ) { - return $this->execute_auto_increment_write( - $this->identifier_value( $tokens[ $index ] ?? null ), - $this->translate_replace_tokens_to_duckdb_sql( $tokens ), - 'Failed to execute DuckDB REPLACE', - $tokens, - $index - ); + $select_index = $this->find_insert_select_index( $tokens, $index ); + if ( null !== $select_index ) { + return $this->execute_replace_select_with_temporal_coercion( $tokens, $index, $select_index ); } $this->assert_values_write_statement( $tokens, $index, 'REPLACE' ); @@ -4890,7 +4879,7 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array } if ( ! isset( $metadata_maps[ $assignment_target_index ] ) ) { - $assignment_target = $references[ $assignment_target_index ]; + $assignment_target = $references[ $assignment_target_index ]; $metadata_maps[ $assignment_target_index ] = $this->write_column_metadata_map( $assignment_target['table_name'], $assignment_target['temporary'] ?? false @@ -11763,6 +11752,364 @@ private function translate_insert_select_tokens_to_duckdb_sql( array $tokens, in . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $table_index ) ); } + /** + * Execute MySQL INSERT ... SELECT with staged temporal write coercion. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param int $select_index Index of the SELECT token. + * @param bool $ignore Whether INSERT IGNORE was used. + * @return WP_DuckDB_Result_Statement + */ + private function execute_insert_select_with_temporal_coercion( array $tokens, int $table_index, int $select_index, bool $ignore ): WP_DuckDB_Result_Statement { + $shape = $this->parse_insert_select_target_shape( $tokens, $table_index, $select_index ); + if ( ! $this->insert_select_shape_requires_temporal_coercion( $shape ) ) { + return $this->execute_auto_increment_write( + $shape['requested_table_name'], + $this->translate_insert_select_tokens_to_duckdb_sql( $tokens, $table_index, $ignore ), + 'Failed to execute DuckDB INSERT', + $tokens, + $table_index + ); + } + + $source_sql = $this->translate_tokens_to_duckdb_sql( $shape['source_tokens'] ); + $stage = $this->create_select_write_stage( $source_sql, 'insert_select_src' ); + + try { + $projection = $this->build_insert_select_projection( $shape, $stage['columns'], true, 'INSERT' ); + $this->validate_staged_temporal_write( $stage['table_name'], $projection['validations'], 'Failed to validate DuckDB INSERT SELECT values' ); + + return $this->execute_auto_increment_write( + $shape['requested_table_name'], + 'INSERT ' + . ( $ignore ? 'OR IGNORE ' : '' ) + . 'INTO ' + . $this->connection->quote_identifier( $shape['table_name'] ) + . ' (' + . $this->quote_identifier_list( $projection['columns'] ) + . ') SELECT ' + . implode( ', ', $projection['expressions'] ) + . ' FROM ' + . $this->connection->quote_identifier( $stage['table_name'] ) + . ' AS ' + . $this->connection->quote_identifier( '__src' ), + 'Failed to execute DuckDB INSERT', + $tokens, + $table_index + ); + } finally { + $this->drop_select_write_stage( $stage['table_name'] ); + } + } + + /** + * Execute MySQL REPLACE ... SELECT with staged temporal write coercion. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param int $select_index Index of the SELECT token. + * @return WP_DuckDB_Result_Statement + */ + private function execute_replace_select_with_temporal_coercion( array $tokens, int $table_index, int $select_index ): WP_DuckDB_Result_Statement { + $shape = $this->parse_insert_select_target_shape( $tokens, $table_index, $select_index ); + $this->assert_replace_select_native_conflict_handling_safe( $shape ); + + if ( ! $this->insert_select_shape_requires_temporal_coercion( $shape ) ) { + return $this->execute_auto_increment_write( + $shape['requested_table_name'], + $this->translate_replace_tokens_to_duckdb_sql( $tokens ), + 'Failed to execute DuckDB REPLACE', + $tokens, + $table_index + ); + } + + $source_sql = $this->translate_tokens_to_duckdb_sql( $shape['source_tokens'] ); + $stage = $this->create_select_write_stage( $source_sql, 'replace_select_src' ); + + try { + $projection = $this->build_insert_select_projection( $shape, $stage['columns'], true, 'REPLACE' ); + $this->validate_staged_temporal_write( $stage['table_name'], $projection['validations'], 'Failed to validate DuckDB REPLACE SELECT values' ); + + return $this->execute_auto_increment_write( + $shape['requested_table_name'], + 'INSERT OR REPLACE INTO ' + . $this->connection->quote_identifier( $shape['table_name'] ) + . ' (' + . $this->quote_identifier_list( $projection['columns'] ) + . ') SELECT ' + . implode( ', ', $projection['expressions'] ) + . ' FROM ' + . $this->connection->quote_identifier( $stage['table_name'] ) + . ' AS ' + . $this->connection->quote_identifier( '__src' ), + 'Failed to execute DuckDB REPLACE', + $tokens, + $table_index + ); + } finally { + $this->drop_select_write_stage( $stage['table_name'] ); + } + } + + /** + * Parse target metadata for INSERT/REPLACE ... SELECT. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param int $select_index Index of the SELECT token. + * @return array{requested_table_name:string,table_name:string,temporary:bool,target_columns:string[],target_metadata:array>,omitted_defaults:array,source_tokens:array} + */ + private function parse_insert_select_target_shape( array $tokens, int $table_index, int $select_index ): array { + $requested_table_name = $this->identifier_value( $tokens[ $table_index ] ?? null ); + $reference = $this->resolve_write_table_reference( $requested_table_name ); + $table_name = $reference['table_name']; + $temporary = $reference['temporary']; + $metadata_map = $this->write_column_metadata_map( $table_name, $temporary ); + $target_columns = array(); + $target_metadata = array(); + $explicit_columns = false; + $index = $table_index + 1; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + $explicit_columns = true; + list( $column_items, $index ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); + foreach ( $column_items as $column_tokens ) { + if ( 1 !== count( $column_tokens ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT/REPLACE ... SELECT statement in DuckDB driver. Target column list must contain only simple identifiers.' ); + } + + $column_name = $this->identifier_value( $column_tokens[0] ); + $column_key = strtolower( $column_name ); + if ( ! isset( $metadata_map[ $column_key ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Unknown INSERT/REPLACE target column '{$column_name}' in DuckDB driver." ); + } + + $target_columns[] = (string) $metadata_map[ $column_key ]['column_name']; + $target_metadata[] = $metadata_map[ $column_key ]; + } + } else { + foreach ( $this->table_column_metadata_rows( $table_name, $temporary ) as $metadata ) { + if ( ! isset( $metadata['column_name'] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT/REPLACE ... SELECT statement in DuckDB driver. Target table column metadata is incomplete.' ); + } + $target_columns[] = (string) $metadata['column_name']; + $target_metadata[] = $metadata; + } + } + + if ( $index !== $select_index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT/REPLACE ... SELECT statement in DuckDB driver. Unexpected target clause before SELECT.' ); + } + + $omitted_defaults = $explicit_columns + ? $this->omitted_non_strict_temporal_default_writes( $table_name, $temporary, $target_columns ) + : array(); + + return array( + 'requested_table_name' => $requested_table_name, + 'table_name' => $table_name, + 'temporary' => $temporary, + 'target_columns' => $target_columns, + 'target_metadata' => $target_metadata, + 'omitted_defaults' => $omitted_defaults, + 'source_tokens' => array_slice( $tokens, $select_index ), + ); + } + + /** + * Reject REPLACE ... SELECT targets that need manual MySQL conflict handling. + * + * Native DuckDB INSERT OR REPLACE can only match one case-sensitive unique + * target. MySQL REPLACE can delete rows matched by any unique key, and MySQL + * case-insensitive keys require manual matching. + * + * @param array{table_name:string,temporary:bool} $shape Target shape. + */ + private function assert_replace_select_native_conflict_handling_safe( array $shape ): void { + $unique_sets = $this->unique_key_column_sets( $shape['table_name'], $shape['temporary'] ); + $case_insensitive_columns = $this->case_insensitive_column_names( $shape['table_name'], $shape['temporary'] ); + if ( count( $unique_sets ) >= 2 || $this->has_case_insensitive_unique_key( $unique_sets, $case_insensitive_columns ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported REPLACE ... SELECT statement in DuckDB driver. Manual conflict handling for multiple unique keys or case-insensitive unique keys is not yet supported.' ); + } + } + + /** + * Check whether a SELECT-input write target needs temporal rewrite work. + * + * @param array{target_metadata:array>,omitted_defaults:array} $shape Target shape. + * @return bool Whether staging/coercion is required. + */ + private function insert_select_shape_requires_temporal_coercion( array $shape ): bool { + if ( count( $shape['omitted_defaults'] ) > 0 ) { + return true; + } + + foreach ( $shape['target_metadata'] as $metadata ) { + if ( $this->is_temporal_write_data_type( $this->mysql_column_data_type( $metadata ) ) ) { + return true; + } + } + + return false; + } + + /** + * Materialize SELECT output into a temporary stage and return ordered columns. + * + * @param string $source_sql Translated DuckDB SELECT SQL. + * @param string $purpose Stage purpose suffix. + * @return array{table_name:string,columns:string[]} + */ + private function create_select_write_stage( string $source_sql, string $purpose ): array { + $stage_table = $this->select_write_stage_table_name( $purpose ); + $this->execute_duckdb_query( + 'CREATE TEMPORARY TABLE ' + . $this->connection->quote_identifier( $stage_table ) + . ' AS ' + . $source_sql, + 'Failed to stage DuckDB SELECT write source' + ); + + $stmt = $this->execute_duckdb_query( + 'SELECT name FROM pragma_table_info(' . $this->connection->quote( $stage_table ) . ') ORDER BY cid', + 'Failed to inspect DuckDB SELECT write source' + ); + $columns = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN ) as $column_name ) { + $columns[] = (string) $column_name; + } + + return array( + 'table_name' => $stage_table, + 'columns' => $columns, + ); + } + + /** + * Build a unique temporary stage table name. + * + * @param string $purpose Stage purpose suffix. + * @return string Stage table name. + */ + private function select_write_stage_table_name( string $purpose ): string { + return '__wp_duckdb_' . $purpose . '_' . str_replace( '.', '_', uniqid( '', true ) ); + } + + /** + * Drop a temporary SELECT write stage. + * + * @param string $stage_table Stage table name. + */ + private function drop_select_write_stage( string $stage_table ): void { + $sql = 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $stage_table ); + try { + $this->execute_duckdb_query( $sql, 'Failed to clean up DuckDB SELECT write source' ); + } catch ( Throwable $e ) { + // Keep the original write/validation exception visible to callers. + } finally { + if ( end( $this->last_duckdb_queries ) === $sql ) { + array_pop( $this->last_duckdb_queries ); + } + } + } + + /** + * Build INSERT/REPLACE SELECT target columns, source projections, and validations. + * + * @param array{target_columns:string[],target_metadata:array>,omitted_defaults:array} $shape Target shape. + * @param string[] $stage_columns Ordered staged SELECT output columns. + * @param bool $coalesce_select_nulls Whether non-strict temporal NOT NULL NULLs should become implicit defaults. + * @param string $statement Statement name for errors. + * @return array{columns:string[],expressions:string[],validations:array} + */ + private function build_insert_select_projection( array $shape, array $stage_columns, bool $coalesce_select_nulls, string $statement ): array { + if ( count( $stage_columns ) !== count( $shape['target_columns'] ) ) { + throw new WP_DuckDB_Driver_Exception( $statement . ' ... SELECT column count does not match target column count in DuckDB driver.' ); + } + + $columns = array(); + $expressions = array(); + $validations = array(); + foreach ( $shape['target_columns'] as $offset => $column_name ) { + $source_sql = $this->connection->quote_identifier( '__src' ) + . '.' + . $this->connection->quote_identifier( $stage_columns[ $offset ] ); + $metadata = $shape['target_metadata'][ $offset ]; + + $validation = $this->temporal_write_validation_for_column( $metadata, array(), $source_sql ); + if ( null !== $validation ) { + $validations[] = $validation; + } + + $columns[] = $column_name; + $expressions[] = $this->coerce_write_value_for_column_sql( + $metadata, + array(), + $source_sql, + $coalesce_select_nulls + ); + } + + foreach ( $shape['omitted_defaults'] as $default_write ) { + $columns[] = $default_write['column_name']; + $expressions[] = $default_write['value_sql']; + } + + return array( + 'columns' => $columns, + 'expressions' => $expressions, + 'validations' => $validations, + ); + } + + /** + * Validate strict temporal staged values before mutating the target table. + * + * @param string $stage_table Stage table name. + * @param array $validations Validation expressions. + * @param string $context Failure context. + */ + private function validate_staged_temporal_write( string $stage_table, array $validations, string $context ): void { + foreach ( $validations as $validation ) { + $stmt = $this->execute_duckdb_query( + 'SELECT (' + . $validation['invalid_display_sql'] + . ') AS invalid_display FROM ' + . $this->connection->quote_identifier( $stage_table ) + . ' AS ' + . $this->connection->quote_identifier( '__src' ) + . ' WHERE (' + . $validation['invalid_display_sql'] + . ') IS NOT NULL LIMIT 1', + $context + ); + + $result = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( false !== $result && null !== $result['invalid_display'] ) { + throw new WP_DuckDB_Driver_Exception( + 'Incorrect ' . $validation['data_type'] . " value: '" . (string) $result['invalid_display'] . "'" + ); + } + } + } + + /** + * Quote an identifier list for generated INSERT targets. + * + * @param string[] $identifiers Identifiers. + * @return string SQL identifier list. + */ + private function quote_identifier_list( array $identifiers ): string { + $quoted = array(); + foreach ( $identifiers as $identifier ) { + $quoted[] = $this->connection->quote_identifier( $identifier ); + } + + return implode( ', ', $quoted ); + } + /** * Translate MySQL INSERT/REPLACE ... VALUES to DuckDB with temporal write coercion. * @@ -11783,7 +12130,7 @@ private function translate_insert_values_tokens_to_duckdb_sql( array $tokens, in array_slice( $tokens, $table_index, - (null === $end_index ? count( $tokens ) : $end_index) - $table_index + ( null === $end_index ? count( $tokens ) : $end_index ) - $table_index ) ); } @@ -11830,9 +12177,9 @@ private function translate_insert_set_tokens_to_duckdb_sql( array $tokens, int $ $supplied = array(); foreach ( $assignments as $assignment ) { - $columns[] = $assignment['column_sql']; + $columns[] = $assignment['column_sql']; $supplied[] = $assignment['column_name']; - $value_sql = $assignment['value_sql']; + $value_sql = $assignment['value_sql']; if ( isset( $metadata_map[ strtolower( $assignment['column_name'] ) ] ) ) { $value_sql = $this->coerce_write_value_for_column_sql( $metadata_map[ strtolower( $assignment['column_name'] ) ], @@ -12193,10 +12540,10 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde ); } - $values_by_column = array(); - $ordered_row = array(); - $coerced_values = array(); - $validation_values = array(); + $values_by_column = array(); + $ordered_row = array(); + $coerced_values = array(); + $validation_values = array(); foreach ( $columns as $offset => $column_name ) { $value_sql = $this->translate_tokens_to_duckdb_sql( $value_items[ $offset ] ); if ( $coerce_for_storage && isset( $metadata_map[ strtolower( $column_name ) ] ) ) { @@ -12226,7 +12573,7 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde } foreach ( $omitted_defaults as $default_write ) { $values_by_column[ strtolower( $default_write['column_name'] ) ] = $default_write['value_sql']; - $ordered_row[] = $default_write['value_sql']; + $ordered_row[] = $default_write['value_sql']; } $rows[] = $values_by_column; $ordered_rows[] = $ordered_row; @@ -12546,13 +12893,13 @@ private function temporal_invalid_write_display_sql( string $data_type, string $ */ private function temporal_try_cast_formatted_sql( string $data_type, string $display_sql ): string { if ( 'date' === $data_type ) { - return "strftime(TRY_CAST(" . $display_sql . " AS DATE), '%Y-%m-%d')"; + return 'strftime(TRY_CAST(' . $display_sql . " AS DATE), '%Y-%m-%d')"; } if ( 'time' === $data_type ) { return 'substr(CAST(TRY_CAST(' . $display_sql . ' AS TIME) AS VARCHAR), 1, 8)'; } - return "strftime(TRY_CAST(" . $display_sql . " AS TIMESTAMP), '%Y-%m-%d %H:%M:%S')"; + return 'strftime(TRY_CAST(' . $display_sql . " AS TIMESTAMP), '%Y-%m-%d %H:%M:%S')"; } /** @@ -12683,14 +13030,14 @@ private function validate_insert_values_write_shape( array $shape, string $conte } foreach ( $row as $validation ) { - $stmt = $this->execute_duckdb_query( + $stmt = $this->execute_duckdb_query( 'SELECT (' . $validation['invalid_display_sql'] . ') AS invalid_display', $context ); $result = $stmt->fetch( PDO::FETCH_ASSOC ); if ( false !== $result && null !== $result['invalid_display'] ) { throw new WP_DuckDB_Driver_Exception( - "Incorrect " . $validation['data_type'] . " value: '" . (string) $result['invalid_display'] . "'" + 'Incorrect ' . $validation['data_type'] . " value: '" . (string) $result['invalid_display'] . "'" ); } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 921fb53c5..b4aae392a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1289,6 +1289,414 @@ public function test_temporal_insert_values_and_set_writes_match_sqlite(): void $this->assertParityRows( $this->temporal_select_sql( 'temporal_writes' ) ); } + public function test_temporal_insert_select_explicit_columns_match_sqlite(): void { + $this->create_temporal_write_table( 'temporal_insert_select_explicit' ); + $this->runParitySetup( + array( + 'CREATE TABLE temporal_insert_select_explicit_source ( + id INT, + d_text VARCHAR(40), + tm_text VARCHAR(40), + dt_text VARCHAR(40), + ts_text VARCHAR(40), + payload VARCHAR(40) + )', + "INSERT INTO temporal_insert_select_explicit_source VALUES + (1, '2025-10-23 18:30:00.123456', '18:30:00.123456', '2025-10-24', '2025-10-24 01:02:03.123456', 'explicit')", + ) + ); + + $this->assertParityRowCount( + 'INSERT INTO temporal_insert_select_explicit (payload, ts, dt, tm, d, id) + SELECT payload, ts_text, dt_text, tm_text, d_text, id + FROM temporal_insert_select_explicit_source' + ); + $this->assertParityRows( $this->temporal_select_sql( 'temporal_insert_select_explicit' ) ); + } + + public function test_temporal_insert_select_implicit_columns_match_sqlite(): void { + $this->create_temporal_write_table( 'temporal_insert_select_implicit' ); + $this->runParitySetup( + array( + 'CREATE TABLE temporal_insert_select_implicit_source ( + id INT, + d_text VARCHAR(40), + tm_text VARCHAR(40), + dt_text VARCHAR(40), + ts_text VARCHAR(40), + payload VARCHAR(40) + )', + "INSERT INTO temporal_insert_select_implicit_source VALUES + (1, '2025-11-01 05:06:07.123456', '18:31:32.654321', '2025-11-02', '2025-11-03 04:05:06.654321', 'implicit')", + ) + ); + + $this->assertParityRowCount( + 'INSERT INTO temporal_insert_select_implicit + SELECT id, d_text, tm_text, dt_text, ts_text, payload + FROM temporal_insert_select_implicit_source' + ); + $this->assertParityRows( $this->temporal_select_sql( 'temporal_insert_select_implicit' ) ); + } + + public function test_temporal_insert_select_strict_errors_leave_rows(): void { + $this->create_temporal_write_table( 'temporal_insert_select_strict' ); + $this->runParitySetup( + array( + "INSERT INTO temporal_insert_select_strict (id, d, tm, dt, ts, payload) VALUES + (1, '2025-01-01', '18:30:00', '2025-01-01 12:00:00', '2025-01-01 12:00:00', 'stable')", + 'CREATE TABLE temporal_insert_select_strict_source ( + id INT, + d_text VARCHAR(40), + dt_text VARCHAR(40), + payload VARCHAR(40) + )', + "INSERT INTO temporal_insert_select_strict_source VALUES + (2, 'bad', '2025-01-02', 'plain'), + (3, '2025-01-03', 'bad', 'ignore')", + ) + ); + + $stable_selects = array( + $this->temporal_select_sql( 'temporal_insert_select_strict' ), + 'SELECT id, d_text, dt_text, payload FROM temporal_insert_select_strict_source ORDER BY id', + ); + + $this->assert_temporal_error_leaves_rows( + "INSERT INTO temporal_insert_select_strict (id, d, dt, payload) + SELECT id, d_text, dt_text, payload + FROM temporal_insert_select_strict_source + WHERE payload = 'plain'", + "Incorrect date value: 'bad'", + $stable_selects + ); + $this->assert_temporal_error_leaves_rows( + "INSERT IGNORE INTO temporal_insert_select_strict (id, d, dt, payload) + SELECT id, d_text, dt_text, payload + FROM temporal_insert_select_strict_source + WHERE payload = 'ignore'", + "Incorrect datetime value: 'bad'", + $stable_selects + ); + } + + public function test_temporal_insert_select_non_strict_defaults_and_nulls_match_sqlite(): void { + $this->assertParityRowCount( "SET SESSION sql_mode = ''" ); + $this->create_temporal_write_table( 'temporal_insert_select_non_strict', 'NOT NULL' ); + $this->runParitySetup( + array( + 'CREATE TABLE temporal_insert_select_non_strict_source ( + id INT, + d_text VARCHAR(40), + dt_text VARCHAR(40), + ts_text VARCHAR(40), + payload VARCHAR(40) + )', + "INSERT INTO temporal_insert_select_non_strict_source VALUES + (1, 'bad', 'bad', 'bad', 'invalid'), + (2, NULL, NULL, NULL, 'selected-nulls')", + ) + ); + + $this->assertParityRowCount( + 'INSERT INTO temporal_insert_select_non_strict (id, d, dt, ts, payload) + SELECT id, d_text, dt_text, ts_text, payload + FROM temporal_insert_select_non_strict_source + ORDER BY id' + ); + $this->assertParityRows( $this->temporal_select_sql( 'temporal_insert_select_non_strict' ) ); + } + + public function test_temporal_insert_ignore_select_match_sqlite(): void { + $this->create_temporal_write_table( 'temporal_insert_ignore_select' ); + $this->runParitySetup( + array( + "INSERT INTO temporal_insert_ignore_select (id, d, tm, dt, ts, payload) VALUES + (1, '2025-01-01', '18:30:00', '2025-01-01 12:00:00', '2025-01-01 12:00:00', 'stable')", + 'CREATE TABLE temporal_insert_ignore_select_source ( + id INT, + d_text VARCHAR(40), + tm_text VARCHAR(40), + dt_text VARCHAR(40), + ts_text VARCHAR(40), + payload VARCHAR(40) + )', + "INSERT INTO temporal_insert_ignore_select_source VALUES + (1, '2025-02-01 01:02:03', '18:31:00.123456', '2025-02-01', '2025-02-01 01:02:03.123456', 'ignored'), + (2, '2025-03-01 01:02:03', '18:32:00.123456', '2025-03-01', '2025-03-01 01:02:03.123456', 'inserted')", + ) + ); + + $this->assertParityRowCount( + 'INSERT IGNORE INTO temporal_insert_ignore_select (id, d, tm, dt, ts, payload) + SELECT id, d_text, tm_text, dt_text, ts_text, payload + FROM temporal_insert_ignore_select_source + ORDER BY id' + ); + $this->assertParityRows( $this->temporal_select_sql( 'temporal_insert_ignore_select' ) ); + } + + public function test_temporal_replace_select_native_conflict_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE temporal_replace_select_native ( + id INT, + d DATE UNIQUE, + dt DATETIME NULL, + ts TIMESTAMP NULL, + payload VARCHAR(40) + )', + "INSERT INTO temporal_replace_select_native (id, d, dt, ts, payload) VALUES + (1, '2025-10-23', '2025-10-23 09:00:00', '2025-10-23 09:00:00', 'old')", + 'CREATE TABLE temporal_replace_select_native_source ( + id INT, + d_text VARCHAR(40), + dt_text VARCHAR(40), + ts_text VARCHAR(40), + payload VARCHAR(40) + )', + "INSERT INTO temporal_replace_select_native_source VALUES + (2, '2025-10-23 18:30:00', '2025-10-24', '2025-10-24 01:02:03.123456', 'new')", + ) + ); + + $this->assertParityRowCount( + 'REPLACE INTO temporal_replace_select_native (id, d, dt, ts, payload) + SELECT id, d_text, dt_text, ts_text, payload + FROM temporal_replace_select_native_source' + ); + $this->assertParityRows( 'SELECT id, d, dt, ts, payload FROM temporal_replace_select_native ORDER BY id' ); + } + + public function test_temporal_replace_select_non_strict_selected_nulls_match_sqlite(): void { + $this->assertParityRowCount( "SET SESSION sql_mode = ''" ); + $this->create_temporal_write_table( 'temporal_replace_select_non_strict_nulls', 'NOT NULL' ); + $this->runParitySetup( + array( + "INSERT INTO temporal_replace_select_non_strict_nulls (id, d, tm, dt, ts, payload) VALUES + (1, '2025-10-23', '18:30:00', '2025-10-23 09:00:00', '2025-10-23 09:00:00', 'old')", + 'CREATE TABLE temporal_replace_select_non_strict_nulls_source ( + id INT, + d_text VARCHAR(40), + tm_text VARCHAR(40), + dt_text VARCHAR(40), + ts_text VARCHAR(40), + payload VARCHAR(40) + )', + "INSERT INTO temporal_replace_select_non_strict_nulls_source VALUES + (1, NULL, NULL, NULL, NULL, 'selected-nulls')", + ) + ); + + $this->assertParityRowCount( + 'REPLACE INTO temporal_replace_select_non_strict_nulls (id, d, tm, dt, ts, payload) + SELECT id, d_text, tm_text, dt_text, ts_text, payload + FROM temporal_replace_select_non_strict_nulls_source' + ); + $this->assertParityRows( $this->temporal_select_sql( 'temporal_replace_select_non_strict_nulls' ) ); + } + + public function test_temporal_replace_select_strict_error_preserves_target(): void { + $this->runParitySetup( + array( + 'CREATE TABLE temporal_replace_select_error ( + id INT PRIMARY KEY, + d DATE, + payload VARCHAR(40) + )', + "INSERT INTO temporal_replace_select_error (id, d, payload) VALUES + (1, '2025-10-23', 'stable')", + 'CREATE TABLE temporal_replace_select_error_source ( + id INT, + d_text VARCHAR(40), + payload VARCHAR(40) + )', + "INSERT INTO temporal_replace_select_error_source VALUES + (1, 'bad', 'stable')", + ) + ); + + $this->assert_temporal_error_leaves_rows( + 'REPLACE INTO temporal_replace_select_error (id, d, payload) + SELECT id, d_text, payload + FROM temporal_replace_select_error_source', + "Incorrect date value: 'bad'", + array( + 'SELECT id, d, payload FROM temporal_replace_select_error ORDER BY id', + 'SELECT id, d_text, payload FROM temporal_replace_select_error_source ORDER BY id', + ) + ); + } + + public function test_replace_select_unsafe_conflict_shapes_are_rejected_without_mutation(): void { + $cases = array( + array( + 'setup' => array( + 'CREATE TABLE replace_select_temporal_multi_unique ( + id INT PRIMARY KEY, + name VARCHAR(20) UNIQUE, + d DATE NULL, + payload VARCHAR(20) + )', + "INSERT INTO replace_select_temporal_multi_unique VALUES + (1, 'one', '2025-01-01', 'old-one'), + (2, 'two', '2025-01-02', 'old-two')", + 'CREATE TABLE replace_select_temporal_multi_unique_source ( + id INT, + name VARCHAR(20), + d_text VARCHAR(40), + payload VARCHAR(20) + )', + "INSERT INTO replace_select_temporal_multi_unique_source VALUES + (1, 'two', '2025-03-03', 'incoming')", + ), + 'sql' => 'REPLACE INTO replace_select_temporal_multi_unique (id, name, d, payload) + SELECT id, name, d_text, payload + FROM replace_select_temporal_multi_unique_source', + 'select' => 'SELECT id, name, d, payload FROM replace_select_temporal_multi_unique ORDER BY id', + 'rows' => array( + array( + 'id' => 1, + 'name' => 'one', + 'd' => '2025-01-01', + 'payload' => 'old-one', + ), + array( + 'id' => 2, + 'name' => 'two', + 'd' => '2025-01-02', + 'payload' => 'old-two', + ), + ), + ), + array( + 'setup' => array( + 'CREATE TABLE replace_select_non_temporal_multi_unique ( + id INT PRIMARY KEY, + email VARCHAR(40) UNIQUE, + payload VARCHAR(20) + )', + "INSERT INTO replace_select_non_temporal_multi_unique VALUES + (1, 'one@example.test', 'old-one'), + (2, 'two@example.test', 'old-two')", + 'CREATE TABLE replace_select_non_temporal_multi_unique_source ( + id INT, + email VARCHAR(40), + payload VARCHAR(20) + )', + "INSERT INTO replace_select_non_temporal_multi_unique_source VALUES + (1, 'two@example.test', 'incoming')", + ), + 'sql' => 'REPLACE INTO replace_select_non_temporal_multi_unique (id, email, payload) + SELECT id, email, payload + FROM replace_select_non_temporal_multi_unique_source', + 'select' => 'SELECT id, email, payload FROM replace_select_non_temporal_multi_unique ORDER BY id', + 'rows' => array( + array( + 'id' => 1, + 'email' => 'one@example.test', + 'payload' => 'old-one', + ), + array( + 'id' => 2, + 'email' => 'two@example.test', + 'payload' => 'old-two', + ), + ), + ), + array( + 'setup' => array( + "CREATE TABLE replace_select_ci_unique ( + id INT, + name VARCHAR(20) NOT NULL DEFAULT '', + payload VARCHAR(20), + UNIQUE KEY name (name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO replace_select_ci_unique VALUES (1, 'first', 'old')", + 'CREATE TABLE replace_select_ci_unique_source ( + id INT, + name VARCHAR(20), + payload VARCHAR(20) + )', + "INSERT INTO replace_select_ci_unique_source VALUES (2, 'FIRST', 'incoming')", + ), + 'sql' => 'REPLACE INTO replace_select_ci_unique (id, name, payload) + SELECT id, name, payload + FROM replace_select_ci_unique_source', + 'select' => 'SELECT id, name, payload FROM replace_select_ci_unique ORDER BY id', + 'rows' => array( + array( + 'id' => 1, + 'name' => 'first', + 'payload' => 'old', + ), + ), + ), + ); + + foreach ( $cases as $case ) { + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + foreach ( $case['setup'] as $query ) { + $driver->query( $query ); + } + + $this->assert_duckdb_error_contains( + $driver, + $case['sql'], + 'Manual conflict handling for multiple unique keys or case-insensitive unique keys is not yet supported' + ); + $this->assertSame( $case['rows'], $driver->query( $case['select'] )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assert_no_select_write_stage_tables( $driver ); + } + } + + public function test_temporal_select_write_stage_tables_are_cleaned_up(): void { + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( 'CREATE TABLE temporal_stage_cleanup (id INT PRIMARY KEY, d DATE NULL, payload VARCHAR(20))' ); + $driver->query( 'CREATE TABLE temporal_stage_cleanup_source (id INT, d_text VARCHAR(40), payload VARCHAR(20))' ); + $driver->query( + "INSERT INTO temporal_stage_cleanup_source VALUES + (1, '2025-01-01', 'valid'), + (2, 'bad', 'invalid')" + ); + + $driver->query( + "INSERT INTO temporal_stage_cleanup (id, d, payload) + SELECT id, d_text, payload + FROM temporal_stage_cleanup_source + WHERE payload = 'valid'" + ); + $this->assert_no_select_write_stage_tables( $driver ); + + $this->assert_duckdb_error_contains( + $driver, + "INSERT INTO temporal_stage_cleanup (id, d, payload) + SELECT id, d_text, payload + FROM temporal_stage_cleanup_source + WHERE payload = 'invalid'", + "Incorrect date value: 'bad'" + ); + $this->assert_no_select_write_stage_tables( $driver ); + + $driver->query( + "REPLACE INTO temporal_stage_cleanup (id, d, payload) + SELECT 1, '2025-02-02', 'replaced'" + ); + $this->assert_no_select_write_stage_tables( $driver ); + } + public function test_temporal_strict_write_errors_match_sqlite(): void { $this->create_temporal_write_table( 'temporal_strict', @@ -3279,6 +3687,18 @@ private function assert_duckdb_error_contains( WP_DuckDB_Driver $driver, string $this->fail( 'DuckDB query should have failed for SQL: ' . $sql ); } + private function assert_no_select_write_stage_tables( WP_DuckDB_Driver $driver ): void { + $this->assertSame( + array(), + $driver->get_connection()->query( + "SELECT table_name FROM information_schema.tables + WHERE table_type = 'LOCAL TEMPORARY' + AND table_name LIKE '\\_\\_wp\\_duckdb\\_%select\\_src\\_%' ESCAPE '\\' + ORDER BY table_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + private function createJoinedUpdateGapDrivers(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid $sqlite_driver = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), From 08eab0d383ff19f217efc3e79c7f67af06e7db01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 06:24:02 +0000 Subject: [PATCH 075/317] Add DuckDB wpdb and PDO surface evidence --- .../duckdb/WP_DuckDB_Connection_Tests.php | 248 +++++++++++++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 333 ++++++++++++++++++ 2 files changed, 581 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index 75943717c..804e70c5d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -188,6 +188,143 @@ public function test_result_statement_fetch_all_group_and_unique_modes_match_pdo ); } + public function test_result_statement_fetch_mode_reference_provider(): void { + $cases = array( + 'fetch_both' => array( + 'columns' => array( 'id', 'name' ), + 'rows' => array( + array( 1, 'Ada' ), + ), + 'mode' => PDO::FETCH_BOTH, + 'method' => 'fetch', + 'expected' => array( + 'id' => 1, + 'name' => 'Ada', + 0 => 1, + 1 => 'Ada', + ), + ), + 'fetch_num' => array( + 'columns' => array( 'id', 'name' ), + 'rows' => array( + array( 1, 'Ada' ), + ), + 'mode' => PDO::FETCH_NUM, + 'method' => 'fetch', + 'expected' => array( 1, 'Ada' ), + ), + 'fetch_assoc' => array( + 'columns' => array( '1', 'abc', '2', '2' ), + 'rows' => array( + array( '1', 'abc', '2', 'two' ), + ), + 'mode' => PDO::FETCH_ASSOC, + 'method' => 'fetch', + 'expected' => array( + 1 => '1', + 'abc' => 'abc', + 2 => 'two', + ), + ), + 'fetch_named' => array( + 'columns' => array( 'id', 'id', 'name' ), + 'rows' => array( + array( 1, 2, 'Ada' ), + ), + 'mode' => PDO::FETCH_NAMED, + 'method' => 'fetch', + 'expected' => array( + 'id' => array( 1, 2 ), + 'name' => 'Ada', + ), + ), + 'fetch_obj' => array( + 'columns' => array( 'id', 'name' ), + 'rows' => array( + array( 1, 'Ada' ), + ), + 'mode' => PDO::FETCH_OBJ, + 'method' => 'fetch', + 'expected' => (object) array( + 'id' => 1, + 'name' => 'Ada', + ), + ), + 'fetch_column' => array( + 'columns' => array( 'id', 'name' ), + 'rows' => array( + array( 1, 'Ada' ), + array( 2, 'Grace' ), + ), + 'mode' => PDO::FETCH_COLUMN, + 'args' => array( 1 ), + 'method' => 'fetchAll', + 'expected' => array( 'Ada', 'Grace' ), + ), + 'fetch_key_pair' => array( + 'columns' => array( 'id', 'name' ), + 'rows' => array( + array( 1, 'Ada' ), + array( 2, 'Grace' ), + ), + 'mode' => PDO::FETCH_KEY_PAIR, + 'method' => 'fetchAll', + 'expected' => array( + 1 => 'Ada', + 2 => 'Grace', + ), + ), + 'fetch_class' => array( + 'columns' => array( 'id', 'name' ), + 'rows' => array( + array( 1, 'Ada' ), + ), + 'mode' => PDO::FETCH_CLASS, + 'args' => array( stdClass::class ), + 'method' => 'fetch', + 'expected' => (object) array( + 'id' => 1, + 'name' => 'Ada', + ), + ), + 'fetch_func' => array( + 'columns' => array( 'first', 'second' ), + 'rows' => array( + array( 'a', 'b' ), + array( 'c', 'd' ), + ), + 'mode' => PDO::FETCH_FUNC, + 'args' => array( + function ( $first, $second ) { + return $first . ':' . $second; + }, + ), + 'method' => 'fetchAll', + 'expected' => array( 'a:b', 'c:d' ), + ), + ); + + foreach ( $cases as $label => $case ) { + $stmt = new WP_DuckDB_Result_Statement( $case['columns'], $case['rows'] ); + $args = $case['args'] ?? array(); + + if ( 'fetchAll' === $case['method'] ) { + $actual = $stmt->fetchAll( $case['mode'], ...$args ); + } else { + $this->assertTrue( $stmt->setFetchMode( $case['mode'], ...$args ), $label ); + $actual = $stmt->fetch(); + } + + if ( is_object( $case['expected'] ) ) { + $this->assertInstanceOf( get_class( $case['expected'] ), $actual, $label ); + $this->assertSame( (array) $case['expected'], (array) $actual, $label ); + } else { + $this->assertSame( $case['expected'], $actual, $label ); + } + $this->assertFalse( $stmt->fetch(), $label ); + } + } + public function test_result_statement_cursor_and_metadata_methods_match_pdo_shape(): void { $stmt = new WP_DuckDB_Result_Statement( array( 'id', 'name' ), @@ -591,6 +728,117 @@ public function test_write_results_report_affected_rows(): void { $this->assertSame( 2, $delete->rowCount() ); } + public function test_live_duckdb_statement_surface_provider(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->query( 'CREATE TABLE live_statement_surface (id INTEGER PRIMARY KEY, label VARCHAR)' ); + $connection->query( "INSERT INTO live_statement_surface VALUES (1, 'one'), (2, 'two')" ); + + $select_cases = array( + 'count_alias_literal' => array( + 'sql' => 'SELECT 7 AS Count', + 'column_count' => 1, + 'row_count' => 0, + 'meta_name' => 'Count', + 'fetch_assoc' => array( 'Count' => 7 ), + 'fetch_column' => 7, + 'fetch_all' => array( + array( 'Count' => 7 ), + ), + ), + 'table_count_alias' => array( + 'sql' => 'SELECT COUNT(*) AS Count FROM live_statement_surface', + 'column_count' => 1, + 'row_count' => 0, + 'meta_name' => 'Count', + 'fetch_assoc' => array( 'Count' => 2 ), + 'fetch_column' => 2, + 'fetch_all' => array( + array( 'Count' => 2 ), + ), + ), + 'two_column_select' => array( + 'sql' => 'SELECT id, label FROM live_statement_surface ORDER BY id LIMIT 1', + 'column_count' => 2, + 'row_count' => 0, + 'meta_name' => 'id', + 'fetch_assoc' => array( + 'id' => 1, + 'label' => 'one', + ), + 'fetch_column' => 1, + 'fetch_all' => array( + array( + 'id' => 1, + 'label' => 'one', + ), + ), + ), + 'zero_row_select' => array( + 'sql' => 'SELECT id, label FROM live_statement_surface WHERE id = 999', + 'column_count' => 2, + 'row_count' => 0, + 'meta_name' => 'id', + 'fetch_assoc' => false, + 'fetch_column' => false, + 'fetch_all' => array(), + ), + ); + + foreach ( $select_cases as $label => $case ) { + $stmt = $connection->query( $case['sql'] ); + $this->assertSame( $case['column_count'], $stmt->columnCount(), $label ); + $this->assertSame( $case['row_count'], $stmt->rowCount(), $label ); + $this->assertSame( $case['meta_name'], $stmt->getColumnMeta( 0 )['name'], $label ); + $this->assertSame( '00000', $stmt->errorCode(), $label ); + $this->assertSame( array( '00000', null, null ), $stmt->errorInfo(), $label ); + $this->assertSame( $case['fetch_assoc'], $stmt->fetch( PDO::FETCH_ASSOC ), $label ); + $this->assertFalse( $stmt->fetch(), $label ); + + $this->assertSame( $case['fetch_all'], $connection->query( $case['sql'] )->fetchAll( PDO::FETCH_ASSOC ), $label ); + $this->assertSame( $case['fetch_column'], $connection->query( $case['sql'] )->fetchColumn(), $label ); + } + + $write_cases = array( + 'create_table' => array( + 'sql' => 'CREATE TABLE live_statement_writes (id INTEGER PRIMARY KEY, label VARCHAR)', + 'row_count' => 0, + ), + 'insert_rows' => array( + 'sql' => "INSERT INTO live_statement_writes VALUES (1, 'one'), (2, 'two')", + 'row_count' => 2, + ), + 'update_changed' => array( + 'sql' => "UPDATE live_statement_writes SET label = 'updated' WHERE id = 1", + 'row_count' => 1, + ), + 'update_no_match' => array( + 'sql' => "UPDATE live_statement_writes SET label = 'missing' WHERE id = 999", + 'row_count' => 0, + ), + 'insert_or_replace' => array( + 'sql' => "INSERT OR REPLACE INTO live_statement_writes VALUES (2, 'replaced')", + 'row_count' => 1, + ), + 'delete_matched_row' => array( + 'sql' => 'DELETE FROM live_statement_writes WHERE id = 1', + 'row_count' => 1, + ), + ); + + foreach ( $write_cases as $label => $case ) { + $stmt = $connection->query( $case['sql'] ); + $this->assertSame( 0, $stmt->columnCount(), $label ); + $this->assertSame( $case['row_count'], $stmt->rowCount(), $label ); + $this->assertFalse( $stmt->getColumnMeta( 0 ), $label ); + $this->assertFalse( $stmt->fetch(), $label ); + $this->assertSame( array(), $stmt->fetchAll(), $label ); + $this->assertSame( '00000', $stmt->errorCode(), $label ); + $this->assertSame( array( '00000', null, null ), $stmt->errorInfo(), $label ); + } + } + public function test_prepared_statement_binds_positional_parameters(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index fd83eb7a8..0fb39cf7c 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -203,6 +203,100 @@ public function test_duckdb_wpdb_col_info_uses_statement_metadata(): void { $this->assertSame( array(), $result['update_col_info'] ); } + public function test_duckdb_wpdb_query_surface_provider(): void { + $result = $this->run_query_surface_state_script(); + $cases = $result['cases']; + + $this->assertTrue( $result['connected'] ); + + $this->assertSame( 1, $cases['select_one_row']['return'] ); + $this->assertSame( 0, $cases['select_one_row']['rows_affected'] ); + $this->assertSame( 1, $cases['select_one_row']['num_rows'] ); + $this->assertSame( 1, $cases['select_one_row']['last_result_count'] ); + $this->assertSame( array( 'answer' ), $cases['select_one_row']['col_info_names'] ); + $this->assertSame( '', $cases['select_one_row']['last_error'] ); + + $this->assertSame( 0, $cases['select_zero_rows']['return'] ); + $this->assertSame( 0, $cases['select_zero_rows']['rows_affected'] ); + $this->assertSame( 0, $cases['select_zero_rows']['num_rows'] ); + $this->assertSame( 0, $cases['select_zero_rows']['last_result_count'] ); + $this->assertSame( array( 'ID', 'post_title' ), $cases['select_zero_rows']['col_info_names'] ); + + $this->assertSame( 1, $cases['show_full_tables']['return'] ); + $this->assertSame( 1, $cases['show_full_tables']['num_rows'] ); + $this->assertSame( + array( 'Tables_in_wordpress_test', 'Table_type' ), + $cases['show_full_tables']['col_info_names'] + ); + + $this->assertSame( 2, $cases['show_columns']['return'] ); + $this->assertSame( 2, $cases['show_columns']['num_rows'] ); + $this->assertSame( + array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + $cases['show_columns']['col_info_names'] + ); + + $this->assertSame( 1, $cases['check_table']['return'] ); + $this->assertSame( 1, $cases['check_table']['num_rows'] ); + $this->assertSame( array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), $cases['check_table']['col_info_names'] ); + + $this->assertSame( 1, $cases['insert_row']['return'] ); + $this->assertSame( 1, $cases['insert_row']['rows_affected'] ); + $this->assertSame( 0, $cases['insert_row']['num_rows'] ); + $this->assertSame( 0, $cases['insert_row']['last_result_count'] ); + $this->assertSame( 11, $cases['insert_row']['insert_id'] ); + $this->assertSame( array(), $cases['insert_row']['col_info_names'] ); + + $this->assertSame( 1, $cases['update_changed']['return'] ); + $this->assertSame( 1, $cases['update_changed']['rows_affected'] ); + $this->assertSame( 0, $cases['update_changed']['num_rows'] ); + $this->assertSame( array(), $cases['update_changed']['col_info_names'] ); + + $this->assertSame( 0, $cases['update_no_match']['return'] ); + $this->assertSame( 0, $cases['update_no_match']['rows_affected'] ); + $this->assertSame( 0, $cases['update_no_match']['num_rows'] ); + + $this->assertSame( 1, $cases['delete_row']['return'] ); + $this->assertSame( 1, $cases['delete_row']['rows_affected'] ); + $this->assertSame( 0, $cases['delete_row']['num_rows'] ); + + $this->assertSame( 1, $cases['replace_row']['return'] ); + $this->assertSame( 1, $cases['replace_row']['rows_affected'] ); + $this->assertSame( 12, $cases['replace_row']['insert_id'] ); + $this->assertSame( array(), $cases['replace_row']['col_info_names'] ); + + $this->assertTrue( $cases['create_table']['return'] ); + $this->assertSame( 0, $cases['create_table']['rows_affected'] ); + $this->assertSame( 0, $cases['create_table']['num_rows'] ); + $this->assertSame( array(), $cases['create_table']['col_info_names'] ); + } + + public function test_duckdb_wpdb_last_error_surface_provider(): void { + $result = $this->run_query_surface_state_script(); + $cases = $result['cases']; + + $this->assertTrue( $result['connected'] ); + + $this->assertFalse( $cases['select_failure']['return'] ); + $this->assertSame( 'Synthetic select failure.', $cases['select_failure']['last_error'] ); + $this->assertSame( 0, $cases['select_failure']['num_rows'] ); + $this->assertSame( 0, $cases['select_failure']['rows_affected'] ); + + $this->assertSame( 1, $cases['select_after_failure']['return'] ); + $this->assertSame( '', $cases['select_after_failure']['last_error'] ); + $this->assertSame( 1, $cases['select_after_failure']['num_rows'] ); + + $this->assertFalse( $cases['insert_failure']['return'] ); + $this->assertSame( 'Synthetic insert failure.', $cases['insert_failure']['last_error'] ); + $this->assertSame( 0, $cases['insert_failure']['insert_id'] ); + $this->assertSame( 0, $cases['insert_failure']['num_rows'] ); + $this->assertSame( 0, $cases['insert_failure']['rows_affected'] ); + + $this->assertSame( 1, $cases['insert_after_failure']['return'] ); + $this->assertSame( '', $cases['insert_after_failure']['last_error'] ); + $this->assertSame( 13, $cases['insert_after_failure']['insert_id'] ); + } + public function test_duckdb_wpdb_db_connect_sets_filtered_sql_mode(): void { $result = $this->run_sql_mode_boot_state_script( false ); @@ -730,6 +824,245 @@ function ( $column ) { return $this->run_isolated_php( $code ); } + private function run_query_surface_state_script(): array { + $plugin_dir = $this->get_plugin_dir(); + $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; + $code = $this->get_wordpress_stub_code(); + $code .= "\nrequire_once " . var_export( $driver_load, true ) . ";\n"; + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= <<<'PHP' + +class WP_DuckDB_Plugin_Query_Surface_Test_Driver extends WP_DuckDB_Driver { + private $insert_id = 0; + + public function __construct() {} + + public function query( string $sql ): WP_DuckDB_Result_Statement { + if ( 'SELECT @@SESSION.sql_mode' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( '@@SESSION.sql_mode' ), + array( + array( 'NO_ENGINE_SUBSTITUTION' ), + ), + 0 + ); + } + + if ( "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'" === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( 'SELECT 42 AS answer' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( 'answer' ), + array( + array( 42 ), + ), + 0, + array( + array( + 'name' => 'answer', + 'native_type' => 'BIGINT', + 'len' => 20, + 'precision' => 0, + 'mysqli:orgname' => 'answer', + 'mysqli:db' => 'wordpress_test', + 'mysqli:charsetnr' => 63, + 'mysqli:flags' => 0, + 'mysqli:type' => 8, + ), + ) + ); + } + + if ( 'SELECT ID, post_title FROM wp_posts WHERE ID = 0' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( 'ID', 'post_title' ), + array(), + 0, + array( + array( + 'name' => 'ID', + 'native_type' => 'LONGLONG', + 'table' => 'wp_posts', + 'len' => 20, + 'precision' => 0, + 'mysqli:orgname' => 'ID', + 'mysqli:orgtable' => 'wp_posts', + 'mysqli:db' => 'wordpress_test', + 'mysqli:charsetnr' => 63, + 'mysqli:flags' => 0, + 'mysqli:type' => 8, + ), + array( + 'name' => 'post_title', + 'native_type' => 'VAR_STRING', + 'table' => 'wp_posts', + 'len' => 764, + 'precision' => 0, + 'mysqli:orgname' => 'post_title', + 'mysqli:orgtable' => 'wp_posts', + 'mysqli:db' => 'wordpress_test', + 'mysqli:charsetnr' => 255, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + ), + ) + ); + } + + if ( 'SHOW FULL TABLES' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( 'Tables_in_wordpress_test', 'Table_type' ), + array( + array( 'wp_posts', 'BASE TABLE' ), + ) + ); + } + + if ( 'SHOW COLUMNS FROM wp_posts' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + array( + array( 'ID', 'bigint unsigned', 'NO', 'PRI', null, 'auto_increment' ), + array( 'post_title', 'text', 'NO', '', null, '' ), + ) + ); + } + + if ( 'CHECK TABLE wp_posts' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + array( + array( 'wordpress_test.wp_posts', 'check', 'status', 'OK' ), + ) + ); + } + + if ( "INSERT INTO wp_posts (post_title) VALUES ('hello')" === $sql ) { + $this->insert_id = 11; + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + if ( "UPDATE wp_posts SET post_title = 'changed' WHERE ID = 11" === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + if ( "UPDATE wp_posts SET post_title = 'missing' WHERE ID = 999" === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( 'DELETE FROM wp_posts WHERE ID = 11' === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + if ( "REPLACE INTO wp_posts (ID, post_title) VALUES (12, 'replacement')" === $sql ) { + $this->insert_id = 12; + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + if ( 'CREATE TABLE wp_surface (id INTEGER)' === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( 'SELECT BROKEN' === $sql ) { + throw new WP_DuckDB_Driver_Exception( + 'Synthetic select failure.', + 'HY000', + new RuntimeException( 'Native synthetic select failure.' ) + ); + } + + if ( 'INSERT INTO wp_posts VALUES (BROKEN)' === $sql ) { + throw new WP_DuckDB_Driver_Exception( + 'Synthetic insert failure.', + 'HY000', + new RuntimeException( 'Native synthetic insert failure.' ) + ); + } + + if ( "INSERT INTO wp_posts (post_title) VALUES ('after-failure')" === $sql ) { + $this->insert_id = 13; + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } + + public function get_insert_id(): int { + return $this->insert_id; + } +} + +class WP_DuckDB_Plugin_Query_Surface_Test_DB extends WP_DuckDB_DB { + public function exported_state( $query_return ) { + $this->load_col_info(); + $col_info = null === $this->col_info + ? null + : array_map( + function ( $column ) { + return (array) $column; + }, + $this->col_info + ); + + return array( + 'return' => $query_return, + 'last_query' => $this->last_query, + 'rows_affected' => $this->rows_affected, + 'num_rows' => $this->num_rows, + 'last_result_count' => count( $this->last_result ), + 'last_error' => $this->last_error, + 'insert_id' => $this->insert_id, + 'col_info_names' => is_array( $col_info ) + ? array_map( + function ( $column ) { + return $column['name']; + }, + $col_info + ) + : null, + ); + } +} + +function wp_duckdb_plugin_query_surface_case( WP_DuckDB_Plugin_Query_Surface_Test_DB $db, $sql ) { + $query_return = $db->query( $sql ); + return $db->exported_state( $query_return ); +} + +$GLOBALS['@duckdb_driver'] = new WP_DuckDB_Plugin_Query_Surface_Test_Driver(); +$db = new WP_DuckDB_Plugin_Query_Surface_Test_DB( 'wordpress_test' ); +$connected = $db->db_connect( false ); +$cases = array( + 'select_one_row' => wp_duckdb_plugin_query_surface_case( $db, 'SELECT 42 AS answer' ), + 'select_zero_rows' => wp_duckdb_plugin_query_surface_case( $db, 'SELECT ID, post_title FROM wp_posts WHERE ID = 0' ), + 'show_full_tables' => wp_duckdb_plugin_query_surface_case( $db, 'SHOW FULL TABLES' ), + 'show_columns' => wp_duckdb_plugin_query_surface_case( $db, 'SHOW COLUMNS FROM wp_posts' ), + 'check_table' => wp_duckdb_plugin_query_surface_case( $db, 'CHECK TABLE wp_posts' ), + 'insert_row' => wp_duckdb_plugin_query_surface_case( $db, "INSERT INTO wp_posts (post_title) VALUES ('hello')" ), + 'update_changed' => wp_duckdb_plugin_query_surface_case( $db, "UPDATE wp_posts SET post_title = 'changed' WHERE ID = 11" ), + 'update_no_match' => wp_duckdb_plugin_query_surface_case( $db, "UPDATE wp_posts SET post_title = 'missing' WHERE ID = 999" ), + 'delete_row' => wp_duckdb_plugin_query_surface_case( $db, 'DELETE FROM wp_posts WHERE ID = 11' ), + 'replace_row' => wp_duckdb_plugin_query_surface_case( $db, "REPLACE INTO wp_posts (ID, post_title) VALUES (12, 'replacement')" ), + 'create_table' => wp_duckdb_plugin_query_surface_case( $db, 'CREATE TABLE wp_surface (id INTEGER)' ), + 'select_failure' => wp_duckdb_plugin_query_surface_case( $db, 'SELECT BROKEN' ), + 'select_after_failure' => wp_duckdb_plugin_query_surface_case( $db, 'SELECT 42 AS answer' ), + 'insert_failure' => wp_duckdb_plugin_query_surface_case( $db, 'INSERT INTO wp_posts VALUES (BROKEN)' ), + 'insert_after_failure' => wp_duckdb_plugin_query_surface_case( $db, "INSERT INTO wp_posts (post_title) VALUES ('after-failure')" ), +); + +echo json_encode( + array( + 'connected' => $connected, + 'cases' => $cases, + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + private function get_dispatcher_include_code( string $include_file ): string { return "\ntry {\n" . "\trequire " . var_export( $include_file, true ) . ";\n" From 900b7fd0c9e08698831829f9f23a82c97de49401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 06:40:25 +0000 Subject: [PATCH 076/317] Handle manual DuckDB REPLACE SELECT conflicts --- .../src/duckdb/class-wp-duckdb-driver.php | 303 +++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 378 ++++++++++++------ 2 files changed, 546 insertions(+), 135 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index bafe0b5c4..d848b8cca 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -11812,10 +11812,12 @@ private function execute_insert_select_with_temporal_coercion( array $tokens, in * @return WP_DuckDB_Result_Statement */ private function execute_replace_select_with_temporal_coercion( array $tokens, int $table_index, int $select_index ): WP_DuckDB_Result_Statement { - $shape = $this->parse_insert_select_target_shape( $tokens, $table_index, $select_index ); - $this->assert_replace_select_native_conflict_handling_safe( $shape ); + $shape = $this->parse_insert_select_target_shape( $tokens, $table_index, $select_index ); + $unique_sets = $this->unique_key_column_sets( $shape['table_name'], $shape['temporary'] ); + $case_insensitive_columns = $this->case_insensitive_column_names( $shape['table_name'], $shape['temporary'] ); + $manual_conflicts = $this->replace_select_requires_manual_conflict_handling( $unique_sets, $case_insensitive_columns ); - if ( ! $this->insert_select_shape_requires_temporal_coercion( $shape ) ) { + if ( ! $manual_conflicts && ! $this->insert_select_shape_requires_temporal_coercion( $shape ) ) { return $this->execute_auto_increment_write( $shape['requested_table_name'], $this->translate_replace_tokens_to_duckdb_sql( $tokens ), @@ -11825,13 +11827,34 @@ private function execute_replace_select_with_temporal_coercion( array $tokens, i ); } - $source_sql = $this->translate_tokens_to_duckdb_sql( $shape['source_tokens'] ); - $stage = $this->create_select_write_stage( $source_sql, 'replace_select_src' ); + $source_sql = $this->translate_tokens_to_duckdb_sql( $shape['source_tokens'] ); + $stage = $this->create_select_write_stage( $source_sql, 'replace_select_src' ); + $write_stage = null; try { $projection = $this->build_insert_select_projection( $shape, $stage['columns'], true, 'REPLACE' ); $this->validate_staged_temporal_write( $stage['table_name'], $projection['validations'], 'Failed to validate DuckDB REPLACE SELECT values' ); + if ( $manual_conflicts ) { + $evaluable_unique_sets = $this->replace_select_evaluable_unique_sets( $unique_sets, $projection['columns'] ); + $write_stage = $this->create_replace_select_write_stage( $stage['table_name'], $projection ); + $this->assert_replace_select_write_stage_has_no_duplicate_unique_groups( + $write_stage['table_name'], + $evaluable_unique_sets, + $case_insensitive_columns + ); + + return $this->execute_replace_select_with_manual_conflict_handling( + $shape, + $tokens, + $table_index, + $write_stage['table_name'], + $projection['columns'], + $evaluable_unique_sets, + $case_insensitive_columns + ); + } + return $this->execute_auto_increment_write( $shape['requested_table_name'], 'INSERT OR REPLACE INTO ' @@ -11849,6 +11872,9 @@ private function execute_replace_select_with_temporal_coercion( array $tokens, i $table_index ); } finally { + if ( null !== $write_stage ) { + $this->drop_select_write_stage( $write_stage['table_name'] ); + } $this->drop_select_write_stage( $stage['table_name'] ); } } @@ -11919,20 +11945,250 @@ private function parse_insert_select_target_shape( array $tokens, int $table_ind } /** - * Reject REPLACE ... SELECT targets that need manual MySQL conflict handling. + * Check whether REPLACE ... SELECT needs manual MySQL conflict handling. * - * Native DuckDB INSERT OR REPLACE can only match one case-sensitive unique - * target. MySQL REPLACE can delete rows matched by any unique key, and MySQL - * case-insensitive keys require manual matching. + * @param array $unique_sets Unique key column sets. + * @param array $case_insensitive_columns Lowercase case-insensitive column-name map. + * @return bool Whether manual conflict handling is required. + */ + private function replace_select_requires_manual_conflict_handling( array $unique_sets, array $case_insensitive_columns ): bool { + return count( $unique_sets ) >= 2 || $this->has_case_insensitive_unique_key( $unique_sets, $case_insensitive_columns ); + } + + /** + * Return unique keys that can be evaluated from staged write columns. * - * @param array{table_name:string,temporary:bool} $shape Target shape. + * @param array $unique_sets Unique key column sets. + * @param string[] $write_columns Columns available in the coerced write stage. + * @return array Unique key column sets whose columns are all available. */ - private function assert_replace_select_native_conflict_handling_safe( array $shape ): void { - $unique_sets = $this->unique_key_column_sets( $shape['table_name'], $shape['temporary'] ); - $case_insensitive_columns = $this->case_insensitive_column_names( $shape['table_name'], $shape['temporary'] ); - if ( count( $unique_sets ) >= 2 || $this->has_case_insensitive_unique_key( $unique_sets, $case_insensitive_columns ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported REPLACE ... SELECT statement in DuckDB driver. Manual conflict handling for multiple unique keys or case-insensitive unique keys is not yet supported.' ); + private function replace_select_evaluable_unique_sets( array $unique_sets, array $write_columns ): array { + $available = array(); + foreach ( $write_columns as $column_name ) { + $available[ strtolower( $column_name ) ] = true; } + + $evaluable_unique_sets = array(); + foreach ( $unique_sets as $column_set ) { + foreach ( $column_set as $column_name ) { + if ( ! isset( $available[ strtolower( $column_name ) ] ) ) { + continue 2; + } + } + $evaluable_unique_sets[] = $column_set; + } + + return $evaluable_unique_sets; + } + + /** + * Materialize coerced REPLACE ... SELECT write values. + * + * @param string $source_stage_table Raw SELECT stage table name. + * @param array{columns:string[],expressions:string[]} $projection Coerced write projection. + * @return array{table_name:string,columns:string[]} + */ + private function create_replace_select_write_stage( string $source_stage_table, array $projection ): array { + $items = array(); + foreach ( $projection['columns'] as $offset => $column_name ) { + $items[] = $projection['expressions'][ $offset ] + . ' AS ' + . $this->connection->quote_identifier( $column_name ); + } + + return $this->create_select_write_stage( + 'SELECT ' + . implode( ', ', $items ) + . ' FROM ' + . $this->connection->quote_identifier( $source_stage_table ) + . ' AS ' + . $this->connection->quote_identifier( '__src' ), + 'replace_select_write' + ); + } + + /** + * Reject incoming rows that conflict with each other on a unique key. + * + * @param string $write_stage_table Coerced write stage table name. + * @param array $unique_sets Unique key column sets. + * @param array $case_insensitive_columns Lowercase case-insensitive column-name map. + */ + private function assert_replace_select_write_stage_has_no_duplicate_unique_groups( + string $write_stage_table, + array $unique_sets, + array $case_insensitive_columns + ): void { + foreach ( $unique_sets as $column_set ) { + $where = array(); + $group_by = array(); + foreach ( $column_set as $column_name ) { + $column_key = strtolower( $column_name ); + $column_sql = $this->connection->quote_identifier( $column_name ); + $where[] = $column_sql . ' IS NOT NULL'; + $group_by[] = isset( $case_insensitive_columns[ $column_key ] ) + ? 'lower(CAST(' . $column_sql . ' AS VARCHAR))' + : $column_sql; + } + + $stmt = $this->execute_duckdb_query( + 'SELECT 1 FROM ' + . $this->connection->quote_identifier( $write_stage_table ) + . ' WHERE ' + . implode( ' AND ', $where ) + . ' GROUP BY ' + . implode( ', ', $group_by ) + . ' HAVING COUNT(*) > 1 LIMIT 1', + 'Failed to validate DuckDB REPLACE SELECT unique groups' + ); + + if ( false !== $stmt->fetch( PDO::FETCH_NUM ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported REPLACE ... SELECT statement in DuckDB driver. Incoming rows contain duplicate values for a unique key.' ); + } + } + } + + /** + * Execute staged REPLACE ... SELECT with manual conflict deletion. + * + * @param array{requested_table_name:string,table_name:string} $shape Target shape. + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param string $write_stage_table Coerced write stage table name. + * @param string[] $write_columns Columns available in the coerced write stage. + * @param array $unique_sets Unique key column sets. + * @param array $case_insensitive_columns Lowercase case-insensitive column-name map. + * @return WP_DuckDB_Result_Statement + */ + private function execute_replace_select_with_manual_conflict_handling( + array $shape, + array $tokens, + int $table_index, + string $write_stage_table, + array $write_columns, + array $unique_sets, + array $case_insensitive_columns + ): WP_DuckDB_Result_Statement { + $started_transaction = false; + if ( ! $this->connection->inTransaction() ) { + $this->connection->beginTransaction(); + $started_transaction = true; + } + + try { + $delete_predicate = $this->replace_select_manual_delete_predicate( + $shape['table_name'], + $write_stage_table, + $unique_sets, + $case_insensitive_columns + ); + + if ( '' !== $delete_predicate ) { + $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( $shape['table_name'] ) + . ' WHERE ' + . $delete_predicate, + 'Failed to delete DuckDB REPLACE SELECT conflicts' + ); + } + + $result = $this->execute_auto_increment_write( + $shape['requested_table_name'], + 'INSERT INTO ' + . $this->connection->quote_identifier( $shape['table_name'] ) + . ' (' + . $this->quote_identifier_list( $write_columns ) + . ') SELECT ' + . $this->quote_qualified_identifier_list( '__incoming', $write_columns ) + . ' FROM ' + . $this->connection->quote_identifier( $write_stage_table ) + . ' AS ' + . $this->connection->quote_identifier( '__incoming' ), + 'Failed to execute DuckDB REPLACE', + $tokens, + $table_index + ); + + if ( $started_transaction ) { + $this->connection->commit(); + } + + return $result; + } catch ( Throwable $e ) { + if ( $started_transaction && $this->connection->inTransaction() ) { + $this->connection->rollback(); + } + throw $e; + } + } + + /** + * Build the manual REPLACE ... SELECT target conflict predicate. + * + * @param string $table_name Target table name. + * @param string $write_stage_table Coerced write stage table name. + * @param array $unique_sets Unique key column sets. + * @param array $case_insensitive_columns Lowercase case-insensitive column-name map. + * @return string SQL predicate, or an empty string when no unique keys exist. + */ + private function replace_select_manual_delete_predicate( + string $table_name, + string $write_stage_table, + array $unique_sets, + array $case_insensitive_columns + ): string { + $branches = array(); + foreach ( $unique_sets as $column_set ) { + $branches[] = '(' . $this->replace_select_manual_unique_key_predicate( $table_name, $column_set, $case_insensitive_columns ) . ')'; + } + + if ( count( $branches ) === 0 ) { + return ''; + } + + return 'EXISTS (SELECT 1 FROM ' + . $this->connection->quote_identifier( $write_stage_table ) + . ' AS ' + . $this->connection->quote_identifier( '__incoming' ) + . ' WHERE ' + . implode( ' OR ', $branches ) + . ')'; + } + + /** + * Build one unique-key conflict branch for manual REPLACE ... SELECT. + * + * @param string $table_name Target table name. + * @param string[] $column_set Unique key columns. + * @param array $case_insensitive_columns Lowercase case-insensitive column-name map. + * @return string SQL predicate. + */ + private function replace_select_manual_unique_key_predicate( + string $table_name, + array $column_set, + array $case_insensitive_columns + ): string { + $where = array(); + foreach ( $column_set as $column_name ) { + $column_key = strtolower( $column_name ); + $target_sql = $this->connection->quote_identifier( $table_name ) + . '.' + . $this->connection->quote_identifier( $column_name ); + $incoming_sql = $this->connection->quote_identifier( '__incoming' ) + . '.' + . $this->connection->quote_identifier( $column_name ); + + $where[] = $incoming_sql . ' IS NOT NULL'; + if ( isset( $case_insensitive_columns[ $column_key ] ) ) { + $where[] = 'lower(CAST(' . $target_sql . ' AS VARCHAR)) = lower(CAST(' . $incoming_sql . ' AS VARCHAR))'; + continue; + } + + $where[] = $target_sql . ' = ' . $incoming_sql; + } + + return implode( ' AND ', $where ); } /** @@ -12110,6 +12366,23 @@ private function quote_identifier_list( array $identifiers ): string { return implode( ', ', $quoted ); } + /** + * Quote a list of identifiers with a shared table alias qualifier. + * + * @param string $alias Table alias. + * @param string[] $identifiers Identifiers. + * @return string SQL identifier list. + */ + private function quote_qualified_identifier_list( string $alias, array $identifiers ): string { + $quoted_alias = $this->connection->quote_identifier( $alias ); + $quoted = array(); + foreach ( $identifiers as $identifier ) { + $quoted[] = $quoted_alias . '.' . $this->connection->quote_identifier( $identifier ); + } + + return implode( ', ', $quoted ); + } + /** * Translate MySQL INSERT/REPLACE ... VALUES to DuckDB with temporal write coercion. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index b4aae392a..f55fd873b 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1528,132 +1528,270 @@ public function test_temporal_replace_select_strict_error_preserves_target(): vo ); } - public function test_replace_select_unsafe_conflict_shapes_are_rejected_without_mutation(): void { - $cases = array( - array( - 'setup' => array( - 'CREATE TABLE replace_select_temporal_multi_unique ( - id INT PRIMARY KEY, - name VARCHAR(20) UNIQUE, - d DATE NULL, - payload VARCHAR(20) - )', - "INSERT INTO replace_select_temporal_multi_unique VALUES - (1, 'one', '2025-01-01', 'old-one'), - (2, 'two', '2025-01-02', 'old-two')", - 'CREATE TABLE replace_select_temporal_multi_unique_source ( - id INT, - name VARCHAR(20), - d_text VARCHAR(40), - payload VARCHAR(20) - )', - "INSERT INTO replace_select_temporal_multi_unique_source VALUES - (1, 'two', '2025-03-03', 'incoming')", - ), - 'sql' => 'REPLACE INTO replace_select_temporal_multi_unique (id, name, d, payload) - SELECT id, name, d_text, payload - FROM replace_select_temporal_multi_unique_source', - 'select' => 'SELECT id, name, d, payload FROM replace_select_temporal_multi_unique ORDER BY id', - 'rows' => array( - array( - 'id' => 1, - 'name' => 'one', - 'd' => '2025-01-01', - 'payload' => 'old-one', - ), - array( - 'id' => 2, - 'name' => 'two', - 'd' => '2025-01-02', - 'payload' => 'old-two', - ), - ), - ), + public function test_replace_select_multiple_unique_keys_match_sqlite(): void { + $this->runParitySetup( array( - 'setup' => array( - 'CREATE TABLE replace_select_non_temporal_multi_unique ( - id INT PRIMARY KEY, - email VARCHAR(40) UNIQUE, - payload VARCHAR(20) - )', - "INSERT INTO replace_select_non_temporal_multi_unique VALUES - (1, 'one@example.test', 'old-one'), - (2, 'two@example.test', 'old-two')", - 'CREATE TABLE replace_select_non_temporal_multi_unique_source ( - id INT, - email VARCHAR(40), - payload VARCHAR(20) - )', - "INSERT INTO replace_select_non_temporal_multi_unique_source VALUES - (1, 'two@example.test', 'incoming')", - ), - 'sql' => 'REPLACE INTO replace_select_non_temporal_multi_unique (id, email, payload) - SELECT id, email, payload - FROM replace_select_non_temporal_multi_unique_source', - 'select' => 'SELECT id, email, payload FROM replace_select_non_temporal_multi_unique ORDER BY id', - 'rows' => array( - array( - 'id' => 1, - 'email' => 'one@example.test', - 'payload' => 'old-one', - ), - array( - 'id' => 2, - 'email' => 'two@example.test', - 'payload' => 'old-two', - ), - ), - ), + 'CREATE TABLE replace_select_multi_unique ( + id INT PRIMARY KEY, + email VARCHAR(40) UNIQUE, + slug VARCHAR(40) UNIQUE, + payload VARCHAR(20) + )', + "INSERT INTO replace_select_multi_unique VALUES + (1, 'one@example.test', 'one', 'old-id'), + (2, 'two@example.test', 'two', 'old-email')", + 'CREATE TABLE replace_select_multi_unique_source ( + id INT, + email VARCHAR(40), + slug VARCHAR(40), + payload VARCHAR(20) + )', + "INSERT INTO replace_select_multi_unique_source VALUES + (1, 'two@example.test', 'incoming', 'new')", + ) + ); + + $this->assertParityRowCount( + 'REPLACE INTO replace_select_multi_unique (id, email, slug, payload) + SELECT id, email, slug, payload + FROM replace_select_multi_unique_source' + ); + $this->assertParityRows( 'SELECT id, email, slug, payload FROM replace_select_multi_unique ORDER BY id' ); + } + + public function test_replace_select_omitted_auto_increment_primary_key_with_secondary_unique_matches_sqlite(): void { + $this->runParitySetup( array( - 'setup' => array( - "CREATE TABLE replace_select_ci_unique ( - id INT, - name VARCHAR(20) NOT NULL DEFAULT '', - payload VARCHAR(20), - UNIQUE KEY name (name) - ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", - "INSERT INTO replace_select_ci_unique VALUES (1, 'first', 'old')", - 'CREATE TABLE replace_select_ci_unique_source ( - id INT, - name VARCHAR(20), - payload VARCHAR(20) - )', - "INSERT INTO replace_select_ci_unique_source VALUES (2, 'FIRST', 'incoming')", - ), - 'sql' => 'REPLACE INTO replace_select_ci_unique (id, name, payload) - SELECT id, name, payload - FROM replace_select_ci_unique_source', - 'select' => 'SELECT id, name, payload FROM replace_select_ci_unique ORDER BY id', - 'rows' => array( - array( - 'id' => 1, - 'name' => 'first', - 'payload' => 'old', - ), - ), - ), + 'CREATE TABLE replace_select_omitted_ai_pk ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(40) NOT NULL, + payload VARCHAR(20), + UNIQUE KEY email_unique (email) + )', + "INSERT INTO replace_select_omitted_ai_pk (email, payload) VALUES + ('one@example.test', 'old-one'), + ('two@example.test', 'old-two')", + 'CREATE TABLE replace_select_omitted_ai_pk_source ( + email VARCHAR(40), + payload VARCHAR(20) + )', + "INSERT INTO replace_select_omitted_ai_pk_source VALUES + ('two@example.test', 'incoming')", + ) ); - foreach ( $cases as $case ) { - $driver = new WP_DuckDB_Driver( - array( - 'path' => ':memory:', - 'database' => 'wp', - ) - ); + $this->assertParityRowCount( + 'REPLACE INTO replace_select_omitted_ai_pk (email, payload) + SELECT email, payload + FROM replace_select_omitted_ai_pk_source' + ); + $this->assertParityRows( 'SELECT id, email, payload FROM replace_select_omitted_ai_pk ORDER BY id' ); + } + + public function test_replace_select_case_insensitive_unique_key_stores_incoming_value(): void { + $this->runParitySetup( + array( + 'CREATE TABLE replace_select_ci_unique ( + name VARCHAR(20), + payload VARCHAR(20), + UNIQUE KEY name_unique (name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO replace_select_ci_unique VALUES ('First', 'old')", + 'CREATE TABLE replace_select_ci_unique_source ( + name VARCHAR(20), + payload VARCHAR(20) + )', + "INSERT INTO replace_select_ci_unique_source VALUES ('first', 'incoming')", + ) + ); - foreach ( $case['setup'] as $query ) { - $driver->query( $query ); - } + $this->assertParityRowCount( + 'REPLACE INTO replace_select_ci_unique (name, payload) + SELECT name, payload + FROM replace_select_ci_unique_source' + ); + $this->assertParityRows( 'SELECT name, payload FROM replace_select_ci_unique ORDER BY name' ); + } - $this->assert_duckdb_error_contains( - $driver, - $case['sql'], - 'Manual conflict handling for multiple unique keys or case-insensitive unique keys is not yet supported' - ); - $this->assertSame( $case['rows'], $driver->query( $case['select'] )->fetchAll( PDO::FETCH_ASSOC ) ); - $this->assert_no_select_write_stage_tables( $driver ); + public function test_temporal_replace_select_multiple_unique_keys_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE temporal_replace_select_multi_unique ( + id INT PRIMARY KEY, + d DATE UNIQUE, + slug VARCHAR(40) UNIQUE, + payload VARCHAR(20) + )', + "INSERT INTO temporal_replace_select_multi_unique VALUES + (1, '2025-10-23', 'one', 'old-id'), + (2, '2025-10-24', 'two', 'old-date')", + 'CREATE TABLE temporal_replace_select_multi_unique_source ( + id INT, + d_text VARCHAR(40), + slug VARCHAR(40), + payload VARCHAR(20) + )', + "INSERT INTO temporal_replace_select_multi_unique_source VALUES + (1, '2025-10-24 01:02:03', 'incoming', 'new')", + ) + ); + + $this->assertParityRowCount( + 'REPLACE INTO temporal_replace_select_multi_unique (id, d, slug, payload) + SELECT id, d_text, slug, payload + FROM temporal_replace_select_multi_unique_source' + ); + $this->assertParityRows( 'SELECT id, d, slug, payload FROM temporal_replace_select_multi_unique ORDER BY id' ); + } + + public function test_temporal_replace_select_case_insensitive_unique_key_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE temporal_replace_select_ci_unique ( + name VARCHAR(20), + d DATE, + payload VARCHAR(20), + UNIQUE KEY name_unique (name) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO temporal_replace_select_ci_unique VALUES ('First', '2025-10-23', 'old')", + 'CREATE TABLE temporal_replace_select_ci_unique_source ( + name VARCHAR(20), + d_text VARCHAR(40), + payload VARCHAR(20) + )', + "INSERT INTO temporal_replace_select_ci_unique_source VALUES + ('first', '2025-10-24 01:02:03', 'incoming')", + ) + ); + + $this->assertParityRowCount( + 'REPLACE INTO temporal_replace_select_ci_unique (name, d, payload) + SELECT name, d_text, payload + FROM temporal_replace_select_ci_unique_source' + ); + $this->assertParityRows( 'SELECT name, d, payload FROM temporal_replace_select_ci_unique ORDER BY name' ); + } + + public function test_replace_select_nullable_unique_nulls_do_not_conflict(): void { + $this->runParitySetup( + array( + 'CREATE TABLE replace_select_nullable_unique ( + id INT PRIMARY KEY, + email VARCHAR(40) UNIQUE, + payload VARCHAR(20) + )', + "INSERT INTO replace_select_nullable_unique VALUES + (1, NULL, 'old-null'), + (2, 'two@example.test', 'old-two')", + 'CREATE TABLE replace_select_nullable_unique_source ( + id INT, + email VARCHAR(40), + payload VARCHAR(20) + )', + "INSERT INTO replace_select_nullable_unique_source VALUES + (3, NULL, 'incoming-null')", + ) + ); + + $this->assertParityRowCount( + 'REPLACE INTO replace_select_nullable_unique (id, email, payload) + SELECT id, email, payload + FROM replace_select_nullable_unique_source' + ); + $this->assertParityRows( 'SELECT id, email, payload FROM replace_select_nullable_unique ORDER BY id' ); + } + + public function test_temporal_replace_select_manual_strict_error_preserves_target(): void { + $this->runParitySetup( + array( + 'CREATE TABLE temporal_replace_select_manual_error ( + id INT PRIMARY KEY, + d DATE UNIQUE, + payload VARCHAR(20) + )', + "INSERT INTO temporal_replace_select_manual_error VALUES + (1, '2025-10-23', 'old-id'), + (2, '2025-10-24', 'old-date')", + 'CREATE TABLE temporal_replace_select_manual_error_source ( + id INT, + d_text VARCHAR(40), + payload VARCHAR(20) + )', + "INSERT INTO temporal_replace_select_manual_error_source VALUES + (1, 'bad', 'incoming')", + ) + ); + + $this->assert_temporal_error_leaves_rows( + 'REPLACE INTO temporal_replace_select_manual_error (id, d, payload) + SELECT id, d_text, payload + FROM temporal_replace_select_manual_error_source', + "Incorrect date value: 'bad'", + array( + 'SELECT id, d, payload FROM temporal_replace_select_manual_error ORDER BY id', + 'SELECT id, d_text, payload FROM temporal_replace_select_manual_error_source ORDER BY id', + ) + ); + } + + public function test_replace_select_rejects_incoming_duplicate_unique_groups_without_mutation(): void { + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + foreach ( + array( + 'CREATE TABLE replace_select_duplicate_incoming ( + id INT PRIMARY KEY, + email VARCHAR(40) UNIQUE, + payload VARCHAR(20) + )', + "INSERT INTO replace_select_duplicate_incoming VALUES + (1, 'one@example.test', 'old-one'), + (2, 'two@example.test', 'old-two')", + 'CREATE TABLE replace_select_duplicate_incoming_source ( + id INT, + email VARCHAR(40), + payload VARCHAR(20) + )', + "INSERT INTO replace_select_duplicate_incoming_source VALUES + (3, 'dup@example.test', 'incoming-a'), + (4, 'dup@example.test', 'incoming-b')", + ) as $query + ) { + $driver->query( $query ); } + + $this->assert_duckdb_error_contains( + $driver, + 'REPLACE INTO replace_select_duplicate_incoming (id, email, payload) + SELECT id, email, payload + FROM replace_select_duplicate_incoming_source', + 'Incoming rows contain duplicate values for a unique key' + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'email' => 'one@example.test', + 'payload' => 'old-one', + ), + array( + 'id' => 2, + 'email' => 'two@example.test', + 'payload' => 'old-two', + ), + ), + $driver->query( + 'SELECT id, email, payload + FROM replace_select_duplicate_incoming + ORDER BY id' + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assert_no_select_write_stage_tables( $driver ); } public function test_temporal_select_write_stage_tables_are_cleaned_up(): void { @@ -3693,7 +3831,7 @@ private function assert_no_select_write_stage_tables( WP_DuckDB_Driver $driver ) $driver->get_connection()->query( "SELECT table_name FROM information_schema.tables WHERE table_type = 'LOCAL TEMPORARY' - AND table_name LIKE '\\_\\_wp\\_duckdb\\_%select\\_src\\_%' ESCAPE '\\' + AND table_name LIKE '\\_\\_wp\\_duckdb\\_%select\\_%' ESCAPE '\\' ORDER BY table_name" )->fetchAll( PDO::FETCH_ASSOC ) ); From 61e6e3570c1ad233a22f348b3afccb002a64b353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 06:40:33 +0000 Subject: [PATCH 077/317] Add DuckDB metadata provider evidence --- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 122 ++++++++++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 218 ++++++++++++++++++ 2 files changed, 340 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 1ba53f32d..89aff3403 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3102,6 +3102,128 @@ public function test_simple_select_result_metadata_uses_recorded_table_columns() $this->assertFalse( $update->getColumnMeta( 0 ) ); } + public function test_expression_and_admin_result_metadata_provider_documents_name_only_contract(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE metadata_provider_items ( + id INT PRIMARY KEY, + name VARCHAR(20) + )' + ); + $driver->query( 'CREATE INDEX metadata_provider_items_name ON metadata_provider_items (name)' ); + $driver->query( "INSERT INTO metadata_provider_items (id, name) VALUES (1, 'alpha')" ); + $driver->query( 'SELECT id FROM metadata_provider_items' ); + + $cases = array( + array( + 'sql' => 'SELECT COUNT(*) AS post_count FROM metadata_provider_items', + 'columns' => array( 'post_count' ), + ), + array( + 'sql' => 'SELECT FOUND_ROWS() AS found_rows', + 'columns' => array( 'found_rows' ), + ), + array( + 'sql' => 'SELECT DATABASE() AS db_name', + 'columns' => array( 'db_name' ), + ), + array( + 'sql' => 'SELECT CAST(42 AS SIGNED) AS signed_value', + 'columns' => array( 'signed_value' ), + ), + array( + 'sql' => "SELECT CONCAT('a', 'b') AS concat_value", + 'columns' => array( 'concat_value' ), + ), + array( + 'sql' => "SHOW COLLATION LIKE 'utf8_bin'", + 'columns' => array( 'Collation', 'Charset', 'Id', 'Default', 'Compiled', 'Sortlen', 'Pad_attribute' ), + ), + array( + 'sql' => 'SHOW DATABASES', + 'columns' => array( 'Database' ), + ), + array( + 'sql' => 'SHOW GRANTS', + 'columns' => array( 'Grants for root@%' ), + ), + array( + 'sql' => 'SHOW VARIABLES', + 'columns' => array( 'Variable_name', 'Value' ), + ), + array( + 'sql' => 'SHOW TABLES', + 'columns' => array( 'Tables_in_wp' ), + ), + array( + 'sql' => 'SHOW FULL TABLES', + 'columns' => array( 'Tables_in_wp', 'Table_type' ), + ), + array( + 'sql' => 'SHOW COLUMNS FROM metadata_provider_items', + 'columns' => array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + ), + array( + 'sql' => 'SHOW FULL COLUMNS FROM metadata_provider_items', + 'columns' => array( 'Field', 'Type', 'Collation', 'Null', 'Key', 'Default', 'Extra', 'Privileges', 'Comment' ), + ), + array( + 'sql' => 'SHOW INDEX FROM metadata_provider_items', + 'columns' => array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Collation', 'Cardinality', 'Sub_part', 'Packed', 'Null', 'Index_type', 'Comment', 'Index_comment', 'Visible', 'Expression' ), + ), + array( + 'sql' => 'SHOW CREATE TABLE metadata_provider_items', + 'columns' => array( 'Table', 'Create Table' ), + ), + array( + 'sql' => "SHOW TABLE STATUS LIKE 'metadata_provider_items'", + 'columns' => array( 'Name', 'Engine', 'Version', 'Row_format', 'Rows', 'Avg_row_length', 'Data_length', 'Max_data_length', 'Index_length', 'Data_free', 'Auto_increment', 'Create_time', 'Update_time', 'Check_time', 'Collation', 'Checksum', 'Create_options', 'Comment' ), + ), + array( + 'sql' => 'DESCRIBE metadata_provider_items', + 'columns' => array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + ), + array( + 'sql' => 'CHECK TABLE metadata_provider_items', + 'columns' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + ), + array( + 'sql' => 'ANALYZE TABLE metadata_provider_items', + 'columns' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + ), + array( + 'sql' => 'OPTIMIZE TABLE metadata_provider_items', + 'columns' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + ), + array( + 'sql' => 'REPAIR TABLE metadata_provider_items', + 'columns' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + ), + ); + + foreach ( $cases as $case ) { + $sql = $case['sql']; + $expected_names = $case['columns']; + $result = $driver->query( $sql ); + + $this->assertSame( 0, $result->rowCount(), $sql ); + $this->assertSame( count( $expected_names ), $result->columnCount(), $sql ); + + foreach ( $expected_names as $index => $expected_name ) { + $this->assertSame( array( 'name' => $expected_name ), $result->getColumnMeta( $index ), $sql ); + } + + $this->assertFalse( $result->getColumnMeta( count( $expected_names ) ), $sql ); + } + } + public function test_simple_select_wildcard_result_metadata_uses_recorded_table_columns(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index 0fb39cf7c..d35fb538b 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -203,6 +203,89 @@ public function test_duckdb_wpdb_col_info_uses_statement_metadata(): void { $this->assertSame( array(), $result['update_col_info'] ); } + public function test_duckdb_wpdb_col_info_defaults_for_name_only_metadata_provider(): void { + $result = $this->run_col_info_fallback_state_script(); + $cases = $result['cases']; + + $this->assertTrue( $result['connected'] ); + + $expected_cases = array( + 'select_count' => array( 'post_count' ), + 'found_rows' => array( 'found_rows' ), + 'show_full_tables' => array( 'Tables_in_wordpress_test', 'Table_type' ), + 'show_columns' => array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + 'show_table_status' => array( + 'Name', + 'Engine', + 'Version', + 'Row_format', + 'Rows', + 'Avg_row_length', + 'Data_length', + 'Max_data_length', + 'Index_length', + 'Data_free', + 'Auto_increment', + 'Create_time', + 'Update_time', + 'Check_time', + 'Collation', + 'Checksum', + 'Create_options', + 'Comment', + ), + 'show_create_table' => array( 'Table', 'Create Table' ), + 'show_index' => array( + 'Table', + 'Non_unique', + 'Key_name', + 'Seq_in_index', + 'Column_name', + 'Collation', + 'Cardinality', + 'Sub_part', + 'Packed', + 'Null', + 'Index_type', + 'Comment', + 'Index_comment', + 'Visible', + 'Expression', + ), + 'check_table' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + 'analyze_table' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + 'optimize_table' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + 'repair_table' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + ); + + foreach ( $expected_cases as $case_name => $expected_names ) { + $this->assertArrayHasKey( $case_name, $cases ); + $this->assertCount( count( $expected_names ), $cases[ $case_name ], $case_name ); + + foreach ( $expected_names as $index => $expected_name ) { + $this->assertSame( + array( + 'name' => $expected_name, + 'orgname' => $expected_name, + 'table' => '', + 'orgtable' => '', + 'def' => '', + 'db' => 'wordpress_test', + 'catalog' => 'def', + 'max_length' => 0, + 'length' => 0, + 'charsetnr' => 224, + 'flags' => 0, + 'type' => 253, + 'decimals' => 0, + ), + $cases[ $case_name ][ $index ], + $case_name . ' column ' . $expected_name + ); + } + } + } + public function test_duckdb_wpdb_query_surface_provider(): void { $result = $this->run_query_surface_state_script(); $cases = $result['cases']; @@ -824,6 +907,141 @@ function ( $column ) { return $this->run_isolated_php( $code ); } + private function run_col_info_fallback_state_script(): array { + $plugin_dir = $this->get_plugin_dir(); + $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; + $code = $this->get_wordpress_stub_code(); + $code .= "\nrequire_once " . var_export( $driver_load, true ) . ";\n"; + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= <<<'PHP' + +class WP_DuckDB_Plugin_Col_Info_Fallback_Test_Driver extends WP_DuckDB_Driver { + public function __construct() {} + + public function query( string $sql ): WP_DuckDB_Result_Statement { + if ( 'SELECT @@SESSION.sql_mode' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( '@@SESSION.sql_mode' ), + array( + array( 'NO_ENGINE_SUBSTITUTION' ), + ), + 0 + ); + } + + if ( "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'" === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + $statement_columns = array( + 'SELECT COUNT(*) AS post_count' => array( 'post_count' ), + 'SELECT FOUND_ROWS() AS found_rows' => array( 'found_rows' ), + 'SHOW FULL TABLES' => array( 'Tables_in_wordpress_test', 'Table_type' ), + 'SHOW COLUMNS FROM wp_posts' => array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + 'SHOW TABLE STATUS' => array( + 'Name', + 'Engine', + 'Version', + 'Row_format', + 'Rows', + 'Avg_row_length', + 'Data_length', + 'Max_data_length', + 'Index_length', + 'Data_free', + 'Auto_increment', + 'Create_time', + 'Update_time', + 'Check_time', + 'Collation', + 'Checksum', + 'Create_options', + 'Comment', + ), + 'SHOW CREATE TABLE wp_posts' => array( 'Table', 'Create Table' ), + 'SHOW INDEX FROM wp_posts' => array( + 'Table', + 'Non_unique', + 'Key_name', + 'Seq_in_index', + 'Column_name', + 'Collation', + 'Cardinality', + 'Sub_part', + 'Packed', + 'Null', + 'Index_type', + 'Comment', + 'Index_comment', + 'Visible', + 'Expression', + ), + 'CHECK TABLE wp_posts' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + 'ANALYZE TABLE wp_posts' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + 'OPTIMIZE TABLE wp_posts' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + 'REPAIR TABLE wp_posts' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + ); + + if ( isset( $statement_columns[ $sql ] ) ) { + $columns = $statement_columns[ $sql ]; + return new WP_DuckDB_Result_Statement( + $columns, + array( + array_fill( 0, count( $columns ), null ), + ), + 0 + ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } +} + +class WP_DuckDB_Plugin_Col_Info_Fallback_Test_DB extends WP_DuckDB_DB { + public function exported_col_info() { + $this->load_col_info(); + return array_map( + function ( $column ) { + return (array) $column; + }, + $this->col_info + ); + } +} + +function wp_duckdb_plugin_col_info_fallback_case( WP_DuckDB_Plugin_Col_Info_Fallback_Test_DB $db, $sql ) { + $db->query( $sql ); + return $db->exported_col_info(); +} + +$GLOBALS['@duckdb_driver'] = new WP_DuckDB_Plugin_Col_Info_Fallback_Test_Driver(); +$db = new WP_DuckDB_Plugin_Col_Info_Fallback_Test_DB( 'wordpress_test' ); +$connected = $db->db_connect( false ); +$cases = array( + 'select_count' => wp_duckdb_plugin_col_info_fallback_case( $db, 'SELECT COUNT(*) AS post_count' ), + 'found_rows' => wp_duckdb_plugin_col_info_fallback_case( $db, 'SELECT FOUND_ROWS() AS found_rows' ), + 'show_full_tables' => wp_duckdb_plugin_col_info_fallback_case( $db, 'SHOW FULL TABLES' ), + 'show_columns' => wp_duckdb_plugin_col_info_fallback_case( $db, 'SHOW COLUMNS FROM wp_posts' ), + 'show_table_status' => wp_duckdb_plugin_col_info_fallback_case( $db, 'SHOW TABLE STATUS' ), + 'show_create_table' => wp_duckdb_plugin_col_info_fallback_case( $db, 'SHOW CREATE TABLE wp_posts' ), + 'show_index' => wp_duckdb_plugin_col_info_fallback_case( $db, 'SHOW INDEX FROM wp_posts' ), + 'check_table' => wp_duckdb_plugin_col_info_fallback_case( $db, 'CHECK TABLE wp_posts' ), + 'analyze_table' => wp_duckdb_plugin_col_info_fallback_case( $db, 'ANALYZE TABLE wp_posts' ), + 'optimize_table' => wp_duckdb_plugin_col_info_fallback_case( $db, 'OPTIMIZE TABLE wp_posts' ), + 'repair_table' => wp_duckdb_plugin_col_info_fallback_case( $db, 'REPAIR TABLE wp_posts' ), +); + +echo json_encode( + array( + 'connected' => $connected, + 'cases' => $cases, + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + private function run_query_surface_state_script(): array { $plugin_dir = $this->get_plugin_dir(); $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; From d28a5d1e1b446248b668395f6d7311a9624c272b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 06:46:46 +0000 Subject: [PATCH 078/317] Normalize DuckDB ODKU insert id policy --- .../src/duckdb/class-wp-duckdb-driver.php | 60 +++++++++--- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 97 +++++++++++++++++++ 2 files changed, 143 insertions(+), 14 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index d848b8cca..15fb23619 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -2047,13 +2047,7 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { if ( $ignore ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT statement in DuckDB driver. INSERT IGNORE ... ON DUPLICATE KEY UPDATE is not supported.' ); } - return $this->execute_auto_increment_write( - $this->identifier_value( $tokens[ $index ] ?? null ), - $this->translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( $tokens, $index, $on_duplicate_index ), - 'Failed to execute DuckDB INSERT', - $tokens, - $index - ); + return $this->execute_insert_on_duplicate_key_update( $tokens, $index, $on_duplicate_index ); } if ( ! $ignore ) { @@ -2069,6 +2063,33 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { ); } + /** + * Execute INSERT ... ON DUPLICATE KEY UPDATE. + * + * Duplicate-update branches must not expose DuckDB sequence values consumed + * by the attempted insert as MySQL insert ids. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param int $on_duplicate_index Index of the ON token. + * @return WP_DuckDB_Result_Statement + */ + private function execute_insert_on_duplicate_key_update( array $tokens, int $table_index, int $on_duplicate_index ): WP_DuckDB_Result_Statement { + $translation = $this->translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( $tokens, $table_index, $on_duplicate_index ); + + if ( $translation['matched'] ) { + return $this->execute_duckdb_query( $translation['sql'], 'Failed to execute DuckDB INSERT' ); + } + + return $this->execute_auto_increment_write( + $this->identifier_value( $tokens[ $table_index ] ?? null ), + $translation['sql'], + 'Failed to execute DuckDB INSERT', + $tokens, + $table_index + ); + } + /** * Execute a supported REPLACE statement. * @@ -12546,9 +12567,9 @@ private function parse_insert_set_assignments( array $tokens ): array { * @param WP_Parser_Token[] $tokens MySQL tokens. * @param int $table_index Index of the table token. * @param int $on_duplicate_index Index of the ON token. - * @return string DuckDB SQL. + * @return array{sql:string,matched:bool} DuckDB SQL and whether the incoming values matched an existing unique key. */ - private function translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( array $tokens, int $table_index, int $on_duplicate_index ): string { + private function translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( array $tokens, int $table_index, int $on_duplicate_index ): array { $insert_shape = $this->parse_on_duplicate_insert_shape( $tokens, $table_index, $on_duplicate_index ); $target = $this->select_on_duplicate_conflict_target( $insert_shape['table_name'], $insert_shape['temporary'], $insert_shape['values_by_column'] ); $update_sql = $this->translate_on_duplicate_update_tokens_to_duckdb_sql( @@ -12562,7 +12583,7 @@ private function translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. UPDATE list is required.' ); } - return $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $table_index, false, 'INSERT', $on_duplicate_index ) + $sql = $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $table_index, false, 'INSERT', $on_duplicate_index ) . ' ON CONFLICT (' . implode( ', ', @@ -12570,11 +12591,16 @@ private function translate_insert_on_duplicate_key_update_tokens_to_duckdb_sql( function ( string $column_name ): string { return $this->connection->quote_identifier( $column_name ); }, - $target + $target['columns'] ) ) . ') DO UPDATE SET ' . $update_sql; + + return array( + 'sql' => $sql, + 'matched' => $target['matched'], + ); } /** @@ -13377,7 +13403,7 @@ private function unique_key_conflict_predicate( array $column_set, array $values * @param string $table_name Table name. * @param bool $temporary Whether the target is a temporary table. * @param array $values_by_column Inserted values keyed by lowercase column name. - * @return string[] Conflict target columns. + * @return array{columns:string[],matched:bool} Conflict target columns and whether an existing row matched. */ private function select_on_duplicate_conflict_target( string $table_name, bool $temporary, array $values_by_column ): array { $eligible_targets = array(); @@ -13403,14 +13429,20 @@ private function select_on_duplicate_conflict_target( string $table_name, bool $ } if ( 1 === count( $matched_targets ) ) { - return $matched_targets[0]; + return array( + 'columns' => $matched_targets[0], + 'matched' => true, + ); } if ( count( $matched_targets ) > 1 ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Insert values match multiple unique key targets.' ); } if ( count( $eligible_targets ) > 0 ) { - return $eligible_targets[0]; + return array( + 'columns' => $eligible_targets[0], + 'matched' => false, + ); } throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Insert values do not include a unique key target.' ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 89aff3403..71f10bfc6 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3725,6 +3725,103 @@ public function test_insert_id_tracks_generated_auto_increment_values(): void { $this->fail( 'Expected duplicate insert to fail.' ); } + public function test_insert_id_tracks_on_duplicate_key_update_policy(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE odku_auto_items ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(100) UNIQUE, + alias VARCHAR(100) UNIQUE, + hits INTEGER NOT NULL DEFAULT 0 + )' + ); + + $driver->query( "INSERT INTO odku_auto_items (slug, alias, hits) VALUES ('existing', 'alias-existing', 1)" ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $driver->query( + "INSERT INTO odku_auto_items (slug, alias, hits) + VALUES ('generated', 'alias-generated', 2) + ON DUPLICATE KEY UPDATE hits = VALUES(hits)" + ); + $this->assertSame( 2, $driver->get_insert_id() ); + + $driver->query( + "INSERT INTO odku_auto_items (id, slug, alias, hits) + VALUES (42, 'explicit', 'alias-explicit', 4) + ON DUPLICATE KEY UPDATE hits = VALUES(hits)" + ); + $this->assertSame( 42, $driver->get_insert_id() ); + + $driver->query( + "INSERT INTO odku_auto_items (id, slug, alias, hits) + VALUES (1, 'primary-change', 'alias-primary-change', 7) + ON DUPLICATE KEY UPDATE slug = VALUES(slug), alias = VALUES(alias), hits = VALUES(hits)" + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $driver->query( + "INSERT INTO odku_auto_items (slug, alias, hits) + VALUES ('primary-change', 'alias-unused-update', 3) + ON DUPLICATE KEY UPDATE hits = hits + VALUES(hits)" + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $driver->query( + "INSERT INTO odku_auto_items (slug, alias, hits) + VALUES ('primary-change', 'alias-unused-noop', 99) + ON DUPLICATE KEY UPDATE hits = hits" + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $driver->query( "INSERT INTO odku_auto_items (id, slug, alias) VALUES (100, 'before-failure', 'alias-before-failure')" ); + $this->assertSame( 100, $driver->get_insert_id() ); + + try { + $driver->query( + "INSERT INTO odku_auto_items (slug, alias, hits) + VALUES ('primary-change', 'alias-generated', 13) + ON DUPLICATE KEY UPDATE alias = VALUES(alias)" + ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertSame( 0, $driver->get_insert_id() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'slug' => 'primary-change', + 'alias' => 'alias-primary-change', + 'hits' => 10, + ), + array( + 'id' => 2, + 'slug' => 'generated', + 'alias' => 'alias-generated', + 'hits' => 2, + ), + array( + 'id' => 42, + 'slug' => 'explicit', + 'alias' => 'alias-explicit', + 'hits' => 4, + ), + array( + 'id' => 100, + 'slug' => 'before-failure', + 'alias' => 'alias-before-failure', + 'hits' => 0, + ), + ), + $driver->query( 'SELECT id, slug, alias, hits FROM odku_auto_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + return; + } + + $this->fail( 'Expected duplicate ODKU update to violate the alias unique key.' ); + } + public function test_insert_on_duplicate_key_update_values_is_emulated(): void { $this->requireDuckDBRuntime(); From 1851624042e0d1b78103558d21e3e5b430fa06a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 06:46:52 +0000 Subject: [PATCH 079/317] Characterize DuckDB wpdb ODKU row counts --- .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index d35fb538b..70cb5401a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -354,6 +354,23 @@ public function test_duckdb_wpdb_query_surface_provider(): void { $this->assertSame( array(), $cases['create_table']['col_info_names'] ); } + public function test_duckdb_wpdb_odku_rows_affected_are_propagated_from_statement(): void { + $result = $this->run_query_surface_state_script(); + $cases = $result['cases']; + + $this->assertTrue( $result['connected'] ); + + $this->assertSame( 1, $cases['odku_duplicate_changed']['return'] ); + $this->assertSame( 1, $cases['odku_duplicate_changed']['rows_affected'] ); + $this->assertSame( 0, $cases['odku_duplicate_changed']['num_rows'] ); + $this->assertSame( array(), $cases['odku_duplicate_changed']['col_info_names'] ); + + $this->assertSame( 1, $cases['odku_duplicate_noop']['return'] ); + $this->assertSame( 1, $cases['odku_duplicate_noop']['rows_affected'] ); + $this->assertSame( 0, $cases['odku_duplicate_noop']['num_rows'] ); + $this->assertSame( array(), $cases['odku_duplicate_noop']['col_info_names'] ); + } + public function test_duckdb_wpdb_last_error_surface_provider(): void { $result = $this->run_query_surface_state_script(); $cases = $result['cases']; @@ -1051,6 +1068,11 @@ private function run_query_surface_state_script(): array { $code .= <<<'PHP' class WP_DuckDB_Plugin_Query_Surface_Test_Driver extends WP_DuckDB_Driver { + const ODKU_CHANGED_SQL = "INSERT INTO wp_posts (ID, post_title) VALUES (11, 'changed') " + . 'ON DUPLICATE KEY UPDATE post_title = VALUES(post_title)'; + const ODKU_NOOP_SQL = "INSERT INTO wp_posts (ID, post_title) VALUES (11, 'same') " + . 'ON DUPLICATE KEY UPDATE post_title = post_title'; + private $insert_id = 0; public function __construct() {} @@ -1166,6 +1188,14 @@ public function query( string $sql ): WP_DuckDB_Result_Statement { return new WP_DuckDB_Result_Statement( array(), array(), 1 ); } + if ( self::ODKU_CHANGED_SQL === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + if ( self::ODKU_NOOP_SQL === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + if ( "UPDATE wp_posts SET post_title = 'missing' WHERE ID = 999" === $sql ) { return new WP_DuckDB_Result_Statement( array(), array(), 0 ); } @@ -1260,6 +1290,14 @@ function wp_duckdb_plugin_query_surface_case( WP_DuckDB_Plugin_Query_Surface_Tes 'check_table' => wp_duckdb_plugin_query_surface_case( $db, 'CHECK TABLE wp_posts' ), 'insert_row' => wp_duckdb_plugin_query_surface_case( $db, "INSERT INTO wp_posts (post_title) VALUES ('hello')" ), 'update_changed' => wp_duckdb_plugin_query_surface_case( $db, "UPDATE wp_posts SET post_title = 'changed' WHERE ID = 11" ), + 'odku_duplicate_changed' => wp_duckdb_plugin_query_surface_case( + $db, + WP_DuckDB_Plugin_Query_Surface_Test_Driver::ODKU_CHANGED_SQL + ), + 'odku_duplicate_noop' => wp_duckdb_plugin_query_surface_case( + $db, + WP_DuckDB_Plugin_Query_Surface_Test_Driver::ODKU_NOOP_SQL + ), 'update_no_match' => wp_duckdb_plugin_query_surface_case( $db, "UPDATE wp_posts SET post_title = 'missing' WHERE ID = 999" ), 'delete_row' => wp_duckdb_plugin_query_surface_case( $db, 'DELETE FROM wp_posts WHERE ID = 11' ), 'replace_row' => wp_duckdb_plugin_query_surface_case( $db, "REPLACE INTO wp_posts (ID, post_title) VALUES (12, 'replacement')" ), From f4e0f97435087c27a774f034271ffc4939cbd167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 06:50:28 +0000 Subject: [PATCH 080/317] Update DuckDB INSERT SET logged SQL expectation --- .../mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 71f10bfc6..9be391e5a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3599,14 +3599,14 @@ public function test_insert_set_is_emulated(): void { $inserted = $driver->query( "INSERT INTO items SET id = 1, name = 'first', hits = 2" ); $this->assertSame( 1, $inserted->rowCount() ); - $this->assertSame( "INSERT INTO items (id, name, hits) VALUES (1, 'first', 2)", $this->lastDuckDBQuery( $driver ) ); + $this->assertSame( "INSERT INTO \"items\" (id, name, hits) VALUES (1, 'first', 2)", $this->lastDuckDBQuery( $driver ) ); $inserted_without_into = $driver->query( "INSERT items SET id = 2, name = 'second'" ); $this->assertSame( 1, $inserted_without_into->rowCount() ); $ignored = $driver->query( "INSERT IGNORE items SET id = 2, name = 'duplicate'" ); $this->assertSame( 0, $ignored->rowCount() ); - $this->assertSame( "INSERT OR IGNORE INTO items (id, name) VALUES (2, 'duplicate')", $this->lastDuckDBQuery( $driver ) ); + $this->assertSame( "INSERT OR IGNORE INTO \"items\" (id, name) VALUES (2, 'duplicate')", $this->lastDuckDBQuery( $driver ) ); $this->assertSame( array( From c084dbdc815208cd67d270b32cc265bb7df9d190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 07:03:43 +0000 Subject: [PATCH 081/317] Add DuckDB WordPress test setup tooling --- composer.json | 17 ++++++++- wp-setup.sh | 100 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 110 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 689b44ed7..f15e37602 100644 --- a/composer.json +++ b/composer.json @@ -50,6 +50,9 @@ "wp-setup": [ "./wp-setup.sh" ], + "wp-setup-duckdb": [ + "WP_TEST_DB_ENGINE=duckdb ./wp-setup.sh" + ], "wp-run": [ "npm --prefix wordpress run" ], @@ -64,9 +67,19 @@ "@putenv COMPOSE_IGNORE_ORPHANS=true", "cd wordpress && if [ -z \"$(node tools/local-env/scripts/docker.js ps -q)\" ]; then cd ..; composer run wp-test-start; fi" ], + "wp-test-ensure-env-duckdb": [ + "if [ ! -f wordpress/src/wp-load.php ] || ! grep -q \"define( 'DB_ENGINE', 'duckdb' );\" wordpress/src/wp-content/db.php 2>/dev/null; then composer run wp-setup-duckdb; fi", + "@putenv COMPOSE_IGNORE_ORPHANS=true", + "cd wordpress && if [ -z \"$(node tools/local-env/scripts/docker.js ps -q)\" ]; then cd ..; composer run wp-test-start; fi" + ], "wp-test-php": [ "@wp-test-ensure-env @no_additional_args", - "rm -rf wordpress/src/wp-content/database/.ht.sqlite @no_additional_args", + "rm -rf wordpress/src/wp-content/database/.ht.sqlite wordpress/src/wp-content/database/.ht.duckdb wordpress/src/wp-content/database/.ht.duckdb.wal @no_additional_args", + "npm --prefix wordpress run test:php -- @additional_args" + ], + "wp-test-php-duckdb": [ + "@wp-test-ensure-env-duckdb @no_additional_args", + "rm -rf wordpress/src/wp-content/database/.ht.duckdb wordpress/src/wp-content/database/.ht.duckdb.wal @no_additional_args", "npm --prefix wordpress run test:php -- @additional_args" ], "wp-test-e2e": [ @@ -75,7 +88,7 @@ ], "wp-test-clean": [ "npm --prefix wordpress run env:clean", - "rm -rf wordpress/src/wp-content/database/.ht.sqlite" + "rm -rf wordpress/src/wp-content/database/.ht.sqlite wordpress/src/wp-content/database/.ht.duckdb wordpress/src/wp-content/database/.ht.duckdb.wal" ] } } diff --git a/wp-setup.sh b/wp-setup.sh index 0e4321010..c04f43f16 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -12,6 +12,76 @@ WP_VERSION="6.7.2" DIR="$(dirname "$0")" WP_DIR="$DIR/wordpress" +WP_TEST_DB_ENGINE="${WP_TEST_DB_ENGINE:-sqlite}" +DUCKDB_PHP_AUTOLOAD="${DUCKDB_PHP_AUTOLOAD:-}" +DUCKDB_DOCKER_VOLUMES="" + +yaml_single_quote() { + local value="$1" + + value=${value//\'/\'\'} + printf "'%s'" "$value" +} + +insert_duckdb_autoload_constant() { + local file="$1" + local autoload="$2" + + php -r ' +$file = $argv[1]; +$autoload = $argv[2]; +$contents = file_get_contents( $file ); +$needle = "require_once \$sqlite_plugin_implementation_folder_path . " . chr(39) . "/wp-includes/db.php" . chr(39) . ";"; +$define = "if ( ! defined( " . chr(39) . "DUCKDB_PHP_AUTOLOAD" . chr(39) . " ) ) {\n\tdefine( " . chr(39) . "DUCKDB_PHP_AUTOLOAD" . chr(39) . ", " . var_export( $autoload, true ) . " );\n}\n\n"; + +if ( false === strpos( $contents, $needle ) ) { + fwrite( STDERR, "Error: Could not find DuckDB drop-in insertion point.\n" ); + exit( 1 ); +} + +file_put_contents( $file, str_replace( $needle, $define . $needle, $contents ) ); +' "$file" "$autoload" +} + +case "$WP_TEST_DB_ENGINE" in + sqlite|duckdb) + ;; + *) + echo "Error: WP_TEST_DB_ENGINE must be either 'sqlite' or 'duckdb'." >&2 + exit 1 + ;; +esac + +if [ "$WP_TEST_DB_ENGINE" = "duckdb" ]; then + if [ -z "$DUCKDB_PHP_AUTOLOAD" ]; then + echo 'Error: DUCKDB_PHP_AUTOLOAD must be set when WP_TEST_DB_ENGINE=duckdb.' >&2 + exit 1 + fi + + case "$DUCKDB_PHP_AUTOLOAD" in + /*) + ;; + *) + echo 'Error: DUCKDB_PHP_AUTOLOAD must be an absolute path.' >&2 + exit 1 + ;; + esac + + if [ ! -f "$DUCKDB_PHP_AUTOLOAD" ]; then + echo "Error: DUCKDB_PHP_AUTOLOAD must point to an existing file: $DUCKDB_PHP_AUTOLOAD" >&2 + exit 1 + fi + + DUCKDB_PHP_AUTOLOAD="$(cd "$(dirname "$DUCKDB_PHP_AUTOLOAD")" && pwd -P)/$(basename "$DUCKDB_PHP_AUTOLOAD")" + DUCKDB_RUNTIME_DIR="$(dirname "$DUCKDB_PHP_AUTOLOAD")" + if [ "$(basename "$DUCKDB_RUNTIME_DIR")" = "vendor" ]; then + DUCKDB_RUNTIME_DIR="$(dirname "$DUCKDB_RUNTIME_DIR")" + fi + + DUCKDB_RUNTIME_VOLUME="$(yaml_single_quote "$DUCKDB_RUNTIME_DIR:$DUCKDB_RUNTIME_DIR:ro")" + DUCKDB_FFI_INI_VOLUME="$(yaml_single_quote "./duckdb-php-conf/zz-duckdb-ffi.ini:/usr/local/etc/php/conf.d/zz-duckdb-ffi.ini:ro")" + DUCKDB_DOCKER_VOLUMES=$(printf ' - %s\n - %s' "$DUCKDB_RUNTIME_VOLUME" "$DUCKDB_FFI_INI_VOLUME") +fi # 1. Ensure that Git is installed. echo "Checking if Git is installed..." @@ -41,6 +111,7 @@ services: volumes: - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database +$DUCKDB_DOCKER_VOLUMES cli: # PHP temporarily pinned to 8.3.10, see: https://github.com/WordPress/wordpress-develop/pull/9602 @@ -48,18 +119,37 @@ services: volumes: - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database +$DUCKDB_DOCKER_VOLUMES EOF +if [ "$WP_TEST_DB_ENGINE" = "duckdb" ]; then + echo "Adding DuckDB PHP FFI configuration..." + mkdir -p "$WP_DIR/duckdb-php-conf" + printf 'ffi.enable=1\n' > "$WP_DIR/duckdb-php-conf/zz-duckdb-ffi.ini" +fi # 4. Add "db.php" to the "wp-content" directory. echo "Adding 'db.php' to the 'wp-content' directory..." rm -f "$WP_DIR"/src/wp-content/db.php -cp "$DIR"/packages/plugin-sqlite-database-integration/db.copy "$WP_DIR"/src/wp-content/db.php +if [ "$WP_TEST_DB_ENGINE" = "duckdb" ]; then + cp "$DIR"/packages/plugin-sqlite-database-integration/db-duckdb.copy "$WP_DIR"/src/wp-content/db.php +else + cp "$DIR"/packages/plugin-sqlite-database-integration/db.copy "$WP_DIR"/src/wp-content/db.php +fi sed -i.bak "s#'{SQLITE_IMPLEMENTATION_FOLDER_PATH}'#__DIR__.'/plugins/sqlite-database-integration'#g" "$WP_DIR"/src/wp-content/db.php -sed -i.bak "s#{SQLITE_PLUGIN}#sqlite-database-integration/load.php#g" "$WP_DIR"/src/wp-content/db.php +if [ "$WP_TEST_DB_ENGINE" = "duckdb" ]; then + insert_duckdb_autoload_constant "$WP_DIR"/src/wp-content/db.php "$DUCKDB_PHP_AUTOLOAD" +else + sed -i.bak "s#{SQLITE_PLUGIN}#sqlite-database-integration/load.php#g" "$WP_DIR"/src/wp-content/db.php +fi -# 5. Rewrite helper class WpdbExposedMethodsForTesting to extend WP_SQLite_DB. -echo "Rewriting helper class 'WpdbExposedMethodsForTesting' to extend WP_SQLite_DB..." -sed -i.bak "s#class WpdbExposedMethodsForTesting extends wpdb {#class WpdbExposedMethodsForTesting extends WP_SQLite_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php +# 5. Rewrite helper class WpdbExposedMethodsForTesting to extend the active database adapter. +if [ "$WP_TEST_DB_ENGINE" = "duckdb" ]; then + WPDB_TEST_HELPER_CLASS="WP_DuckDB_DB" +else + WPDB_TEST_HELPER_CLASS="WP_SQLite_DB" +fi +echo "Rewriting helper class 'WpdbExposedMethodsForTesting' to extend $WPDB_TEST_HELPER_CLASS..." +sed -i.bak "s#class WpdbExposedMethodsForTesting extends wpdb {#class WpdbExposedMethodsForTesting extends $WPDB_TEST_HELPER_CLASS {#g" "$WP_DIR"/tests/phpunit/includes/utils.php # 6. Install dependencies. echo "Installing dependencies..." From 9540444a02c19af1738dcba5ee2a7b6ab739f756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 07:07:39 +0000 Subject: [PATCH 082/317] Support aliased DuckDB sql_mode selects --- .../src/duckdb/class-wp-duckdb-driver.php | 32 +++++++++++++++---- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 3 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 32 +++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 15fb23619..db2a7d828 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1621,26 +1621,46 @@ private function parse_session_system_variable_reference( array $tokens ): ?arra } $session_scoped = false; - if ( 2 === count( $tokens ) ) { - $name = $this->session_system_variable_name( $tokens[1] ); - } elseif ( - 4 === count( $tokens ) + if ( + isset( $tokens[3] ) && WP_MySQL_Lexer::SESSION_SYMBOL === $tokens[1]->id && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[2]->id ) { $name = $this->session_system_variable_name( $tokens[3] ); $session_scoped = true; + $index = 4; } else { - return null; + $name = $this->session_system_variable_name( $tokens[1] ); + $index = 2; } if ( null === $name || ! $this->is_supported_session_system_variable_reference( $name, $session_scoped ) ) { return null; } + $alias = $this->concatenate_token_bytes( array_slice( $tokens, 0, $index ) ); + if ( isset( $tokens[ $index ] ) ) { + if ( 'sql_mode' !== $name ) { + return null; + } + + if ( WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $alias = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + } else { + $alias = $this->identifier_value( $tokens[ $index ] ); + ++$index; + } + } + + if ( count( $tokens ) !== $index ) { + return null; + } + return array( 'name' => $name, - 'alias' => $this->concatenate_token_bytes( $tokens ), + 'alias' => $alias, ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index f55fd873b..8fa729884 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -356,6 +356,9 @@ public function test_sql_mode_bootstrap_sql_matches_sqlite(): void { $this->assertParityRowCount( 'SET CHARACTER SET utf8mb4' ); $this->assertParityRows( 'SELECT @@SESSION.sql_mode, @@sql_mode' ); + $this->assertParityRows( 'SELECT @@SESSION.sql_mode AS sql_mode' ); + $this->assertParityRows( 'SELECT @@sql_mode AS mode' ); + $this->assertParityRows( 'SELECT @@ SESSION.sql_mode AS spaced_sql_mode' ); $this->assertParityRowCount( 'SET NAMES utf8mb4, autocommit = 0' ); $this->assertParityRows( 'SELECT @@autocommit' ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 9be391e5a..0406f0038 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1211,6 +1211,38 @@ public function test_sql_mode_bootstrap_statements_are_emulated(): void { } } + public function test_aliased_sql_mode_select_expressions_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( "SET SESSION sql_mode = 'STRICT_TRANS_TABLES, NO_ZERO_DATE, NO_ZERO_IN_DATE'" ); + + foreach ( + array( + array( + 'sql' => 'SELECT @@SESSION.sql_mode AS sql_mode', + 'alias' => 'sql_mode', + ), + array( + 'sql' => 'SELECT @@sql_mode AS mode', + 'alias' => 'mode', + ), + array( + 'sql' => 'SELECT @@ SESSION.sql_mode AS spaced_sql_mode', + 'alias' => 'spaced_sql_mode', + ), + ) as $case + ) { + $read = $driver->query( $case['sql'] ); + $this->assertSame( array( 'name' => $case['alias'] ), $read->getColumnMeta( 0 ) ); + $this->assertSame( + array( $case['alias'] => 'STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE' ), + $read->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + } + public function test_builtin_system_variables_are_emulated(): void { $this->requireDuckDBRuntime(); From bef522d50965325e4e1d7fea69f9de19c57c21e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 07:18:01 +0000 Subject: [PATCH 083/317] Add DuckDB text and blob write coercion parity --- .../src/duckdb/class-wp-duckdb-driver.php | 122 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 23 ++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 2 +- 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index db2a7d828..1ea84718e 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -13012,7 +13012,7 @@ private function omitted_non_strict_temporal_default_writes( string $table_name, } /** - * Coerce a target-column value for temporal storage when needed. + * Coerce a target-column value for storage when needed. * * @param array $metadata Column metadata. * @param WP_Parser_Token[] $value_tokens RHS value tokens. @@ -13022,7 +13022,19 @@ private function omitted_non_strict_temporal_default_writes( string $table_name, */ private function coerce_write_value_for_column_sql( array $metadata, array $value_tokens, string $value_sql, bool $coalesce_non_strict_not_null ): string { $data_type = $this->mysql_column_data_type( $metadata ); - if ( ! $this->is_temporal_write_data_type( $data_type ) || $this->is_default_value_tokens( $value_tokens ) ) { + if ( $this->is_default_value_tokens( $value_tokens ) ) { + return $value_sql; + } + + if ( $this->is_character_write_data_type( $data_type ) ) { + return $this->coerce_character_write_value_sql( $value_tokens, $value_sql ); + } + + if ( $this->is_blob_write_data_type( $data_type ) ) { + return $this->coerce_blob_write_value_sql( $value_tokens, $value_sql ); + } + + if ( ! $this->is_temporal_write_data_type( $data_type ) ) { return $value_sql; } @@ -13047,6 +13059,112 @@ private function coerce_write_value_for_column_sql( array $metadata, array $valu return $value_sql; } + /** + * Check whether a data type needs character write coercion. + * + * @param string $data_type MySQL data type. + * @return bool Whether the type is character-backed. + */ + private function is_character_write_data_type( string $data_type ): bool { + return in_array( $data_type, array( 'char', 'varchar', 'text', 'tinytext', 'mediumtext', 'longtext' ), true ); + } + + /** + * Check whether a data type needs blob write coercion. + * + * @param string $data_type MySQL data type. + * @return bool Whether the type is blob-backed. + */ + private function is_blob_write_data_type( string $data_type ): bool { + return in_array( $data_type, array( 'blob', 'tinyblob', 'mediumblob', 'longblob' ), true ); + } + + /** + * Coerce a write value to MySQL/SQLite-like text storage. + * + * @param WP_Parser_Token[] $value_tokens RHS value tokens. + * @param string $value_sql Translated RHS SQL. + * @return string Coerced value SQL. + */ + private function coerce_character_write_value_sql( array $value_tokens, string $value_sql ): string { + $hex_sql = $this->binary_literal_write_hex_sql( $value_tokens ); + if ( null !== $hex_sql ) { + return 'decode(from_hex(' . $hex_sql . '))'; + } + + return $this->write_value_display_sql( $value_tokens, $value_sql ); + } + + /** + * Coerce a write value to MySQL/SQLite-like blob storage. + * + * @param WP_Parser_Token[] $value_tokens RHS value tokens. + * @param string $value_sql Translated RHS SQL. + * @return string Coerced value SQL. + */ + private function coerce_blob_write_value_sql( array $value_tokens, string $value_sql ): string { + $hex_sql = $this->binary_literal_write_hex_sql( $value_tokens ); + if ( null !== $hex_sql ) { + return 'from_hex(' . $hex_sql . ')'; + } + + return 'CAST(' . $this->write_value_display_sql( $value_tokens, $value_sql ) . ' AS BLOB)'; + } + + /** + * Return a quoted hex string for a MySQL binary literal token. + * + * @param WP_Parser_Token[] $value_tokens RHS value tokens. + * @return string|null Quoted hex string SQL, or null for non-binary literals. + */ + private function binary_literal_write_hex_sql( array $value_tokens ): ?string { + if ( 1 !== count( $value_tokens ) ) { + return null; + } + + $token = $value_tokens[0]; + $value = $token->get_value(); + if ( WP_MySQL_Lexer::HEX_NUMBER === $token->id ) { + if ( strlen( $value ) >= 2 && '0' === $value[0] && 'x' === strtolower( $value[1] ) ) { + return $this->connection->quote( substr( $value, 2 ) ); + } + + if ( strlen( $value ) >= 3 && 'x' === strtolower( $value[0] ) && "'" === $value[1] ) { + return $this->connection->quote( substr( $value, 2, -1 ) ); + } + + return null; + } + + if ( WP_MySQL_Lexer::BIN_NUMBER !== $token->id ) { + return null; + } + + if ( strlen( $value ) >= 2 && '0' === $value[0] && 'b' === strtolower( $value[1] ) ) { + $bits = substr( $value, 2 ); + } elseif ( strlen( $value ) >= 3 && 'b' === strtolower( $value[0] ) && "'" === $value[1] ) { + $bits = substr( $value, 2, -1 ); + } else { + return null; + } + + if ( '' === $bits ) { + return $this->connection->quote( '' ); + } + + $bits = str_pad( $bits, (int) ceil( strlen( $bits ) / 4 ) * 4, '0', STR_PAD_LEFT ); + $hex = ''; + for ( $offset = 0; $offset < strlen( $bits ); $offset += 4 ) { + $hex .= dechex( bindec( substr( $bits, $offset, 4 ) ) ); + } + + if ( 1 === strlen( $hex ) % 2 ) { + $hex = '0' . $hex; + } + + return $this->connection->quote( $hex ); + } + /** * Build a non-throwing strict temporal validation expression for a target column. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 8fa729884..24d30861d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -129,6 +129,29 @@ public function test_create_insert_select_update_delete_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_non_temporal_text_and_blob_write_coercions_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE write_coercions (id INTEGER PRIMARY KEY, text_value TEXT, blob_value BLOB)', + ) + ); + + $this->assertParityRowCount( + "INSERT INTO write_coercions (id, text_value, blob_value) VALUES + (1, TRUE, TRUE), + (2, 0x62, 0x62), + (3, x'63', x'63'), + (4, 123.456, 123.456)" + ); + $this->assertParityRows( 'SELECT id, text_value, blob_value FROM write_coercions ORDER BY id' ); + + $this->assertParityRowCount( 'UPDATE write_coercions SET text_value = FALSE, blob_value = FALSE WHERE id = 4' ); + $this->assertParityRows( 'SELECT id, text_value, blob_value FROM write_coercions ORDER BY id' ); + + $this->assertParityRowCount( "REPLACE INTO write_coercions (id, text_value, blob_value) VALUES (2, x'64', x'65')" ); + $this->assertParityRows( 'SELECT id, text_value, blob_value FROM write_coercions ORDER BY id' ); + } + public function test_show_full_tables_sql_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 0406f0038..6600ebe51 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3873,7 +3873,7 @@ public function test_insert_on_duplicate_key_update_values_is_emulated(): void { ); $this->assertSame( 1, $duplicate_primary->rowCount() ); $this->assertSame( - 'INSERT INTO items(id, name, hits) VALUES (1, \'renamed\', 7) ON CONFLICT ("id") DO UPDATE SET name = excluded."name", hits = excluded."hits"', + 'INSERT INTO items(id, name, hits) VALUES (1, \'renamed\', 7) ON CONFLICT ("id") DO UPDATE SET name = CAST((excluded."name") AS VARCHAR), hits = excluded."hits"', $this->lastDuckDBQuery( $driver ) ); From 1a04515d90d616f92e5a6dcd8d84c2ae7b576ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 07:30:28 +0000 Subject: [PATCH 084/317] Track DuckDB current database for information_schema --- .../src/duckdb/class-wp-duckdb-driver.php | 384 +++++++++++++++--- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 38 ++ 2 files changed, 359 insertions(+), 63 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 1ea84718e..98558f081 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -112,6 +112,11 @@ class WP_DuckDB_Driver { */ private $database; + /** + * @var string + */ + private $current_database; + /** * @var int */ @@ -206,6 +211,7 @@ public function __construct( array $options = array() ) { if ( '' === $this->database ) { throw new InvalidArgumentException( 'DuckDB driver option "database" must not be empty.' ); } + $this->current_database = $this->database; if ( isset( $options['connection'] ) ) { if ( ! $options['connection'] instanceof WP_DuckDB_Connection ) { @@ -266,6 +272,9 @@ public function query( string $query ): WP_DuckDB_Result_Statement { case WP_MySQL_Lexer::SET_SYMBOL: $this->found_rows = 0; return $this->execute_set_statement( $tokens ); + case WP_MySQL_Lexer::USE_SYMBOL: + $this->found_rows = 0; + return $this->execute_use_statement( $tokens ); case WP_MySQL_Lexer::SELECT_SYMBOL: return $this->execute_select( $tokens ); case WP_MySQL_Lexer::CREATE_SYMBOL: @@ -1119,6 +1128,8 @@ private function parse_simple_select_table_reference( array $tokens ): ?array { if ( 0 === strcasecmp( $database, 'information_schema' ) || 0 !== strcasecmp( $database, $this->database ) ) { return null; } + } elseif ( $this->is_information_schema_current_database() ) { + return null; } $table_reference = $this->resolve_visible_user_table_reference( $table_name ); @@ -1853,6 +1864,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; + $this->assert_unqualified_write_allowed_in_current_database(); $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Expected column list in CREATE TABLE.' ); ++$index; @@ -2004,6 +2016,7 @@ private function execute_create_index( array $tokens ): WP_DuckDB_Result_Stateme $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; + $this->assert_unqualified_write_allowed_in_current_database(); $table_reference = $this->resolve_visible_user_table_reference( $table_name ); if ( null === $table_reference ) { throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in CREATE INDEX statement." ); @@ -2622,6 +2635,8 @@ private function parse_multi_delete_table_reference( array $tokens ): array { if ( 0 !== strcasecmp( $database, $this->database ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Only the current database is supported.' ); } + } else { + $this->assert_unqualified_write_allowed_in_current_database(); } if ( $this->is_duckdb_internal_table_name( $table_name ) ) { @@ -2764,6 +2779,8 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo if ( 0 !== strcasecmp( $database, $this->database ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only the current database is supported.' ); } + } else { + $this->assert_unqualified_write_allowed_in_current_database(); } if ( $this->is_duckdb_internal_table_name( $table_name ) ) { @@ -3047,6 +3064,22 @@ private function contains_information_schema_reference( array $tokens ): bool { ) { return true; } + + foreach ( + array( + 'tables', + 'columns', + 'statistics', + 'table_constraints', + 'key_column_usage', + 'referential_constraints', + 'check_constraints', + ) as $information_schema_table + ) { + if ( null !== $this->information_schema_reference_length( $tokens, $index, $information_schema_table ) ) { + return true; + } + } } return false; @@ -3328,6 +3361,8 @@ private function parse_single_table_dml_reference( array $tokens, int $index, st if ( 0 !== strcasecmp( $database, $this->database ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only the current database is supported.' ); } + } else { + $this->assert_unqualified_write_allowed_in_current_database(); } $alias = null; @@ -3392,6 +3427,8 @@ private function parse_schema_lifecycle_table_reference( array $tokens, int $ind if ( 0 !== strcasecmp( $database, $this->database ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only the current database is supported.' ); } + } else { + $this->assert_unqualified_write_allowed_in_current_database(); } if ( $this->is_duckdb_internal_table_name( $table_name ) ) { @@ -3468,6 +3505,39 @@ private function empty_ddl_result(): WP_DuckDB_Result_Statement { return new WP_DuckDB_Result_Statement( array(), array(), 0 ); } + /** + * Execute USE database for the configured database and information_schema. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_use_statement( array $tokens ): WP_DuckDB_Result_Statement { + $this->expect_token( $tokens, 0, WP_MySQL_Lexer::USE_SYMBOL, 'Expected USE.' ); + $database = $this->identifier_value( $tokens[1] ?? null ); + + if ( 2 !== count( $tokens ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported USE statement in DuckDB driver. Use USE database.' ); + } + + if ( 0 === strcasecmp( $database, 'information_schema' ) ) { + $this->current_database = 'information_schema'; + return $this->empty_ddl_result(); + } + + if ( 0 === strcasecmp( $database, $this->database ) ) { + $this->current_database = $this->database; + return $this->empty_ddl_result(); + } + + throw new WP_DuckDB_Driver_Exception( + sprintf( + "can't use schema '%s', only '%s' and 'information_schema' are supported", + $database, + $this->database + ) + ); + } + /** * Execute bounded MySQL SET session-variable assignments. * @@ -8597,17 +8667,16 @@ private function execute_show_create_table( array $tokens ): WP_DuckDB_Result_St throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW CREATE TABLE statement in DuckDB driver. Use SHOW CREATE TABLE [database.]table.' ); } - if ( null !== $database ) { - if ( 0 === strcasecmp( $database, 'information_schema' ) ) { - throw new WP_DuckDB_Driver_Exception( sprintf( "SHOW command denied to user 'duckdb'@'%%' for table '%s'", $table_name ) ); - } + $effective_database = null === $database ? $this->current_database : $database; + if ( 0 === strcasecmp( $effective_database, 'information_schema' ) ) { + throw new WP_DuckDB_Driver_Exception( sprintf( "SHOW command denied to user 'duckdb'@'%%' for table '%s'", $table_name ) ); + } - if ( 0 !== strcasecmp( $database, $this->database ) ) { - return new WP_DuckDB_Result_Statement( - array( 'Table', 'Create Table' ), - array() - ); - } + if ( 0 !== strcasecmp( $effective_database, $this->database ) ) { + return new WP_DuckDB_Result_Statement( + array( 'Table', 'Create Table' ), + array() + ); } $table_reference = $this->resolve_visible_user_table_reference( $table_name ); @@ -8783,7 +8852,7 @@ private function format_show_create_table_check_constraint( array $check_constra */ private function execute_show_table_status( array $tokens ): WP_DuckDB_Result_Statement { $index = 3; - $database = $this->database; + $database = $this->current_database; if ( isset( $tokens[ $index ] ) @@ -8939,7 +9008,7 @@ private function execute_show_columns( array $tokens ): WP_DuckDB_Result_Stateme * @return array{database:string,table_name:string,database_explicit:bool,next_index:int} */ private function parse_metadata_table_reference( array $tokens, int $index, bool $allow_database_clause ): array { - $database = $this->database; + $database = $this->current_database; $database_explicit = false; $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; @@ -8989,7 +9058,7 @@ private function execute_show_tables( array $tokens ): WP_DuckDB_Result_Statemen $this->expect_token( $tokens, $index, WP_MySQL_Lexer::TABLES_SYMBOL, 'Expected TABLES in SHOW TABLES statement.' ); ++$index; - $database = $this->database; + $database = $this->current_database; if ( isset( $tokens[ $index ] ) && ( WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $index ]->id ) @@ -11079,39 +11148,51 @@ private function translate_tokens_to_duckdb_sql( for ( $index = 0; $index < count( $tokens ); ++$index ) { $token = $tokens[ $index ]; - if ( $rewrite_information_schema_tables && $this->is_information_schema_tables_reference( $tokens, $index ) ) { + if ( $this->is_configured_database_select_table_qualifier( $tokens, $index ) ) { + ++$index; + continue; + } + + $information_schema_reference_length = $this->information_schema_tables_reference_length( $tokens, $index ); + if ( $rewrite_information_schema_tables && null !== $information_schema_reference_length ) { $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_TABLES_TABLE ); - $index += 2; + $index += $information_schema_reference_length - 1; continue; } - if ( $rewrite_information_schema_columns && $this->is_information_schema_columns_reference( $tokens, $index ) ) { + $information_schema_reference_length = $this->information_schema_columns_reference_length( $tokens, $index ); + if ( $rewrite_information_schema_columns && null !== $information_schema_reference_length ) { $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_COLUMNS_TABLE ); - $index += 2; + $index += $information_schema_reference_length - 1; continue; } - if ( $rewrite_information_schema_statistics && $this->is_information_schema_statistics_reference( $tokens, $index ) ) { + $information_schema_reference_length = $this->information_schema_statistics_reference_length( $tokens, $index ); + if ( $rewrite_information_schema_statistics && null !== $information_schema_reference_length ) { $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_STATISTICS_TABLE ); - $index += 2; + $index += $information_schema_reference_length - 1; continue; } - if ( $rewrite_information_schema_table_constraints && $this->is_information_schema_table_constraints_reference( $tokens, $index ) ) { + $information_schema_reference_length = $this->information_schema_table_constraints_reference_length( $tokens, $index ); + if ( $rewrite_information_schema_table_constraints && null !== $information_schema_reference_length ) { $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE ); - $index += 2; + $index += $information_schema_reference_length - 1; continue; } - if ( $rewrite_information_schema_key_column_usage && $this->is_information_schema_key_column_usage_reference( $tokens, $index ) ) { + $information_schema_reference_length = $this->information_schema_key_column_usage_reference_length( $tokens, $index ); + if ( $rewrite_information_schema_key_column_usage && null !== $information_schema_reference_length ) { $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE ); - $index += 2; + $index += $information_schema_reference_length - 1; continue; } - if ( $rewrite_information_schema_referential_constraints && $this->is_information_schema_referential_constraints_reference( $tokens, $index ) ) { + $information_schema_reference_length = $this->information_schema_referential_constraints_reference_length( $tokens, $index ); + if ( $rewrite_information_schema_referential_constraints && null !== $information_schema_reference_length ) { $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_REFERENTIAL_CONSTRAINTS_TABLE ); - $index += 2; + $index += $information_schema_reference_length - 1; continue; } - if ( $rewrite_information_schema_check_constraints && $this->is_information_schema_check_constraints_reference( $tokens, $index ) ) { + $information_schema_reference_length = $this->information_schema_check_constraints_reference_length( $tokens, $index ); + if ( $rewrite_information_schema_check_constraints && null !== $information_schema_reference_length ) { $pieces[] = $this->connection->quote_identifier( self::INFO_SCHEMA_CHECK_CONSTRAINTS_TABLE ); - $index += 2; + $index += $information_schema_reference_length - 1; continue; } @@ -11166,7 +11247,7 @@ private function translate_tokens_to_duckdb_sql( } if ( $this->is_empty_function_call( $tokens, $index, 'DATABASE' ) ) { - $pieces[] = $this->connection->quote( $this->database ); + $pieces[] = $this->connection->quote( $this->current_database ); $index += 2; continue; } @@ -12925,6 +13006,8 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde * @return array{table_name:string,temporary:bool} */ private function resolve_write_table_reference( string $requested_table_name ): array { + $this->assert_unqualified_write_allowed_in_current_database(); + $reference = $this->resolve_visible_user_table_reference( $requested_table_name ); if ( null !== $reference ) { return $reference; @@ -15866,6 +15949,125 @@ private function invalidate_information_schema_compatibility_tables(): void { } } + /** + * Check whether the current database is information_schema. + * + * @return bool Whether information_schema is selected. + */ + private function is_information_schema_current_database(): bool { + return 0 === strcasecmp( $this->current_database, 'information_schema' ); + } + + /** + * Build a stable information_schema access-denied exception. + * + * @return WP_DuckDB_Driver_Exception Exception. + */ + private function new_information_schema_access_denied_exception(): WP_DuckDB_Driver_Exception { + return new WP_DuckDB_Driver_Exception( "Access denied for user 'duckdb'@'%' to database 'information_schema'" ); + } + + /** + * Assert that an unqualified write target is not implicitly information_schema. + */ + private function assert_unqualified_write_allowed_in_current_database(): void { + if ( $this->is_information_schema_current_database() ) { + throw $this->new_information_schema_access_denied_exception(); + } + } + + /** + * Check whether a token is an identifier with the requested value. + * + * @param WP_Parser_Token|null $token Token. + * @param string $identifier Identifier value. + * @return bool Whether the token matches. + */ + private function token_identifier_equals( $token, string $identifier ): bool { + return $token instanceof WP_Parser_Token + && ! $this->is_non_identifier_token( $token ) + && 0 === strcasecmp( $token->get_value(), $identifier ); + } + + /** + * Return the token count for a reference to an information_schema table. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @param string $table_name information_schema table name. + * @return int|null Token count, or null when there is no reference. + */ + private function information_schema_reference_length( array $tokens, int $index, string $table_name ): ?int { + if ( + isset( $tokens[ $index + 2 ] ) + && $this->token_identifier_equals( $tokens[ $index ], 'information_schema' ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && $this->token_identifier_equals( $tokens[ $index + 2 ], $table_name ) + ) { + return 3; + } + + if ( + $this->is_information_schema_current_database() + && $this->token_identifier_equals( $tokens[ $index ] ?? null, $table_name ) + && $this->is_unqualified_information_schema_table_reference_context( $tokens, $index ) + ) { + return 1; + } + + return null; + } + + /** + * Check whether an unqualified token appears in a table-reference position. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return bool Whether this position can be an unqualified table reference. + */ + private function is_unqualified_information_schema_table_reference_context( array $tokens, int $index ): bool { + if ( + ( isset( $tokens[ $index - 1 ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index - 1 ]->id ) + || ( isset( $tokens[ $index + 1 ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id ) + ) { + return false; + } + + if ( ! isset( $tokens[ $index - 1 ] ) ) { + return false; + } + + return in_array( + $tokens[ $index - 1 ]->id, + array( + WP_MySQL_Lexer::FROM_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + ), + true + ); + } + + /** + * Check whether a SELECT token starts an explicit configured-database table qualifier. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return bool Whether the configured database qualifier should be stripped. + */ + private function is_configured_database_select_table_qualifier( array $tokens, int $index ): bool { + if ( + ! isset( $tokens[ $index - 1 ], $tokens[ $index + 2 ] ) + || ! in_array( $tokens[ $index - 1 ]->id, array( WP_MySQL_Lexer::FROM_SYMBOL, WP_MySQL_Lexer::JOIN_SYMBOL ), true ) + || ! $this->token_identifier_equals( $tokens[ $index ], $this->database ) + || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $index + 1 ]->id + || $this->is_non_identifier_token( $tokens[ $index + 2 ] ) + ) { + return false; + } + + return true; + } + /** * Read recorded MySQL column metadata. * @@ -15895,7 +16097,7 @@ private function column_metadata_rows( string $table_name, bool $temporary = fal */ private function uses_information_schema_tables( array $tokens ): bool { for ( $index = 0; $index < count( $tokens ); ++$index ) { - if ( $this->is_information_schema_tables_reference( $tokens, $index ) ) { + if ( null !== $this->information_schema_tables_reference_length( $tokens, $index ) ) { return true; } } @@ -15911,10 +16113,18 @@ private function uses_information_schema_tables( array $tokens ): bool { * @return bool Whether the sequence is information_schema.tables. */ private function is_information_schema_tables_reference( array $tokens, int $index ): bool { - return isset( $tokens[ $index + 2 ] ) - && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) - && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id - && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'tables' ); + return null !== $this->information_schema_tables_reference_length( $tokens, $index ); + } + + /** + * Return token count for information_schema.tables at an offset. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return int|null Token count, or null when absent. + */ + private function information_schema_tables_reference_length( array $tokens, int $index ): ?int { + return $this->information_schema_reference_length( $tokens, $index, 'tables' ); } /** @@ -15925,7 +16135,7 @@ private function is_information_schema_tables_reference( array $tokens, int $ind */ private function uses_information_schema_columns( array $tokens ): bool { for ( $index = 0; $index < count( $tokens ); ++$index ) { - if ( $this->is_information_schema_columns_reference( $tokens, $index ) ) { + if ( null !== $this->information_schema_columns_reference_length( $tokens, $index ) ) { return true; } } @@ -15941,10 +16151,18 @@ private function uses_information_schema_columns( array $tokens ): bool { * @return bool Whether the sequence is information_schema.columns. */ private function is_information_schema_columns_reference( array $tokens, int $index ): bool { - return isset( $tokens[ $index + 2 ] ) - && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) - && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id - && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'columns' ); + return null !== $this->information_schema_columns_reference_length( $tokens, $index ); + } + + /** + * Return token count for information_schema.columns at an offset. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return int|null Token count, or null when absent. + */ + private function information_schema_columns_reference_length( array $tokens, int $index ): ?int { + return $this->information_schema_reference_length( $tokens, $index, 'columns' ); } /** @@ -15955,7 +16173,7 @@ private function is_information_schema_columns_reference( array $tokens, int $in */ private function uses_information_schema_statistics( array $tokens ): bool { for ( $index = 0; $index < count( $tokens ); ++$index ) { - if ( $this->is_information_schema_statistics_reference( $tokens, $index ) ) { + if ( null !== $this->information_schema_statistics_reference_length( $tokens, $index ) ) { return true; } } @@ -15971,10 +16189,18 @@ private function uses_information_schema_statistics( array $tokens ): bool { * @return bool Whether the sequence is information_schema.statistics. */ private function is_information_schema_statistics_reference( array $tokens, int $index ): bool { - return isset( $tokens[ $index + 2 ] ) - && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) - && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id - && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'statistics' ); + return null !== $this->information_schema_statistics_reference_length( $tokens, $index ); + } + + /** + * Return token count for information_schema.statistics at an offset. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return int|null Token count, or null when absent. + */ + private function information_schema_statistics_reference_length( array $tokens, int $index ): ?int { + return $this->information_schema_reference_length( $tokens, $index, 'statistics' ); } /** @@ -15985,7 +16211,7 @@ private function is_information_schema_statistics_reference( array $tokens, int */ private function uses_information_schema_table_constraints( array $tokens ): bool { for ( $index = 0; $index < count( $tokens ); ++$index ) { - if ( $this->is_information_schema_table_constraints_reference( $tokens, $index ) ) { + if ( null !== $this->information_schema_table_constraints_reference_length( $tokens, $index ) ) { return true; } } @@ -16001,10 +16227,18 @@ private function uses_information_schema_table_constraints( array $tokens ): boo * @return bool Whether the sequence is information_schema.table_constraints. */ private function is_information_schema_table_constraints_reference( array $tokens, int $index ): bool { - return isset( $tokens[ $index + 2 ] ) - && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) - && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id - && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'table_constraints' ); + return null !== $this->information_schema_table_constraints_reference_length( $tokens, $index ); + } + + /** + * Return token count for information_schema.table_constraints at an offset. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return int|null Token count, or null when absent. + */ + private function information_schema_table_constraints_reference_length( array $tokens, int $index ): ?int { + return $this->information_schema_reference_length( $tokens, $index, 'table_constraints' ); } /** @@ -16015,7 +16249,7 @@ private function is_information_schema_table_constraints_reference( array $token */ private function uses_information_schema_key_column_usage( array $tokens ): bool { for ( $index = 0; $index < count( $tokens ); ++$index ) { - if ( $this->is_information_schema_key_column_usage_reference( $tokens, $index ) ) { + if ( null !== $this->information_schema_key_column_usage_reference_length( $tokens, $index ) ) { return true; } } @@ -16031,10 +16265,18 @@ private function uses_information_schema_key_column_usage( array $tokens ): bool * @return bool Whether the sequence is information_schema.key_column_usage. */ private function is_information_schema_key_column_usage_reference( array $tokens, int $index ): bool { - return isset( $tokens[ $index + 2 ] ) - && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) - && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id - && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'key_column_usage' ); + return null !== $this->information_schema_key_column_usage_reference_length( $tokens, $index ); + } + + /** + * Return token count for information_schema.key_column_usage at an offset. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return int|null Token count, or null when absent. + */ + private function information_schema_key_column_usage_reference_length( array $tokens, int $index ): ?int { + return $this->information_schema_reference_length( $tokens, $index, 'key_column_usage' ); } /** @@ -16045,7 +16287,7 @@ private function is_information_schema_key_column_usage_reference( array $tokens */ private function uses_information_schema_referential_constraints( array $tokens ): bool { for ( $index = 0; $index < count( $tokens ); ++$index ) { - if ( $this->is_information_schema_referential_constraints_reference( $tokens, $index ) ) { + if ( null !== $this->information_schema_referential_constraints_reference_length( $tokens, $index ) ) { return true; } } @@ -16061,10 +16303,18 @@ private function uses_information_schema_referential_constraints( array $tokens * @return bool Whether the sequence is information_schema.referential_constraints. */ private function is_information_schema_referential_constraints_reference( array $tokens, int $index ): bool { - return isset( $tokens[ $index + 2 ] ) - && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) - && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id - && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'referential_constraints' ); + return null !== $this->information_schema_referential_constraints_reference_length( $tokens, $index ); + } + + /** + * Return token count for information_schema.referential_constraints at an offset. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return int|null Token count, or null when absent. + */ + private function information_schema_referential_constraints_reference_length( array $tokens, int $index ): ?int { + return $this->information_schema_reference_length( $tokens, $index, 'referential_constraints' ); } /** @@ -16075,7 +16325,7 @@ private function is_information_schema_referential_constraints_reference( array */ private function uses_information_schema_check_constraints( array $tokens ): bool { for ( $index = 0; $index < count( $tokens ); ++$index ) { - if ( $this->is_information_schema_check_constraints_reference( $tokens, $index ) ) { + if ( null !== $this->information_schema_check_constraints_reference_length( $tokens, $index ) ) { return true; } } @@ -16091,10 +16341,18 @@ private function uses_information_schema_check_constraints( array $tokens ): boo * @return bool Whether the sequence is information_schema.check_constraints. */ private function is_information_schema_check_constraints_reference( array $tokens, int $index ): bool { - return isset( $tokens[ $index + 2 ] ) - && 0 === strcasecmp( $tokens[ $index ]->get_value(), 'information_schema' ) - && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id - && 0 === strcasecmp( $tokens[ $index + 2 ]->get_value(), 'check_constraints' ); + return null !== $this->information_schema_check_constraints_reference_length( $tokens, $index ); + } + + /** + * Return token count for information_schema.check_constraints at an offset. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token offset. + * @return int|null Token count, or null when absent. + */ + private function information_schema_check_constraints_reference_length( array $tokens, int $index ): ?int { + return $this->information_schema_reference_length( $tokens, $index, 'check_constraints' ); } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 24d30861d..eac87e178 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -234,6 +234,44 @@ public function test_show_admin_metadata_sql_matches_sqlite(): void { $this->assertParityRows( "SHOW LOCAL VARIABLES WHERE Variable_name = 'version'" ); } + public function test_use_information_schema_collision_state_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE tables (id INT)', + 'INSERT INTO tables (id) VALUES (1), (2)', + ) + ); + + $this->assertParityRows( 'SELECT DATABASE() AS db' ); + $this->assertParityRows( 'SELECT id FROM tables ORDER BY id' ); + $this->assertParityRows( + "SELECT TABLE_SCHEMA, TABLE_NAME + FROM information_schema.tables + WHERE TABLE_NAME = 'tables' + ORDER BY TABLE_SCHEMA, TABLE_NAME" + ); + + $this->runParitySetup( array( 'USE information_schema' ) ); + + $this->assertParityRows( 'SELECT DATABASE() AS db' ); + $this->assertParityRows( + "SELECT TABLE_SCHEMA, TABLE_NAME + FROM tables + WHERE TABLE_NAME = 'tables' + ORDER BY TABLE_SCHEMA, TABLE_NAME" + ); + $this->assertParityRows( 'SELECT id FROM wp.tables ORDER BY id' ); + $this->assertParityRows( "SHOW TABLES LIKE 'tables'" ); + $this->assertParityErrorContains( "INSERT INTO tables (TABLE_NAME) VALUES ('x')", 'Access denied' ); + + $this->runParitySetup( array( 'USE wp' ) ); + + $this->assertParityRows( 'SELECT DATABASE() AS db' ); + $this->assertParityRows( 'SELECT id FROM tables ORDER BY id' ); + $this->assertParityRows( "SHOW TABLES LIKE 'tables'" ); + $this->assertParityErrorContains( 'USE other', "can't use schema" ); + } + public function test_show_admin_metadata_found_rows_match_sqlite(): void { foreach ( array( From 3c0cd032165f5a29bd09e84090685984baeea723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 07:30:32 +0000 Subject: [PATCH 085/317] Add DuckDB metadata type matrix evidence --- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 641 ++++++++++++++++++ 1 file changed, 641 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 6600ebe51..5d5c4b7d4 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3134,6 +3134,83 @@ public function test_simple_select_result_metadata_uses_recorded_table_columns() $this->assertFalse( $update->getColumnMeta( 0 ) ); } + public function test_direct_column_metadata_type_matrix_for_supported_mysql_types(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $this->createMetadataTypeMatrixTable( $driver ); + + $result = $driver->query( 'SELECT * FROM metadata_type_matrix WHERE id = 0' ); + + $this->assertSame( 13, $result->columnCount() ); + $this->assertSame( array(), $result->fetchAll( PDO::FETCH_ASSOC ) ); + + foreach ( $this->metadataTypeMatrixResultMetadata() as $index => $expected ) { + $metadata = $result->getColumnMeta( $index ); + foreach ( $expected as $key => $value ) { + $this->assertArrayHasKey( $key, $metadata, $expected['name'] . ' metadata key ' . $key ); + $this->assertSame( $value, $metadata[ $key ], $expected['name'] . ' metadata key ' . $key ); + } + } + + $this->assertFalse( $result->getColumnMeta( 13 ) ); + } + + public function test_type_default_charset_metadata_rows_for_supported_mysql_types(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $this->createMetadataTypeMatrixTable( $driver ); + + $show_columns = $this->metadataTypeMatrixShowColumnRows(); + $this->assertSame( + $show_columns, + $driver->query( 'SHOW COLUMNS FROM metadata_type_matrix' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + $show_columns, + $driver->query( 'DESCRIBE metadata_type_matrix' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + $show_columns, + $driver->query( 'DESC metadata_type_matrix' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $show_full_columns = $this->metadataTypeMatrixShowFullColumnRows(); + $this->assertSame( + $show_full_columns, + $driver->query( 'SHOW FULL COLUMNS FROM metadata_type_matrix' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + $show_full_columns, + $driver->query( 'SHOW FULL FIELDS FROM metadata_type_matrix' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + $this->metadataTypeMatrixInformationSchemaRows(), + $driver->query( + "SELECT COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE, + CHARACTER_MAXIMUM_LENGTH, CHARACTER_OCTET_LENGTH, + NUMERIC_PRECISION, NUMERIC_SCALE, DATETIME_PRECISION, + CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE, COLUMN_KEY, + EXTRA, COLUMN_COMMENT + FROM information_schema.columns + WHERE table_schema = 'wp' AND table_name = 'metadata_type_matrix' + ORDER BY ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_expression_and_admin_result_metadata_provider_documents_name_only_contract(): void { $this->requireDuckDBRuntime(); @@ -9462,6 +9539,570 @@ public function test_unsupported_alter_table_add_not_null_without_default_on_non $driver->query( 'ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL' ); } + private function createMetadataTypeMatrixTable( WP_DuckDB_Driver $driver ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $driver->query( + "CREATE TABLE metadata_type_matrix ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + flag TINYINT UNSIGNED NOT NULL DEFAULT '10', + small_code SMALLINT NOT NULL DEFAULT 14, + medium_code MEDIUMINT, + count_col INT UNSIGNED, + score DOUBLE, + price DECIMAL(10,2) DEFAULT 1.25, + slug CHAR(10), + title VARCHAR(20) NOT NULL DEFAULT 'untitled', + body LONGTEXT, + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + updated_at TIMESTAMP NULL, + payload BLOB + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + } + + private function metadataTypeMatrixResultMetadata(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return array( + array( + 'native_type' => 'LONGLONG', + 'table' => 'metadata_type_matrix', + 'name' => 'id', + 'len' => 20, + 'precision' => 0, + 'duckdb:decl_type' => 'bigint(20) unsigned', + 'mysqli:orgname' => 'id', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 8, + ), + array( + 'native_type' => 'TINY', + 'table' => 'metadata_type_matrix', + 'name' => 'flag', + 'len' => 3, + 'precision' => 0, + 'duckdb:decl_type' => 'tinyint unsigned', + 'mysqli:orgname' => 'flag', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 1, + ), + array( + 'native_type' => 'SHORT', + 'table' => 'metadata_type_matrix', + 'name' => 'small_code', + 'len' => 6, + 'precision' => 0, + 'duckdb:decl_type' => 'smallint', + 'mysqli:orgname' => 'small_code', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 2, + ), + array( + 'native_type' => 'INT24', + 'table' => 'metadata_type_matrix', + 'name' => 'medium_code', + 'len' => 9, + 'precision' => 0, + 'duckdb:decl_type' => 'mediumint', + 'mysqli:orgname' => 'medium_code', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 9, + ), + array( + 'native_type' => 'LONG', + 'table' => 'metadata_type_matrix', + 'name' => 'count_col', + 'len' => 10, + 'precision' => 0, + 'duckdb:decl_type' => 'int unsigned', + 'mysqli:orgname' => 'count_col', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 3, + ), + array( + 'native_type' => 'DOUBLE', + 'table' => 'metadata_type_matrix', + 'name' => 'score', + 'len' => 22, + 'precision' => 31, + 'duckdb:decl_type' => 'double', + 'mysqli:orgname' => 'score', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 5, + ), + array( + 'native_type' => 'NEWDECIMAL', + 'table' => 'metadata_type_matrix', + 'name' => 'price', + 'len' => 12, + 'precision' => 2, + 'duckdb:decl_type' => 'decimal(10, 2)', + 'mysqli:orgname' => 'price', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 246, + ), + array( + 'native_type' => 'STRING', + 'table' => 'metadata_type_matrix', + 'name' => 'slug', + 'len' => 40, + 'precision' => 0, + 'duckdb:decl_type' => 'char(10)', + 'mysqli:orgname' => 'slug', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 254, + ), + array( + 'native_type' => 'VAR_STRING', + 'table' => 'metadata_type_matrix', + 'name' => 'title', + 'len' => 80, + 'precision' => 0, + 'duckdb:decl_type' => 'varchar(20)', + 'mysqli:orgname' => 'title', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 253, + ), + array( + 'native_type' => 'BLOB', + 'table' => 'metadata_type_matrix', + 'name' => 'body', + 'len' => 4294967295, + 'precision' => 0, + 'duckdb:decl_type' => 'longtext', + 'mysqli:orgname' => 'body', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 252, + ), + array( + 'native_type' => 'DATETIME', + 'table' => 'metadata_type_matrix', + 'name' => 'created_at', + 'len' => 19, + 'precision' => 0, + 'duckdb:decl_type' => 'datetime', + 'mysqli:orgname' => 'created_at', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 12, + ), + array( + 'native_type' => 'TIMESTAMP', + 'table' => 'metadata_type_matrix', + 'name' => 'updated_at', + 'len' => 19, + 'precision' => 0, + 'duckdb:decl_type' => 'timestamp', + 'mysqli:orgname' => 'updated_at', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 7, + ), + array( + 'native_type' => 'BLOB', + 'table' => 'metadata_type_matrix', + 'name' => 'payload', + 'len' => 65535, + 'precision' => 0, + 'duckdb:decl_type' => 'blob', + 'mysqli:orgname' => 'payload', + 'mysqli:orgtable' => 'metadata_type_matrix', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 252, + ), + ); + } + + private function metadataTypeMatrixShowColumnRows(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return array( + array( + 'Field' => 'id', + 'Type' => 'bigint(20) unsigned', + 'Null' => 'NO', + 'Key' => 'PRI', + 'Default' => null, + 'Extra' => 'auto_increment', + ), + array( + 'Field' => 'flag', + 'Type' => 'tinyint unsigned', + 'Null' => 'NO', + 'Key' => '', + 'Default' => '10', + 'Extra' => '', + ), + array( + 'Field' => 'small_code', + 'Type' => 'smallint', + 'Null' => 'NO', + 'Key' => '', + 'Default' => '14', + 'Extra' => '', + ), + array( + 'Field' => 'medium_code', + 'Type' => 'mediumint', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'count_col', + 'Type' => 'int unsigned', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'score', + 'Type' => 'double', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'price', + 'Type' => 'decimal(10, 2)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => '1.25', + 'Extra' => '', + ), + array( + 'Field' => 'slug', + 'Type' => 'char(10)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'title', + 'Type' => 'varchar(20)', + 'Null' => 'NO', + 'Key' => '', + 'Default' => 'untitled', + 'Extra' => '', + ), + array( + 'Field' => 'body', + 'Type' => 'longtext', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'created_at', + 'Type' => 'datetime', + 'Null' => 'NO', + 'Key' => '', + 'Default' => '0000-00-00 00:00:00', + 'Extra' => '', + ), + array( + 'Field' => 'updated_at', + 'Type' => 'timestamp', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'payload', + 'Type' => 'blob', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + ); + } + + private function metadataTypeMatrixShowFullColumnRows(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $collations = array( + 'id' => null, + 'flag' => null, + 'small_code' => null, + 'medium_code' => null, + 'count_col' => null, + 'score' => null, + 'price' => null, + 'slug' => 'utf8mb4_0900_ai_ci', + 'title' => 'utf8mb4_0900_ai_ci', + 'body' => 'utf8mb4_0900_ai_ci', + 'created_at' => null, + 'updated_at' => null, + 'payload' => null, + ); + $rows = array(); + + foreach ( $this->metadataTypeMatrixShowColumnRows() as $row ) { + $rows[] = array( + 'Field' => $row['Field'], + 'Type' => $row['Type'], + 'Collation' => $collations[ $row['Field'] ], + 'Null' => $row['Null'], + 'Key' => $row['Key'], + 'Default' => $row['Default'], + 'Extra' => $row['Extra'], + 'Privileges' => 'select,insert,update,references', + 'Comment' => '', + ); + } + + return $rows; + } + + private function metadataTypeMatrixInformationSchemaRows(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return array( + array( + 'COLUMN_NAME' => 'id', + 'COLUMN_DEFAULT' => null, + 'IS_NULLABLE' => 'NO', + 'DATA_TYPE' => 'bigint', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => 20, + 'NUMERIC_SCALE' => 0, + 'DATETIME_PRECISION' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'bigint(20) unsigned', + 'COLUMN_KEY' => 'PRI', + 'EXTRA' => 'auto_increment', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'flag', + 'COLUMN_DEFAULT' => '10', + 'IS_NULLABLE' => 'NO', + 'DATA_TYPE' => 'tinyint', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => 3, + 'NUMERIC_SCALE' => 0, + 'DATETIME_PRECISION' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'tinyint unsigned', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'small_code', + 'COLUMN_DEFAULT' => '14', + 'IS_NULLABLE' => 'NO', + 'DATA_TYPE' => 'smallint', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => 5, + 'NUMERIC_SCALE' => 0, + 'DATETIME_PRECISION' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'smallint', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'medium_code', + 'COLUMN_DEFAULT' => null, + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'mediumint', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => 7, + 'NUMERIC_SCALE' => 0, + 'DATETIME_PRECISION' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'mediumint', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'count_col', + 'COLUMN_DEFAULT' => null, + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'int', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => 10, + 'NUMERIC_SCALE' => 0, + 'DATETIME_PRECISION' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'int unsigned', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'score', + 'COLUMN_DEFAULT' => null, + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'double', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => 22, + 'NUMERIC_SCALE' => 0, + 'DATETIME_PRECISION' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'double', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'price', + 'COLUMN_DEFAULT' => '1.25', + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'decimal', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => 10, + 'NUMERIC_SCALE' => 2, + 'DATETIME_PRECISION' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'decimal(10, 2)', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'slug', + 'COLUMN_DEFAULT' => null, + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'char', + 'CHARACTER_MAXIMUM_LENGTH' => 10, + 'CHARACTER_OCTET_LENGTH' => 40, + 'NUMERIC_PRECISION' => null, + 'NUMERIC_SCALE' => null, + 'DATETIME_PRECISION' => null, + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + 'COLUMN_TYPE' => 'char(10)', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'title', + 'COLUMN_DEFAULT' => 'untitled', + 'IS_NULLABLE' => 'NO', + 'DATA_TYPE' => 'varchar', + 'CHARACTER_MAXIMUM_LENGTH' => 20, + 'CHARACTER_OCTET_LENGTH' => 80, + 'NUMERIC_PRECISION' => null, + 'NUMERIC_SCALE' => null, + 'DATETIME_PRECISION' => null, + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + 'COLUMN_TYPE' => 'varchar(20)', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'body', + 'COLUMN_DEFAULT' => null, + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'longtext', + 'CHARACTER_MAXIMUM_LENGTH' => 4294967295, + 'CHARACTER_OCTET_LENGTH' => 4294967295, + 'NUMERIC_PRECISION' => null, + 'NUMERIC_SCALE' => null, + 'DATETIME_PRECISION' => null, + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + 'COLUMN_TYPE' => 'longtext', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'created_at', + 'COLUMN_DEFAULT' => '0000-00-00 00:00:00', + 'IS_NULLABLE' => 'NO', + 'DATA_TYPE' => 'datetime', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => null, + 'NUMERIC_SCALE' => null, + 'DATETIME_PRECISION' => 0, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'datetime', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'updated_at', + 'COLUMN_DEFAULT' => null, + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'timestamp', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => null, + 'NUMERIC_SCALE' => null, + 'DATETIME_PRECISION' => 0, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'timestamp', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + array( + 'COLUMN_NAME' => 'payload', + 'COLUMN_DEFAULT' => null, + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'blob', + 'CHARACTER_MAXIMUM_LENGTH' => 65535, + 'CHARACTER_OCTET_LENGTH' => 65535, + 'NUMERIC_PRECISION' => null, + 'NUMERIC_SCALE' => null, + 'DATETIME_PRECISION' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'blob', + 'COLUMN_KEY' => '', + 'EXTRA' => '', + 'COLUMN_COMMENT' => '', + ), + ); + } + private function alter_table_foreign_key_lifecycle_snapshot( WP_DuckDB_Driver $driver, string $table_name ): array { return array( 'columns' => $driver->query( 'SHOW COLUMNS FROM ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), From 4b754b64eebd09189f7696353e3865927219ebb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 07:30:38 +0000 Subject: [PATCH 086/317] Add DuckDB wpdb diagnostic evidence --- .../duckdb/WP_DuckDB_Connection_Tests.php | 91 +++++++++++++++++++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 45 +++++++++ 2 files changed, 136 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index 804e70c5d..ae2bfb506 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -594,6 +594,97 @@ function () use ( $statement ): void { ); } + public function test_connection_failure_is_exception_first_and_next_success_has_clear_error_state(): void { + $success_result = $this->createDuckDBResult( array( 'ok' ), array( array( 1 ) ) ); + $previous = new RuntimeException( 'native broken select' ); + $connection = new WP_DuckDB_Connection( + array( + 'duckdb' => new class( $success_result, $previous ) { + private $success_result; + private $previous; + + public function __construct( $success_result, Throwable $previous ) { + $this->success_result = $success_result; + $this->previous = $previous; + } + + public function query( string $sql ) { + if ( 'SELECT BROKEN' === $sql ) { + throw $this->previous; + } + + if ( 'SELECT 1 AS ok' === $sql ) { + return $this->success_result; + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } + }, + ) + ); + + try { + $connection->query( 'SELECT BROKEN' ); + $this->fail( 'Expected WP_DuckDB_Driver_Exception.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringStartsWith( 'DuckDB query failed: native broken select', $e->getMessage() ); + $this->assertSame( 'HY000', $e->getCode() ); + $this->assertSame( $previous, $e->getPrevious() ); + } + + $stmt = $connection->query( 'SELECT 1 AS ok' ); + + $this->assertSame( 1, $stmt->columnCount() ); + $this->assertSame( 0, $stmt->rowCount() ); + $this->assertSame( array( 'ok' => 1 ), $stmt->fetch( PDO::FETCH_ASSOC ) ); + $this->assertSame( '00000', $stmt->errorCode() ); + $this->assertSame( array( '00000', null, null ), $stmt->errorInfo() ); + } + + public function test_sql_warnings_policy_is_explicitly_unsupported(): void { + $empty_result = $this->createDuckDBResult( array(), array() ); + $duckdb = new class( $empty_result ) { + public $queries = array(); + private $empty_result; + + public function __construct( $empty_result ) { + $this->empty_result = $empty_result; + } + + public function query( string $sql ) { + $this->queries[] = $sql; + return $this->empty_result; + } + }; + $driver = new WP_DuckDB_Driver( + array( + 'connection' => new WP_DuckDB_Connection( array( 'duckdb' => $duckdb ) ), + ) + ); + + $cases = array( + 'SET sql_warnings = ON' => 'Unsupported SET session variable in DuckDB driver: sql_warnings.', + 'SHOW WARNINGS' => 'Unsupported SHOW statement in DuckDB driver.', + 'SHOW ERRORS' => 'Unsupported SHOW statement in DuckDB driver.', + ); + + foreach ( $cases as $sql => $message ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected WP_DuckDB_Driver_Exception for: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage(), $sql ); + } + } + + $this->assertSame( + array( + 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(d, f)', + ), + $duckdb->queries + ); + } + public function test_successful_statement_error_info_remains_clear_after_failure_characterization(): void { $stmt = new WP_DuckDB_Result_Statement( array(), array() ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index 70cb5401a..f62135ed8 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -397,6 +397,37 @@ public function test_duckdb_wpdb_last_error_surface_provider(): void { $this->assertSame( 13, $cases['insert_after_failure']['insert_id'] ); } + public function test_duckdb_wpdb_failure_diagnostics_clear_metadata_and_preserve_non_insert_id(): void { + $result = $this->run_query_surface_state_script(); + $cases = $result['cases']; + + $this->assertTrue( $result['connected'] ); + + $this->assertFalse( $cases['select_failure']['return'] ); + $this->assertSame( array(), $cases['select_failure']['col_info_names'] ); + $this->assertSame( 0, $cases['select_failure']['num_rows'] ); + $this->assertSame( 0, $cases['select_failure']['rows_affected'] ); + + $this->assertFalse( $cases['insert_failure']['return'] ); + $this->assertSame( array(), $cases['insert_failure']['col_info_names'] ); + $this->assertSame( 0, $cases['insert_failure']['insert_id'] ); + $this->assertSame( 0, $cases['insert_failure']['num_rows'] ); + $this->assertSame( 0, $cases['insert_failure']['rows_affected'] ); + + $this->assertFalse( $cases['update_failure_after_insert']['return'] ); + $this->assertSame( 'Synthetic update failure.', $cases['update_failure_after_insert']['last_error'] ); + $this->assertSame( 13, $cases['update_failure_after_insert']['insert_id'] ); + $this->assertSame( array(), $cases['update_failure_after_insert']['col_info_names'] ); + $this->assertSame( 0, $cases['update_failure_after_insert']['num_rows'] ); + $this->assertSame( 0, $cases['update_failure_after_insert']['rows_affected'] ); + + $this->assertTrue( $cases['create_after_update_failure']['return'] ); + $this->assertSame( '', $cases['create_after_update_failure']['last_error'] ); + $this->assertSame( array(), $cases['create_after_update_failure']['col_info_names'] ); + $this->assertSame( 0, $cases['create_after_update_failure']['num_rows'] ); + $this->assertSame( 0, $cases['create_after_update_failure']['rows_affected'] ); + } + public function test_duckdb_wpdb_db_connect_sets_filtered_sql_mode(): void { $result = $this->run_sql_mode_boot_state_script( false ); @@ -1234,6 +1265,18 @@ public function query( string $sql ): WP_DuckDB_Result_Statement { return new WP_DuckDB_Result_Statement( array(), array(), 1 ); } + if ( "UPDATE wp_posts SET post_title = BROKEN WHERE ID = 13" === $sql ) { + throw new WP_DuckDB_Driver_Exception( + 'Synthetic update failure.', + 'HY000', + new RuntimeException( 'Native synthetic update failure.' ) + ); + } + + if ( 'CREATE TABLE wp_surface_after_failure (id INTEGER)' === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + throw new RuntimeException( 'Unexpected query: ' . $sql ); } @@ -1306,6 +1349,8 @@ function wp_duckdb_plugin_query_surface_case( WP_DuckDB_Plugin_Query_Surface_Tes 'select_after_failure' => wp_duckdb_plugin_query_surface_case( $db, 'SELECT 42 AS answer' ), 'insert_failure' => wp_duckdb_plugin_query_surface_case( $db, 'INSERT INTO wp_posts VALUES (BROKEN)' ), 'insert_after_failure' => wp_duckdb_plugin_query_surface_case( $db, "INSERT INTO wp_posts (post_title) VALUES ('after-failure')" ), + 'update_failure_after_insert' => wp_duckdb_plugin_query_surface_case( $db, "UPDATE wp_posts SET post_title = BROKEN WHERE ID = 13" ), + 'create_after_update_failure' => wp_duckdb_plugin_query_surface_case( $db, 'CREATE TABLE wp_surface_after_failure (id INTEGER)' ), ); echo json_encode( From 37699a4ff63640aaffe813dd646e9000db9081ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 07:41:06 +0000 Subject: [PATCH 087/317] Add DuckDB non-strict numeric write coercion parity --- .../src/duckdb/class-wp-duckdb-driver.php | 85 ++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 204 ++++++++++++++++++ 2 files changed, 289 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 98558f081..3f4dc316b 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -13117,6 +13117,19 @@ private function coerce_write_value_for_column_sql( array $metadata, array $valu return $this->coerce_blob_write_value_sql( $value_tokens, $value_sql ); } + if ( $this->is_numeric_write_data_type( $data_type ) && ! $this->is_strict_sql_mode_active() ) { + $value_sql = $this->coerce_numeric_write_value_sql( $data_type, $value_sql ); + if ( + $coalesce_non_strict_not_null + && isset( $metadata['is_nullable'] ) + && 'NO' === strtoupper( (string) $metadata['is_nullable'] ) + ) { + $value_sql = 'COALESCE(' . $value_sql . ', 0)'; + } + + return $value_sql; + } + if ( ! $this->is_temporal_write_data_type( $data_type ) ) { return $value_sql; } @@ -13162,6 +13175,33 @@ private function is_blob_write_data_type( string $data_type ): bool { return in_array( $data_type, array( 'blob', 'tinyblob', 'mediumblob', 'longblob' ), true ); } + /** + * Check whether a data type needs non-strict numeric write coercion. + * + * @param string $data_type MySQL data type. + * @return bool Whether the type is numeric-backed. + */ + private function is_numeric_write_data_type( string $data_type ): bool { + return in_array( + $data_type, + array( + 'tinyint', + 'smallint', + 'mediumint', + 'int', + 'bigint', + 'float', + 'double', + 'real', + 'decimal', + 'dec', + 'fixed', + 'numeric', + ), + true + ); + } + /** * Coerce a write value to MySQL/SQLite-like text storage. * @@ -13194,6 +13234,51 @@ private function coerce_blob_write_value_sql( array $value_tokens, string $value return 'CAST(' . $this->write_value_display_sql( $value_tokens, $value_sql ) . ' AS BLOB)'; } + /** + * Coerce a write value to MySQL/SQLite-like numeric storage in non-strict mode. + * + * @param string $data_type MySQL data type. + * @param string $value_sql Translated RHS SQL. + * @return string Coerced value SQL. + */ + private function coerce_numeric_write_value_sql( string $data_type, string $value_sql ): string { + return 'CASE' + . ' WHEN (' . $value_sql . ') IS NULL THEN NULL' + . ' ELSE COALESCE(TRY_CAST((' . $value_sql . ') AS ' . $this->numeric_write_cast_type( $data_type ) . '), 0)' + . ' END'; + } + + /** + * Return the DuckDB cast target for a MySQL numeric data type. + * + * @param string $data_type MySQL data type. + * @return string DuckDB type. + */ + private function numeric_write_cast_type( string $data_type ): string { + switch ( $data_type ) { + case 'tinyint': + return 'TINYINT'; + case 'smallint': + return 'SMALLINT'; + case 'mediumint': + case 'int': + return 'INTEGER'; + case 'bigint': + return 'BIGINT'; + case 'float': + return 'FLOAT'; + case 'decimal': + case 'dec': + case 'fixed': + case 'numeric': + return 'DECIMAL'; + case 'double': + case 'real': + default: + return 'DOUBLE'; + } + } + /** * Return a quoted hex string for a MySQL binary literal token. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index eac87e178..c1931aabe 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -152,6 +152,210 @@ public function test_non_temporal_text_and_blob_write_coercions_match_sqlite(): $this->assertParityRows( 'SELECT id, text_value, blob_value FROM write_coercions ORDER BY id' ); } + public function test_non_strict_numeric_write_coercions_match_sqlite(): void { + $this->assertParityRowCount( "SET SESSION sql_mode = ''" ); + $this->runParitySetup( + array( + 'CREATE TABLE numeric_write_coercions ( + id INT PRIMARY KEY, + int_value INT, + decimal_value DECIMAL(10,2), + float_value FLOAT + )', + ) + ); + + $this->assertParityRowCount( + "INSERT INTO numeric_write_coercions (id, int_value, decimal_value, float_value) VALUES + (1, 'test', 'test', 'test'), + (2, '', '', '')" + ); + $this->assertParityRowCount( + "INSERT INTO numeric_write_coercions SET + id = 3, + int_value = 'set-value', + decimal_value = 'set-value', + float_value = 'set-value'" + ); + $this->assertParityRowCount( + 'INSERT INTO numeric_write_coercions (id, int_value, decimal_value, float_value) + VALUES (4, 7, 8.25, 9.5)' + ); + $this->assertParityRowCount( + "UPDATE numeric_write_coercions + SET int_value = 'update-value', + decimal_value = 'update-value', + float_value = 'update-value' + WHERE id = 4" + ); + $this->assertParityRowCount( + "REPLACE INTO numeric_write_coercions (id, int_value, decimal_value, float_value) + VALUES (2, 'replace-value', 'replace-value', 'replace-value')" + ); + $this->assertParityRowCount( + 'INSERT INTO numeric_write_coercions (id, int_value, decimal_value, float_value) + VALUES (3, 10, 11.25, 12.5) + ON DUPLICATE KEY UPDATE + int_value = "odku-value", + decimal_value = "odku-value", + float_value = "odku-value"' + ); + $this->assertParityRowCount( + 'INSERT INTO numeric_write_coercions (id, int_value, decimal_value, float_value) + VALUES (5, "odku-insert", "odku-insert", "odku-insert") + ON DUPLICATE KEY UPDATE int_value = VALUES(int_value)' + ); + + $this->assertParityRows( 'SELECT id, int_value, decimal_value, float_value FROM numeric_write_coercions ORDER BY id' ); + } + + public function test_non_strict_numeric_update_null_not_null_matches_sqlite(): void { + $this->assertParityRowCount( "SET SESSION sql_mode = ''" ); + $this->runParitySetup( + array( + 'CREATE TABLE numeric_not_null_coercions ( + id INT PRIMARY KEY, + int_value INT NOT NULL, + decimal_value DECIMAL(10,2) NOT NULL, + float_value FLOAT NOT NULL + )', + 'INSERT INTO numeric_not_null_coercions (id, int_value, decimal_value, float_value) + VALUES (1, 7, 8.25, 9.5)', + ) + ); + + $this->assertParityRowCount( + 'UPDATE numeric_not_null_coercions + SET int_value = NULL, + decimal_value = NULL, + float_value = NULL + WHERE id = 1' + ); + $this->assertParityRows( 'SELECT id, int_value, decimal_value, float_value FROM numeric_not_null_coercions ORDER BY id' ); + $this->assertParityErrorContains( + 'INSERT INTO numeric_not_null_coercions (id, int_value, decimal_value, float_value) + VALUES (2, NULL, NULL, NULL)', + 'NOT NULL' + ); + $this->assertParityRows( 'SELECT id, int_value, decimal_value, float_value FROM numeric_not_null_coercions ORDER BY id' ); + + $this->assertParityRowCount( + 'UPDATE numeric_not_null_coercions + SET int_value = 7, + decimal_value = 8.25, + float_value = 9.5 + WHERE id = 1' + ); + $this->assertParityErrorContains( + 'INSERT INTO numeric_not_null_coercions (id, int_value, decimal_value, float_value) + VALUES (1, 10, 11.25, 12.5) + ON DUPLICATE KEY UPDATE int_value = NULL', + 'NOT NULL' + ); + $this->assertParityRows( 'SELECT id, int_value, decimal_value, float_value FROM numeric_not_null_coercions ORDER BY id' ); + } + + public function test_strict_numeric_write_errors_preserve_duckdb_rows(): void { + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( + 'CREATE TABLE strict_numeric_write_coercions ( + id INT PRIMARY KEY, + int_value INT, + decimal_value DECIMAL(10,2), + float_value FLOAT + )' + ); + $driver->query( + 'INSERT INTO strict_numeric_write_coercions (id, int_value, decimal_value, float_value) + VALUES (1, 7, 8.25, 9.5)' + ); + + $this->assert_duckdb_error_contains( + $driver, + "INSERT INTO strict_numeric_write_coercions (id, int_value, decimal_value, float_value) + VALUES (2, 'test', 'test', 'test')", + 'Failed to execute DuckDB INSERT' + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'int_value' => 7, + 'decimal_value' => 8.25, + 'float_value' => 9.5, + ), + ), + $driver->query( 'SELECT id, int_value, decimal_value, float_value FROM strict_numeric_write_coercions ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assert_duckdb_error_contains( + $driver, + "REPLACE INTO strict_numeric_write_coercions (id, int_value, decimal_value, float_value) + VALUES (1, 'test', 'test', 'test')", + 'Failed to execute DuckDB REPLACE' + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'int_value' => 7, + 'decimal_value' => 8.25, + 'float_value' => 9.5, + ), + ), + $driver->query( 'SELECT id, int_value, decimal_value, float_value FROM strict_numeric_write_coercions ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assert_duckdb_error_contains( + $driver, + "INSERT INTO strict_numeric_write_coercions (id, int_value, decimal_value, float_value) + VALUES (1, 10, 11.25, 12.5) + ON DUPLICATE KEY UPDATE + int_value = 'test', + decimal_value = 'test', + float_value = 'test'", + 'Failed to execute DuckDB INSERT' + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'int_value' => 7, + 'decimal_value' => 8.25, + 'float_value' => 9.5, + ), + ), + $driver->query( 'SELECT id, int_value, decimal_value, float_value FROM strict_numeric_write_coercions ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assert_duckdb_error_contains( + $driver, + "UPDATE strict_numeric_write_coercions + SET int_value = 'test', + decimal_value = 'test', + float_value = 'test' + WHERE id = 1", + 'Failed to execute DuckDB UPDATE' + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'int_value' => 7, + 'decimal_value' => 8.25, + 'float_value' => 9.5, + ), + ), + $driver->query( 'SELECT id, int_value, decimal_value, float_value FROM strict_numeric_write_coercions ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_show_full_tables_sql_matches_sqlite(): void { $this->runParitySetup( array( From bf517994041f09a55af3407e64cc9729fe13e562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 07:53:15 +0000 Subject: [PATCH 088/317] Add DuckDB SELECT text and blob coercion parity --- .../src/duckdb/class-wp-duckdb-driver.php | 320 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 93 +++++ 2 files changed, 396 insertions(+), 17 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 3f4dc316b..d91786dc6 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -11884,8 +11884,10 @@ private function translate_insert_select_tokens_to_duckdb_sql( array $tokens, in * @return WP_DuckDB_Result_Statement */ private function execute_insert_select_with_temporal_coercion( array $tokens, int $table_index, int $select_index, bool $ignore ): WP_DuckDB_Result_Statement { - $shape = $this->parse_insert_select_target_shape( $tokens, $table_index, $select_index ); - if ( ! $this->insert_select_shape_requires_temporal_coercion( $shape ) ) { + $shape = $this->parse_insert_select_target_shape( $tokens, $table_index, $select_index ); + $text_blob_write_plan = $this->insert_select_text_blob_coercion_plan( $shape ); + $requires_write_stage = $this->insert_select_shape_requires_temporal_coercion( $shape ) || null !== $text_blob_write_plan; + if ( ! $requires_write_stage ) { return $this->execute_auto_increment_write( $shape['requested_table_name'], $this->translate_insert_select_tokens_to_duckdb_sql( $tokens, $table_index, $ignore ), @@ -11895,11 +11897,19 @@ private function execute_insert_select_with_temporal_coercion( array $tokens, in ); } - $source_sql = $this->translate_tokens_to_duckdb_sql( $shape['source_tokens'] ); + $source_sql = null === $text_blob_write_plan + ? $this->translate_tokens_to_duckdb_sql( $shape['source_tokens'] ) + : $text_blob_write_plan['source_sql']; $stage = $this->create_select_write_stage( $source_sql, 'insert_select_src' ); try { - $projection = $this->build_insert_select_projection( $shape, $stage['columns'], true, 'INSERT' ); + $projection = $this->build_insert_select_projection( + $shape, + $stage['columns'], + true, + 'INSERT', + null === $text_blob_write_plan ? array() : $text_blob_write_plan['precoerced_offsets'] + ); $this->validate_staged_temporal_write( $stage['table_name'], $projection['validations'], 'Failed to validate DuckDB INSERT SELECT values' ); return $this->execute_auto_increment_write( @@ -11938,8 +11948,12 @@ private function execute_replace_select_with_temporal_coercion( array $tokens, i $unique_sets = $this->unique_key_column_sets( $shape['table_name'], $shape['temporary'] ); $case_insensitive_columns = $this->case_insensitive_column_names( $shape['table_name'], $shape['temporary'] ); $manual_conflicts = $this->replace_select_requires_manual_conflict_handling( $unique_sets, $case_insensitive_columns ); + $text_blob_write_plan = $this->insert_select_text_blob_coercion_plan( $shape ); + $requires_write_stage = $manual_conflicts + || $this->insert_select_shape_requires_temporal_coercion( $shape ) + || null !== $text_blob_write_plan; - if ( ! $manual_conflicts && ! $this->insert_select_shape_requires_temporal_coercion( $shape ) ) { + if ( ! $requires_write_stage ) { return $this->execute_auto_increment_write( $shape['requested_table_name'], $this->translate_replace_tokens_to_duckdb_sql( $tokens ), @@ -11949,12 +11963,20 @@ private function execute_replace_select_with_temporal_coercion( array $tokens, i ); } - $source_sql = $this->translate_tokens_to_duckdb_sql( $shape['source_tokens'] ); + $source_sql = null === $text_blob_write_plan + ? $this->translate_tokens_to_duckdb_sql( $shape['source_tokens'] ) + : $text_blob_write_plan['source_sql']; $stage = $this->create_select_write_stage( $source_sql, 'replace_select_src' ); $write_stage = null; try { - $projection = $this->build_insert_select_projection( $shape, $stage['columns'], true, 'REPLACE' ); + $projection = $this->build_insert_select_projection( + $shape, + $stage['columns'], + true, + 'REPLACE', + null === $text_blob_write_plan ? array() : $text_blob_write_plan['precoerced_offsets'] + ); $this->validate_staged_temporal_write( $stage['table_name'], $projection['validations'], 'Failed to validate DuckDB REPLACE SELECT values' ); if ( $manual_conflicts ) { @@ -12001,6 +12023,267 @@ private function execute_replace_select_with_temporal_coercion( array $tokens, i } } + /** + * Build a narrow literal coercion plan for INSERT/REPLACE ... SELECT text/blob targets. + * + * @param array{target_columns:string[],target_metadata:array>,source_tokens:array} $shape Target shape. + * @return array{source_sql:string,precoerced_offsets:array}|null Coercion plan, or null when outside the supported slice. + */ + private function insert_select_text_blob_coercion_plan( array $shape ): ?array { + $select_items = $this->simple_insert_select_items( $shape['source_tokens'] ); + if ( null === $select_items || count( $select_items ) !== count( $shape['target_columns'] ) ) { + return null; + } + + $precoerced_offsets = array(); + $stage_items = array(); + foreach ( $select_items as $offset => $item_tokens ) { + $source_sql = $this->translate_tokens_to_duckdb_sql( $item_tokens ); + $metadata = $shape['target_metadata'][ $offset ]; + if ( $this->select_item_requires_text_blob_write_coercion( $this->mysql_column_data_type( $metadata ), $item_tokens ) ) { + $source_sql = $this->coerce_write_value_for_column_sql( $metadata, $item_tokens, $source_sql, true ); + $precoerced_offsets[ $offset ] = true; + } + + $stage_items[] = $source_sql + . ' AS ' + . $this->connection->quote_identifier( $this->insert_select_write_stage_column_name( $offset ) ); + } + + if ( count( $precoerced_offsets ) === 0 ) { + return null; + } + + $list_end = $this->top_level_select_list_end( $shape['source_tokens'] ); + $tail_sql = $this->translate_tokens_to_duckdb_sql( array_slice( $shape['source_tokens'], $list_end ) ); + + return array( + 'source_sql' => 'SELECT ' . implode( ', ', $stage_items ) . ( '' === $tail_sql ? '' : ' ' . $tail_sql ), + 'precoerced_offsets' => $precoerced_offsets, + ); + } + + /** + * Return expression token lists for a simple single-block SELECT. + * + * @param WP_Parser_Token[] $source_tokens SELECT tokens. + * @return array>|null Expression tokens, or null when outside the supported slice. + */ + private function simple_insert_select_items( array $source_tokens ): ?array { + if ( ! isset( $source_tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $source_tokens[0]->id ) { + return null; + } + if ( $this->insert_select_has_top_level_set_operator( $source_tokens ) ) { + return null; + } + if ( isset( $source_tokens[1] ) && $this->is_unsupported_simple_insert_select_option( $source_tokens[1] ) ) { + return null; + } + + $list_end = $this->top_level_select_list_end( $source_tokens ); + if ( $list_end <= 1 ) { + return null; + } + + $items = array(); + foreach ( $this->split_top_level_select_item_ranges( $source_tokens, 1, $list_end ) as $range ) { + if ( $this->select_item_is_wildcard( $range['tokens'] ) ) { + return null; + } + + $item_tokens = $this->strip_simple_select_item_alias( $range['tokens'] ); + if ( null === $item_tokens || count( $item_tokens ) === 0 ) { + return null; + } + + $items[] = $item_tokens; + } + + return count( $items ) > 0 ? $items : null; + } + + /** + * Check whether a SELECT token stream contains a top-level set operator. + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @return bool Whether a set operator is present. + */ + private function insert_select_has_top_level_set_operator( array $tokens ): bool { + return $this->contains_top_level_token_id( $tokens, WP_MySQL_Lexer::UNION_SYMBOL ) + || $this->contains_top_level_token_id( $tokens, WP_MySQL_Lexer::INTERSECT_SYMBOL ) + || $this->contains_top_level_token_id( $tokens, WP_MySQL_Lexer::EXCEPT_SYMBOL ); + } + + /** + * Check whether a SELECT option keeps this statement outside the narrow literal slice. + * + * @param WP_Parser_Token $token Token after SELECT. + * @return bool Whether the option is unsupported. + */ + private function is_unsupported_simple_insert_select_option( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::ALL_SYMBOL, + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::DISTINCTROW_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::SQL_BIG_RESULT_SYMBOL, + WP_MySQL_Lexer::SQL_BUFFER_RESULT_SYMBOL, + WP_MySQL_Lexer::SQL_CACHE_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::SQL_NO_CACHE_SYMBOL, + WP_MySQL_Lexer::SQL_SMALL_RESULT_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ), + true + ); + } + + /** + * Strip a supported explicit SELECT-list alias. + * + * @param WP_Parser_Token[] $tokens SELECT item tokens. + * @return array|null Expression tokens, or null for unsupported alias shape. + */ + private function strip_simple_select_item_alias( array $tokens ): ?array { + $as_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::AS_SYMBOL ); + if ( null === $as_index ) { + if ( $this->select_item_has_implicit_alias( $tokens ) ) { + return null; + } + return $tokens; + } + + if ( 0 === $as_index || count( $tokens ) !== $as_index + 2 ) { + return null; + } + + $this->identifier_value( $tokens[ $as_index + 1 ] ); + return array_slice( $tokens, 0, $as_index ); + } + + /** + * Check whether a SELECT item appears to have an unsupported implicit alias. + * + * @param WP_Parser_Token[] $tokens SELECT item tokens. + * @return bool Whether the item has an implicit alias. + */ + private function select_item_has_implicit_alias( array $tokens ): bool { + if ( count( $tokens ) < 2 ) { + return false; + } + + $last_token = $tokens[ count( $tokens ) - 1 ]; + if ( ! $this->is_unquoted_or_backtick_identifier_token( $last_token ) ) { + return false; + } + + return ! $this->is_qualified_identifier_tokens( $tokens ); + } + + /** + * Check whether a token can serve as a simple identifier. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token is an identifier. + */ + private function is_unquoted_or_backtick_identifier_token( WP_Parser_Token $token ): bool { + return WP_MySQL_Lexer::IDENTIFIER === $token->id || WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id; + } + + /** + * Check whether tokens are a simple qualified identifier path. + * + * @param WP_Parser_Token[] $tokens SELECT item tokens. + * @return bool Whether tokens are an identifier path. + */ + private function is_qualified_identifier_tokens( array $tokens ): bool { + foreach ( $tokens as $offset => $token ) { + if ( 0 === $offset % 2 ) { + if ( ! $this->is_unquoted_or_backtick_identifier_token( $token ) ) { + return false; + } + continue; + } + + if ( WP_MySQL_Lexer::DOT_SYMBOL !== $token->id ) { + return false; + } + } + + return true; + } + + /** + * Check whether a target SELECT item needs token-aware text/blob write coercion. + * + * @param string $target_data_type MySQL target data type. + * @param WP_Parser_Token[] $item_tokens SELECT item expression tokens. + * @return bool Whether token-aware coercion is needed. + */ + private function select_item_requires_text_blob_write_coercion( string $target_data_type, array $item_tokens ): bool { + if ( $this->is_character_write_data_type( $target_data_type ) ) { + return $this->is_boolean_literal_tokens( $item_tokens ) || null !== $this->binary_literal_write_hex_sql( $item_tokens ); + } + + if ( ! $this->is_blob_write_data_type( $target_data_type ) ) { + return false; + } + + return $this->is_boolean_literal_tokens( $item_tokens ) + || null !== $this->binary_literal_write_hex_sql( $item_tokens ) + || $this->is_string_literal_tokens( $item_tokens ) + || $this->is_signed_or_unsigned_number_literal_tokens( $item_tokens ); + } + + /** + * Check whether tokens are TRUE or FALSE. + * + * @param WP_Parser_Token[] $tokens Value tokens. + * @return bool Whether tokens are a boolean literal. + */ + private function is_boolean_literal_tokens( array $tokens ): bool { + return 1 === count( $tokens ) + && ( WP_MySQL_Lexer::TRUE_SYMBOL === $tokens[0]->id || WP_MySQL_Lexer::FALSE_SYMBOL === $tokens[0]->id ); + } + + /** + * Check whether tokens are a string literal. + * + * @param WP_Parser_Token[] $tokens Value tokens. + * @return bool Whether tokens are a quoted string literal. + */ + private function is_string_literal_tokens( array $tokens ): bool { + return 1 === count( $tokens ) + && ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $tokens[0]->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[0]->id ); + } + + /** + * Check whether tokens are an unsigned or signed number literal. + * + * @param WP_Parser_Token[] $tokens Value tokens. + * @return bool Whether tokens are a number literal. + */ + private function is_signed_or_unsigned_number_literal_tokens( array $tokens ): bool { + return ( 1 === count( $tokens ) && $this->is_number_token( $tokens[0] ) ) + || ( + 2 === count( $tokens ) + && $this->is_sign_token( $tokens[0] ) + && $this->is_number_token( $tokens[1] ) + ); + } + + /** + * Return the generated source stage column name for an INSERT/REPLACE SELECT offset. + * + * @param int $offset SELECT-list offset. + * @return string Stage column name. + */ + private function insert_select_write_stage_column_name( int $offset ): string { + return '__wp_write_' . $offset; + } + /** * Parse target metadata for INSERT/REPLACE ... SELECT. * @@ -12397,12 +12680,13 @@ private function drop_select_write_stage( string $stage_table ): void { * Build INSERT/REPLACE SELECT target columns, source projections, and validations. * * @param array{target_columns:string[],target_metadata:array>,omitted_defaults:array} $shape Target shape. - * @param string[] $stage_columns Ordered staged SELECT output columns. - * @param bool $coalesce_select_nulls Whether non-strict temporal NOT NULL NULLs should become implicit defaults. - * @param string $statement Statement name for errors. + * @param string[] $stage_columns Ordered staged SELECT output columns. + * @param bool $coalesce_select_nulls Whether non-strict temporal NOT NULL NULLs should become implicit defaults. + * @param string $statement Statement name for errors. + * @param array $precoerced_offsets SELECT offsets already coerced for storage in the stage. * @return array{columns:string[],expressions:string[],validations:array} */ - private function build_insert_select_projection( array $shape, array $stage_columns, bool $coalesce_select_nulls, string $statement ): array { + private function build_insert_select_projection( array $shape, array $stage_columns, bool $coalesce_select_nulls, string $statement, array $precoerced_offsets = array() ): array { if ( count( $stage_columns ) !== count( $shape['target_columns'] ) ) { throw new WP_DuckDB_Driver_Exception( $statement . ' ... SELECT column count does not match target column count in DuckDB driver.' ); } @@ -12422,12 +12706,14 @@ private function build_insert_select_projection( array $shape, array $stage_colu } $columns[] = $column_name; - $expressions[] = $this->coerce_write_value_for_column_sql( - $metadata, - array(), - $source_sql, - $coalesce_select_nulls - ); + $expressions[] = isset( $precoerced_offsets[ $offset ] ) + ? $source_sql + : $this->coerce_write_value_for_column_sql( + $metadata, + array(), + $source_sql, + $coalesce_select_nulls + ); } foreach ( $shape['omitted_defaults'] as $default_write ) { diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index c1931aabe..d89f0184a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1457,6 +1457,99 @@ public function test_insert_select_and_replace_select_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name FROM items ORDER BY id' ); } + public function test_insert_select_text_and_blob_write_coercions_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE insert_select_write_coercions (id INTEGER PRIMARY KEY, text_value TEXT, blob_value BLOB)', + ) + ); + + $this->assertParityRowCount( 'INSERT INTO insert_select_write_coercions (id, text_value, blob_value) SELECT 1, TRUE, TRUE' ); + $this->assertParityRowCount( 'INSERT INTO insert_select_write_coercions (id, text_value, blob_value) SELECT 2, FALSE, FALSE' ); + $this->assertParityRowCount( 'INSERT INTO insert_select_write_coercions (id, text_value, blob_value) SELECT 3, 0x62, 0x62' ); + $this->assertParityRowCount( "INSERT INTO insert_select_write_coercions (id, text_value, blob_value) SELECT 4, x'63', x'63'" ); + $this->assertParityRowCount( "INSERT INTO insert_select_write_coercions (id, text_value, blob_value) SELECT 5, b'01100100', b'01100100'" ); + $this->assertParityRowCount( 'INSERT INTO insert_select_write_coercions (id, text_value, blob_value) SELECT 6, 0b01100101, 0b01100101' ); + $this->assertParityRowCount( 'INSERT INTO insert_select_write_coercions (id, text_value, blob_value) SELECT 7, 123.456, 123.456' ); + $this->assertParityRowCount( 'INSERT INTO insert_select_write_coercions (id, text_value, blob_value) SELECT 8, -7, -7' ); + $this->assertParityRowCount( "INSERT INTO insert_select_write_coercions (id, text_value, blob_value) SELECT 9, 'plain' AS text_alias, 'plain' AS blob_alias" ); + $this->assertParityRows( 'SELECT id, text_value, blob_value FROM insert_select_write_coercions ORDER BY id' ); + } + + public function test_insert_select_text_blob_with_temporal_staging_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE insert_select_temporal_write_coercions ( + id INTEGER PRIMARY KEY, + d DATE, + text_value TEXT, + blob_value BLOB + )', + ) + ); + + $this->assertParityRowCount( + "INSERT INTO insert_select_temporal_write_coercions (id, d, text_value, blob_value) + SELECT 1, '2025-01-01', TRUE, TRUE" + ); + $this->assertParityRowCount( + "INSERT INTO insert_select_temporal_write_coercions (id, d, text_value, blob_value) + SELECT 2, '2025-01-02', 0x62, 0x62" + ); + $this->assertParityRowCount( + "INSERT INTO insert_select_temporal_write_coercions (id, d, text_value, blob_value) + SELECT 3, '2025-01-03', x'63', x'63'" + ); + $this->assertParityRows( 'SELECT id, d, text_value, blob_value FROM insert_select_temporal_write_coercions ORDER BY id' ); + } + + public function test_insert_ignore_select_text_and_blob_write_coercions_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE insert_ignore_select_write_coercions (id INTEGER PRIMARY KEY, text_value TEXT, blob_value BLOB)', + "INSERT INTO insert_ignore_select_write_coercions VALUES (1, 'old', 'old')", + ) + ); + + $this->assertParityRowCount( 'INSERT IGNORE INTO insert_ignore_select_write_coercions (id, text_value, blob_value) SELECT 1, TRUE, TRUE' ); + $this->assertParityRows( 'SELECT id, text_value, blob_value FROM insert_ignore_select_write_coercions ORDER BY id' ); + $this->assertParityRowCount( 'INSERT IGNORE INTO insert_ignore_select_write_coercions (id, text_value, blob_value) SELECT 2, TRUE, TRUE' ); + $this->assertParityRows( 'SELECT id, text_value, blob_value FROM insert_ignore_select_write_coercions ORDER BY id' ); + } + + public function test_replace_select_text_and_blob_write_coercions_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE replace_select_write_coercions (id INTEGER PRIMARY KEY, text_value TEXT, blob_value BLOB)', + "INSERT INTO replace_select_write_coercions VALUES (1, 'old', 'old')", + ) + ); + + $this->assertParityRowCount( 'REPLACE INTO replace_select_write_coercions (id, text_value, blob_value) SELECT 1, TRUE, TRUE' ); + $this->assertParityRowCount( "REPLACE INTO replace_select_write_coercions (id, text_value, blob_value) SELECT 2, x'63', x'63'" ); + $this->assertParityRows( 'SELECT id, text_value, blob_value FROM replace_select_write_coercions ORDER BY id' ); + } + + public function test_replace_select_manual_conflict_text_and_blob_write_coercions_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE replace_select_manual_write_coercions ( + id INTEGER PRIMARY KEY, + u INTEGER UNIQUE, + text_value TEXT, + blob_value BLOB + )', + "INSERT INTO replace_select_manual_write_coercions VALUES (1, 1, 'old', 'old')", + ) + ); + + $this->assertParityRowCount( + 'REPLACE INTO replace_select_manual_write_coercions (id, u, text_value, blob_value) + SELECT 2, 1, TRUE, TRUE' + ); + $this->assertParityRows( 'SELECT id, u, text_value, blob_value FROM replace_select_manual_write_coercions ORDER BY id' ); + } + public function test_regexp_and_not_regexp_match_sqlite(): void { $this->runParitySetup( array( From 710155e0fab7aba7d44f9ecb82722571c6045458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 07:58:41 +0000 Subject: [PATCH 089/317] Support qualified DuckDB DDL metadata targets --- .../src/duckdb/class-wp-duckdb-driver.php | 30 ++++++----- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 54 +++++++++++++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index d91786dc6..dfabba914 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1862,9 +1862,9 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme $index += 3; } - $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); - ++$index; - $this->assert_unqualified_write_allowed_in_current_database(); + $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'CREATE TABLE' ); + $table_name = $reference['requested_table_name']; + $index = $reference['next_index']; $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Expected column list in CREATE TABLE.' ); ++$index; @@ -2014,12 +2014,11 @@ private function execute_create_index( array $tokens ): WP_DuckDB_Result_Stateme $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ON_SYMBOL, 'Expected ON in CREATE INDEX statement.' ); ++$index; - $table_name = $this->identifier_value( $tokens[ $index ] ?? null ); - ++$index; - $this->assert_unqualified_write_allowed_in_current_database(); - $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, 'CREATE INDEX' ); + $index = $reference['next_index']; + $table_reference = $this->resolve_visible_user_table_reference( $reference['requested_table_name'] ); if ( null === $table_reference ) { - throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$table_name}' in CREATE INDEX statement." ); + throw new WP_DuckDB_Driver_Exception( "Unknown table '{$this->database}.{$reference['requested_table_name']}' in CREATE INDEX statement." ); } $table_name = $table_reference['table_name']; @@ -9118,15 +9117,22 @@ private function execute_show_tables( array $tokens ): WP_DuckDB_Result_Statemen */ private function execute_show_index( array $tokens ): WP_DuckDB_Result_Statement { if ( - 4 !== count( $tokens ) + ! isset( $tokens[2] ) || ( WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[2]->id && WP_MySQL_Lexer::IN_SYMBOL !== $tokens[2]->id ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW INDEX statement in DuckDB driver. Use SHOW INDEX FROM table.' ); } - $table_name = $this->identifier_value( $tokens[3] ); - $table_reference = $this->resolve_visible_user_table_reference( $table_name ); - $rows = null === $table_reference ? array() : $this->index_rows_for_table( $table_reference['table_name'], $table_reference['temporary'] ); + $requested_reference = $this->parse_metadata_table_reference( $tokens, 3, true ); + if ( count( $tokens ) !== $requested_reference['next_index'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW INDEX statement in DuckDB driver. Use SHOW INDEX FROM table.' ); + } + + $rows = array(); + if ( 0 === strcasecmp( $requested_reference['database'], $this->database ) ) { + $table_reference = $this->resolve_visible_user_table_reference( $requested_reference['table_name'] ); + $rows = null === $table_reference ? array() : $this->index_rows_for_table( $table_reference['table_name'], $table_reference['temporary'] ); + } return new WP_DuckDB_Result_Statement( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index d89f0184a..0e2d0d5ab 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -2970,6 +2970,60 @@ public function test_show_columns_qualified_and_filtered_metadata_matches_sqlite } } + public function test_qualified_create_table_and_show_indexes_after_use_information_schema_matches_sqlite(): void { + $this->runParitySetup( + array( + 'USE information_schema', + 'CREATE TABLE wp.qualified_ddl ( + id INT PRIMARY KEY, + name VARCHAR(20), + KEY idx_name (name) + )', + ) + ); + + $this->assertParityRows( 'SHOW TABLES FROM wp' ); + $this->assertParityRows( 'SHOW CREATE TABLE wp.qualified_ddl' ); + $this->assertParityRows( 'SHOW COLUMNS FROM wp.qualified_ddl' ); + + foreach ( + array( + 'SHOW INDEXES FROM wp.qualified_ddl', + 'SHOW INDEXES FROM information_schema.qualified_ddl FROM wp', + 'SHOW INDEXES FROM qualified_ddl FROM wp', + ) as $sql + ) { + $this->assertParityRowColumns( + $sql, + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + } + + $this->assertParityRows( 'SHOW INDEXES FROM qualified_ddl' ); + } + + public function test_qualified_create_index_after_use_information_schema_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE qualified_idx (id INT PRIMARY KEY, name VARCHAR(20))', + 'USE information_schema', + 'CREATE INDEX idx_name ON wp.qualified_idx (name)', + ) + ); + + $this->assertParityRowColumns( + 'SHOW INDEXES FROM wp.qualified_idx', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + + $this->runParitySetup( array( 'DROP INDEX idx_name ON wp.qualified_idx' ) ); + + $this->assertParityRowColumns( + 'SHOW INDEXES FROM wp.qualified_idx', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + } + public function test_alter_table_add_column_metadata_matches_sqlite(): void { $this->runParitySetup( array( From 13f37beb91521f180414ba2077f19720e2598b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 08:10:03 +0000 Subject: [PATCH 090/317] Treat DuckDB ALTER table options as no-ops --- .../src/duckdb/class-wp-duckdb-driver.php | 196 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 44 ++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 87 ++++++++ 3 files changed, 326 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index dfabba914..b11247d86 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -5219,6 +5219,7 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD COLUMN, ADD INDEX, DROP COLUMN, and DROP INDEX are supported.' ); } + $this->validate_alter_table_supported_action_shapes( $actions ); $this->validate_alter_table_rebuild_action_combination( $table_name, $actions, $temporary ); $this->validate_alter_table_constraint_actions( $table_name, $actions, $temporary ); @@ -5228,6 +5229,11 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Empty action.' ); } + if ( $this->is_alter_table_no_op_action( $action ) ) { + $result = $this->empty_ddl_result(); + continue; + } + if ( WP_MySQL_Lexer::DROP_SYMBOL === $action[0]->id ) { if ( $this->is_alter_table_drop_foreign_key_constraint_action( $table_name, $action, $temporary ) ) { $result = $this->execute_alter_table_drop_foreign_key_constraint( $table_name, $action, $temporary ); @@ -5286,6 +5292,178 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen return $result ?? new WP_DuckDB_Result_Statement( array(), array(), 0 ); } + /** + * Reject unsupported ALTER TABLE action shapes before any mutation. + * + * @param array $actions ALTER action token groups. + */ + private function validate_alter_table_supported_action_shapes( array $actions ): void { + foreach ( $actions as $action ) { + if ( ! isset( $action[0] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Empty action.' ); + } + + if ( $this->is_alter_table_no_op_action( $action ) ) { + continue; + } + + if ( WP_MySQL_Lexer::ADD_SYMBOL === $action[0]->id ) { + $this->validate_alter_table_add_action_shape( $action ); + continue; + } + + if ( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL === $action[0]->id ) { + $this->parse_alter_table_auto_increment_action_value( $action ); + continue; + } + + if ( + WP_MySQL_Lexer::CHANGE_SYMBOL === $action[0]->id + || WP_MySQL_Lexer::MODIFY_SYMBOL === $action[0]->id + ) { + if ( $this->contains_auto_increment_token( $action ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY AUTO_INCREMENT requires a table rebuild.' ); + } + continue; + } + + if ( WP_MySQL_Lexer::DROP_SYMBOL === $action[0]->id ) { + continue; + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only ADD, DROP, CHANGE, MODIFY, AUTO_INCREMENT, table option, and ENABLE/DISABLE KEYS actions are supported.' ); + } + } + + /** + * Reject known unsupported ADD COLUMN shapes before earlier actions mutate. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens starting at ADD. + */ + private function validate_alter_table_add_action_shape( array $tokens ): void { + $alter_item = array_slice( $tokens, 1 ); + if ( count( $alter_item ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN requires a column definition.' ); + } + + if ( + $this->is_alter_table_add_check_constraint_action( $alter_item ) + || $this->is_alter_table_add_foreign_key_constraint_action( $alter_item ) + || $this->is_alter_table_add_primary_key_item( $alter_item ) + || $this->is_create_table_index_item( $alter_item ) + || WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $alter_item[0]->id + ) { + return; + } + + $column_tokens = $alter_item; + if ( isset( $column_tokens[0] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $column_tokens[0]->id ) { + $column_tokens = array_slice( $column_tokens, 1 ); + } + + if ( count( $column_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN requires a column definition.' ); + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $column_tokens[0]->id ) { + list( $items, $index ) = $this->collect_parenthesized_items( $column_tokens, 1 ); + if ( 1 !== count( $items ) || count( $column_tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD COLUMN definition is supported.' ); + } + $column_tokens = $items[0]; + } + + if ( $this->contains_auto_increment_token( $column_tokens ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN AUTO_INCREMENT is not supported.' ); + } + } + + /** + * Check whether an ALTER TABLE action is accepted as a no-op. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @return bool Whether this action is a no-op. + */ + private function is_alter_table_no_op_action( array $tokens ): bool { + return $this->is_alter_table_no_op_table_options_action( $tokens ) + || $this->is_alter_table_no_op_key_maintenance_action( $tokens ); + } + + /** + * Check whether an ALTER TABLE action only carries no-op table options. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @return bool Whether this action only contains accepted table options. + */ + private function is_alter_table_no_op_table_options_action( array $tokens ): bool { + $matched = false; + $index = 0; + + while ( $index < count( $tokens ) ) { + $token = $tokens[ $index ]; + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + ++$index; + continue; + } + + if ( + WP_MySQL_Lexer::ENGINE_SYMBOL === $token->id + || WP_MySQL_Lexer::COLLATE_SYMBOL === $token->id + || WP_MySQL_Lexer::COMMENT_SYMBOL === $token->id + || WP_MySQL_Lexer::CHARSET_SYMBOL === $token->id + || WP_MySQL_Lexer::ROW_FORMAT_SYMBOL === $token->id + ) { + $index = $this->skip_option_value( $tokens, $index + 1 ); + $matched = true; + continue; + } + + if ( WP_MySQL_Lexer::CHAR_SYMBOL === $token->id || WP_MySQL_Lexer::CHARACTER_SYMBOL === $token->id ) { + if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $index + 1 ]->id ) { + return false; + } + $index = $this->skip_option_value( $tokens, $index + 2 ); + $matched = true; + continue; + } + + return false; + } + + return $matched; + } + + /** + * Check whether an ALTER TABLE action is ENABLE/DISABLE KEYS. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens. + * @return bool Whether this action is key-maintenance no-op syntax. + */ + private function is_alter_table_no_op_key_maintenance_action( array $tokens ): bool { + return 2 === count( $tokens ) + && ( + WP_MySQL_Lexer::ENABLE_SYMBOL === $tokens[0]->id + || WP_MySQL_Lexer::DISABLE_SYMBOL === $tokens[0]->id + ) + && WP_MySQL_Lexer::KEYS_SYMBOL === $tokens[1]->id; + } + + /** + * Check whether a token stream contains AUTO_INCREMENT. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether AUTO_INCREMENT is present. + */ + private function contains_auto_increment_token( array $tokens ): bool { + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL === $token->id ) { + return true; + } + } + + return false; + } + /** * Reject combined ALTER TABLE rebuild actions before any mutation. * @@ -6875,7 +7053,7 @@ function ( array $row ): string { * @return WP_DuckDB_Result_Statement */ private function execute_alter_table_set_auto_increment( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { - $requested_next = $this->parse_auto_increment_option_value( $tokens, 1, 'ALTER TABLE' ); + $requested_next = $this->parse_alter_table_auto_increment_action_value( $tokens ); $metadata = $this->auto_increment_metadata_for_table( $table_name, $temporary ); if ( null === $metadata ) { return $this->empty_ddl_result(); @@ -6893,6 +7071,22 @@ function () use ( $table_name, $next_value, $temporary ): WP_DuckDB_Result_State ); } + /** + * Parse ALTER TABLE ... AUTO_INCREMENT = N, allowing trailing no-op options. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens starting at AUTO_INCREMENT. + * @return int Requested next AUTO_INCREMENT value. + */ + private function parse_alter_table_auto_increment_action_value( array $tokens ): int { + $requested_next = $this->parse_auto_increment_option_value( $tokens, 1, 'ALTER TABLE', true ); + $index = $this->skip_option_value( $tokens, 1 ); + if ( isset( $tokens[ $index ] ) && ! $this->is_alter_table_no_op_table_options_action( array_slice( $tokens, $index ) ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE AUTO_INCREMENT option in DuckDB driver.' ); + } + + return $requested_next; + } + /** * Execute ALTER TABLE ... DROP INDEX|KEY. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 0e2d0d5ab..928a10046 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -3896,6 +3896,50 @@ public function test_alter_table_auto_increment_seed_matches_sqlite(): void { $this->assertParityRows( 'SHOW CREATE TABLE alter_plain' ); } + public function test_alter_table_options_and_key_maintenance_noops_match_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE alter_options ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(20) + ) ENGINE=MyISAM DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='Original comment'", + "INSERT INTO alter_options (name) VALUES ('first'), ('second')", + ) + ); + + foreach ( + array( + 'ALTER TABLE alter_options ENGINE=InnoDB', + 'ALTER TABLE alter_options DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "ALTER TABLE alter_options COMMENT = 'Ignored comment'", + 'ALTER TABLE alter_options ROW_FORMAT=DYNAMIC', + 'ALTER TABLE alter_options DISABLE KEYS', + 'ALTER TABLE alter_options ENABLE KEYS', + ) as $sql + ) { + $this->assertParityRowCount( $sql ); + } + + $this->assertParityRows( 'SELECT id, name FROM alter_options ORDER BY id' ); + $this->assertParityRows( 'SHOW CREATE TABLE alter_options' ); + $this->assertParityRows( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'alter_options'" + ); + + $this->assertParityRowCount( 'ALTER TABLE alter_options AUTO_INCREMENT = 50, ENGINE=InnoDB, DISABLE KEYS' ); + $this->assertParityRows( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'alter_options'" + ); + + $this->runParitySetup( array( "INSERT INTO alter_options (name) VALUES ('third')" ) ); + $this->assertParityRows( 'SELECT id, name FROM alter_options ORDER BY id' ); + $this->assertParityRows( 'SHOW CREATE TABLE alter_options' ); + } + public function test_temporary_table_auto_increment_seed_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 5d5c4b7d4..27cede39e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -6390,6 +6390,80 @@ public function test_truncate_table_preserves_schema_and_resets_auto_increment() ); } + public function test_alter_table_options_and_key_maintenance_are_noops(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE alter_options_noop ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(20) + ) ENGINE=MyISAM DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='Original comment'" + ); + $driver->query( "INSERT INTO alter_options_noop (name) VALUES ('first'), ('second')" ); + + $before = $this->alter_table_auto_increment_snapshot( $driver, 'alter_options_noop' ); + $this->assertSame( array( array( 'AUTO_INCREMENT' => 3 ) ), $before['auto_increment'] ); + + foreach ( + array( + 'ALTER TABLE alter_options_noop ENGINE=InnoDB', + 'ALTER TABLE alter_options_noop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "ALTER TABLE alter_options_noop COMMENT = 'Ignored comment'", + 'ALTER TABLE alter_options_noop ROW_FORMAT=DYNAMIC', + 'ALTER TABLE alter_options_noop DISABLE KEYS', + 'ALTER TABLE alter_options_noop ENABLE KEYS', + ) as $sql + ) { + $this->assertSame( 0, $driver->query( $sql )->rowCount(), 'Unexpected row count for SQL: ' . $sql ); + $this->assertSame( $before, $this->alter_table_auto_increment_snapshot( $driver, 'alter_options_noop' ), 'No-op ALTER TABLE mutated state for SQL: ' . $sql ); + } + } + + public function test_mixed_alter_table_auto_increment_rejections_do_not_mutate(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE alter_ai_guard ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(20) + )' + ); + $driver->query( "INSERT INTO alter_ai_guard (name) VALUES ('first')" ); + + $before = $this->alter_table_auto_increment_snapshot( $driver, 'alter_ai_guard' ); + foreach ( + array( + 'ALTER TABLE alter_ai_guard AUTO_INCREMENT = 50, ALGORITHM=INPLACE' => 'Only ADD, DROP, CHANGE, MODIFY, AUTO_INCREMENT, table option, and ENABLE/DISABLE KEYS actions are supported', + 'ALTER TABLE alter_ai_guard AUTO_INCREMENT = 50, ADD COLUMN generated_id BIGINT AUTO_INCREMENT' => 'ADD COLUMN AUTO_INCREMENT is not supported', + ) as $sql => $message + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected mixed ALTER TABLE AUTO_INCREMENT rejection for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + + $this->assertSame( + $before, + $this->alter_table_auto_increment_snapshot( $driver, 'alter_ai_guard' ), + 'Mixed ALTER TABLE AUTO_INCREMENT rejection mutated state for SQL: ' . $sql + ); + } + } + public function test_drop_index_updates_metadata_and_unique_enforcement(): void { $this->requireDuckDBRuntime(); @@ -10103,6 +10177,19 @@ private function metadataTypeMatrixInformationSchemaRows(): array { // phpcs:ign ); } + private function alter_table_auto_increment_snapshot( WP_DuckDB_Driver $driver, string $table_name ): array { + return array( + 'rows' => $driver->query( 'SELECT * FROM ' . $table_name . ' ORDER BY 1' )->fetchAll( PDO::FETCH_ASSOC ), + 'show_create' => $driver->query( 'SHOW CREATE TABLE ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), + 'auto_increment' => $driver->query( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = '{$table_name}'" + )->fetchAll( PDO::FETCH_ASSOC ), + 'status' => $driver->query( "SHOW TABLE STATUS LIKE '{$table_name}'" )->fetchAll( PDO::FETCH_ASSOC ), + ); + } + private function alter_table_foreign_key_lifecycle_snapshot( WP_DuckDB_Driver $driver, string $table_name ): array { return array( 'columns' => $driver->query( 'SHOW COLUMNS FROM ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), From 59b2c3e69a4ecb6cc4a6de0cbf6763bf812db6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 08:19:47 +0000 Subject: [PATCH 091/317] Add DuckDB alias storage type parity --- .../src/duckdb/class-wp-duckdb-driver.php | 100 +++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 51 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 319 +++++++++++++++++- 3 files changed, 462 insertions(+), 8 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index b11247d86..a0147746c 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -72,7 +72,10 @@ class WP_DuckDB_Driver { WP_MySQL_Lexer::BIGINT_SYMBOL => 'BIGINT', WP_MySQL_Lexer::FLOAT_SYMBOL => 'FLOAT', WP_MySQL_Lexer::DOUBLE_SYMBOL => 'DOUBLE', + WP_MySQL_Lexer::REAL_SYMBOL => 'DOUBLE', WP_MySQL_Lexer::DECIMAL_SYMBOL => 'DECIMAL', + WP_MySQL_Lexer::DEC_SYMBOL => 'DECIMAL', + WP_MySQL_Lexer::FIXED_SYMBOL => 'DECIMAL', WP_MySQL_Lexer::NUMERIC_SYMBOL => 'DECIMAL', WP_MySQL_Lexer::CHAR_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::VARCHAR_SYMBOL => 'VARCHAR', @@ -88,6 +91,8 @@ class WP_DuckDB_Driver { WP_MySQL_Lexer::TINYBLOB_SYMBOL => 'BLOB', WP_MySQL_Lexer::MEDIUMBLOB_SYMBOL => 'BLOB', WP_MySQL_Lexer::LONGBLOB_SYMBOL => 'BLOB', + WP_MySQL_Lexer::BINARY_SYMBOL => 'BLOB', + WP_MySQL_Lexer::VARBINARY_SYMBOL => 'BLOB', ); const TEMPORAL_IMPLICIT_DEFAULT_MAP = array( @@ -10302,7 +10307,81 @@ private function mysql_column_type_from_tokens( array $tokens, int $type_index ) break; } - return strtolower( $this->join_sql_pieces( $pieces ) ); + return $this->canonical_mysql_column_type( + strtolower( $this->join_sql_pieces( $pieces ) ), + $tokens[ $type_index ] + ); + } + + /** + * Canonicalize MySQL-facing column types to match SQLite driver metadata. + * + * @param string $column_type MySQL-facing column type. + * @param WP_Parser_Token $type_token Type token. + * @return string Canonical column type. + */ + private function canonical_mysql_column_type( string $column_type, WP_Parser_Token $type_token ): string { + $column_type = preg_replace( '/,\s+/', ',', $column_type ); + if ( null === $column_type ) { + $column_type = ''; + } + + switch ( $type_token->id ) { + case WP_MySQL_Lexer::REAL_SYMBOL: + return $this->mysql_column_type_with_base( $column_type, 'double' ); + + case WP_MySQL_Lexer::DECIMAL_SYMBOL: + case WP_MySQL_Lexer::DEC_SYMBOL: + case WP_MySQL_Lexer::NUMERIC_SYMBOL: + case WP_MySQL_Lexer::FIXED_SYMBOL: + return $this->mysql_column_type_with_default_attributes( + $this->mysql_column_type_with_base( $column_type, 'decimal' ), + '(10,0)' + ); + + case WP_MySQL_Lexer::BINARY_SYMBOL: + return $this->mysql_column_type_with_default_attributes( + $this->mysql_column_type_with_base( $column_type, 'binary' ), + '(1)' + ); + + case WP_MySQL_Lexer::VARBINARY_SYMBOL: + return $this->mysql_column_type_with_base( $column_type, 'varbinary' ); + } + + return $column_type; + } + + /** + * Replace the leading type name in a MySQL column type. + * + * @param string $column_type MySQL-facing column type. + * @param string $base_type Canonical base type. + * @return string Column type with canonical base. + */ + private function mysql_column_type_with_base( string $column_type, string $base_type ): string { + $canonical = preg_replace( '/^[a-z]+/', $base_type, $column_type, 1 ); + return null === $canonical ? $column_type : $canonical; + } + + /** + * Add default type attributes before unsigned/zerofill modifiers. + * + * @param string $column_type MySQL-facing column type. + * @param string $attributes Default attributes, including parentheses. + * @return string Column type with default attributes when absent. + */ + private function mysql_column_type_with_default_attributes( string $column_type, string $attributes ): string { + if ( false !== strpos( $column_type, '(' ) ) { + return $column_type; + } + + if ( preg_match( '/\s+(?:unsigned|zerofill)\b/', $column_type, $matches, PREG_OFFSET_CAPTURE ) ) { + $offset = $matches[0][1]; + return substr( $column_type, 0, $offset ) . $attributes . substr( $column_type, $offset ); + } + + return $column_type . $attributes; } /** @@ -13658,7 +13737,7 @@ private function is_character_write_data_type( string $data_type ): bool { * @return bool Whether the type is blob-backed. */ private function is_blob_write_data_type( string $data_type ): bool { - return in_array( $data_type, array( 'blob', 'tinyblob', 'mediumblob', 'longblob' ), true ); + return in_array( $data_type, array( 'binary', 'varbinary', 'blob', 'tinyblob', 'mediumblob', 'longblob' ), true ); } /** @@ -18293,6 +18372,9 @@ private function column_type_attributes( string $column_type, ?string $collation if ( in_array( $data_type, array( 'char', 'varchar' ), true ) ) { $char_length = $length ?? 1; $octet_length = $char_length * $this->charset_max_bytes( $charset ); + } elseif ( in_array( $data_type, array( 'binary', 'varbinary' ), true ) ) { + $char_length = $length ?? 1; + $octet_length = $char_length; } elseif ( 'tinytext' === $data_type || 'tinyblob' === $data_type ) { $char_length = 255; $octet_length = 255; @@ -18335,6 +18417,10 @@ private function data_type_from_column_type( string $column_type ): string { $map = array( 'integer' => 'int', 'boolean' => 'tinyint', + 'real' => 'double', + 'dec' => 'decimal', + 'fixed' => 'decimal', + 'numeric' => 'decimal', ); return $map[ $data_type ] ?? $data_type; @@ -18368,14 +18454,20 @@ private function numeric_attributes_from_data_type( string $data_type, string $c 'mediumint' => 7, 'int' => 10, 'bigint' => false === strpos( $column_type, 'unsigned' ) ? 19 : 20, - 'float' => 12, - 'double' => 22, ); if ( array_key_exists( $data_type, $precision_map ) ) { return array( $precision_map[ $data_type ], 0 ); } + if ( 'float' === $data_type || 'double' === $data_type ) { + if ( preg_match( '/\((\d+)(?:\s*,\s*(\d+))?\)/', $column_type, $matches ) ) { + return array( (int) $matches[1], isset( $matches[2] ) ? (int) $matches[2] : null ); + } + + return array( 'float' === $data_type ? 12 : 22, null ); + } + if ( 'decimal' === $data_type ) { if ( preg_match( '/\((\d+)(?:\s*,\s*(\d+))?\)/', $column_type, $matches ) ) { return array( (int) $matches[1], isset( $matches[2] ) ? (int) $matches[2] : 0 ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 928a10046..03e08e7df 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -3134,6 +3134,57 @@ public function test_information_schema_columns_metadata_matches_sqlite(): void ); } + public function test_alias_storage_type_family_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE alias_storage_types ( + id INT PRIMARY KEY, + real_col REAL, + dec_col DEC, + dec_ps_col DEC(10,2), + fixed_col FIXED, + binary_col BINARY, + binary_len_col BINARY(8), + varbinary_col VARBINARY(16) + )', + "INSERT INTO alias_storage_types + (id, real_col, dec_col, dec_ps_col, fixed_col, binary_col, binary_len_col, varbinary_col) + VALUES + (1, '3.5', '4', 5.25, '6', B'01000001', 0x6263, x'646566')", + ) + ); + + $this->assertParityRows( 'SHOW COLUMNS FROM alias_storage_types' ); + $this->assertParityRows( 'SHOW FULL COLUMNS FROM alias_storage_types' ); + $this->assertParityRows( 'DESCRIBE alias_storage_types' ); + $this->assertParityRows( 'SHOW CREATE TABLE alias_storage_types' ); + $this->assertParityRows( + "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, + CHARACTER_OCTET_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, + CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'alias_storage_types' + ORDER BY ordinal_position" + ); + $this->assertParityRows( + 'SELECT id, + CAST(real_col * 10 AS SIGNED) AS real_x10, + CAST(dec_col AS SIGNED) AS dec_int, + CAST(dec_ps_col * 100 AS SIGNED) AS dec_ps_cents, + CAST(fixed_col AS SIGNED) AS fixed_int + FROM alias_storage_types + ORDER BY id' + ); + $this->assertParityRows( + 'SELECT LOWER(HEX(binary_col)) AS binary_hex, + LOWER(HEX(binary_len_col)) AS binary_len_hex, + LOWER(HEX(varbinary_col)) AS varbinary_hex + FROM alias_storage_types + ORDER BY id' + ); + } + public function test_information_schema_statistics_metadata_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 27cede39e..8b5ffd9a0 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3211,6 +3211,317 @@ public function test_type_default_charset_metadata_rows_for_supported_mysql_type ); } + public function test_alias_storage_type_family_metadata_and_writes(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE alias_storage_types ( + id INT PRIMARY KEY, + real_col REAL, + dec_col DEC, + dec_ps_col DEC(10,2), + fixed_col FIXED, + binary_col BINARY, + binary_len_col BINARY(8), + varbinary_col VARBINARY(16) + )' + ); + $driver->query( + "INSERT INTO alias_storage_types + (id, real_col, dec_col, dec_ps_col, fixed_col, binary_col, binary_len_col, varbinary_col) + VALUES + (1, '3.5', '4', 5.25, '6', B'01000001', 0x6263, x'646566')" + ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'real_col' => 3.5, + 'dec_col' => 4.0, + 'dec_ps_col' => 5.25, + 'fixed_col' => 6.0, + ), + ), + $driver->query( + 'SELECT id, real_col, dec_col, dec_ps_col, fixed_col + FROM alias_storage_types' + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'binary_hex' => '41', + 'binary_len_hex' => '6263', + 'varbinary_hex' => '646566', + ), + ), + $driver->query( + 'SELECT LOWER(HEX(binary_col)) AS binary_hex, + LOWER(HEX(binary_len_col)) AS binary_len_hex, + LOWER(HEX(varbinary_col)) AS varbinary_hex + FROM alias_storage_types' + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $show_columns = array_slice( $driver->query( 'SHOW COLUMNS FROM alias_storage_types' )->fetchAll( PDO::FETCH_ASSOC ), 1 ); + $this->assertSame( + array( + array( + 'Field' => 'real_col', + 'Type' => 'double', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'dec_col', + 'Type' => 'decimal(10,0)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'dec_ps_col', + 'Type' => 'decimal(10,2)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'fixed_col', + 'Type' => 'decimal(10,0)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'binary_col', + 'Type' => 'binary(1)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'binary_len_col', + 'Type' => 'binary(8)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + array( + 'Field' => 'varbinary_col', + 'Type' => 'varbinary(16)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + ), + $show_columns + ); + $this->assertSame( + $show_columns, + array_slice( $driver->query( 'DESCRIBE alias_storage_types' )->fetchAll( PDO::FETCH_ASSOC ), 1 ) + ); + + $this->assertSame( + array( + array( + 'COLUMN_NAME' => 'real_col', + 'DATA_TYPE' => 'double', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => 22, + 'NUMERIC_SCALE' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'double', + ), + array( + 'COLUMN_NAME' => 'dec_col', + 'DATA_TYPE' => 'decimal', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => 10, + 'NUMERIC_SCALE' => 0, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'decimal(10,0)', + ), + array( + 'COLUMN_NAME' => 'dec_ps_col', + 'DATA_TYPE' => 'decimal', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => 10, + 'NUMERIC_SCALE' => 2, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'decimal(10,2)', + ), + array( + 'COLUMN_NAME' => 'fixed_col', + 'DATA_TYPE' => 'decimal', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'NUMERIC_PRECISION' => 10, + 'NUMERIC_SCALE' => 0, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'decimal(10,0)', + ), + array( + 'COLUMN_NAME' => 'binary_col', + 'DATA_TYPE' => 'binary', + 'CHARACTER_MAXIMUM_LENGTH' => 1, + 'CHARACTER_OCTET_LENGTH' => 1, + 'NUMERIC_PRECISION' => null, + 'NUMERIC_SCALE' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'binary(1)', + ), + array( + 'COLUMN_NAME' => 'binary_len_col', + 'DATA_TYPE' => 'binary', + 'CHARACTER_MAXIMUM_LENGTH' => 8, + 'CHARACTER_OCTET_LENGTH' => 8, + 'NUMERIC_PRECISION' => null, + 'NUMERIC_SCALE' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'binary(8)', + ), + array( + 'COLUMN_NAME' => 'varbinary_col', + 'DATA_TYPE' => 'varbinary', + 'CHARACTER_MAXIMUM_LENGTH' => 16, + 'CHARACTER_OCTET_LENGTH' => 16, + 'NUMERIC_PRECISION' => null, + 'NUMERIC_SCALE' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'varbinary(16)', + ), + ), + $driver->query( + "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, + CHARACTER_OCTET_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, + CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'alias_storage_types' + AND column_name <> 'id' + ORDER BY ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $result = $driver->query( + 'SELECT real_col, dec_col, dec_ps_col, fixed_col, binary_col, binary_len_col, varbinary_col + FROM alias_storage_types + WHERE 0 = 1' + ); + $expected_metadata = array( + array( + 'name' => 'real_col', + 'native_type' => 'DOUBLE', + 'len' => 22, + 'precision' => 31, + 'duckdb:decl_type' => 'double', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 5, + ), + array( + 'name' => 'dec_col', + 'native_type' => 'NEWDECIMAL', + 'len' => 10, + 'precision' => 0, + 'duckdb:decl_type' => 'decimal(10,0)', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 246, + ), + array( + 'name' => 'dec_ps_col', + 'native_type' => 'NEWDECIMAL', + 'len' => 12, + 'precision' => 2, + 'duckdb:decl_type' => 'decimal(10,2)', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 246, + ), + array( + 'name' => 'fixed_col', + 'native_type' => 'NEWDECIMAL', + 'len' => 10, + 'precision' => 0, + 'duckdb:decl_type' => 'decimal(10,0)', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 246, + ), + array( + 'name' => 'binary_col', + 'native_type' => 'BLOB', + 'len' => 1, + 'precision' => 0, + 'duckdb:decl_type' => 'binary(1)', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 254, + ), + array( + 'name' => 'binary_len_col', + 'native_type' => 'BLOB', + 'len' => 8, + 'precision' => 0, + 'duckdb:decl_type' => 'binary(8)', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 254, + ), + array( + 'name' => 'varbinary_col', + 'native_type' => 'BLOB', + 'len' => 16, + 'precision' => 0, + 'duckdb:decl_type' => 'varbinary(16)', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 253, + ), + ); + foreach ( $expected_metadata as $index => $expected ) { + $metadata = $result->getColumnMeta( $index ); + foreach ( $expected as $key => $value ) { + $this->assertSame( $value, $metadata[ $key ], $expected['name'] . ' metadata key ' . $key ); + } + } + + $create_sql = $driver->query( 'SHOW CREATE TABLE alias_storage_types' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + foreach ( + array( + '`real_col` double', + '`dec_col` decimal(10,0)', + '`dec_ps_col` decimal(10,2)', + '`fixed_col` decimal(10,0)', + '`binary_col` binary(1)', + '`binary_len_col` binary(8)', + '`varbinary_col` varbinary(16)', + ) as $expected_fragment + ) { + $this->assertStringContainsString( $expected_fragment, $create_sql ); + } + } + public function test_expression_and_admin_result_metadata_provider_documents_name_only_contract(): void { $this->requireDuckDBRuntime(); @@ -9719,7 +10030,7 @@ private function metadataTypeMatrixResultMetadata(): array { // phpcs:ignore Wor 'name' => 'price', 'len' => 12, 'precision' => 2, - 'duckdb:decl_type' => 'decimal(10, 2)', + 'duckdb:decl_type' => 'decimal(10,2)', 'mysqli:orgname' => 'price', 'mysqli:orgtable' => 'metadata_type_matrix', 'mysqli:db' => 'wp', @@ -9859,7 +10170,7 @@ private function metadataTypeMatrixShowColumnRows(): array { // phpcs:ignore Wor ), array( 'Field' => 'price', - 'Type' => 'decimal(10, 2)', + 'Type' => 'decimal(10,2)', 'Null' => 'YES', 'Key' => '', 'Default' => '1.25', @@ -10046,7 +10357,7 @@ private function metadataTypeMatrixInformationSchemaRows(): array { // phpcs:ign 'CHARACTER_MAXIMUM_LENGTH' => null, 'CHARACTER_OCTET_LENGTH' => null, 'NUMERIC_PRECISION' => 22, - 'NUMERIC_SCALE' => 0, + 'NUMERIC_SCALE' => null, 'DATETIME_PRECISION' => null, 'CHARACTER_SET_NAME' => null, 'COLLATION_NAME' => null, @@ -10067,7 +10378,7 @@ private function metadataTypeMatrixInformationSchemaRows(): array { // phpcs:ign 'DATETIME_PRECISION' => null, 'CHARACTER_SET_NAME' => null, 'COLLATION_NAME' => null, - 'COLUMN_TYPE' => 'decimal(10, 2)', + 'COLUMN_TYPE' => 'decimal(10,2)', 'COLUMN_KEY' => '', 'EXTRA' => '', 'COLUMN_COMMENT' => '', From 991e2353f3f751f5ca59f3ec07f688c9a729b13b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 08:29:57 +0000 Subject: [PATCH 092/317] Add DuckDB joined DML CROSS JOIN parity --- .../src/duckdb/class-wp-duckdb-driver.php | 39 +++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 93 ++++++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 188 +++++++++++++++++- 3 files changed, 300 insertions(+), 20 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index a0147746c..3866e7bcf 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -2836,12 +2836,17 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo */ private function parse_joined_update_join_chain( array $tokens, int $index, array $left_reference, array &$sources, array &$join_predicates, string $statement = 'UPDATE' ): void { while ( $index < count( $tokens ) ) { + $join_type = 'INNER'; if ( WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $index ]->id ) { ++$index; $this->expect_token( $tokens, $index, WP_MySQL_Lexer::JOIN_SYMBOL, 'Expected JOIN after INNER.' ); + } elseif ( WP_MySQL_Lexer::CROSS_SYMBOL === $tokens[ $index ]->id ) { + $join_type = 'CROSS'; + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::JOIN_SYMBOL, 'Expected JOIN after CROSS.' ); } elseif ( WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { if ( $this->is_unsupported_joined_update_join_token( $tokens[ $index ] ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only comma joins and INNER JOIN ... ON are supported.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported.' ); } throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Table reference options are not supported.' ); } @@ -2852,6 +2857,10 @@ private function parse_joined_update_join_chain( array $tokens, int $index, arra $index = $source['next_index']; if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::USING_SYMBOL === $tokens[ $index ]->id ) { + if ( 'CROSS' === $join_type ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. CROSS JOIN ... USING is not supported.' ); + } + if ( 'DELETE' !== $statement ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. JOIN ... USING is not supported.' ); } @@ -2865,6 +2874,14 @@ private function parse_joined_update_join_chain( array $tokens, int $index, arra continue; } + if ( 'CROSS' === $join_type ) { + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $index ]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. CROSS JOIN ... ON is not supported.' ); + } + $left_reference = $source['reference']; + continue; + } + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ON_SYMBOL, 'Expected ON in joined ' . $statement . ' statement.' ); ++$index; $predicate_end = $this->find_next_joined_update_join_index( $tokens, $index ) ?? count( $tokens ); @@ -3012,7 +3029,6 @@ private function is_unsupported_joined_update_join_token( WP_Parser_Token $token WP_MySQL_Lexer::LEFT_SYMBOL, WP_MySQL_Lexer::RIGHT_SYMBOL, WP_MySQL_Lexer::NATURAL_SYMBOL, - WP_MySQL_Lexer::CROSS_SYMBOL, WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, WP_MySQL_Lexer::USING_SYMBOL, ), @@ -3020,6 +3036,19 @@ private function is_unsupported_joined_update_join_token( WP_Parser_Token $token ); } + /** + * Check whether a token can start a joined UPDATE join operator. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether this token starts a supported or unsupported join operator. + */ + private function is_joined_update_join_start_token( WP_Parser_Token $token ): bool { + return WP_MySQL_Lexer::JOIN_SYMBOL === $token->id + || WP_MySQL_Lexer::INNER_SYMBOL === $token->id + || WP_MySQL_Lexer::CROSS_SYMBOL === $token->id + || $this->is_unsupported_joined_update_join_token( $token ); + } + /** * Find the next join operator in a joined UPDATE table item. * @@ -3040,11 +3069,7 @@ private function find_next_joined_update_join_index( array $tokens, int $start ) } if ( 0 === $depth - && ( - WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $index ]->id - || WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $index ]->id - || $this->is_unsupported_joined_update_join_token( $tokens[ $index ] ) - ) + && $this->is_joined_update_join_start_token( $tokens[ $index ] ) ) { return $index; } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 03e08e7df..2d1654f68 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -924,6 +924,25 @@ public function test_joined_update_matches_sqlite(): void { $this->assertParityRows( 'SELECT id, status, score FROM posts ORDER BY id' ); } + public function test_cross_joined_update_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)', + 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)', + "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30)", + "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (4, 'b4', 1), (5, 'b5', 0)", + ) + ); + + $this->assertParityRowCount( + "UPDATE t1 a CROSS JOIN t2 b + SET a.note = 'cross' + WHERE b.id = 4" + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + } + public function test_joined_update_non_first_target_matches_sqlite(): void { $this->runParitySetup( array( @@ -986,6 +1005,15 @@ public function test_multi_target_joined_update_rejection_matches_sqlite(): void ); $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); $this->assertParityRows( 'SELECT id, note FROM t2 ORDER BY id' ); + + $this->assertParityErrorContains( + "UPDATE t1 a CROSS JOIN t2 b + SET a.note = 'target', b.note = 'source' + WHERE b.id = 3", + 'UPDATE statement modifying multiple tables' + ); + $this->assertParityRows( 'SELECT id, note FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note FROM t2 ORDER BY id' ); } public function test_joined_update_unqualified_unique_unaliased_target_matches_sqlite(): void { @@ -1012,7 +1040,7 @@ public function test_joined_update_unsupported_join_forms_document_current_duckd array( array( 'sql' => "UPDATE t1 a LEFT JOIN t2 b ON a.id = b.id SET a.note = 'left'", - 'duckdb_message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', 'sqlite_row_count' => 2, 'sqlite_rows' => array( array( @@ -1039,7 +1067,7 @@ public function test_joined_update_unsupported_join_forms_document_current_duckd ), array( 'sql' => "UPDATE t1 a RIGHT JOIN t2 b ON a.id = b.id SET a.note = 'right'", - 'duckdb_message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', 'sqlite_row_count' => 2, 'sqlite_rows' => array( array( @@ -1064,6 +1092,33 @@ public function test_joined_update_unsupported_join_forms_document_current_duckd ), ), ), + array( + 'sql' => 'UPDATE t1 a CROSS JOIN t2 b ON a.id = b.id SET a.note = b.note WHERE b.flag = 1', + 'duckdb_message' => 'CROSS JOIN ... ON is not supported', + 'sqlite_row_count' => 2, + 'sqlite_rows' => array( + array( + 'id' => '1', + 'note' => 'b1', + 'only_t1' => '10', + ), + array( + 'id' => '2', + 'note' => 'a2', + 'only_t1' => '20', + ), + array( + 'id' => '3', + 'note' => 'b3', + 'only_t1' => '30', + ), + array( + 'id' => '4', + 'note' => 'a4', + 'only_t1' => '40', + ), + ), + ), array( 'sql' => "UPDATE t1 a JOIN t2 b USING (id) SET a.note = 'using'", 'duckdb_message' => 'JOIN ... USING is not supported', @@ -1093,7 +1148,7 @@ public function test_joined_update_unsupported_join_forms_document_current_duckd ), array( 'sql' => "UPDATE t1 a NATURAL JOIN t2 b SET a.note = 'natural'", - 'duckdb_message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', 'sqlite_row_count' => 4, 'sqlite_rows' => array( array( @@ -1315,6 +1370,38 @@ public function test_joined_delete_using_columns_match_sqlite(): void { $this->assertParityRows( 'SELECT id, flag FROM t2 ORDER BY id' ); } + public function test_cross_joined_delete_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)', + 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)', + "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30)", + "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (4, 'b4', 1), (5, 'b5', 0)", + ) + ); + + $this->assertParityRowCount( + 'DELETE a FROM t1 a CROSS JOIN t2 b + WHERE a.id = 2 AND b.id = 4' + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + 'DELETE a, b FROM t1 a CROSS JOIN t2 b + WHERE a.id = 1 AND b.id = 4' + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + 'DELETE FROM a USING t1 a CROSS JOIN t2 b + WHERE a.id = 3 AND b.id = 5' + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + } + public function test_single_target_joined_delete_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 8b5ffd9a0..b100f22c8 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1794,6 +1794,69 @@ public function test_joined_update_rewrites_join_and_comma_forms(): void { ); } + public function test_joined_update_rewrites_cross_join(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)' ); + $driver->query( 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30)" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (4, 'b4', 1), (5, 'b5', 0)" ); + + $updated = $driver->query( + "UPDATE t1 a CROSS JOIN t2 b + SET a.note = 'cross' + WHERE b.id = 4" + ); + + $this->assertSame( 3, $updated->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'cross', + 'only_t1' => 10, + ), + array( + 'id' => 2, + 'note' => 'cross', + 'only_t1' => 20, + ), + array( + 'id' => 3, + 'note' => 'cross', + 'only_t1' => 30, + ), + ), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'b1', + 'flag' => 1, + ), + array( + 'id' => 3, + 'note' => 'b3', + 'flag' => 1, + ), + array( + 'id' => 4, + 'note' => 'b4', + 'flag' => 1, + ), + array( + 'id' => 5, + 'note' => 'b5', + 'flag' => 0, + ), + ), + $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_joined_update_rewrites_derived_table_claim_query(): void { $this->requireDuckDBRuntime(); @@ -2137,17 +2200,25 @@ public function test_joined_update_rejects_unsupported_shapes(): void { 'sql' => "UPDATE t1 a, t2 b SET a.note = 'target', b.note = 'source' WHERE a.id = b.id", 'message' => 'UPDATE statement modifying multiple tables is not supported', ), + array( + 'sql' => "UPDATE t1 a CROSS JOIN t2 b SET a.note = 'target', b.note = 'source' WHERE b.id = 3", + 'message' => 'UPDATE statement modifying multiple tables is not supported', + ), array( 'sql' => "UPDATE t1 a LEFT JOIN t2 b ON a.id = b.id SET a.note = 'target'", - 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', ), array( 'sql' => "UPDATE t1 a RIGHT JOIN t2 b ON a.id = b.id SET a.note = 'target'", - 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', ), array( 'sql' => "UPDATE t1 a NATURAL JOIN t2 b SET a.note = 'target'", - 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', + ), + array( + 'sql' => "UPDATE t1 a CROSS JOIN t2 b ON a.id = b.id SET a.note = 'target'", + 'message' => 'CROSS JOIN ... ON is not supported', ), array( 'sql' => "UPDATE t1 a JOIN t2 b USING (id) SET a.note = 'target'", @@ -2711,6 +2782,103 @@ public function test_joined_delete_rewrites_join_using_columns(): void { ); } + public function test_joined_delete_rewrites_cross_join_forms(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)' ); + $driver->query( 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30)" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (4, 'b4', 1), (5, 'b5', 0)" ); + + $single_target_delete = $driver->query( + 'DELETE a FROM t1 a CROSS JOIN t2 b + WHERE a.id = 2 AND b.id = 4' + ); + $this->assertSame( 1, $single_target_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'a1', + 'only_t1' => 10, + ), + array( + 'id' => 3, + 'note' => 'a3', + 'only_t1' => 30, + ), + ), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $multi_target_delete = $driver->query( + 'DELETE a, b FROM t1 a CROSS JOIN t2 b + WHERE a.id = 1 AND b.id = 4' + ); + $this->assertSame( 2, $multi_target_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 3, + 'note' => 'a3', + 'only_t1' => 30, + ), + ), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'b1', + 'flag' => 1, + ), + array( + 'id' => 3, + 'note' => 'b3', + 'flag' => 1, + ), + array( + 'id' => 5, + 'note' => 'b5', + 'flag' => 0, + ), + ), + $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $using_form_delete = $driver->query( + 'DELETE FROM a USING t1 a CROSS JOIN t2 b + WHERE a.id = 3 AND b.id = 5' + ); + $this->assertSame( 1, $using_form_delete->rowCount() ); + $this->assertSame( + array(), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'b1', + 'flag' => 1, + ), + array( + 'id' => 3, + 'note' => 'b3', + 'flag' => 1, + ), + array( + 'id' => 5, + 'note' => 'b5', + 'flag' => 0, + ), + ), + $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_single_target_joined_delete_rewrites_join_using_and_alias_forms(): void { $this->requireDuckDBRuntime(); @@ -2934,23 +3102,23 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { array( array( 'sql' => 'DELETE a, b FROM t1 a LEFT JOIN t2 b ON a.id = b.id', - 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', ), array( 'sql' => 'DELETE a, b FROM t1 a RIGHT JOIN t2 b ON a.id = b.id', - 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', ), array( - 'sql' => 'DELETE a, b FROM t1 a CROSS JOIN t2 b', - 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'sql' => 'DELETE a, b FROM t1 a NATURAL JOIN t2 b', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', ), array( - 'sql' => 'DELETE a, b FROM t1 a NATURAL JOIN t2 b', - 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'sql' => 'DELETE a, b FROM t1 a CROSS JOIN t2 b ON a.id = b.id', + 'message' => 'CROSS JOIN ... ON is not supported', ), array( 'sql' => 'DELETE a, b FROM t1 a STRAIGHT_JOIN t2 b ON a.id = b.id', - 'message' => 'Only comma joins and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', ), array( 'sql' => 'DELETE a, b FROM t1 a JOIN t2 b USING (missing_id)', From c61c5e8f95cd3c315d263c5f779276b6fc93f660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 08:36:34 +0000 Subject: [PATCH 093/317] Add DuckDB FK action rejection coverage --- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index b100f22c8..165330838 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -9775,6 +9775,68 @@ public function test_alter_table_add_foreign_key_rejects_existing_row_violations $driver->query( 'INSERT INTO alter_fk_validate_child (id, parent_id) VALUES (3, 405)' ); } + public function test_alter_table_foreign_key_actions_reject_without_metadata_or_rebuild(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE alter_fk_action_guard_parent (id INT PRIMARY KEY)' ); + $driver->query( 'INSERT INTO alter_fk_action_guard_parent (id) VALUES (0), (1)' ); + $driver->query( 'CREATE TABLE alter_fk_action_guard_child (id INT PRIMARY KEY, parent_id INT DEFAULT 0, KEY parent_idx (parent_id))' ); + $driver->query( 'INSERT INTO alter_fk_action_guard_child (id, parent_id) VALUES (1, 1)' ); + + foreach ( + array( + 'ALTER TABLE alter_fk_action_guard_child ADD CONSTRAINT fk_action_cascade FOREIGN KEY (parent_id) REFERENCES alter_fk_action_guard_parent (id) ON DELETE CASCADE' => 'ON DELETE CASCADE is not supported', + 'ALTER TABLE alter_fk_action_guard_child ADD CONSTRAINT fk_action_set_null FOREIGN KEY (parent_id) REFERENCES alter_fk_action_guard_parent (id) ON DELETE SET NULL' => 'ON DELETE SET NULL is not supported', + 'ALTER TABLE alter_fk_action_guard_child ADD CONSTRAINT fk_action_set_default FOREIGN KEY (parent_id) REFERENCES alter_fk_action_guard_parent (id) ON DELETE SET DEFAULT' => 'ON DELETE SET DEFAULT is not supported', + 'ALTER TABLE alter_fk_action_guard_child ADD CONSTRAINT fk_action_update_cascade FOREIGN KEY (parent_id) REFERENCES alter_fk_action_guard_parent (id) ON UPDATE CASCADE' => 'ON UPDATE CASCADE is not supported', + ) as $sql => $message + ) { + $before = array( + 'child' => $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_action_guard_child' ), + 'parent_rows' => $driver->query( 'SELECT id FROM alter_fk_action_guard_parent ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'foreign_key_metadata' => $this->foreign_key_rejection_metadata_snapshot( $driver ), + ); + + try { + $driver->query( $sql ); + $this->fail( 'Expected unsupported FOREIGN KEY action to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + + $this->assertSame( + $before, + array( + 'child' => $this->alter_table_foreign_key_lifecycle_snapshot( $driver, 'alter_fk_action_guard_child' ), + 'parent_rows' => $driver->query( 'SELECT id FROM alter_fk_action_guard_parent ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'foreign_key_metadata' => $this->foreign_key_rejection_metadata_snapshot( $driver ), + ), + 'Unsupported FOREIGN KEY action mutated schema, metadata, or data for SQL: ' . $sql + ); + } + + $driver->query( 'INSERT INTO alter_fk_action_guard_child (id, parent_id) VALUES (2, 404)' ); + $this->assertSame( + array( + array( + 'id' => 1, + 'parent_id' => 1, + ), + array( + 'id' => 2, + 'parent_id' => 404, + ), + ), + $driver->query( 'SELECT id, parent_id FROM alter_fk_action_guard_child ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_alter_table_foreign_key_limitations_reject_before_mutation(): void { $this->requireDuckDBRuntime(); @@ -10017,10 +10079,12 @@ public function test_unsupported_create_table_foreign_key_actions_throw_before_m $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); $driver->query( 'CREATE TABLE parents (id INT PRIMARY KEY)' ); + $driver->query( 'INSERT INTO parents (id) VALUES (1)' ); foreach ( array( 'CREATE TABLE child_cascade (parent_id INT, FOREIGN KEY (parent_id) REFERENCES parents (id) ON DELETE CASCADE)' => 'ON DELETE CASCADE is not supported', + 'CREATE TABLE child_update_cascade (parent_id INT, FOREIGN KEY (parent_id) REFERENCES parents (id) ON UPDATE CASCADE)' => 'ON UPDATE CASCADE is not supported', 'CREATE TABLE child_set_null (parent_id INT, FOREIGN KEY (parent_id) REFERENCES parents (id) ON UPDATE SET NULL)' => 'ON UPDATE SET NULL is not supported', 'CREATE TABLE child_set_default (parent_id INT DEFAULT 0, FOREIGN KEY (parent_id) REFERENCES parents (id) ON DELETE SET DEFAULT)' => 'ON DELETE SET DEFAULT is not supported', 'CREATE TABLE child_inline_cascade (parent_id INT REFERENCES parents (id) ON DELETE CASCADE)' => 'ON DELETE CASCADE is not supported', @@ -10028,18 +10092,38 @@ public function test_unsupported_create_table_foreign_key_actions_throw_before_m 'CREATE TABLE child_inline_set_default (parent_id INT DEFAULT 0 REFERENCES parents (id) ON DELETE SET DEFAULT)' => 'ON DELETE SET DEFAULT is not supported', ) as $sql => $message ) { + $before = array( + 'tables' => $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ), + 'parent_rows' => $driver->query( 'SELECT id FROM parents ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'foreign_key_metadata' => $this->foreign_key_rejection_metadata_snapshot( $driver ), + ); + try { $driver->query( $sql ); $this->fail( 'Expected unsupported FOREIGN KEY action to reject SQL: ' . $sql ); } catch ( WP_DuckDB_Driver_Exception $e ) { $this->assertStringContainsString( $message, $e->getMessage() ); } + + $this->assertSame( + $before, + array( + 'tables' => $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ), + 'parent_rows' => $driver->query( 'SELECT id FROM parents ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'foreign_key_metadata' => $this->foreign_key_rejection_metadata_snapshot( $driver ), + ), + 'Unsupported FOREIGN KEY action created schema, metadata, or data for SQL: ' . $sql + ); } $this->assertSame( array( array( 'Tables_in_wp' => 'parents' ) ), $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( + array( array( 'id' => 1 ) ), + $driver->query( 'SELECT id FROM parents ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); } public function test_unsupported_inline_references_shapes_throw_before_mutation(): void { @@ -10724,6 +10808,31 @@ private function alter_table_constraint_guard_snapshot( WP_DuckDB_Driver $driver ); } + private function foreign_key_rejection_metadata_snapshot( WP_DuckDB_Driver $driver ): array { + return array( + 'table_constraints' => $driver->query( + "SELECT TABLE_NAME, CONSTRAINT_NAME, CONSTRAINT_TYPE + FROM information_schema.table_constraints + WHERE table_schema = 'wp' + AND constraint_type = 'FOREIGN KEY' + ORDER BY table_name, constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'referential_constraints' => $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME, UPDATE_RULE, DELETE_RULE + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' + ORDER BY table_name, constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'key_column_usage' => $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND referenced_table_name IS NOT NULL + ORDER BY table_name, constraint_name, ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ), + ); + } + private function alter_table_check_lifecycle_snapshot( WP_DuckDB_Driver $driver, string $table_name ): array { return array( 'columns' => $driver->query( 'SHOW COLUMNS FROM ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), From 4e32da39189b036a420b2abcb752056cc5d47f98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 08:43:55 +0000 Subject: [PATCH 094/317] Add DuckDB seeded RAND INSERT VALUES parity --- .../src/duckdb/class-wp-duckdb-driver.php | 232 ++++++++++++------ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 17 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 39 ++- 3 files changed, 212 insertions(+), 76 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 3866e7bcf..18cc7361a 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -962,6 +962,16 @@ private function next_seeded_rand_value( int $seed, array &$state ): float { return (float) $state['seed1'] / (float) $max_value; } + /** + * Format a deterministic seeded RAND() value as a DuckDB numeric literal. + * + * @param float $value RAND() value. + * @return string SQL numeric literal. + */ + private function seeded_rand_numeric_literal( float $value ): string { + return sprintf( '%.17g', $value ); + } + /** * Derive bounded result metadata for SELECT column_list FROM single_table. * @@ -2093,7 +2103,7 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_auto_increment_write( $this->identifier_value( $tokens[ $index ] ?? null ), - $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $index, $ignore ), + $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $index, $ignore, 'INSERT', null, true ), 'Failed to execute DuckDB INSERT', $tokens, $index @@ -12160,7 +12170,7 @@ private function translate_replace_tokens_to_duckdb_insert_sql( array $tokens ): * @return string DuckDB SQL. */ private function translate_insert_ignore_tokens_to_duckdb_sql( array $tokens, int $table_index ): string { - return $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $table_index, true ); + return $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $table_index, true, 'INSERT', null, true ); } /** @@ -13103,11 +13113,12 @@ private function quote_qualified_identifier_list( string $alias, array $identifi * @param bool $ignore Whether INSERT IGNORE was used. * @param string $verb DuckDB INSERT verb. * @param int|null $end_index Optional token index where VALUES input ends. + * @param bool $rewrite_seeded_rand_literals Whether literal seeded RAND() calls are supported in VALUES. * @return string DuckDB SQL. */ - private function translate_insert_values_tokens_to_duckdb_sql( array $tokens, int $table_index, bool $ignore, string $verb = 'INSERT', ?int $end_index = null ): string { - $shape = $this->parse_insert_values_write_shape( $tokens, $table_index, $end_index, true ); - if ( ! $shape['requires_coercion'] ) { + private function translate_insert_values_tokens_to_duckdb_sql( array $tokens, int $table_index, bool $ignore, string $verb = 'INSERT', ?int $end_index = null, bool $rewrite_seeded_rand_literals = false ): string { + $shape = $this->parse_insert_values_write_shape( $tokens, $table_index, $end_index, true, $rewrite_seeded_rand_literals ); + if ( ! $shape['requires_coercion'] && ! $shape['requires_seeded_rand_rewrite'] ) { return $verb . ( $ignore ? ' OR IGNORE' : '' ) . ' INTO ' @@ -13376,7 +13387,7 @@ private function parse_on_duplicate_insert_shape( array $tokens, int $table_inde * @param int $table_index Index of the table token. */ private function assert_insert_values_do_not_conflict_with_case_insensitive_unique_keys( array $tokens, int $table_index ): void { - $insert_shape = $this->parse_insert_values_shape( $tokens, $table_index, true ); + $insert_shape = $this->parse_insert_values_shape( $tokens, $table_index, true, true ); $case_insensitive_columns = $this->case_insensitive_column_names( $insert_shape['table_name'], $insert_shape['temporary'] ); if ( count( $case_insensitive_columns ) === 0 ) { return; @@ -13416,13 +13427,14 @@ private function assert_insert_values_do_not_conflict_with_case_insensitive_uniq /** * Parse a supported INSERT ... VALUES shape into per-row value SQL. * - * @param WP_Parser_Token[] $tokens MySQL tokens. - * @param int $table_index Index of the table token. - * @param bool $coerce_for_storage Whether values should be coerced for storage. + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param bool $coerce_for_storage Whether values should be coerced for storage. + * @param bool $rewrite_seeded_rand_literals Whether literal seeded RAND() calls are supported in VALUES. * @return array{table_name:string,temporary:bool,rows:array>} */ - private function parse_insert_values_shape( array $tokens, int $table_index, bool $coerce_for_storage = false ): array { - $shape = $this->parse_insert_values_write_shape( $tokens, $table_index, null, $coerce_for_storage ); + private function parse_insert_values_shape( array $tokens, int $table_index, bool $coerce_for_storage = false, bool $rewrite_seeded_rand_literals = false ): array { + $shape = $this->parse_insert_values_write_shape( $tokens, $table_index, null, $coerce_for_storage, $rewrite_seeded_rand_literals ); return array( 'table_name' => $shape['table_name'], @@ -13434,13 +13446,14 @@ private function parse_insert_values_shape( array $tokens, int $table_index, boo /** * Parse a supported INSERT/REPLACE ... VALUES shape into ordered and keyed value SQL. * - * @param WP_Parser_Token[] $tokens MySQL tokens. - * @param int $table_index Index of the table token. - * @param int|null $end_index Optional token index where VALUES input ends. - * @param bool $coerce_for_storage Whether values should be coerced for storage. - * @return array{table_name:string,temporary:bool,columns:string[],rows:array>,ordered_rows:array,coerced_value_rows:array,temporal_validation_rows:array>>,requires_coercion:bool} + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $table_index Index of the table token. + * @param int|null $end_index Optional token index where VALUES input ends. + * @param bool $coerce_for_storage Whether values should be coerced for storage. + * @param bool $rewrite_seeded_rand_literals Whether literal seeded RAND() calls are supported in VALUES. + * @return array{table_name:string,temporary:bool,columns:string[],rows:array>,ordered_rows:array,coerced_value_rows:array,temporal_validation_rows:array>>,requires_coercion:bool,requires_seeded_rand_rewrite:bool} */ - private function parse_insert_values_write_shape( array $tokens, int $table_index, ?int $end_index = null, bool $coerce_for_storage = false ): array { + private function parse_insert_values_write_shape( array $tokens, int $table_index, ?int $end_index = null, bool $coerce_for_storage = false, bool $rewrite_seeded_rand_literals = false ): array { if ( null !== $end_index ) { $tokens = array_slice( $tokens, 0, $end_index ); } @@ -13457,32 +13470,14 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde list( $column_items, $index ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); foreach ( $column_items as $column_tokens ) { if ( 1 !== count( $column_tokens ) ) { - return array( - 'table_name' => $table_name, - 'temporary' => $temporary, - 'columns' => array(), - 'rows' => array(), - 'ordered_rows' => array(), - 'coerced_value_rows' => array(), - 'temporal_validation_rows' => array(), - 'requires_coercion' => false, - ); + return $this->empty_insert_values_write_shape( $table_name, $temporary ); } $columns[] = $this->identifier_value( $column_tokens[0] ); } } else { foreach ( $this->table_column_metadata_rows( $table_name, $temporary ) as $row ) { if ( ! array_key_exists( 'column_name', $row ) ) { - return array( - 'table_name' => $table_name, - 'temporary' => $temporary, - 'columns' => array(), - 'rows' => array(), - 'ordered_rows' => array(), - 'coerced_value_rows' => array(), - 'temporal_validation_rows' => array(), - 'requires_coercion' => false, - ); + return $this->empty_insert_values_write_shape( $table_name, $temporary ); } $columns[] = (string) $row['column_name']; } @@ -13497,37 +13492,21 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde } if ( count( $columns ) === 0 || ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $index ]->id ) { - return array( - 'table_name' => $table_name, - 'temporary' => $temporary, - 'columns' => array(), - 'rows' => array(), - 'ordered_rows' => array(), - 'coerced_value_rows' => array(), - 'temporal_validation_rows' => array(), - 'requires_coercion' => false, - ); + return $this->empty_insert_values_write_shape( $table_name, $temporary ); } ++$index; - $rows = array(); - $ordered_rows = array(); - $coerced_value_rows = array(); - $temporal_validation_rows = array(); - $requires_coercion = false; + $rows = array(); + $ordered_rows = array(); + $coerced_value_rows = array(); + $temporal_validation_rows = array(); + $requires_coercion = false; + $requires_seeded_rand_rewrite = false; + $seeded_rand_state = array(); while ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { list( $value_items, $index ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); if ( count( $columns ) !== count( $value_items ) ) { - return array( - 'table_name' => $table_name, - 'temporary' => $temporary, - 'columns' => array(), - 'rows' => array(), - 'ordered_rows' => array(), - 'coerced_value_rows' => array(), - 'temporal_validation_rows' => array(), - 'requires_coercion' => false, - ); + return $this->empty_insert_values_write_shape( $table_name, $temporary ); } $values_by_column = array(); @@ -13535,7 +13514,12 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde $coerced_values = array(); $validation_values = array(); foreach ( $columns as $offset => $column_name ) { - $value_sql = $this->translate_tokens_to_duckdb_sql( $value_items[ $offset ] ); + $value_sql = $this->translate_insert_values_value_tokens_to_duckdb_sql( + $value_items[ $offset ], + $rewrite_seeded_rand_literals, + $seeded_rand_state, + $requires_seeded_rand_rewrite + ); if ( $coerce_for_storage && isset( $metadata_map[ strtolower( $column_name ) ] ) ) { $validation = $this->temporal_write_validation_for_column( $metadata_map[ strtolower( $column_name ) ], @@ -13578,17 +13562,121 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde } return array( - 'table_name' => $table_name, - 'temporary' => $temporary, - 'columns' => $storage_columns, - 'rows' => $rows, - 'ordered_rows' => $ordered_rows, - 'coerced_value_rows' => $coerced_value_rows, - 'temporal_validation_rows' => $temporal_validation_rows, - 'requires_coercion' => $requires_coercion || count( $omitted_defaults ) > 0, + 'table_name' => $table_name, + 'temporary' => $temporary, + 'columns' => $storage_columns, + 'rows' => $rows, + 'ordered_rows' => $ordered_rows, + 'coerced_value_rows' => $coerced_value_rows, + 'temporal_validation_rows' => $temporal_validation_rows, + 'requires_coercion' => $requires_coercion || count( $omitted_defaults ) > 0, + 'requires_seeded_rand_rewrite' => $requires_seeded_rand_rewrite, + ); + } + + /** + * Return an empty INSERT/REPLACE VALUES write shape. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is temporary. + * @return array{table_name:string,temporary:bool,columns:string[],rows:array>,ordered_rows:array,coerced_value_rows:array,temporal_validation_rows:array>>,requires_coercion:bool,requires_seeded_rand_rewrite:bool} + */ + private function empty_insert_values_write_shape( string $table_name, bool $temporary ): array { + return array( + 'table_name' => $table_name, + 'temporary' => $temporary, + 'columns' => array(), + 'rows' => array(), + 'ordered_rows' => array(), + 'coerced_value_rows' => array(), + 'temporal_validation_rows' => array(), + 'requires_coercion' => false, + 'requires_seeded_rand_rewrite' => false, + ); + } + + /** + * Translate one INSERT/REPLACE VALUES value expression, optionally rewriting literal seeded RAND() calls. + * + * @param WP_Parser_Token[] $tokens Value expression tokens. + * @param bool $rewrite_seeded_rand_literals Whether literal seeded RAND() calls are supported. + * @param array $seeded_rand_state Per-statement seeded RAND() state. + * @param bool $requires_seeded_rand_rewrite Whether any value required a seeded RAND() rewrite. + * @return string DuckDB SQL. + */ + private function translate_insert_values_value_tokens_to_duckdb_sql( array $tokens, bool $rewrite_seeded_rand_literals, array &$seeded_rand_state, bool &$requires_seeded_rand_rewrite ): string { + if ( ! $rewrite_seeded_rand_literals ) { + return $this->translate_tokens_to_duckdb_sql( $tokens ); + } + + $seeded_rand_rewrites = $this->seeded_rand_insert_values_rewrite_map( $tokens, $seeded_rand_state ); + if ( count( $seeded_rand_rewrites ) === 0 ) { + return $this->translate_tokens_to_duckdb_sql( $tokens ); + } + + $requires_seeded_rand_rewrite = true; + return $this->translate_tokens_to_duckdb_sql( + $tokens, + false, + false, + false, + false, + false, + false, + false, + $seeded_rand_rewrites ); } + /** + * Build deterministic literal rewrites for seeded RAND() calls in INSERT VALUES. + * + * @param WP_Parser_Token[] $tokens Value expression tokens. + * @param array $seeded_rand_state Per-statement seeded RAND() state. + * @return array + */ + private function seeded_rand_insert_values_rewrite_map( array $tokens, array &$seeded_rand_state ): array { + $rewrites = array(); + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( + ! isset( $tokens[ $index + 2 ] ) + || $this->is_non_identifier_token( $tokens[ $index ] ) + || 0 !== strcasecmp( $tokens[ $index ]->get_value(), 'RAND' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index + 2 ]->id ) { + $index += 2; + continue; + } + + $close_index = $this->matching_parenthesis_index( $tokens, $index + 1 ); + if ( null === $close_index ) { + throw new WP_DuckDB_Driver_Exception( 'Unbalanced parentheses in DuckDB driver statement.' ); + } + + $seed_tokens = array_slice( $tokens, $index + 2, $close_index - $index - 2 ); + if ( count( $this->split_top_level_comma_items( $seed_tokens ) ) !== 1 ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB INSERT ... VALUES supports exactly one seed argument.' ); + } + + $seed = $this->parse_seeded_rand_literal_seed( $seed_tokens ); + if ( null === $seed ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB INSERT ... VALUES supports only literal numeric, string, or NULL seeds.' ); + } + + $rewrites[ $index ] = array( + 'end' => $close_index, + 'replacement' => $this->seeded_rand_numeric_literal( $this->next_seeded_rand_value( $seed, $seeded_rand_state ) ), + ); + $index = $close_index; + } + + return $rewrites; + } + /** * Resolve a DML write target, preferring visible temporary tables. * @@ -15424,7 +15512,7 @@ private function is_empty_function_call( array $tokens, int $index, string $name * * @param WP_Parser_Token[] $tokens Token stream. * @param int $index Current token index, advanced on match. - * @param array $seeded_rand_rewrites Allowed rewrites. + * @param array $seeded_rand_rewrites Allowed rewrites. * @return string|null Replacement SQL, or null when the current token is not RAND(seed). */ private function translate_seeded_rand_function_call( array $tokens, int &$index, array $seeded_rand_rewrites ): ?string { diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 2d1654f68..77f0b5184 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -72,6 +72,23 @@ public function test_select_seeded_rand_multi_row_sequence_matches_sqlite(): voi $this->assertParityRows( 'SELECT id, RAND(3) AS r FROM seeded_rand_rows ORDER BY id' ); } + public function test_insert_values_seeded_rand_literals_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE seeded_rand_out (id INT, value DOUBLE, other DOUBLE)', + ) + ); + + $this->assertParityRowCount( 'INSERT INTO seeded_rand_out (id, value, other) VALUES (1, RAND(1), RAND(1)), (2, RAND(1), RAND(1))' ); + $this->assertParityRows( 'SELECT id, value, other FROM seeded_rand_out ORDER BY id' ); + + $this->assertParityRowCount( 'INSERT INTO seeded_rand_out (id, value, other) VALUES (3, RAND(1), RAND(NULL))' ); + $this->assertParityRows( 'SELECT id, value, other FROM seeded_rand_out ORDER BY id' ); + + $this->assertParityRowCount( 'INSERT INTO seeded_rand_out (id, value, other) VALUES (4, RAND(1) + 0, 0 + RAND(1))' ); + $this->assertParityRows( 'SELECT id, value, other FROM seeded_rand_out ORDER BY id' ); + } + public function test_select_cast_convert_binary_expressions_match_sqlite(): void { foreach ( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 165330838..55d6a7fab 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -227,6 +227,34 @@ public function test_select_seeded_and_unseeded_rand_are_independent(): void { } } + public function test_insert_values_seeded_rand_literals_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE seeded_rand_values (id INT, value DOUBLE, other DOUBLE)' ); + + $driver->query( 'INSERT INTO seeded_rand_values (id, value, other) VALUES (1, RAND(1), RAND(1)), (2, RAND(1), RAND(1))' ); + $rows = $driver->query( 'SELECT id, value, other FROM seeded_rand_values ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertCount( 2, $rows ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $rows[0]['value'], 1e-12 ); + $this->assertEqualsWithDelta( 0.87161418038571, (float) $rows[0]['other'], 1e-12 ); + $this->assertEqualsWithDelta( 0.14186032129625, (float) $rows[1]['value'], 1e-12 ); + $this->assertEqualsWithDelta( 0.09445909605777, (float) $rows[1]['other'], 1e-12 ); + + $driver->query( 'INSERT INTO seeded_rand_values (id, value, other) VALUES (3, RAND(1), RAND(NULL))' ); + $row = $driver->query( 'SELECT value, other FROM seeded_rand_values WHERE id = 3' )->fetch( PDO::FETCH_ASSOC ); + + $this->assertEqualsWithDelta( 0.40540353712198, (float) $row['value'], 1e-12 ); + $this->assertEqualsWithDelta( 0.15522042769494, (float) $row['other'], 1e-12 ); + + $driver->query( 'INSERT INTO seeded_rand_values (id, value, other) VALUES (4, RAND(1) + 0, 0 + RAND(1))' ); + $row = $driver->query( 'SELECT value, other FROM seeded_rand_values WHERE id = 4' )->fetch( PDO::FETCH_ASSOC ); + + $this->assertEqualsWithDelta( 0.40540353712198, (float) $row['value'], 1e-12 ); + $this->assertEqualsWithDelta( 0.87161418038571, (float) $row['other'], 1e-12 ); + } + public function test_select_seeded_rand_unsupported_shapes_are_rejected(): void { $this->requireDuckDBRuntime(); @@ -265,10 +293,6 @@ public function test_select_seeded_rand_unsupported_shapes_are_rejected(): void 'sql' => 'UPDATE t SET value = RAND(1)', 'message' => 'top-level SELECT expression', ), - array( - 'sql' => 'INSERT INTO t (value) VALUES (RAND(1))', - 'message' => 'top-level SELECT expression', - ), ); foreach ( $unsupported_contexts as $case ) { @@ -279,6 +303,13 @@ public function test_select_seeded_rand_unsupported_shapes_are_rejected(): void $this->assertStringContainsString( $case['message'], $e->getMessage(), $case['sql'] ); } } + + try { + $driver->query( 'INSERT INTO t (value) VALUES (RAND(CAST(1 AS SIGNED)))' ); + $this->fail( 'Expected unsupported non-literal INSERT VALUES seeded RAND() shape to fail.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'literal numeric, string, or NULL seeds', $e->getMessage() ); + } } public function test_date_format_function_is_emulated(): void { From 656832f69239d8dc8d0ddd4207d68f4b03a1a57d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 08:45:42 +0000 Subject: [PATCH 095/317] Add DuckDB ALTER UNIQUE constraint support --- .../src/duckdb/class-wp-duckdb-driver.php | 278 ++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 92 +++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 317 +++++++++++++++++- 3 files changed, 683 insertions(+), 4 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 18cc7361a..534000d75 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -5280,6 +5280,11 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen continue; } + if ( $this->is_alter_table_drop_unique_constraint_action( $table_name, $action, $temporary ) ) { + $result = $this->execute_alter_table_drop_unique_constraint( $table_name, $action, $temporary ); + continue; + } + if ( $this->is_alter_table_drop_check_constraint_action( $action ) ) { $result = $this->execute_alter_table_drop_check_constraint( $table_name, $action, $temporary ); continue; @@ -5324,6 +5329,11 @@ private function execute_alter_table( array $tokens ): WP_DuckDB_Result_Statemen continue; } + if ( $this->is_alter_table_add_unique_constraint_action( $alter_item ) ) { + $result = $this->execute_alter_table_add_unique_constraint( $table_name, $alter_item, $temporary ); + continue; + } + $result = $this->is_create_table_index_item( $alter_item ) ? $this->execute_alter_table_add_index( $table_name, $alter_item, $temporary ) : $this->execute_alter_table_add_column( $table_name, $alter_item, $temporary ); @@ -5526,9 +5536,15 @@ private function validate_alter_table_rebuild_action_combination( string $table_ if ( $this->is_alter_table_add_primary_key_action( $action ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD PRIMARY KEY cannot be combined with other ALTER TABLE actions.' ); } + if ( WP_MySQL_Lexer::ADD_SYMBOL === $action[0]->id && $this->is_alter_table_add_unique_constraint_action( array_slice( $action, 1 ) ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD UNIQUE constraint cannot be combined with other ALTER TABLE actions.' ); + } if ( $this->is_alter_table_drop_primary_key_action( $action ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP PRIMARY KEY cannot be combined with other ALTER TABLE actions.' ); } + if ( $this->is_alter_table_drop_unique_constraint_action( $table_name, $action, $temporary ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP UNIQUE constraint cannot be combined with other ALTER TABLE actions.' ); + } if ( $this->is_alter_table_drop_column_rebuild_action( $table_name, $action, $temporary ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP COLUMN requiring a table rebuild cannot be combined with other ALTER TABLE actions.' ); } @@ -5813,6 +5829,7 @@ private function validate_alter_table_add_constraint_action( string $table_name, $contains_check = false; $contains_foreign_key = false; $contains_primary_key = false; + $contains_unique = false; foreach ( $items as $item ) { if ( $this->is_create_table_check_constraint( $item ) ) { $contains_check = true; @@ -5823,9 +5840,12 @@ private function validate_alter_table_add_constraint_action( string $table_name, if ( $this->is_table_primary_key_item( $item ) ) { $contains_primary_key = true; } + if ( $this->is_alter_table_add_unique_constraint_item( $item ) ) { + $contains_unique = true; + } } - if ( $contains_check || $contains_foreign_key || $contains_primary_key ) { + if ( $contains_check || $contains_foreign_key || $contains_primary_key || $contains_unique ) { $parenthesized_end = count( $tokens ); if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id ) { list( , $parenthesized_end ) = $this->collect_parenthesized_items( $tokens, 1 ); @@ -5836,6 +5856,7 @@ private function validate_alter_table_add_constraint_action( string $table_name, || count( $tokens ) !== $parenthesized_end || ( $contains_check && $contains_foreign_key ) || ( $contains_primary_key && ( $contains_check || $contains_foreign_key ) ) + || ( $contains_unique && ( $contains_check || $contains_foreign_key || $contains_primary_key ) ) ) { if ( $contains_foreign_key && ! $contains_check ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD FOREIGN KEY constraint is supported.' ); @@ -5843,6 +5864,9 @@ private function validate_alter_table_add_constraint_action( string $table_name, if ( $contains_primary_key && ! $contains_check && ! $contains_foreign_key ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD PRIMARY KEY constraint is supported.' ); } + if ( $contains_unique && ! $contains_check && ! $contains_foreign_key && ! $contains_primary_key ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD UNIQUE constraint is supported.' ); + } throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD CHECK constraint is supported.' ); } } @@ -5870,6 +5894,12 @@ private function validate_alter_table_add_constraint_action( string $table_name, continue; } + if ( $this->is_alter_table_add_unique_constraint_item( $item ) ) { + $index_definition = $this->translate_create_table_index( $table_name, $this->alter_table_unique_constraint_index_tokens( $item ), $temporary ); + $this->assert_alter_table_add_unique_constraint_supported( $table_name, $index_definition, $temporary ); + continue; + } + if ( isset( $item[0] ) && WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $item[0]->id ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD CONSTRAINT is not supported.' ); } @@ -6023,6 +6053,13 @@ private function validate_alter_table_drop_constraint_action( string $table_name unset( $foreign_key_names[ strtolower( $constraint_name ) ] ); return; } + if ( 'UNIQUE' === $constraint_type ) { + $index_name = $this->resolve_secondary_index_name( $table_name, $constraint_name, $temporary ); + if ( null === $index_name ) { + throw new WP_DuckDB_Driver_Exception( "Unknown constraint '{$constraint_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + return; + } if ( 'CHECK' !== $constraint_type ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP CONSTRAINT currently supports CHECK and FOREIGN KEY constraints only.' ); } @@ -6150,6 +6187,38 @@ private function is_alter_table_add_foreign_key_constraint_action( array $tokens return false; } + /** + * Check whether ALTER TABLE ... ADD targets a named UNIQUE constraint. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @return bool Whether this is an ADD UNIQUE constraint action. + */ + private function is_alter_table_add_unique_constraint_action( array $tokens ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + foreach ( $this->alter_table_add_items_for_constraint_detection( $tokens ) as $item ) { + if ( $this->is_alter_table_add_unique_constraint_item( $item ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a table item is CONSTRAINT name UNIQUE. + * + * @param WP_Parser_Token[] $tokens Item tokens. + * @return bool Whether this is a named UNIQUE constraint. + */ + private function is_alter_table_add_unique_constraint_item( array $tokens ): bool { + return isset( $tokens[0], $tokens[2] ) + && WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::UNIQUE_SYMBOL === $tokens[2]->id; + } + /** * Execute ALTER TABLE ... ADD CHECK. * @@ -6358,6 +6427,103 @@ private function assert_alter_table_add_primary_key_supported( string $table_nam $this->assert_alter_table_add_primary_key_existing_rows_valid( $table_name, $primary_key_columns ); } + /** + * Execute ALTER TABLE ... ADD CONSTRAINT name UNIQUE. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_add_unique_constraint( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $item = $this->single_alter_table_add_unique_constraint_item( $tokens ); + $index_definition = $this->translate_create_table_index( $table_name, $this->alter_table_unique_constraint_index_tokens( $item ), $temporary ); + $this->assert_alter_table_add_unique_constraint_supported( $table_name, $index_definition, $temporary ); + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $index_definition, $temporary ): WP_DuckDB_Result_Statement { + $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB UNIQUE constraint index' ); + $this->record_index_metadata( $index_definition ); + $this->refresh_column_key_metadata( $table_name, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + + /** + * Return the single UNIQUE item from a supported ALTER TABLE ... ADD UNIQUE constraint action. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens after ADD. + * @return WP_Parser_Token[] UNIQUE constraint tokens. + */ + private function single_alter_table_add_unique_constraint_item( array $tokens ): array { + if ( ! isset( $tokens[0] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD UNIQUE requires a UNIQUE constraint.' ); + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[0]->id ) { + return $tokens; + } + + list( $items, $index ) = $this->collect_parenthesized_items( $tokens, 1 ); + if ( 1 !== count( $items ) || count( $tokens ) !== $index || ! $this->is_alter_table_add_unique_constraint_item( $items[0] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. Only a single ADD UNIQUE constraint is supported.' ); + } + + return $items[0]; + } + + /** + * Normalize CONSTRAINT name UNIQUE tokens into the secondary-index token shape. + * + * @param WP_Parser_Token[] $tokens UNIQUE constraint tokens. + * @return WP_Parser_Token[] UNIQUE index tokens. + */ + private function alter_table_unique_constraint_index_tokens( array $tokens ): array { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CONSTRAINT_SYMBOL, 'Expected CONSTRAINT in ALTER TABLE ADD UNIQUE action.' ); + ++$index; + + $constraint_name_token = $tokens[ $index ] ?? null; + $this->identifier_value( $constraint_name_token ); + ++$index; + + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::UNIQUE_SYMBOL, 'Expected UNIQUE in ALTER TABLE ADD CONSTRAINT action.' ); + $index_tokens = array_slice( $tokens, $index ); + $name_index = 1; + + if ( + isset( $index_tokens[ $name_index ] ) + && ( WP_MySQL_Lexer::KEY_SYMBOL === $index_tokens[ $name_index ]->id || WP_MySQL_Lexer::INDEX_SYMBOL === $index_tokens[ $name_index ]->id ) + ) { + ++$name_index; + } + + $name_index = $this->skip_optional_index_type( $index_tokens, $name_index ); + if ( isset( $index_tokens[ $name_index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $index_tokens[ $name_index ]->id ) { + array_splice( $index_tokens, $name_index, 0, array( $constraint_name_token ) ); + } + + return $index_tokens; + } + + /** + * Validate ALTER TABLE ... ADD UNIQUE before mutation. + * + * @param string $table_name Table name. + * @param array{table_name:string,index_name:string,unique:bool,columns:array} $index_definition Planned index definition. + * @param bool $temporary Whether the target is a temporary table. + */ + private function assert_alter_table_add_unique_constraint_supported( string $table_name, array $index_definition, bool $temporary = false ): void { + if ( ! $index_definition['unique'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD UNIQUE constraint requires a UNIQUE index definition.' ); + } + + $this->assert_secondary_index_name_available( $table_name, $index_definition['index_name'], $temporary ); + $this->assert_secondary_index_columns_exist( $table_name, $index_definition, 'UNIQUE constraint', $temporary ); + } + /** * Validate existing rows before adding a PRIMARY KEY. * @@ -6470,6 +6636,26 @@ private function is_alter_table_drop_foreign_key_constraint_action( string $tabl return 'FOREIGN KEY' === $this->resolve_alter_table_drop_constraint_type( $table_name, $constraint_name, $temporary ); } + /** + * Check whether ALTER TABLE ... DROP CONSTRAINT targets a UNIQUE constraint. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens Action tokens starting at DROP. + * @param bool $temporary Whether the target is a temporary table. + * @return bool Whether this is a DROP UNIQUE constraint action. + */ + private function is_alter_table_drop_unique_constraint_action( string $table_name, array $tokens, bool $temporary = false ): bool { + if ( + ! isset( $tokens[1] ) + || WP_MySQL_Lexer::CONSTRAINT_SYMBOL !== $tokens[1]->id + ) { + return false; + } + + $constraint_name = $this->parse_alter_table_drop_constraint_name( $tokens ); + return 'UNIQUE' === $this->resolve_alter_table_drop_constraint_type( $table_name, $constraint_name, $temporary ); + } + /** * Execute ALTER TABLE ... DROP CHECK or DROP CONSTRAINT for CHECK constraints. * @@ -6544,6 +6730,30 @@ function () use ( $table_name, $metadata, $temporary ): WP_DuckDB_Result_Stateme ); } + /** + * Execute ALTER TABLE ... DROP CONSTRAINT for UNIQUE constraints. + * + * @param string $table_name Table name. + * @param WP_Parser_Token[] $tokens ALTER action tokens starting at DROP. + * @param bool $temporary Whether the target is a temporary table. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_table_drop_unique_constraint( string $table_name, array $tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { + $constraint_name = $this->parse_alter_table_drop_constraint_name( $tokens ); + $index_name = $this->resolve_secondary_index_name( $table_name, $constraint_name, $temporary ); + if ( null === $index_name ) { + throw new WP_DuckDB_Driver_Exception( "Unknown constraint '{$constraint_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $index_name, $temporary ): WP_DuckDB_Result_Statement { + $this->drop_secondary_index( $table_name, $index_name, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + /** * Resolve a recorded CHECK constraint row by name. * @@ -6586,6 +6796,29 @@ private function parse_alter_table_drop_check_constraint_name( array $tokens, in return $constraint_name; } + /** + * Parse ALTER TABLE ... DROP CONSTRAINT name. + * + * @param WP_Parser_Token[] $tokens ALTER action tokens starting at DROP. + * @return string Constraint name. + */ + private function parse_alter_table_drop_constraint_name( array $tokens ): string { + $index = 0; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::DROP_SYMBOL, 'Expected DROP in ALTER TABLE action.' ); + ++$index; + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CONSTRAINT_SYMBOL, 'Expected CONSTRAINT in ALTER TABLE DROP action.' ); + ++$index; + + $constraint_name = $this->identifier_value( $tokens[ $index ] ?? null ); + ++$index; + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. DROP CONSTRAINT options are not supported.' ); + } + + return $constraint_name; + } + /** * Parse ALTER TABLE ... DROP FOREIGN KEY name. * @@ -16520,6 +16753,49 @@ private function resolve_secondary_index_name( string $table_name, string $mysql return null; } + /** + * Reject duplicate secondary index names before CREATE INDEX IF NOT EXISTS can drift metadata. + * + * @param string $table_name Table name. + * @param string $mysql_index_name MySQL-facing index name. + * @param bool $temporary Whether the target is a temporary table. + */ + private function assert_secondary_index_name_available( string $table_name, string $mysql_index_name, bool $temporary = false ): void { + if ( null !== $this->resolve_secondary_index_name( $table_name, $mysql_index_name, $temporary ) ) { + throw new WP_DuckDB_Driver_Exception( "Duplicate key name '{$mysql_index_name}' on table '{$this->database}.{$table_name}' in DuckDB driver." ); + } + } + + /** + * Reject unknown or repeated secondary-index columns before mutating physical indexes. + * + * @param string $table_name Table name. + * @param array{columns:array} $index_definition Planned index definition. + * @param string $context Error context. + * @param bool $temporary Whether the target is a temporary table. + */ + private function assert_secondary_index_columns_exist( string $table_name, array $index_definition, string $context, bool $temporary = false ): void { + $metadata_by_column = array(); + foreach ( $this->table_column_metadata_rows( $table_name, $temporary ) as $column ) { + $metadata_by_column[ strtolower( (string) $column['column_name'] ) ] = true; + } + + $seen_columns = array(); + foreach ( $index_definition['columns'] as $column ) { + $column_name = (string) $column['name']; + $normalized_column_name = strtolower( $column_name ); + + if ( isset( $seen_columns[ $normalized_column_name ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Duplicate column name '{$column_name}' in {$context}." ); + } + $seen_columns[ $normalized_column_name ] = true; + + if ( ! isset( $metadata_by_column[ $normalized_column_name ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Unknown column '{$column_name}' on table '{$this->database}.{$table_name}' in {$context}." ); + } + } + } + /** * Drop a physical DuckDB secondary index by its MySQL-facing name. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 77f0b5184..aa2ccc7cb 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -3652,6 +3652,98 @@ public function test_alter_table_check_constraint_actions_match_sqlite(): void { $this->assertParityRowCount( "INSERT INTO alter_check_gap (id, label) VALUES (-1, 'after_drop')" ); } + public function test_alter_table_add_unique_constraint_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE alter_unique_gap ( + id INT PRIMARY KEY, + tenant_id INT, + slug VARCHAR(50), + name VARCHAR(50) + )', + "INSERT INTO alter_unique_gap (id, tenant_id, slug, name) VALUES + (1, 1, 'home', 'Home'), + (2, 1, 'about', 'About')", + 'ALTER TABLE alter_unique_gap ADD CONSTRAINT name_unique UNIQUE (name)', + 'ALTER TABLE alter_unique_gap ADD CONSTRAINT tenant_slug_unique UNIQUE (tenant_id, slug)', + ) + ); + + $this->assertParityRowColumns( + 'SHOW INDEX FROM alter_unique_gap', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRowColumns( + 'SHOW COLUMNS FROM alter_unique_gap', + array( 'Field', 'Key' ) + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'alter_unique_gap' + ORDER BY constraint_name" + ); + $this->assertParityRows( + "SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'alter_unique_gap' + ORDER BY index_name, seq_in_index" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, COLUMN_NAME, ORDINAL_POSITION + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name = 'alter_unique_gap' + AND referenced_table_name IS NULL + ORDER BY constraint_name, ordinal_position" + ); + } + + public function test_alter_table_drop_unique_constraint_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE alter_unique_drop_gap ( + id INT PRIMARY KEY, + name VARCHAR(50) + )', + "INSERT INTO alter_unique_drop_gap (id, name) VALUES (1, 'first'), (2, 'second')", + 'ALTER TABLE alter_unique_drop_gap ADD CONSTRAINT name_unique UNIQUE (name)', + ) + ); + + $this->assertParityRowCount( 'ALTER TABLE alter_unique_drop_gap DROP CONSTRAINT name_unique' ); + $this->assertParityRowColumns( + 'SHOW INDEX FROM alter_unique_drop_gap', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRowColumns( + 'SHOW COLUMNS FROM alter_unique_drop_gap', + array( 'Field', 'Key' ) + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'alter_unique_drop_gap' + ORDER BY constraint_name" + ); + $this->assertParityRows( + "SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'alter_unique_drop_gap' + ORDER BY index_name, seq_in_index" + ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, COLUMN_NAME, ORDINAL_POSITION + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name = 'alter_unique_drop_gap' + AND referenced_table_name IS NULL + ORDER BY constraint_name, ordinal_position" + ); + $this->assertParityRowCount( "INSERT INTO alter_unique_drop_gap (id, name) VALUES (3, 'first')" ); + $this->assertParityRows( 'SELECT id, name FROM alter_unique_drop_gap ORDER BY id' ); + } + public function test_unique_key_foreign_key_metadata_documents_current_duckdb_gap(): void { $sqlite_driver = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 55d6a7fab..ef51fa91d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -4701,6 +4701,290 @@ public function test_alter_table_add_unique_index_is_supported(): void { $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('siteurl', 'duplicate')" ); } + public function test_alter_table_add_unique_constraint_updates_metadata_and_enforces_uniqueness(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE alter_unique_add ( + id INT PRIMARY KEY, + name VARCHAR(50), + slug VARCHAR(50) + )' + ); + $driver->query( "INSERT INTO alter_unique_add (id, name, slug) VALUES (1, 'first', 'a'), (2, 'second', 'b')" ); + + $result = $driver->query( 'ALTER TABLE alter_unique_add ADD CONSTRAINT name_unique UNIQUE (name)' ); + + $this->assertSame( 0, $result->rowCount() ); + $this->assertSame( + array( 'PRIMARY', 'name_unique' ), + array_column( $driver->query( 'SHOW INDEX FROM alter_unique_add' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) + ); + $this->assertSame( + array( + 'id' => 'PRI', + 'name' => 'UNI', + 'slug' => '', + ), + array_column( $driver->query( 'SHOW COLUMNS FROM alter_unique_add' )->fetchAll( PDO::FETCH_ASSOC ), 'Key', 'Field' ) + ); + + $show_create = $driver->query( 'SHOW CREATE TABLE alter_unique_add' )->fetch( PDO::FETCH_ASSOC ); + $this->assertStringContainsString( 'UNIQUE KEY `name_unique` (`name`)', $show_create['Create Table'] ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'name_unique', + 'CONSTRAINT_TYPE' => 'UNIQUE', + 'ENFORCED' => 'YES', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' + AND table_name = 'alter_unique_add' + AND constraint_name = 'name_unique'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'INDEX_NAME' => 'name_unique', + 'NON_UNIQUE' => 0, + 'SEQ_IN_INDEX' => 1, + 'COLUMN_NAME' => 'name', + ), + ), + $driver->query( + "SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME + FROM information_schema.statistics + WHERE table_schema = 'wp' + AND table_name = 'alter_unique_add' + AND index_name = 'name_unique'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'name_unique', + 'COLUMN_NAME' => 'name', + 'ORDINAL_POSITION' => 1, + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, COLUMN_NAME, ORDINAL_POSITION + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name = 'alter_unique_add' + AND constraint_name = 'name_unique'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + try { + $driver->query( "INSERT INTO alter_unique_add (id, name, slug) VALUES (3, 'first', 'duplicate')" ); + $this->fail( 'Expected duplicate INSERT to fail after ADD UNIQUE constraint.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + + try { + $driver->query( "UPDATE alter_unique_add SET name = 'first' WHERE id = 2" ); + $this->fail( 'Expected duplicate UPDATE to fail after ADD UNIQUE constraint.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB UPDATE', $e->getMessage() ); + } + } + + public function test_alter_table_add_composite_unique_constraint_updates_key_column_usage(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE alter_unique_composite ( + tenant_id INT, + slug VARCHAR(50), + label VARCHAR(50) + )' + ); + $driver->query( "INSERT INTO alter_unique_composite (tenant_id, slug, label) VALUES (1, 'home', 'Home'), (1, 'about', 'About')" ); + + $this->assertSame( + 0, + $driver->query( 'ALTER TABLE alter_unique_composite ADD CONSTRAINT tenant_slug_unique UNIQUE (tenant_id, slug)' )->rowCount() + ); + + $statistics = $driver->query( + "SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX, NON_UNIQUE + FROM information_schema.statistics + WHERE table_schema = 'wp' + AND table_name = 'alter_unique_composite' + AND index_name = 'tenant_slug_unique' + ORDER BY seq_in_index" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'tenant_id', 'slug' ), array_column( $statistics, 'COLUMN_NAME' ) ); + $this->assertSame( array( 1, 2 ), array_map( 'intval', array_column( $statistics, 'SEQ_IN_INDEX' ) ) ); + $this->assertSame( array( 0, 0 ), array_map( 'intval', array_column( $statistics, 'NON_UNIQUE' ) ) ); + + $key_column_usage = $driver->query( + "SELECT CONSTRAINT_NAME, COLUMN_NAME, ORDINAL_POSITION + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name = 'alter_unique_composite' + AND constraint_name = 'tenant_slug_unique' + ORDER BY ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'tenant_id', 'slug' ), array_column( $key_column_usage, 'COLUMN_NAME' ) ); + $this->assertSame( array( 1, 2 ), array_map( 'intval', array_column( $key_column_usage, 'ORDINAL_POSITION' ) ) ); + } + + public function test_alter_table_add_unique_constraint_failures_do_not_mutate_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE alter_unique_guard (id INT PRIMARY KEY, name VARCHAR(50), slug VARCHAR(50))' ); + $driver->query( "INSERT INTO alter_unique_guard (id, name, slug) VALUES (1, 'same', 'a'), (2, 'same', 'b')" ); + + $before = $this->alter_table_unique_constraint_snapshot( $driver, 'alter_unique_guard' ); + try { + $driver->query( 'ALTER TABLE alter_unique_guard ADD CONSTRAINT name_unique UNIQUE (name)' ); + $this->fail( 'Expected ADD UNIQUE constraint to reject duplicate existing values.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to create DuckDB UNIQUE constraint index', $e->getMessage() ); + } + $this->assertSame( $before, $this->alter_table_unique_constraint_snapshot( $driver, 'alter_unique_guard' ) ); + + try { + $driver->query( 'ALTER TABLE alter_unique_guard ADD CONSTRAINT missing_unique UNIQUE (missing_column)' ); + $this->fail( 'Expected ADD UNIQUE constraint to reject unknown columns.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "Unknown column 'missing_column'", $e->getMessage() ); + } + $this->assertSame( $before, $this->alter_table_unique_constraint_snapshot( $driver, 'alter_unique_guard' ) ); + } + + public function test_alter_table_add_unique_constraint_name_precedence_and_duplicate_names(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE alter_unique_precedence (id INT PRIMARY KEY, name VARCHAR(50))' ); + $driver->query( "INSERT INTO alter_unique_precedence (id, name) VALUES (1, 'first'), (2, 'second')" ); + + $driver->query( 'ALTER TABLE alter_unique_precedence ADD CONSTRAINT constraint_name UNIQUE KEY index_name (name)' ); + $this->assertSame( + array( 'PRIMARY', 'index_name' ), + array_column( $driver->query( 'SHOW INDEX FROM alter_unique_precedence' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) + ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'index_name', + 'CONSTRAINT_TYPE' => 'UNIQUE', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE + FROM information_schema.table_constraints + WHERE table_schema = 'wp' + AND table_name = 'alter_unique_precedence' + AND constraint_type = 'UNIQUE'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + try { + $driver->query( 'ALTER TABLE alter_unique_precedence DROP CONSTRAINT constraint_name' ); + $this->fail( 'Expected DROP CONSTRAINT to use the explicit index name, not the constraint label.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "Unknown constraint 'constraint_name'", $e->getMessage() ); + } + + $before = $this->alter_table_unique_constraint_snapshot( $driver, 'alter_unique_precedence' ); + try { + $driver->query( 'ALTER TABLE alter_unique_precedence ADD CONSTRAINT other_name UNIQUE KEY index_name (name)' ); + $this->fail( 'Expected ADD UNIQUE constraint to reject duplicate explicit index names.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "Duplicate key name 'index_name'", $e->getMessage() ); + } + $this->assertSame( $before, $this->alter_table_unique_constraint_snapshot( $driver, 'alter_unique_precedence' ) ); + } + + public function test_alter_table_drop_unique_constraint_removes_metadata_and_enforcement(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE alter_unique_drop (id INT PRIMARY KEY, name VARCHAR(50))' ); + $driver->query( "INSERT INTO alter_unique_drop (id, name) VALUES (1, 'first'), (2, 'second')" ); + $driver->query( 'ALTER TABLE alter_unique_drop ADD CONSTRAINT name_unique UNIQUE (name)' ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_unique_drop DROP CONSTRAINT name_unique' )->rowCount() ); + $this->assertSame( + array( 'PRIMARY' ), + array_column( $driver->query( 'SHOW INDEX FROM alter_unique_drop' )->fetchAll( PDO::FETCH_ASSOC ), 'Key_name' ) + ); + $this->assertSame( + array( + 'id' => 'PRI', + 'name' => '', + ), + array_column( $driver->query( 'SHOW COLUMNS FROM alter_unique_drop' )->fetchAll( PDO::FETCH_ASSOC ), 'Key', 'Field' ) + ); + $this->assertSame( + array(), + $driver->query( + "SELECT CONSTRAINT_NAME + FROM information_schema.table_constraints + WHERE table_schema = 'wp' + AND table_name = 'alter_unique_drop' + AND constraint_name = 'name_unique'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array(), + $driver->query( + "SELECT INDEX_NAME + FROM information_schema.statistics + WHERE table_schema = 'wp' + AND table_name = 'alter_unique_drop' + AND index_name = 'name_unique'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO alter_unique_drop (id, name) VALUES (3, 'first')" )->rowCount() ); + } + + public function test_alter_table_drop_unique_constraint_missing_and_ambiguous_names_do_not_mutate(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE alter_unique_ambiguous ( + id INT PRIMARY KEY, + name VARCHAR(50), + CONSTRAINT shared_name CHECK (id >= 0) + )' + ); + $driver->query( "INSERT INTO alter_unique_ambiguous (id, name) VALUES (1, 'first'), (2, 'second')" ); + $driver->query( 'ALTER TABLE alter_unique_ambiguous ADD CONSTRAINT shared_name UNIQUE (name)' ); + + $before = $this->alter_table_unique_constraint_snapshot( $driver, 'alter_unique_ambiguous' ); + try { + $driver->query( 'ALTER TABLE alter_unique_ambiguous DROP CONSTRAINT missing_name' ); + $this->fail( 'Expected DROP CONSTRAINT missing name to reject.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "Unknown constraint 'missing_name'", $e->getMessage() ); + } + $this->assertSame( $before, $this->alter_table_unique_constraint_snapshot( $driver, 'alter_unique_ambiguous' ) ); + + try { + $driver->query( 'ALTER TABLE alter_unique_ambiguous DROP CONSTRAINT shared_name' ); + $this->fail( 'Expected DROP CONSTRAINT with cross-type duplicate names to be ambiguous.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( "Ambiguous constraint 'shared_name'", $e->getMessage() ); + } + $this->assertSame( $before, $this->alter_table_unique_constraint_snapshot( $driver, 'alter_unique_ambiguous' ) ); + } + public function test_alter_table_add_column_updates_data_and_metadata(): void { $this->requireDuckDBRuntime(); @@ -9990,10 +10274,8 @@ public function test_unsupported_alter_table_constraint_actions_throw_before_mut foreach ( array( - 'ALTER TABLE alter_constraint_guard ADD CONSTRAINT added_unique UNIQUE KEY (id)' => 'ADD CONSTRAINT is not supported', 'ALTER TABLE alter_constraint_guard ADD COLUMN score INT CHECK (score >= 0)' => 'Inline CHECK constraints are only supported in CREATE TABLE', 'ALTER TABLE alter_constraint_guard ADD COLUMN parent_ref INT REFERENCES alter_parent (id)' => 'Inline REFERENCES constraints are only supported in CREATE TABLE', - 'ALTER TABLE alter_constraint_guard DROP CONSTRAINT id_unique' => 'DROP CONSTRAINT currently supports CHECK and FOREIGN KEY constraints only', ) as $sql => $message ) { try { @@ -10059,7 +10341,7 @@ public function test_unsupported_alter_table_constraint_actions_in_multi_action_ 'ALTER TABLE alter_constraint_guard ADD CHECK (id >= 0), ADD CONSTRAINT added_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD/DROP CHECK cannot be combined with other ALTER TABLE actions', 'ALTER TABLE alter_constraint_guard DROP FOREIGN KEY existing_fk, ADD COLUMN should_not_exist INT DEFAULT 2' => 'ADD/DROP FOREIGN KEY cannot be combined with other ALTER TABLE actions', 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT added_fk FOREIGN KEY (parent_id) REFERENCES alter_parent (id)' => 'ADD/DROP FOREIGN KEY cannot be combined with other ALTER TABLE actions', - 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT added_unique UNIQUE KEY (id)' => 'ADD CONSTRAINT is not supported', + 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT added_unique UNIQUE KEY (id)' => 'ADD UNIQUE constraint cannot be combined with other ALTER TABLE actions', 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, DROP CONSTRAINT existing_fk' => 'ADD/DROP FOREIGN KEY cannot be combined with other ALTER TABLE actions', 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD COLUMN inline_check INT CHECK (inline_check >= 0)' => 'Inline CHECK constraints are only supported in CREATE TABLE', 'ALTER TABLE alter_constraint_guard ADD COLUMN should_not_exist INT DEFAULT 2, ADD COLUMN inline_parent INT REFERENCES alter_parent (id)' => 'Inline REFERENCES constraints are only supported in CREATE TABLE', @@ -10864,6 +11146,35 @@ private function foreign_key_rejection_metadata_snapshot( WP_DuckDB_Driver $driv ); } + private function alter_table_unique_constraint_snapshot( WP_DuckDB_Driver $driver, string $table_name ): array { + return array( + 'columns' => $driver->query( 'SHOW COLUMNS FROM ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), + 'rows' => $driver->query( 'SELECT * FROM ' . $table_name . ' ORDER BY 1' )->fetchAll( PDO::FETCH_ASSOC ), + 'indexes' => $driver->query( 'SHOW INDEX FROM ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), + 'table_constraints' => $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = '{$table_name}' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ), + 'statistics' => $driver->query( + "SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = '{$table_name}' + ORDER BY index_name, seq_in_index" + )->fetchAll( PDO::FETCH_ASSOC ), + 'key_column_usage' => $driver->query( + "SELECT CONSTRAINT_NAME, COLUMN_NAME, ORDINAL_POSITION + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' + AND table_name = '{$table_name}' + AND referenced_table_name IS NULL + ORDER BY constraint_name, ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ), + 'show_create' => $driver->query( 'SHOW CREATE TABLE ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), + ); + } + private function alter_table_check_lifecycle_snapshot( WP_DuckDB_Driver $driver, string $table_name ): array { return array( 'columns' => $driver->query( 'SHOW COLUMNS FROM ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), From 3ab122c1be749880c1e596efee1d8be1ab0d8a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 08:50:39 +0000 Subject: [PATCH 096/317] Add DuckDB BIT type-family parity --- .../src/duckdb/class-wp-duckdb-driver.php | 432 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 52 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 291 ++++++++++++ 3 files changed, 773 insertions(+), 2 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 534000d75..8f612c788 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -50,6 +50,10 @@ class WP_DuckDB_Driver { 'version_comment' => true, ); + const BIT_SIGNED_BIGINT_MAX_DECIMAL = '9223372036854775807'; + const BIT_SIGNED_BIGINT_MAX_HEX = '7fffffffffffffff'; + const BIT_SIGNED_BIGINT_MAX_BITS = 63; + const SQL_MODE_ALIASES = array( 'TRADITIONAL' => array( 'STRICT_TRANS_TABLES', @@ -62,6 +66,7 @@ class WP_DuckDB_Driver { ); const DATA_TYPE_MAP = array( + WP_MySQL_Lexer::BIT_SYMBOL => 'BIGINT', WP_MySQL_Lexer::BOOL_SYMBOL => 'BOOLEAN', WP_MySQL_Lexer::BOOLEAN_SYMBOL => 'BOOLEAN', WP_MySQL_Lexer::TINYINT_SYMBOL => 'TINYINT', @@ -10219,6 +10224,7 @@ private function translate_create_table_column( string $table_name, array $token $unique_key = false; $auto_increment = false; $default_sql = null; + $default_mysql = null; $constraints = array(); $checks = array(); $foreign_keys = array(); @@ -10245,7 +10251,13 @@ private function translate_create_table_column( string $table_name, array $token break; case WP_MySQL_Lexer::DEFAULT_SYMBOL: ++$index; - $default_sql = $this->translate_default_literal( $tokens, $index ); + if ( WP_MySQL_Lexer::BIT_SYMBOL === $type_token->id ) { + $default = $this->translate_bit_default_literal( $tokens, $index ); + $default_sql = $default['sql']; + $default_mysql = $default['mysql']; + } else { + $default_sql = $this->translate_default_literal( $tokens, $index ); + } break; case WP_MySQL_Lexer::PRIMARY_SYMBOL: $this->expect_token( $tokens, $index + 1, WP_MySQL_Lexer::KEY_SYMBOL, 'Expected KEY after PRIMARY in column definition.' ); @@ -10374,12 +10386,17 @@ private function translate_create_table_column( string $table_name, array $token ); } + $column_default = null; + if ( ! $auto_increment && null !== $default_sql ) { + $column_default = null !== $default_mysql ? $default_mysql : $this->normalize_describe_default( $default_sql ); + } + $metadata = array( 'column_name' => $column_name, 'column_type' => $this->mysql_column_type_from_tokens( $tokens, $type_index ), 'is_nullable' => $not_null ? 'NO' : 'YES', 'column_key' => $primary_key ? 'PRI' : ( $unique_key ? 'UNI' : '' ), - 'column_default' => $auto_increment || null === $default_sql ? null : $this->normalize_describe_default( $default_sql ), + 'column_default' => $column_default, 'extra' => $auto_increment ? 'auto_increment' : '', 'collation_name' => $metadata_collation_name, 'comment' => $this->mysql_column_comment_from_tokens( $tokens ), @@ -10595,6 +10612,12 @@ private function canonical_mysql_column_type( string $column_type, WP_Parser_Tok } switch ( $type_token->id ) { + case WP_MySQL_Lexer::BIT_SYMBOL: + return $this->mysql_column_type_with_default_attributes( + $this->mysql_column_type_with_base( $column_type, 'bit' ), + '(1)' + ); + case WP_MySQL_Lexer::REAL_SYMBOL: return $this->mysql_column_type_with_base( $column_type, 'double' ); @@ -11673,6 +11696,39 @@ private function translate_default_literal( array $tokens, int &$index ): string throw new WP_DuckDB_Driver_Exception( 'Only DEFAULT literals are supported by the DuckDB driver.' ); } + /** + * Translate a BIT DEFAULT literal and advance the index. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index, passed by reference. + * @return array{sql:string,mysql:string|null} DuckDB SQL literal and MySQL-facing metadata literal. + */ + private function translate_bit_default_literal( array $tokens, int &$index ): array { + if ( ! isset( $tokens[ $index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'DEFAULT requires a literal in the DuckDB driver.' ); + } + + $consumed = 1; + $literal = $this->bit_literal_sql_and_default_from_tokens( array( $tokens[ $index ] ) ); + if ( + null === $literal + && isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $index ]->id + && $this->is_number_token( $tokens[ $index + 1 ] ) + ) { + $consumed = 2; + $literal = $this->bit_literal_sql_and_default_from_numeric_string( $tokens[ $index + 1 ]->get_bytes() ); + } + + if ( null === $literal ) { + throw new WP_DuckDB_Driver_Exception( 'Only BIT DEFAULT literals are supported by the DuckDB driver.' ); + } + + $index += $consumed; + + return $literal; + } + /** * Convert MySQL tokens to DuckDB SQL. * @@ -12770,6 +12826,10 @@ private function is_qualified_identifier_tokens( array $tokens ): bool { * @return bool Whether token-aware coercion is needed. */ private function select_item_requires_text_blob_write_coercion( string $target_data_type, array $item_tokens ): bool { + if ( 'bit' === $target_data_type ) { + return null !== $this->bit_literal_value_sql( $item_tokens ); + } + if ( $this->is_character_write_data_type( $target_data_type ) ) { return $this->is_boolean_literal_tokens( $item_tokens ) || null !== $this->binary_literal_write_hex_sql( $item_tokens ); } @@ -14028,6 +14088,26 @@ private function coerce_write_value_for_column_sql( array $metadata, array $valu return $this->coerce_blob_write_value_sql( $value_tokens, $value_sql ); } + if ( 'bit' === $data_type ) { + $bit_sql = $this->bit_literal_value_sql( $value_tokens ); + if ( null !== $bit_sql ) { + return $bit_sql; + } + + if ( ! $this->is_strict_sql_mode_active() ) { + $value_sql = $this->coerce_numeric_write_value_sql( $data_type, $value_sql ); + if ( + $coalesce_non_strict_not_null + && isset( $metadata['is_nullable'] ) + && 'NO' === strtoupper( (string) $metadata['is_nullable'] ) + ) { + $value_sql = 'COALESCE(' . $value_sql . ', 0)'; + } + } + + return $value_sql; + } + if ( $this->is_numeric_write_data_type( $data_type ) && ! $this->is_strict_sql_mode_active() ) { $value_sql = $this->coerce_numeric_write_value_sql( $data_type, $value_sql ); if ( @@ -14167,6 +14247,8 @@ private function coerce_numeric_write_value_sql( string $data_type, string $valu */ private function numeric_write_cast_type( string $data_type ): string { switch ( $data_type ) { + case 'bit': + return 'BIGINT'; case 'tinyint': return 'TINYINT'; case 'smallint': @@ -14190,6 +14272,348 @@ private function numeric_write_cast_type( string $data_type ): string { } } + /** + * Return an integer storage literal for a MySQL BIT value token list. + * + * @param WP_Parser_Token[] $value_tokens RHS value tokens. + * @return string|null Integer SQL literal, or null for non-BIT literals. + */ + private function bit_literal_value_sql( array $value_tokens ): ?string { + $literal = $this->bit_literal_sql_and_default_from_tokens( $value_tokens ); + if ( null === $literal ) { + return null; + } + + return $literal['sql']; + } + + /** + * Return DuckDB storage and MySQL metadata literals for a BIT value token list. + * + * @param WP_Parser_Token[] $value_tokens Value tokens. + * @return array{sql:string,mysql:string|null}|null Literal pair, or null when unsupported. + */ + private function bit_literal_sql_and_default_from_tokens( array $value_tokens ): ?array { + if ( + 2 === count( $value_tokens ) + && $this->is_sign_token( $value_tokens[0] ) + && $this->is_number_token( $value_tokens[1] ) + ) { + if ( WP_MySQL_Lexer::MINUS_OPERATOR === $value_tokens[0]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Only non-negative integer BIT literals within signed BIGINT range are supported by the DuckDB driver.' ); + } + + return $this->bit_literal_sql_and_default_from_numeric_string( $value_tokens[1]->get_bytes() ); + } + + if ( 1 !== count( $value_tokens ) ) { + return null; + } + + $token = $value_tokens[0]; + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id || WP_MySQL_Lexer::NULL2_SYMBOL === $token->id ) { + return array( + 'sql' => 'NULL', + 'mysql' => null, + ); + } + + if ( WP_MySQL_Lexer::TRUE_SYMBOL === $token->id ) { + return array( + 'sql' => '1', + 'mysql' => "b'1'", + ); + } + + if ( WP_MySQL_Lexer::FALSE_SYMBOL === $token->id ) { + return array( + 'sql' => '0', + 'mysql' => "b'0'", + ); + } + + if ( $this->is_number_token( $token ) ) { + return $this->bit_literal_sql_and_default_from_numeric_string( $token->get_bytes() ); + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $this->bit_literal_sql_and_default_from_numeric_string( $token->get_value() ); + } + + if ( WP_MySQL_Lexer::BIN_NUMBER === $token->id ) { + $bits = $this->bit_literal_bits_from_binary_token( $token ); + if ( null === $bits ) { + return null; + } + return $this->bit_literal_sql_and_default_from_bits( $bits ); + } + + if ( WP_MySQL_Lexer::HEX_NUMBER === $token->id ) { + $hex = $this->bit_literal_hex_from_hex_token( $token ); + if ( null === $hex ) { + return null; + } + return $this->bit_literal_sql_and_default_from_hex( $hex ); + } + + return null; + } + + /** + * Return DuckDB storage and MySQL metadata literals for a numeric BIT value. + * + * @param string $value Numeric value. + * @return array{sql:string,mysql:string}|null Literal pair, or null when unsupported. + */ + private function bit_literal_sql_and_default_from_numeric_string( string $value ): ?array { + $value = trim( $value ); + if ( ! preg_match( '/^\+?\d+\z/', $value ) ) { + if ( is_numeric( $value ) ) { + throw new WP_DuckDB_Driver_Exception( 'Only non-negative integer BIT literals within signed BIGINT range are supported by the DuckDB driver.' ); + } + return null; + } + + $decimal = $this->normalize_unsigned_decimal_string( ltrim( $value, '+' ) ); + $this->assert_bit_decimal_within_signed_bigint( $decimal ); + + return $this->bit_literal_sql_and_default_from_decimal_string( $decimal ); + } + + /** + * Return DuckDB storage and MySQL metadata literals for BIT bits. + * + * @param string $bits Binary digits. + * @return array{sql:string,mysql:string} Literal pair. + */ + private function bit_literal_sql_and_default_from_bits( string $bits ): array { + $normalized = ltrim( $bits, '0' ); + if ( '' === $normalized ) { + $normalized = '0'; + } + if ( strlen( $normalized ) > self::BIT_SIGNED_BIGINT_MAX_BITS ) { + throw new WP_DuckDB_Driver_Exception( 'BIT literal exceeds signed BIGINT range in DuckDB driver.' ); + } + + return array( + 'sql' => $this->decimal_string_from_bits( $normalized ), + 'mysql' => "b'{$normalized}'", + ); + } + + /** + * Return DuckDB storage and MySQL metadata literals for BIT hex digits. + * + * @param string $hex Hex digits. + * @return array{sql:string,mysql:string} Literal pair. + */ + private function bit_literal_sql_and_default_from_hex( string $hex ): array { + $normalized = strtolower( ltrim( $hex, '0' ) ); + if ( '' === $normalized ) { + $normalized = '0'; + } + if ( + strlen( $normalized ) > strlen( self::BIT_SIGNED_BIGINT_MAX_HEX ) + || ( + strlen( $normalized ) === strlen( self::BIT_SIGNED_BIGINT_MAX_HEX ) + && strcmp( $normalized, self::BIT_SIGNED_BIGINT_MAX_HEX ) > 0 + ) + ) { + throw new WP_DuckDB_Driver_Exception( 'BIT literal exceeds signed BIGINT range in DuckDB driver.' ); + } + + return $this->bit_literal_sql_and_default_from_bits( $this->bits_from_hex_string( $normalized ) ); + } + + /** + * Return DuckDB storage and MySQL metadata literals for a normalized decimal BIT integer. + * + * @param string $decimal Normalized non-negative decimal value. + * @return array{sql:string,mysql:string} Literal pair. + */ + private function bit_literal_sql_and_default_from_decimal_string( string $decimal ): array { + return array( + 'sql' => $decimal, + 'mysql' => "b'" . $this->bits_from_decimal_string( $decimal ) . "'", + ); + } + + /** + * Normalize unsigned decimal digits. + * + * @param string $decimal Decimal digits. + * @return string Normalized decimal digits. + */ + private function normalize_unsigned_decimal_string( string $decimal ): string { + $normalized = ltrim( $decimal, '0' ); + return '' === $normalized ? '0' : $normalized; + } + + /** + * Assert that a normalized decimal BIT value fits signed BIGINT storage. + * + * @param string $decimal Normalized non-negative decimal value. + */ + private function assert_bit_decimal_within_signed_bigint( string $decimal ): void { + if ( + strlen( $decimal ) > strlen( self::BIT_SIGNED_BIGINT_MAX_DECIMAL ) + || ( + strlen( $decimal ) === strlen( self::BIT_SIGNED_BIGINT_MAX_DECIMAL ) + && strcmp( $decimal, self::BIT_SIGNED_BIGINT_MAX_DECIMAL ) > 0 + ) + ) { + throw new WP_DuckDB_Driver_Exception( 'BIT literal exceeds signed BIGINT range in DuckDB driver.' ); + } + } + + /** + * Convert a bounded bit string to normalized decimal digits. + * + * @param string $bits Binary digits. + * @return string Decimal digits. + */ + private function decimal_string_from_bits( string $bits ): string { + $decimal = '0'; + for ( $index = 0; $index < strlen( $bits ); ++$index ) { + $decimal = $this->decimal_string_double_and_add( $decimal, '1' === $bits[ $index ] ? 1 : 0 ); + } + + return $decimal; + } + + /** + * Convert normalized decimal digits to a bit string. + * + * @param string $decimal Normalized decimal digits. + * @return string Binary digits. + */ + private function bits_from_decimal_string( string $decimal ): string { + if ( '0' === $decimal ) { + return '0'; + } + + $bits = ''; + while ( '0' !== $decimal ) { + list( $decimal, $remainder ) = $this->decimal_string_divide_by_two( $decimal ); + $bits = (string) $remainder . $bits; + } + + return $bits; + } + + /** + * Convert normalized hex digits to a normalized bit string. + * + * @param string $hex Hex digits. + * @return string Binary digits. + */ + private function bits_from_hex_string( string $hex ): string { + $bits = ''; + $map = array( + '0' => '0000', + '1' => '0001', + '2' => '0010', + '3' => '0011', + '4' => '0100', + '5' => '0101', + '6' => '0110', + '7' => '0111', + '8' => '1000', + '9' => '1001', + 'a' => '1010', + 'b' => '1011', + 'c' => '1100', + 'd' => '1101', + 'e' => '1110', + 'f' => '1111', + ); + + for ( $index = 0; $index < strlen( $hex ); ++$index ) { + $bits .= $map[ $hex[ $index ] ]; + } + + $normalized = ltrim( $bits, '0' ); + return '' === $normalized ? '0' : $normalized; + } + + /** + * Double decimal digits and add one binary digit. + * + * @param string $decimal Normalized decimal digits. + * @param int $add Either 0 or 1. + * @return string Normalized decimal digits. + */ + private function decimal_string_double_and_add( string $decimal, int $add ): string { + $result = ''; + $carry = $add; + for ( $index = strlen( $decimal ) - 1; $index >= 0; --$index ) { + $value = ( (int) $decimal[ $index ] * 2 ) + $carry; + $result = (string) ( $value % 10 ) . $result; + $carry = intdiv( $value, 10 ); + } + if ( $carry > 0 ) { + $result = (string) $carry . $result; + } + + return $this->normalize_unsigned_decimal_string( $result ); + } + + /** + * Divide decimal digits by two. + * + * @param string $decimal Normalized decimal digits. + * @return array{0:string,1:int} Quotient digits and remainder. + */ + private function decimal_string_divide_by_two( string $decimal ): array { + $quotient = ''; + $remainder = 0; + for ( $index = 0; $index < strlen( $decimal ); ++$index ) { + $value = ( $remainder * 10 ) + (int) $decimal[ $index ]; + $quotient .= (string) intdiv( $value, 2 ); + $remainder = $value % 2; + } + + return array( $this->normalize_unsigned_decimal_string( $quotient ), $remainder ); + } + + /** + * Extract bits from a MySQL bit literal token. + * + * @param WP_Parser_Token $token Token. + * @return string|null Bit string, or null when unsupported. + */ + private function bit_literal_bits_from_binary_token( WP_Parser_Token $token ): ?string { + $value = $token->get_value(); + if ( strlen( $value ) >= 2 && '0' === $value[0] && 'b' === strtolower( $value[1] ) ) { + $bits = substr( $value, 2 ); + } elseif ( strlen( $value ) >= 3 && 'b' === strtolower( $value[0] ) && "'" === $value[1] ) { + $bits = substr( $value, 2, -1 ); + } else { + return null; + } + + return 1 === preg_match( '/\A[01]*\z/', $bits ) ? $bits : null; + } + + /** + * Extract hex digits from a MySQL hex literal token. + * + * @param WP_Parser_Token $token Token. + * @return string|null Hex string, or null when unsupported. + */ + private function bit_literal_hex_from_hex_token( WP_Parser_Token $token ): ?string { + $value = $token->get_value(); + if ( strlen( $value ) >= 2 && '0' === $value[0] && 'x' === strtolower( $value[1] ) ) { + $hex = substr( $value, 2 ); + } elseif ( strlen( $value ) >= 3 && 'x' === strtolower( $value[0] ) && "'" === $value[1] ) { + $hex = substr( $value, 2, -1 ); + } else { + return null; + } + + return 1 === preg_match( '/\A[0-9a-fA-F]*\z/', $hex ) ? $hex : null; + } + /** * Return a quoted hex string for a MySQL binary literal token. * @@ -18837,6 +19261,10 @@ private function column_type_length( string $column_type ): ?int { * @return array{0:int|null,1:int|null} */ private function numeric_attributes_from_data_type( string $data_type, string $column_type ): array { + if ( 'bit' === $data_type ) { + return array( $this->column_type_length( $column_type ) ?? 1, null ); + } + $precision_map = array( 'tinyint' => 3, 'smallint' => 5, diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index aa2ccc7cb..a68e825af 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -3289,6 +3289,58 @@ public function test_alias_storage_type_family_metadata_matches_sqlite(): void { ); } + public function test_bit_type_family_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE bit_type_family ( + id INT PRIMARY KEY, + plain BIT, + flags BIT(4) DEFAULT b'0101', + from_hex BIT(8) DEFAULT 0x0a, + quoted_zero BIT(1) DEFAULT '0', + integer_five BIT(4) DEFAULT 5, + truthy BIT(1) DEFAULT TRUE, + falsey BIT(1) DEFAULT FALSE + )", + 'INSERT INTO bit_type_family (id, plain) VALUES (1, 3)', + "INSERT INTO bit_type_family + (id, plain, flags, from_hex, quoted_zero, integer_five, truthy, falsey) + VALUES + (2, 4, 5, 6, 1, '7', FALSE, TRUE)", + "INSERT INTO bit_type_family + (id, plain, flags, from_hex, quoted_zero, integer_five, truthy, falsey) + VALUES + (3, '7', 8, 9, 0, 10, TRUE, FALSE)", + 'UPDATE bit_type_family + SET plain = 10, + flags = 11, + from_hex = 12, + truthy = TRUE, + falsey = FALSE + WHERE id = 3', + ) + ); + + $this->assertParityRows( 'SHOW COLUMNS FROM bit_type_family' ); + $this->assertParityRows( 'SHOW FULL COLUMNS FROM bit_type_family' ); + $this->assertParityRows( 'DESCRIBE bit_type_family' ); + $this->assertParityRows( 'SHOW CREATE TABLE bit_type_family' ); + $this->assertParityRows( + "SELECT COLUMN_NAME, COLUMN_DEFAULT, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, + CHARACTER_OCTET_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, + CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'bit_type_family' + ORDER BY ordinal_position" + ); + $this->assertParityRows( + 'SELECT id, plain, flags, from_hex, quoted_zero, integer_five, truthy, falsey + FROM bit_type_family + ORDER BY id' + ); + } + public function test_information_schema_statistics_metadata_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index ef51fa91d..78147962a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3721,6 +3721,297 @@ public function test_alias_storage_type_family_metadata_and_writes(): void { } } + public function test_bit_type_family_metadata_defaults_and_writes(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE bit_type_family ( + id INT PRIMARY KEY, + plain BIT, + flags BIT(4) DEFAULT b'0101', + from_hex BIT(8) DEFAULT 0x0a, + quoted_zero BIT(1) DEFAULT '0', + integer_five BIT(4) DEFAULT 5, + truthy BIT(1) DEFAULT TRUE, + falsey BIT(1) DEFAULT FALSE + )" + ); + $driver->query( "INSERT INTO bit_type_family (id, plain) VALUES (1, b'0011')" ); + $driver->query( + "INSERT INTO bit_type_family + (id, plain, flags, from_hex, quoted_zero, integer_five, truthy, falsey) + VALUES + (2, 0b0100, x'05', 0x06, 1, '7', FALSE, TRUE)" + ); + $driver->query( + "INSERT INTO bit_type_family + (id, plain, flags, from_hex, quoted_zero, integer_five, truthy, falsey) + VALUES + (3, '7', 8, b'00001001', 0, 10, TRUE, FALSE)" + ); + $driver->query( + "UPDATE bit_type_family + SET plain = b'1010', + flags = 0x0b, + from_hex = 12, + truthy = TRUE, + falsey = FALSE + WHERE id = 3" + ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'plain' => 3, + 'flags' => 5, + 'from_hex' => 10, + 'quoted_zero' => 0, + 'integer_five' => 5, + 'truthy' => 1, + 'falsey' => 0, + ), + array( + 'id' => 2, + 'plain' => 4, + 'flags' => 5, + 'from_hex' => 6, + 'quoted_zero' => 1, + 'integer_five' => 7, + 'truthy' => 0, + 'falsey' => 1, + ), + array( + 'id' => 3, + 'plain' => 10, + 'flags' => 11, + 'from_hex' => 12, + 'quoted_zero' => 0, + 'integer_five' => 10, + 'truthy' => 1, + 'falsey' => 0, + ), + ), + $driver->query( + 'SELECT id, plain, flags, from_hex, quoted_zero, integer_five, truthy, falsey + FROM bit_type_family + ORDER BY id' + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $show_columns = array_column( + $driver->query( 'SHOW COLUMNS FROM bit_type_family' )->fetchAll( PDO::FETCH_ASSOC ), + null, + 'Field' + ); + $this->assertSame( 'bit(1)', $show_columns['plain']['Type'] ); + $this->assertSame( 'bit(4)', $show_columns['flags']['Type'] ); + $this->assertSame( 'bit(8)', $show_columns['from_hex']['Type'] ); + $this->assertNull( $show_columns['plain']['Default'] ); + $this->assertSame( "b'101'", $show_columns['flags']['Default'] ); + $this->assertSame( "b'1010'", $show_columns['from_hex']['Default'] ); + $this->assertSame( "b'0'", $show_columns['quoted_zero']['Default'] ); + $this->assertSame( "b'101'", $show_columns['integer_five']['Default'] ); + $this->assertSame( "b'1'", $show_columns['truthy']['Default'] ); + $this->assertSame( "b'0'", $show_columns['falsey']['Default'] ); + $this->assertSame( + $driver->query( 'SHOW COLUMNS FROM bit_type_family' )->fetchAll( PDO::FETCH_ASSOC ), + $driver->query( 'DESCRIBE bit_type_family' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'COLUMN_NAME' => 'plain', + 'COLUMN_DEFAULT' => null, + 'DATA_TYPE' => 'bit', + 'NUMERIC_PRECISION' => 1, + 'NUMERIC_SCALE' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'bit(1)', + ), + array( + 'COLUMN_NAME' => 'flags', + 'COLUMN_DEFAULT' => "b'101'", + 'DATA_TYPE' => 'bit', + 'NUMERIC_PRECISION' => 4, + 'NUMERIC_SCALE' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'bit(4)', + ), + array( + 'COLUMN_NAME' => 'from_hex', + 'COLUMN_DEFAULT' => "b'1010'", + 'DATA_TYPE' => 'bit', + 'NUMERIC_PRECISION' => 8, + 'NUMERIC_SCALE' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'bit(8)', + ), + array( + 'COLUMN_NAME' => 'quoted_zero', + 'COLUMN_DEFAULT' => "b'0'", + 'DATA_TYPE' => 'bit', + 'NUMERIC_PRECISION' => 1, + 'NUMERIC_SCALE' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'bit(1)', + ), + array( + 'COLUMN_NAME' => 'integer_five', + 'COLUMN_DEFAULT' => "b'101'", + 'DATA_TYPE' => 'bit', + 'NUMERIC_PRECISION' => 4, + 'NUMERIC_SCALE' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'bit(4)', + ), + array( + 'COLUMN_NAME' => 'truthy', + 'COLUMN_DEFAULT' => "b'1'", + 'DATA_TYPE' => 'bit', + 'NUMERIC_PRECISION' => 1, + 'NUMERIC_SCALE' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'bit(1)', + ), + array( + 'COLUMN_NAME' => 'falsey', + 'COLUMN_DEFAULT' => "b'0'", + 'DATA_TYPE' => 'bit', + 'NUMERIC_PRECISION' => 1, + 'NUMERIC_SCALE' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'bit(1)', + ), + ), + $driver->query( + "SELECT COLUMN_NAME, COLUMN_DEFAULT, DATA_TYPE, NUMERIC_PRECISION, + NUMERIC_SCALE, CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'bit_type_family' + AND column_name <> 'id' + ORDER BY ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $result = $driver->query( 'SELECT plain, flags FROM bit_type_family WHERE 0 = 1' ); + $expected_metadata = array( + array( + 'name' => 'plain', + 'native_type' => 'BIT', + 'len' => 1, + 'precision' => 0, + 'duckdb:decl_type' => 'bit(1)', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 16, + ), + array( + 'name' => 'flags', + 'native_type' => 'BIT', + 'len' => 1, + 'precision' => 0, + 'duckdb:decl_type' => 'bit(4)', + 'mysqli:charsetnr' => 63, + 'mysqli:type' => 16, + ), + ); + foreach ( $expected_metadata as $index => $expected ) { + $metadata = $result->getColumnMeta( $index ); + foreach ( $expected as $key => $value ) { + $this->assertSame( $value, $metadata[ $key ], $expected['name'] . ' metadata key ' . $key ); + } + } + + $create_sql = $driver->query( 'SHOW CREATE TABLE bit_type_family' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + foreach ( + array( + '`plain` bit(1) DEFAULT NULL', + "`flags` bit(4) DEFAULT b'101'", + "`from_hex` bit(8) DEFAULT b'1010'", + "`quoted_zero` bit(1) DEFAULT b'0'", + "`integer_five` bit(4) DEFAULT b'101'", + "`truthy` bit(1) DEFAULT b'1'", + "`falsey` bit(1) DEFAULT b'0'", + ) as $expected_fragment + ) { + $this->assertStringContainsString( $expected_fragment, $create_sql ); + } + } + + public function test_bit_type_family_rejects_out_of_range_literals_without_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $max_bits = str_repeat( '1', 63 ); + $too_large_bits = str_repeat( '1', 64 ); + $driver->query( "CREATE TABLE bit_boundary (id INT PRIMARY KEY, value BIT(63) DEFAULT b'{$max_bits}')" ); + $driver->query( 'INSERT INTO bit_boundary (id, value) VALUES (1, 0x7fffffffffffffff)' ); + $row = $driver->query( 'SELECT value FROM bit_boundary' )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( '9223372036854775807', (string) $row['value'] ); + + try { + $driver->query( "CREATE TABLE bit_default_overflow (value BIT(64) DEFAULT b'{$too_large_bits}')" ); + $this->fail( 'Expected oversized BIT default to be rejected.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'BIT literal exceeds signed BIGINT range', $e->getMessage() ); + } + $this->assertSame( + array(), + $driver->query( "SHOW TABLES LIKE 'bit_default_overflow'" )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'CREATE TABLE bit_write_overflow (id INT PRIMARY KEY, value BIT)' ); + $driver->query( "INSERT INTO bit_write_overflow (id, value) VALUES (1, b'101')" ); + + foreach ( + array( + "INSERT INTO bit_write_overflow (id, value) VALUES (2, 1), (3, b'{$too_large_bits}')", + 'INSERT INTO bit_write_overflow (id, value) VALUES (2, 9223372036854775808)', + 'INSERT INTO bit_write_overflow (id, value) VALUES (2, +9223372036854775808)', + "INSERT INTO bit_write_overflow (id, value) VALUES (2, '9223372036854775808')", + 'UPDATE bit_write_overflow SET value = 0x8000000000000000 WHERE id = 1', + ) as $sql + ) { + try { + $driver->query( $sql ); + $this->fail( 'Expected oversized BIT write to be rejected for SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'BIT literal exceeds signed BIGINT range', $e->getMessage() ); + } + + $this->assertSame( + array( + array( + 'id' => 1, + 'value' => 5, + ), + ), + $driver->query( 'SELECT id, value FROM bit_write_overflow ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + } + public function test_expression_and_admin_result_metadata_provider_documents_name_only_contract(): void { $this->requireDuckDBRuntime(); From 9849375aba59a6e3dc774c8730d2511b3503621c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 08:52:31 +0000 Subject: [PATCH 097/317] Add DuckDB joined UPDATE USING parity --- .../src/duckdb/class-wp-duckdb-driver.php | 40 ++--- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 48 ++---- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 156 +++++++++++++++++- 3 files changed, 185 insertions(+), 59 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 8f612c788..0507bc59d 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -2861,7 +2861,7 @@ private function parse_joined_update_join_chain( array $tokens, int $index, arra $this->expect_token( $tokens, $index, WP_MySQL_Lexer::JOIN_SYMBOL, 'Expected JOIN after CROSS.' ); } elseif ( WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { if ( $this->is_unsupported_joined_update_join_token( $tokens[ $index ] ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported.' ); } throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Table reference options are not supported.' ); } @@ -2876,11 +2876,7 @@ private function parse_joined_update_join_chain( array $tokens, int $index, arra throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. CROSS JOIN ... USING is not supported.' ); } - if ( 'DELETE' !== $statement ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. JOIN ... USING is not supported.' ); - } - - $using = $this->parse_joined_delete_using_predicates( $tokens, $index, $left_reference, $source['reference'] ); + $using = $this->parse_joined_dml_using_predicates( $tokens, $index, $left_reference, $source['reference'], $statement ); foreach ( $using['predicates'] as $predicate ) { $join_predicates[] = $predicate; } @@ -2910,34 +2906,35 @@ private function parse_joined_update_join_chain( array $tokens, int $index, arra } /** - * Parse DELETE JOIN ... USING columns into explicit equality predicates. + * Parse JOIN ... USING columns into explicit equality predicates. * * @param WP_Parser_Token[] $tokens Table reference tokens. * @param int $index Index at USING. * @param array $left_reference Left table reference. * @param array $right_reference Right table reference. + * @param string $statement Statement name for diagnostics. * @return array{predicates:array,next_index:int} Generated predicates and next token index. */ - private function parse_joined_delete_using_predicates( array $tokens, int $index, array $left_reference, array $right_reference ): array { + private function parse_joined_dml_using_predicates( array $tokens, int $index, array $left_reference, array $right_reference, string $statement ): array { if ( null === $left_reference['table_name'] || null === $right_reference['table_name'] ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. JOIN ... USING requires base table references.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. JOIN ... USING requires base table references.' ); } - $this->expect_token( $tokens, $index, WP_MySQL_Lexer::USING_SYMBOL, 'Expected USING in joined DELETE statement.' ); + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::USING_SYMBOL, 'Expected USING in joined ' . $statement . ' statement.' ); ++$index; - $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Unsupported DELETE statement in DuckDB driver. JOIN ... USING requires a column list.' ); + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::OPEN_PAR_SYMBOL, 'Unsupported ' . $statement . ' statement in DuckDB driver. JOIN ... USING requires a column list.' ); $close_index = $this->skip_balanced_parentheses( $tokens, $index ) - 1; $column_tokens = array_slice( $tokens, $index + 1, $close_index - $index - 1 ); if ( count( $column_tokens ) === 0 ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. JOIN ... USING requires a column list.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. JOIN ... USING requires a column list.' ); } $seen = array(); $predicates = array(); foreach ( $this->split_top_level_comma_items( $column_tokens ) as $item ) { if ( 1 !== count( $item ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. JOIN ... USING supports only column names.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. JOIN ... USING supports only column names.' ); } $column = $this->identifier_value( $item[0] ); @@ -2954,13 +2951,16 @@ private function parse_joined_delete_using_predicates( array $tokens, int $index throw new WP_DuckDB_Driver_Exception( "Unknown JOIN ... USING column '{$column}' in DuckDB driver." ); } - $predicates[] = $this->connection->quote_identifier( $left_reference['alias'] ) - . '.' - . $this->connection->quote_identifier( $column ) - . ' = ' - . $this->connection->quote_identifier( $right_reference['alias'] ) - . '.' - . $this->connection->quote_identifier( $column ); + // Match the SQLite UPDATE rewrite, which consumes USING without preserving a join predicate. + if ( 'DELETE' === $statement ) { + $predicates[] = $this->connection->quote_identifier( $left_reference['alias'] ) + . '.' + . $this->connection->quote_identifier( $column ) + . ' = ' + . $this->connection->quote_identifier( $right_reference['alias'] ) + . '.' + . $this->connection->quote_identifier( $column ); + } } return array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index a68e825af..85432b263 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -960,6 +960,21 @@ public function test_cross_joined_update_matches_sqlite(): void { $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); } + public function test_joined_update_using_columns_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)', + 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)', + "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30), (4, 'a4', 40)", + "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (5, 'b5', 1)", + ) + ); + + $this->assertParityRowCount( "UPDATE t1 a JOIN t2 b USING (id) SET a.note = 'using' WHERE b.id = 5" ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + } + public function test_joined_update_non_first_target_matches_sqlite(): void { $this->runParitySetup( array( @@ -1057,7 +1072,7 @@ public function test_joined_update_unsupported_join_forms_document_current_duckd array( array( 'sql' => "UPDATE t1 a LEFT JOIN t2 b ON a.id = b.id SET a.note = 'left'", - 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', + 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', 'sqlite_row_count' => 2, 'sqlite_rows' => array( array( @@ -1084,7 +1099,7 @@ public function test_joined_update_unsupported_join_forms_document_current_duckd ), array( 'sql' => "UPDATE t1 a RIGHT JOIN t2 b ON a.id = b.id SET a.note = 'right'", - 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', + 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', 'sqlite_row_count' => 2, 'sqlite_rows' => array( array( @@ -1136,36 +1151,9 @@ public function test_joined_update_unsupported_join_forms_document_current_duckd ), ), ), - array( - 'sql' => "UPDATE t1 a JOIN t2 b USING (id) SET a.note = 'using'", - 'duckdb_message' => 'JOIN ... USING is not supported', - 'sqlite_row_count' => 4, - 'sqlite_rows' => array( - array( - 'id' => '1', - 'note' => 'using', - 'only_t1' => '10', - ), - array( - 'id' => '2', - 'note' => 'using', - 'only_t1' => '20', - ), - array( - 'id' => '3', - 'note' => 'using', - 'only_t1' => '30', - ), - array( - 'id' => '4', - 'note' => 'using', - 'only_t1' => '40', - ), - ), - ), array( 'sql' => "UPDATE t1 a NATURAL JOIN t2 b SET a.note = 'natural'", - 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', + 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', 'sqlite_row_count' => 4, 'sqlite_rows' => array( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 78147962a..6884a912f 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1888,6 +1888,116 @@ public function test_joined_update_rewrites_cross_join(): void { ); } + public function test_joined_update_rewrites_join_using_columns(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, site_id INT, note VARCHAR(20), only_t1 INT)' ); + $driver->query( 'CREATE TABLE t2 (id INT, site_id INT, replacement VARCHAR(20), flag VARCHAR(20), note VARCHAR(20))' ); + $driver->query( + "INSERT INTO t1 VALUES + (1, 10, 'a1', 100), + (2, 10, 'a2', 200), + (3, 10, 'a3', 300), + (1, 20, 'a4', 400)" + ); + $driver->query( + "INSERT INTO t2 VALUES + (1, 10, 'u1', 'apply', 'b1'), + (2, 10, 'u2', 'skip', 'b2'), + (3, 10, 'u3', 'apply', 'b3'), + (1, 20, 'u4', 'apply', 'b4'), + (4, 10, 'u5', 'apply', 'b5')" + ); + + $target_update = $driver->query( + "UPDATE t1 AS a + INNER JOIN t2 AS b USING (id, site_id) + SET a.note = 'using' + WHERE b.id = 4 AND b.site_id = 10" + ); + + $this->assertSame( 4, $target_update->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'site_id' => 10, + 'note' => 'using', + 'only_t1' => 100, + ), + array( + 'id' => 2, + 'site_id' => 10, + 'note' => 'using', + 'only_t1' => 200, + ), + array( + 'id' => 3, + 'site_id' => 10, + 'note' => 'using', + 'only_t1' => 300, + ), + array( + 'id' => 1, + 'site_id' => 20, + 'note' => 'using', + 'only_t1' => 400, + ), + ), + $driver->query( 'SELECT id, site_id, note, only_t1 FROM t1 ORDER BY site_id, id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $source_update = $driver->query( + "UPDATE t1 a + JOIN t2 b USING (id, site_id) + SET b.note = 'source-using' + WHERE a.id = 2 AND a.site_id = 10" + ); + + $this->assertSame( 5, $source_update->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'site_id' => 10, + 'replacement' => 'u1', + 'flag' => 'apply', + 'note' => 'source-using', + ), + array( + 'id' => 2, + 'site_id' => 10, + 'replacement' => 'u2', + 'flag' => 'skip', + 'note' => 'source-using', + ), + array( + 'id' => 3, + 'site_id' => 10, + 'replacement' => 'u3', + 'flag' => 'apply', + 'note' => 'source-using', + ), + array( + 'id' => 4, + 'site_id' => 10, + 'replacement' => 'u5', + 'flag' => 'apply', + 'note' => 'source-using', + ), + array( + 'id' => 1, + 'site_id' => 20, + 'replacement' => 'u4', + 'flag' => 'apply', + 'note' => 'source-using', + ), + ), + $driver->query( 'SELECT id, site_id, replacement, flag, note FROM t2 ORDER BY site_id, id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_joined_update_rewrites_derived_table_claim_query(): void { $this->requireDuckDBRuntime(); @@ -2235,25 +2345,53 @@ public function test_joined_update_rejects_unsupported_shapes(): void { 'sql' => "UPDATE t1 a CROSS JOIN t2 b SET a.note = 'target', b.note = 'source' WHERE b.id = 3", 'message' => 'UPDATE statement modifying multiple tables is not supported', ), + array( + 'sql' => "UPDATE t1 a JOIN t2 b USING (id) SET a.note = 'target', b.note = 'source'", + 'message' => 'UPDATE statement modifying multiple tables is not supported', + ), array( 'sql' => "UPDATE t1 a LEFT JOIN t2 b ON a.id = b.id SET a.note = 'target'", - 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', ), array( 'sql' => "UPDATE t1 a RIGHT JOIN t2 b ON a.id = b.id SET a.note = 'target'", - 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', ), array( 'sql' => "UPDATE t1 a NATURAL JOIN t2 b SET a.note = 'target'", - 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', ), array( 'sql' => "UPDATE t1 a CROSS JOIN t2 b ON a.id = b.id SET a.note = 'target'", 'message' => 'CROSS JOIN ... ON is not supported', ), array( - 'sql' => "UPDATE t1 a JOIN t2 b USING (id) SET a.note = 'target'", - 'message' => 'JOIN ... USING is not supported', + 'sql' => "UPDATE t1 a CROSS JOIN t2 b USING (id) SET a.note = 'target'", + 'message' => 'CROSS JOIN ... USING is not supported', + ), + array( + 'sql' => "UPDATE t1 a JOIN t2 b USING () SET a.note = 'target'", + 'message' => 'could not parse MySQL statement', + ), + array( + 'sql' => "UPDATE t1 a JOIN t2 b USING (id + id) SET a.note = 'target'", + 'message' => 'could not parse MySQL statement', + ), + array( + 'sql' => "UPDATE t1 a JOIN t2 b USING (id, id) SET a.note = 'target'", + 'message' => "Duplicate JOIN ... USING column 'id'", + ), + array( + 'sql' => "UPDATE t1 a JOIN t2 b USING (missing_id) SET a.note = 'target'", + 'message' => "Unknown JOIN ... USING column 'missing_id'", + ), + array( + 'sql' => "UPDATE t1 a JOIN (SELECT id FROM t2) b USING (id) SET a.note = 'target'", + 'message' => 'JOIN ... USING requires base table references', + ), + array( + 'sql' => "UPDATE t1 a JOIN t2 b USING (id) SET note = 'ambiguous'", + 'message' => "Ambiguous unqualified UPDATE target column 'note'", ), array( 'sql' => "UPDATE t1 a JOIN t2 b ON a.id = b.id SET note = 'ambiguous'", @@ -3133,15 +3271,15 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { array( array( 'sql' => 'DELETE a, b FROM t1 a LEFT JOIN t2 b ON a.id = b.id', - 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', ), array( 'sql' => 'DELETE a, b FROM t1 a RIGHT JOIN t2 b ON a.id = b.id', - 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', ), array( 'sql' => 'DELETE a, b FROM t1 a NATURAL JOIN t2 b', - 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', ), array( 'sql' => 'DELETE a, b FROM t1 a CROSS JOIN t2 b ON a.id = b.id', @@ -3149,7 +3287,7 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { ), array( 'sql' => 'DELETE a, b FROM t1 a STRAIGHT_JOIN t2 b ON a.id = b.id', - 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON are supported', + 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', ), array( 'sql' => 'DELETE a, b FROM t1 a JOIN t2 b USING (missing_id)', From 806f2063f938fedcfec7000f9815f4384bd149e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 08:56:13 +0000 Subject: [PATCH 098/317] Add DuckDB joined DELETE derived source parity --- .../src/duckdb/class-wp-duckdb-driver.php | 51 +++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 106 +++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 268 +++++++++++++++++- 3 files changed, 408 insertions(+), 17 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 0507bc59d..347f6f2a3 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -2419,6 +2419,11 @@ private function parse_multi_table_delete_shape( array $tokens ): ?array { throw new WP_DuckDB_Driver_Exception( "Unknown DELETE target alias '{$target_alias}' in DuckDB driver." ); } $reference = $references['by_alias'][ $key ]; + if ( null === $reference['table_name'] ) { + throw new WP_DuckDB_Driver_Exception( + "Unsupported DELETE statement in DuckDB driver. Derived table alias '{$target_alias}' cannot be targeted." + ); + } $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'DELETE', $reference['temporary'] ); $targets[] = array( 'alias' => $reference['alias'], @@ -2535,12 +2540,21 @@ private function joined_dml_predicate_sql( $predicate ): string { private function parse_multi_delete_target_aliases( array $tokens ): array { $aliases = array(); foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { - if ( 1 !== count( $item ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. DELETE target wildcards are not supported.' ); + if ( + 3 === count( $item ) + && WP_MySQL_Lexer::DOT_SYMBOL === $item[1]->id + && WP_MySQL_Lexer::MULT_OPERATOR === $item[2]->id + ) { + $alias = $this->identifier_value( $item[0] ); + } elseif ( 1 === count( $item ) ) { + $alias = $this->identifier_value( $item[0] ); + } else { + throw new WP_DuckDB_Driver_Exception( + 'Unsupported DELETE statement in DuckDB driver. DELETE targets must be aliases or alias wildcards.' + ); } - $alias = $this->identifier_value( $item[0] ); - $key = strtolower( $alias ); + $key = strtolower( $alias ); if ( isset( $aliases[ $key ] ) ) { throw new WP_DuckDB_Driver_Exception( "Duplicate DELETE target alias '{$alias}' in DuckDB driver." ); } @@ -2554,10 +2568,13 @@ private function parse_multi_delete_target_aliases( array $tokens ): array { * Parse comma-separated table references for a bounded multi-table DELETE. * * @param WP_Parser_Token[] $tokens Table reference tokens. - * @return array{sql:string,by_alias:array,join_predicates:array|string>} SQL and references keyed by lowercase alias. + * @return array{sql:string,by_alias:array,join_predicates:array|string>} SQL and references keyed by lowercase alias. */ private function parse_multi_delete_table_references( array $tokens ): array { - if ( $this->contains_top_level_join_token( $tokens ) ) { + if ( + $this->contains_top_level_join_token( $tokens ) + || $this->contains_top_level_derived_table_factor( $tokens ) + ) { return $this->parse_joined_multi_delete_table_references( $tokens ); } @@ -2591,7 +2608,7 @@ private function parse_multi_delete_table_references( array $tokens ): array { * Parse joined table references for a bounded multi-table DELETE. * * @param WP_Parser_Token[] $tokens Table reference tokens. - * @return array{sql:string,by_alias:array,join_predicates:array|string>} SQL and references keyed by lowercase alias. + * @return array{sql:string,by_alias:array,join_predicates:array|string>} SQL and references keyed by lowercase alias. */ private function parse_joined_multi_delete_table_references( array $tokens ): array { $joined_references = $this->parse_joined_update_table_references( $tokens, 'DELETE', false ); @@ -2609,10 +2626,6 @@ private function parse_joined_multi_delete_table_references( array $tokens ): ar } foreach ( $references as $reference ) { - if ( null === $reference['table_name'] ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported DELETE statement in DuckDB driver. Derived table sources are not supported.' ); - } - $key = strtolower( $reference['alias'] ); $by_alias[ $key ] = array( 'alias' => $reference['alias'], @@ -3002,6 +3015,22 @@ private function contains_top_level_join_token( array $tokens ): bool { return false; } + /** + * Check whether a table-reference list contains a top-level derived table factor. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether a top-level comma item starts with a derived table. + */ + private function contains_top_level_derived_table_factor( array $tokens ): bool { + foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { + if ( isset( $item[0] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $item[0]->id ) { + return true; + } + } + + return false; + } + /** * Check whether a token stream contains a token at top-level depth. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 85432b263..b50b0b520 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1407,6 +1407,112 @@ public function test_cross_joined_delete_matches_sqlite(): void { $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); } + public function test_joined_delete_target_wildcards_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wildcard_t1 (id INT, note VARCHAR(20))', + 'CREATE TABLE wildcard_t2 (id INT, target_id INT, flag VARCHAR(20))', + "INSERT INTO wildcard_t1 VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')", + "INSERT INTO wildcard_t2 VALUES (10, 1, 'drop'), (11, 3, 'drop'), (12, 4, 'keep')", + ) + ); + + $this->assertParityRowCount( + "DELETE a.* FROM wildcard_t1 a + JOIN wildcard_t2 b ON b.target_id = a.id + WHERE b.flag = 'drop'" + ); + $this->assertParityRows( 'SELECT id, note FROM wildcard_t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM wildcard_t2 ORDER BY id' ); + + $this->runParitySetup( + array( + 'CREATE TABLE wildcard_multi_t1 (id INT, note VARCHAR(20))', + 'CREATE TABLE wildcard_multi_t2 (id INT, target_id INT, flag VARCHAR(20))', + "INSERT INTO wildcard_multi_t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')", + "INSERT INTO wildcard_multi_t2 VALUES (10, 1, 'drop'), (11, 3, 'drop'), (12, 2, 'keep')", + ) + ); + + $this->assertParityRowCount( + "DELETE a.*, b FROM wildcard_multi_t1 a + JOIN wildcard_multi_t2 b ON b.target_id = a.id + WHERE b.flag = 'drop'" + ); + $this->assertParityRows( 'SELECT id, note FROM wildcard_multi_t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, target_id, flag FROM wildcard_multi_t2 ORDER BY id' ); + } + + public function test_joined_delete_derived_sources_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE derived_delete_t1 (id INT, note VARCHAR(20))', + 'CREATE TABLE derived_delete_t2 (id INT, flag VARCHAR(20), note VARCHAR(20))', + "INSERT INTO derived_delete_t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')", + "INSERT INTO derived_delete_t2 VALUES (1, 'drop', 'x'), (3, 'drop', 'z'), (4, 'keep', 'other')", + ) + ); + + $this->assertParityRowCount( + "DELETE a FROM derived_delete_t1 a + JOIN (SELECT id FROM derived_delete_t2 WHERE flag = 'drop') b ON a.id = b.id" + ); + $this->assertParityRows( 'SELECT id, note FROM derived_delete_t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, flag FROM derived_delete_t2 ORDER BY id' ); + + $this->runParitySetup( + array( + 'CREATE TABLE derived_comma_t1 (id INT, note VARCHAR(20))', + 'CREATE TABLE derived_comma_t2 (id INT, flag VARCHAR(20))', + "INSERT INTO derived_comma_t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')", + "INSERT INTO derived_comma_t2 VALUES (1, 'drop'), (2, 'keep'), (3, 'drop')", + ) + ); + + $this->assertParityRowCount( + "DELETE a FROM derived_comma_t1 a, (SELECT id FROM derived_comma_t2 WHERE flag = 'drop') b + WHERE a.id = b.id" + ); + $this->assertParityRows( 'SELECT id, note FROM derived_comma_t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, flag FROM derived_comma_t2 ORDER BY id' ); + + $this->runParitySetup( + array( + 'CREATE TABLE derived_multi_t1 (id INT, note VARCHAR(20))', + 'CREATE TABLE derived_multi_t2 (id INT, flag VARCHAR(20))', + 'CREATE TABLE derived_multi_t3 (id INT, note VARCHAR(20))', + "INSERT INTO derived_multi_t1 VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')", + "INSERT INTO derived_multi_t2 VALUES (1, 'drop'), (3, 'drop'), (4, 'keep')", + "INSERT INTO derived_multi_t3 VALUES (1, 'x'), (3, 'z'), (4, 'other')", + ) + ); + + $this->assertParityRowCount( + "DELETE a, c FROM derived_multi_t1 a + JOIN derived_multi_t3 c ON c.id = a.id + JOIN (SELECT id FROM derived_multi_t2 WHERE flag = 'drop') b ON b.id = a.id" + ); + $this->assertParityRows( 'SELECT id, note FROM derived_multi_t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, flag FROM derived_multi_t2 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note FROM derived_multi_t3 ORDER BY id' ); + + $this->runParitySetup( + array( + 'CREATE TABLE derived_first_t2 (id INT, flag VARCHAR(20))', + 'CREATE TABLE derived_first_t3 (id INT, note VARCHAR(20))', + "INSERT INTO derived_first_t2 VALUES (1, 'drop'), (2, 'keep'), (3, 'drop')", + "INSERT INTO derived_first_t3 VALUES (1, 'x'), (2, 'y'), (3, 'z')", + ) + ); + + $this->assertParityRowCount( + "DELETE c FROM (SELECT id FROM derived_first_t2 WHERE flag = 'drop') b + JOIN derived_first_t3 c ON c.id = b.id" + ); + $this->assertParityRows( 'SELECT id, flag FROM derived_first_t2 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note FROM derived_first_t3 ORDER BY id' ); + } + public function test_single_target_joined_delete_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 6884a912f..ea88628e4 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3048,6 +3048,266 @@ public function test_joined_delete_rewrites_cross_join_forms(): void { ); } + public function test_joined_delete_accepts_target_wildcard_aliases(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, target_id INT, flag VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')" ); + $driver->query( "INSERT INTO t2 VALUES (10, 1, 'drop'), (11, 3, 'drop'), (12, 4, 'keep')" ); + + $single_target_delete = $driver->query( + "DELETE a.* FROM t1 a + JOIN t2 b ON b.target_id = a.id + WHERE b.flag = 'drop'" + ); + + $this->assertSame( 2, $single_target_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'b', + ), + array( + 'id' => 4, + 'note' => 'd', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 10, + 'target_id' => 1, + 'flag' => 'drop', + ), + array( + 'id' => 11, + 'target_id' => 3, + 'flag' => 'drop', + ), + array( + 'id' => 12, + 'target_id' => 4, + 'flag' => 'keep', + ), + ), + $driver->query( 'SELECT id, target_id, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, target_id INT, flag VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')" ); + $driver->query( "INSERT INTO t2 VALUES (10, 1, 'drop'), (11, 3, 'drop'), (12, 2, 'keep')" ); + + $multi_target_delete = $driver->query( + "DELETE a.*, b FROM t1 a + JOIN t2 b ON b.target_id = a.id + WHERE b.flag = 'drop'" + ); + + $this->assertSame( 4, $multi_target_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'b', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 12, + 'target_id' => 2, + 'flag' => 'keep', + ), + ), + $driver->query( 'SELECT id, target_id, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_joined_delete_accepts_derived_read_only_sources(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, flag VARCHAR(20), note VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'drop', 'x'), (3, 'drop', 'z'), (4, 'keep', 'other')" ); + + $single_target_delete = $driver->query( + "DELETE a FROM t1 a + JOIN (SELECT id FROM t2 WHERE flag = 'drop') b ON a.id = b.id" + ); + + $this->assertSame( 2, $single_target_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'b', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'flag' => 'drop', + ), + array( + 'id' => 3, + 'flag' => 'drop', + ), + array( + 'id' => 4, + 'flag' => 'keep', + ), + ), + $driver->query( 'SELECT id, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, flag VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'drop'), (2, 'keep'), (3, 'drop')" ); + + $comma_source_delete = $driver->query( + "DELETE a FROM t1 a, (SELECT id FROM t2 WHERE flag = 'drop') b + WHERE a.id = b.id" + ); + + $this->assertSame( 2, $comma_source_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'b', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'flag' => 'drop', + ), + array( + 'id' => 2, + 'flag' => 'keep', + ), + array( + 'id' => 3, + 'flag' => 'drop', + ), + ), + $driver->query( 'SELECT id, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t2 (id INT, flag VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t3 (id INT, note VARCHAR(20))' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'drop'), (3, 'drop'), (4, 'keep')" ); + $driver->query( "INSERT INTO t3 VALUES (1, 'x'), (3, 'z'), (4, 'other')" ); + + $multi_target_delete = $driver->query( + "DELETE a, c FROM t1 a + JOIN t3 c ON c.id = a.id + JOIN (SELECT id FROM t2 WHERE flag = 'drop') b ON b.id = a.id" + ); + + $this->assertSame( 4, $multi_target_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'b', + ), + array( + 'id' => 4, + 'note' => 'd', + ), + ), + $driver->query( 'SELECT id, note FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'flag' => 'drop', + ), + array( + 'id' => 3, + 'flag' => 'drop', + ), + array( + 'id' => 4, + 'flag' => 'keep', + ), + ), + $driver->query( 'SELECT id, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 4, + 'note' => 'other', + ), + ), + $driver->query( 'SELECT id, note FROM t3 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t2 (id INT, flag VARCHAR(20))' ); + $driver->query( 'CREATE TABLE t3 (id INT, note VARCHAR(20))' ); + $driver->query( "INSERT INTO t2 VALUES (1, 'drop'), (2, 'keep'), (3, 'drop')" ); + $driver->query( "INSERT INTO t3 VALUES (1, 'x'), (2, 'y'), (3, 'z')" ); + + $target_not_first_delete = $driver->query( + "DELETE c FROM (SELECT id FROM t2 WHERE flag = 'drop') b + JOIN t3 c ON c.id = b.id" + ); + + $this->assertSame( 2, $target_not_first_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'y', + ), + ), + $driver->query( 'SELECT id, note FROM t3 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'flag' => 'drop', + ), + array( + 'id' => 2, + 'flag' => 'keep', + ), + array( + 'id' => 3, + 'flag' => 'drop', + ), + ), + $driver->query( 'SELECT id, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_single_target_joined_delete_rewrites_join_using_and_alias_forms(): void { $this->requireDuckDBRuntime(); @@ -3306,12 +3566,8 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { 'message' => "Duplicate table alias 'a'", ), array( - 'sql' => 'DELETE a, b FROM t1 a JOIN (SELECT id FROM t2) b ON a.id = b.id', - 'message' => 'Derived table sources are not supported', - ), - array( - 'sql' => 'DELETE a.* FROM t1 a', - 'message' => 'DELETE target wildcards are not supported', + 'sql' => 'DELETE b FROM t1 a JOIN (SELECT id FROM t2) b ON a.id = b.id', + 'message' => "Derived table alias 'b' cannot be targeted", ), array( 'sql' => 'DELETE t FROM information_schema.tables t', From 89114c0d7ebbac43a457a9ce428a86b721b4efb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 08:57:29 +0000 Subject: [PATCH 099/317] Add DuckDB STRAIGHT_JOIN update parity --- .../src/duckdb/class-wp-duckdb-driver.php | 10 ++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 19 +++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 75 +++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 347f6f2a3..5a2c8fff3 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -2872,6 +2872,11 @@ private function parse_joined_update_join_chain( array $tokens, int $index, arra $join_type = 'CROSS'; ++$index; $this->expect_token( $tokens, $index, WP_MySQL_Lexer::JOIN_SYMBOL, 'Expected JOIN after CROSS.' ); + } elseif ( WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === $tokens[ $index ]->id ) { + if ( 'UPDATE' !== $statement ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported.' ); + } + $join_type = 'STRAIGHT'; } elseif ( WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { if ( $this->is_unsupported_joined_update_join_token( $tokens[ $index ] ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported.' ); @@ -2888,6 +2893,9 @@ private function parse_joined_update_join_chain( array $tokens, int $index, arra if ( 'CROSS' === $join_type ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. CROSS JOIN ... USING is not supported.' ); } + if ( 'STRAIGHT' === $join_type ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. STRAIGHT_JOIN ... USING is not supported.' ); + } $using = $this->parse_joined_dml_using_predicates( $tokens, $index, $left_reference, $source['reference'], $statement ); foreach ( $using['predicates'] as $predicate ) { @@ -3073,7 +3081,6 @@ private function is_unsupported_joined_update_join_token( WP_Parser_Token $token WP_MySQL_Lexer::LEFT_SYMBOL, WP_MySQL_Lexer::RIGHT_SYMBOL, WP_MySQL_Lexer::NATURAL_SYMBOL, - WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, WP_MySQL_Lexer::USING_SYMBOL, ), true @@ -3090,6 +3097,7 @@ private function is_joined_update_join_start_token( WP_Parser_Token $token ): bo return WP_MySQL_Lexer::JOIN_SYMBOL === $token->id || WP_MySQL_Lexer::INNER_SYMBOL === $token->id || WP_MySQL_Lexer::CROSS_SYMBOL === $token->id + || WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === $token->id || $this->is_unsupported_joined_update_join_token( $token ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index b50b0b520..4e5d2d2bf 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -960,6 +960,25 @@ public function test_cross_joined_update_matches_sqlite(): void { $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); } + public function test_straight_joined_update_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)', + 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)', + "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30)", + "INSERT INTO t2 VALUES (1, 'b1', 1), (2, 'b2', 0), (3, 'b3', 1), (4, 'b4', 1)", + ) + ); + + $this->assertParityRowCount( + "UPDATE t1 a STRAIGHT_JOIN t2 b ON a.id = b.id + SET a.note = 'straight', a.only_t1 = a.only_t1 + b.flag + WHERE b.flag = 1" + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + } + public function test_joined_update_using_columns_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index ea88628e4..e3a721fda 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1888,6 +1888,69 @@ public function test_joined_update_rewrites_cross_join(): void { ); } + public function test_joined_update_rewrites_straight_join_on(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)' ); + $driver->query( 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30)" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'b1', 1), (2, 'b2', 0), (3, 'b3', 1), (4, 'b4', 1)" ); + + $updated = $driver->query( + "UPDATE t1 a STRAIGHT_JOIN t2 b ON a.id = b.id + SET a.note = 'straight', a.only_t1 = a.only_t1 + b.flag + WHERE b.flag = 1" + ); + + $this->assertSame( 2, $updated->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'straight', + 'only_t1' => 11, + ), + array( + 'id' => 2, + 'note' => 'a2', + 'only_t1' => 20, + ), + array( + 'id' => 3, + 'note' => 'straight', + 'only_t1' => 31, + ), + ), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'b1', + 'flag' => 1, + ), + array( + 'id' => 2, + 'note' => 'b2', + 'flag' => 0, + ), + array( + 'id' => 3, + 'note' => 'b3', + 'flag' => 1, + ), + array( + 'id' => 4, + 'note' => 'b4', + 'flag' => 1, + ), + ), + $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_joined_update_rewrites_join_using_columns(): void { $this->requireDuckDBRuntime(); @@ -2345,6 +2408,10 @@ public function test_joined_update_rejects_unsupported_shapes(): void { 'sql' => "UPDATE t1 a CROSS JOIN t2 b SET a.note = 'target', b.note = 'source' WHERE b.id = 3", 'message' => 'UPDATE statement modifying multiple tables is not supported', ), + array( + 'sql' => "UPDATE t1 a STRAIGHT_JOIN t2 b ON a.id = b.id SET a.note = 'target', b.note = 'source'", + 'message' => 'UPDATE statement modifying multiple tables is not supported', + ), array( 'sql' => "UPDATE t1 a JOIN t2 b USING (id) SET a.note = 'target', b.note = 'source'", 'message' => 'UPDATE statement modifying multiple tables is not supported', @@ -2369,6 +2436,14 @@ public function test_joined_update_rejects_unsupported_shapes(): void { 'sql' => "UPDATE t1 a CROSS JOIN t2 b USING (id) SET a.note = 'target'", 'message' => 'CROSS JOIN ... USING is not supported', ), + array( + 'sql' => "UPDATE t1 a STRAIGHT_JOIN t2 b SET a.note = 'target'", + 'message' => 'Expected ON in joined UPDATE statement', + ), + array( + 'sql' => "UPDATE t1 a STRAIGHT_JOIN t2 b USING (id) SET a.note = 'target'", + 'message' => 'STRAIGHT_JOIN ... USING is not supported', + ), array( 'sql' => "UPDATE t1 a JOIN t2 b USING () SET a.note = 'target'", 'message' => 'could not parse MySQL statement', From 51b67c4464d3f57b5dcf12a5cb5a84da5ee35636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:00:45 +0000 Subject: [PATCH 100/317] Add DuckDB CHECK NOT ENFORCED metadata support --- .../src/duckdb/class-wp-duckdb-driver.php | 119 +++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 50 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 302 ++++++++++++++++-- 3 files changed, 415 insertions(+), 56 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 5a2c8fff3..8681e05d3 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1916,7 +1916,9 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme if ( $this->is_create_table_check_constraint( $item ) ) { $check_constraint = $this->translate_table_check_constraint( $table_name, $item, $check_names ); - $constraints[] = $check_constraint['sql']; + if ( null !== $check_constraint['sql'] ) { + $constraints[] = $check_constraint['sql']; + } $check_constraints[] = $check_constraint['metadata']; continue; } @@ -5915,10 +5917,12 @@ private function validate_alter_table_add_constraint_action( string $table_name, foreach ( $items as $item ) { if ( $this->is_create_table_check_constraint( $item ) ) { - $this->assert_no_active_transaction_for_table_rebuild( 'ADD/DROP CHECK' ); - $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD/DROP CHECK', $temporary ); $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); - $this->translate_table_check_constraint( $table_name, $item, $check_names ); + $check = $this->translate_table_check_constraint( $table_name, $item, $check_names ); + if ( $this->is_check_constraint_enforced( $check['metadata'] ) ) { + $this->assert_no_active_transaction_for_table_rebuild( 'ADD/DROP CHECK' ); + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD/DROP CHECK', $temporary ); + } continue; } @@ -6068,8 +6072,10 @@ private function validate_alter_table_drop_constraint_action( string $table_name if ( null === $check ) { return; } - $this->assert_no_active_transaction_for_table_rebuild( 'ADD/DROP CHECK' ); - $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD/DROP CHECK', $temporary ); + if ( $this->is_check_constraint_enforced( $check ) ) { + $this->assert_no_active_transaction_for_table_rebuild( 'ADD/DROP CHECK' ); + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD/DROP CHECK', $temporary ); + } $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); unset( $check_names[ strtolower( (string) $check['constraint_name'] ) ] ); return; @@ -6079,8 +6085,11 @@ private function validate_alter_table_drop_constraint_action( string $table_name $constraint_name = $this->parse_alter_table_drop_check_constraint_name( $tokens, WP_MySQL_Lexer::CONSTRAINT_SYMBOL ); $constraint_type = $this->resolve_alter_table_drop_constraint_type( $table_name, $constraint_name, $temporary ); if ( 'CHECK' === $constraint_type ) { - $this->assert_no_active_transaction_for_table_rebuild( 'ADD/DROP CHECK' ); - $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD/DROP CHECK', $temporary ); + $check = $this->resolve_check_constraint_metadata_row( $table_name, $constraint_name, $temporary ); + if ( null !== $check && $this->is_check_constraint_enforced( $check ) ) { + $this->assert_no_active_transaction_for_table_rebuild( 'ADD/DROP CHECK' ); + $this->assert_not_referenced_parent_for_table_rebuild( $table_name, 'ADD/DROP CHECK', $temporary ); + } $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); unset( $check_names[ strtolower( $constraint_name ) ] ); return; @@ -6276,6 +6285,16 @@ private function execute_alter_table_add_check_constraint( string $table_name, a $metadata = $this->check_constraint_metadata_rows( $table_name, $temporary ); $metadata[] = $check['metadata']; + if ( ! $this->is_check_constraint_enforced( $check['metadata'] ) ) { + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $metadata, $temporary ): WP_DuckDB_Result_Statement { + $this->record_check_metadata( $table_name, $metadata, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + return $this->execute_schema_lifecycle_change( function () use ( $table_name, $metadata, $temporary ): WP_DuckDB_Result_Statement { $this->rebuild_table_with_check_constraints( $table_name, $metadata, $temporary ); @@ -6725,6 +6744,16 @@ function ( array $constraint ) use ( $check ): bool { ) ); + if ( ! $this->is_check_constraint_enforced( $check ) ) { + return $this->execute_schema_lifecycle_change( + function () use ( $table_name, $metadata, $temporary ): WP_DuckDB_Result_Statement { + $this->record_check_metadata( $table_name, $metadata, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + return $this->execute_schema_lifecycle_change( function () use ( $table_name, $metadata, $temporary ): WP_DuckDB_Result_Statement { $this->rebuild_table_with_check_constraints( $table_name, $metadata, $temporary ); @@ -9348,6 +9377,9 @@ private function format_show_create_table_check_constraint( array $check_constra $sql = ' CONSTRAINT '; $sql .= $this->quote_mysql_identifier( (string) $check_constraint['constraint_name'] ); $sql .= ' CHECK (' . (string) $check_constraint['check_clause'] . ')'; + if ( ! $this->is_check_constraint_enforced( $check_constraint ) ) { + $sql .= ' /*!80016 NOT ENFORCED */'; + } return $sql; } @@ -10349,7 +10381,9 @@ private function translate_create_table_column( string $table_name, array $token throw new WP_DuckDB_Driver_Exception( 'Unsupported inline CHECK constraint in DuckDB driver. Inline CHECK constraints are only supported in CREATE TABLE.' ); } $check = $this->translate_inline_check_constraint( $table_name, $tokens, $index, $check_names ); - $constraints[] = $check['sql']; + if ( null !== $check['sql'] ) { + $constraints[] = $check['sql']; + } $checks[] = $check['metadata']; break; case WP_MySQL_Lexer::REFERENCES_SYMBOL: @@ -10451,7 +10485,7 @@ private function translate_create_table_column( string $table_name, array $token * @param WP_Parser_Token[] $tokens Column definition tokens. * @param int $index Current index, advanced past the CHECK clause. * @param array $check_names Existing MySQL-facing CHECK names, keyed lowercase. - * @return array{sql:string,metadata:array{constraint_name:string,check_clause:string,enforced:string}} + * @return array{sql:string|null,metadata:array{constraint_name:string,check_clause:string,enforced:string}} */ private function translate_inline_check_constraint( string $table_name, array $tokens, int &$index, array &$check_names ): array { $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CHECK_SYMBOL, 'Expected CHECK constraint.' ); @@ -10465,16 +10499,18 @@ private function translate_inline_check_constraint( string $table_name, array $t } $index = $expression_end; + $enforced = 'YES'; if ( isset( $tokens[ $index ] ) ) { if ( WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $index ]->id && isset( $tokens[ $index + 1 ] ) && WP_MySQL_Lexer::ENFORCED_SYMBOL === $tokens[ $index + 1 ]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE CHECK constraint in DuckDB driver: NOT ENFORCED is not supported.' ); + $enforced = 'NO'; + $index += 2; } - if ( WP_MySQL_Lexer::ENFORCED_SYMBOL === $tokens[ $index ]->id ) { + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::ENFORCED_SYMBOL === $tokens[ $index ]->id ) { ++$index; } } @@ -10482,19 +10518,23 @@ private function translate_inline_check_constraint( string $table_name, array $t $constraint_name = $this->generate_check_constraint_name( $table_name, $check_names ); $this->register_check_constraint_name( $constraint_name, $check_names ); - $duckdb_expression = $this->translate_tokens_to_duckdb_sql( $expression_tokens ); - $mysql_expression = $this->mysql_check_clause_from_tokens( $expression_tokens ); - - return array( - 'sql' => 'CONSTRAINT ' + $mysql_expression = $this->mysql_check_clause_from_tokens( $expression_tokens ); + $sql = null; + if ( 'YES' === $enforced ) { + $duckdb_expression = $this->translate_tokens_to_duckdb_sql( $expression_tokens ); + $sql = 'CONSTRAINT ' . $this->connection->quote_identifier( $constraint_name ) . ' CHECK (' . $duckdb_expression - . ')', + . ')'; + } + + return array( + 'sql' => $sql, 'metadata' => array( 'constraint_name' => $constraint_name, 'check_clause' => $mysql_expression, - 'enforced' => 'YES', + 'enforced' => $enforced, ), ); } @@ -10886,7 +10926,7 @@ private function is_create_table_check_constraint( array $tokens ): bool { * @param string $table_name Table name. * @param WP_Parser_Token[] $tokens Constraint tokens. * @param array $check_names Existing MySQL-facing CHECK names, keyed lowercase. - * @return array{sql:string,metadata:array{constraint_name:string,check_clause:string,enforced:string}} + * @return array{sql:string|null,metadata:array{constraint_name:string,check_clause:string,enforced:string}} */ private function translate_table_check_constraint( string $table_name, array $tokens, array &$check_names ): array { $index = 0; @@ -10911,16 +10951,18 @@ private function translate_table_check_constraint( string $table_name, array $to } $index = $expression_end; + $enforced = 'YES'; if ( isset( $tokens[ $index ] ) ) { if ( WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $index ]->id && isset( $tokens[ $index + 1 ] ) && WP_MySQL_Lexer::ENFORCED_SYMBOL === $tokens[ $index + 1 ]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE CHECK constraint in DuckDB driver: NOT ENFORCED is not supported.' ); + $enforced = 'NO'; + $index += 2; } - if ( WP_MySQL_Lexer::ENFORCED_SYMBOL === $tokens[ $index ]->id ) { + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::ENFORCED_SYMBOL === $tokens[ $index ]->id ) { ++$index; } } @@ -10934,19 +10976,23 @@ private function translate_table_check_constraint( string $table_name, array $to } $this->register_check_constraint_name( $constraint_name, $check_names ); - $duckdb_expression = $this->translate_tokens_to_duckdb_sql( $expression_tokens ); - $mysql_expression = $this->mysql_check_clause_from_tokens( $expression_tokens ); - - return array( - 'sql' => 'CONSTRAINT ' + $mysql_expression = $this->mysql_check_clause_from_tokens( $expression_tokens ); + $sql = null; + if ( 'YES' === $enforced ) { + $duckdb_expression = $this->translate_tokens_to_duckdb_sql( $expression_tokens ); + $sql = 'CONSTRAINT ' . $this->connection->quote_identifier( $constraint_name ) . ' CHECK (' . $duckdb_expression - . ')', + . ')'; + } + + return array( + 'sql' => $sql, 'metadata' => array( 'constraint_name' => $constraint_name, 'check_clause' => $mysql_expression, - 'enforced' => 'YES', + 'enforced' => $enforced, ), ); } @@ -10984,6 +11030,16 @@ private function register_check_constraint_name( string $constraint_name, array $check_names[ $key ] = true; } + /** + * Check whether recorded CHECK metadata should be enforced by DuckDB. + * + * @param array $check_constraint CHECK metadata row. + * @return bool Whether the CHECK constraint is enforced. + */ + private function is_check_constraint_enforced( array $check_constraint ): bool { + return 'NO' !== strtoupper( (string) ( $check_constraint['enforced'] ?? 'YES' ) ); + } + /** * Format CHECK expression tokens for MySQL-facing metadata. * @@ -18484,7 +18540,7 @@ private function information_schema_table_constraints_row( array $constraint ): 'TABLE_SCHEMA' => $this->database, 'TABLE_NAME' => $constraint['table_name'], 'CONSTRAINT_TYPE' => $constraint['constraint_type'], - 'ENFORCED' => 'YES', + 'ENFORCED' => $constraint['enforced'] ?? 'YES', ); } @@ -18898,7 +18954,7 @@ private function information_schema_foreign_key_constraint_rows(): array { /** * Build normalized CHECK constraint rows. * - * @return array + * @return array */ private function information_schema_check_constraint_rows(): array { $rows = array(); @@ -18909,6 +18965,7 @@ private function information_schema_check_constraint_rows(): array { 'table_name' => $table_name, 'constraint_name' => (string) $check_constraint['constraint_name'], 'constraint_type' => 'CHECK', + 'enforced' => (string) $check_constraint['enforced'], ); } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 4e5d2d2bf..baa1788ae 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -3637,6 +3637,56 @@ public function test_inline_check_constraints_match_sqlite(): void { $this->assertParityRows( 'SHOW CREATE TABLE inline_check_metadata' ); } + public function test_check_not_enforced_constraints_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE check_not_enforced ( + id INT CHECK (id > 0) NOT ENFORCED, + amount INT, + CONSTRAINT amount_limit CHECK (amount < 5) NOT ENFORCED, + CONSTRAINT amount_positive CHECK (amount > 0) + )', + 'INSERT INTO check_not_enforced (id, amount) VALUES (0, 10)', + 'ALTER TABLE check_not_enforced ADD CONSTRAINT added_less_than_five CHECK (amount < 5) NOT ENFORCED', + 'ALTER TABLE check_not_enforced ADD CHECK (id > 10) NOT ENFORCED', + ) + ); + + $this->assertParityRowCount( 'INSERT INTO check_not_enforced (id, amount) VALUES (0, 10)' ); + $this->assertParityErrorContains( + 'INSERT INTO check_not_enforced (id, amount) VALUES (1, -1)', + 'CHECK constraint failed' + ); + $this->assertParityRows( 'SELECT id, amount FROM check_not_enforced ORDER BY id, amount' ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'check_not_enforced' + ORDER BY constraint_name" + ); + $this->assertParityRows( + "SELECT tc.CONSTRAINT_NAME, cc.CHECK_CLAUSE + FROM information_schema.table_constraints AS tc + JOIN information_schema.check_constraints AS cc + ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + WHERE tc.table_schema = 'wp' AND tc.table_name = 'check_not_enforced' + ORDER BY tc.constraint_name" + ); + $this->assertParityRows( 'SHOW CREATE TABLE check_not_enforced' ); + + $this->assertParityRowCount( 'ALTER TABLE check_not_enforced DROP CHECK added_less_than_five' ); + $this->assertParityRowCount( 'ALTER TABLE check_not_enforced DROP CONSTRAINT check_not_enforced_chk_2' ); + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'check_not_enforced' + ORDER BY constraint_name" + ); + $this->assertParityRows( 'SHOW CREATE TABLE check_not_enforced' ); + $this->assertParityRowCount( 'INSERT INTO check_not_enforced (id, amount) VALUES (0, 10)' ); + } + public function test_simple_table_level_foreign_keys_match_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index e3a721fda..34cdcd22a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -6452,6 +6452,154 @@ public function test_create_table_inline_check_constraints_use_native_enforcemen ); } + public function test_create_table_check_not_enforced_uses_metadata_without_native_enforcement(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( + 'CREATE TABLE checks_not_enforced ( + id INT CHECK (id > 0) NOT ENFORCED, + amount INT, + CONSTRAINT amount_limit CHECK (amount < 5) NOT ENFORCED, + CONSTRAINT amount_positive CHECK (amount > 0) + )' + ); + + $native_create_queries = array_values( + array_filter( + $driver->get_last_duckdb_queries(), + function ( string $sql ): bool { + return 0 === strpos( $sql, 'CREATE TABLE "checks_not_enforced" ' ); + } + ) + ); + $this->assertCount( 1, $native_create_queries ); + $this->assertStringNotContainsString( 'amount_limit', $native_create_queries[0] ); + $this->assertStringNotContainsString( 'checks_not_enforced_chk_1', $native_create_queries[0] ); + $this->assertStringContainsString( 'amount_positive', $native_create_queries[0] ); + + $this->assertSame( 1, $driver->query( 'INSERT INTO checks_not_enforced (id, amount) VALUES (0, 10)' )->rowCount() ); + try { + $driver->query( 'INSERT INTO checks_not_enforced (id, amount) VALUES (1, -1)' ); + $this->fail( 'Expected enforced CHECK constraint to reject a negative amount.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'amount_limit', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'NO', + ), + array( + 'CONSTRAINT_NAME' => 'amount_positive', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'YES', + ), + array( + 'CONSTRAINT_NAME' => 'checks_not_enforced_chk_1', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'NO', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'checks_not_enforced' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'amount_limit', + 'CHECK_CLAUSE' => 'amount < 5', + ), + array( + 'CONSTRAINT_NAME' => 'amount_positive', + 'CHECK_CLAUSE' => 'amount > 0', + ), + array( + 'CONSTRAINT_NAME' => 'checks_not_enforced_chk_1', + 'CHECK_CLAUSE' => 'id > 0', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create = $driver->query( 'SHOW CREATE TABLE checks_not_enforced' )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `checks_not_enforced` (', + ' `id` int DEFAULT NULL,', + ' `amount` int DEFAULT NULL,', + ' CONSTRAINT `amount_limit` CHECK (amount < 5) /*!80016 NOT ENFORCED */,', + ' CONSTRAINT `amount_positive` CHECK (amount > 0),', + ' CONSTRAINT `checks_not_enforced_chk_1` CHECK (id > 0) /*!80016 NOT ENFORCED */', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $create['Create Table'] + ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE checks_not_enforced DROP CHECK amount_positive' )->rowCount() ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'amount_limit', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'NO', + ), + array( + 'CONSTRAINT_NAME' => 'checks_not_enforced_chk_1', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'NO', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'checks_not_enforced' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $create = $driver->query( 'SHOW CREATE TABLE checks_not_enforced' )->fetch( PDO::FETCH_ASSOC ); + $this->assertStringNotContainsString( 'amount_positive', $create['Create Table'] ); + $this->assertStringContainsString( 'CONSTRAINT `amount_limit` CHECK (amount < 5) /*!80016 NOT ENFORCED */', $create['Create Table'] ); + $this->assertSame( 1, $driver->query( 'INSERT INTO checks_not_enforced (id, amount) VALUES (0, -1)' )->rowCount() ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE checks_not_enforced DROP CHECK amount_limit' )->rowCount() ); + $this->assertSame( 0, $driver->query( 'ALTER TABLE checks_not_enforced DROP CONSTRAINT checks_not_enforced_chk_1' )->rowCount() ); + $this->assertSame( + array(), + $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'checks_not_enforced' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $create = $driver->query( 'SHOW CREATE TABLE checks_not_enforced' )->fetch( PDO::FETCH_ASSOC ); + $this->assertStringNotContainsString( 'NOT ENFORCED', $create['Create Table'] ); + $this->assertStringNotContainsString( 'CONSTRAINT', $create['Create Table'] ); + $this->assertSame( 1, $driver->query( 'INSERT INTO checks_not_enforced (id, amount) VALUES (0, -1)' )->rowCount() ); + } + public function test_create_table_foreign_keys_use_native_enforcement_and_mysql_metadata(): void { $this->requireDuckDBRuntime(); @@ -9482,6 +9630,135 @@ public function test_alter_table_add_check_constraint_rebuilds_table_metadata_an } } + public function test_alter_table_add_check_not_enforced_records_metadata_without_rebuild(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE alter_check_not_enforced ( + id INT, + amount INT, + CONSTRAINT amount_positive CHECK (amount > 0), + KEY amount_idx (amount) + )' + ); + $driver->query( 'INSERT INTO alter_check_not_enforced (id, amount) VALUES (1, 20)' ); + + $this->assertSame( + 0, + $driver->query( 'ALTER TABLE alter_check_not_enforced ADD CONSTRAINT amount_less_than_five CHECK (amount < 5) NOT ENFORCED' )->rowCount() + ); + $this->assertSame( + array(), + array_values( + array_filter( + $driver->get_last_duckdb_queries(), + function ( string $sql ): bool { + return false !== strpos( $sql, '__wp_duckdb_rebuild_' ); + } + ) + ) + ); + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_check_not_enforced ADD CHECK (id > 10) NOT ENFORCED' )->rowCount() ); + + $this->assertSame( 1, $driver->query( 'INSERT INTO alter_check_not_enforced (id, amount) VALUES (2, 20)' )->rowCount() ); + try { + $driver->query( 'INSERT INTO alter_check_not_enforced (id, amount) VALUES (3, -1)' ); + $this->fail( 'Expected enforced CHECK constraint to remain enforced after metadata-only CHECK additions.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'CHECK constraint failed', $e->getMessage() ); + } + + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'alter_check_not_enforced_chk_1', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'NO', + ), + array( + 'CONSTRAINT_NAME' => 'amount_less_than_five', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'NO', + ), + array( + 'CONSTRAINT_NAME' => 'amount_positive', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'YES', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'alter_check_not_enforced' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'alter_check_not_enforced_chk_1', + 'CHECK_CLAUSE' => 'id > 10', + ), + array( + 'CONSTRAINT_NAME' => 'amount_less_than_five', + 'CHECK_CLAUSE' => 'amount < 5', + ), + array( + 'CONSTRAINT_NAME' => 'amount_positive', + 'CHECK_CLAUSE' => 'amount > 0', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_schema = 'wp' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create_rows = $driver->query( 'SHOW CREATE TABLE alter_check_not_enforced' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertStringContainsString( + 'CONSTRAINT `amount_less_than_five` CHECK (amount < 5) /*!80016 NOT ENFORCED */', + $create_rows[0]['Create Table'] + ); + $this->assertStringContainsString( + 'CONSTRAINT `alter_check_not_enforced_chk_1` CHECK (id > 10) /*!80016 NOT ENFORCED */', + $create_rows[0]['Create Table'] + ); + $this->assertStringContainsString( 'CONSTRAINT `amount_positive` CHECK (amount > 0)', $create_rows[0]['Create Table'] ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE alter_check_not_enforced DROP CHECK amount_less_than_five' )->rowCount() ); + $this->assertSame( + 0, + $driver->query( 'ALTER TABLE alter_check_not_enforced DROP CONSTRAINT alter_check_not_enforced_chk_1' )->rowCount() + ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'amount_positive', + 'CONSTRAINT_TYPE' => 'CHECK', + 'ENFORCED' => 'YES', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'alter_check_not_enforced' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $create_rows = $driver->query( 'SHOW CREATE TABLE alter_check_not_enforced' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertStringNotContainsString( 'NOT ENFORCED', $create_rows[0]['Create Table'] ); + $this->assertStringContainsString( 'KEY `amount_idx` (`amount`)', $create_rows[0]['Create Table'] ); + $this->assertSame( 1, $driver->query( 'INSERT INTO alter_check_not_enforced (id, amount) VALUES (4, 20)' )->rowCount() ); + } + public function test_alter_table_add_check_constraint_violation_rolls_back_schema_data_and_indexes(): void { $this->requireDuckDBRuntime(); @@ -11122,31 +11399,6 @@ public function test_unsupported_alter_table_constraint_actions_in_multi_action_ } } - public function test_unsupported_create_table_check_constraint_not_enforced_throws_driver_exception(): void { - $this->requireDuckDBRuntime(); - - $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); - - foreach ( - array( - 'CREATE TABLE checks_named (id INT, CONSTRAINT positive CHECK (id > 0) NOT ENFORCED)', - 'CREATE TABLE checks_inline (id INT CHECK (id > 0) NOT ENFORCED)', - ) as $sql - ) { - try { - $driver->query( $sql ); - $this->fail( 'Expected unsupported CHECK NOT ENFORCED to reject SQL: ' . $sql ); - } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( - 'Unsupported CREATE TABLE CHECK constraint in DuckDB driver: NOT ENFORCED is not supported.', - $e->getMessage() - ); - } - } - - $this->assertSame( array(), $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) ); - } - public function test_unsupported_create_table_foreign_key_actions_throw_before_mutation(): void { $this->requireDuckDBRuntime(); From d5e2bde278c58970ca425b858e5475b310fe42d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:01:07 +0000 Subject: [PATCH 101/317] Fix DuckDB CHECK NOT ENFORCED formatting --- .../src/duckdb/class-wp-duckdb-driver.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 8681e05d3..8c1dab4d5 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1915,7 +1915,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme } if ( $this->is_create_table_check_constraint( $item ) ) { - $check_constraint = $this->translate_table_check_constraint( $table_name, $item, $check_names ); + $check_constraint = $this->translate_table_check_constraint( $table_name, $item, $check_names ); if ( null !== $check_constraint['sql'] ) { $constraints[] = $check_constraint['sql']; } @@ -10380,11 +10380,11 @@ private function translate_create_table_column( string $table_name, array $token if ( ! $include_inline_constraints ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported inline CHECK constraint in DuckDB driver. Inline CHECK constraints are only supported in CREATE TABLE.' ); } - $check = $this->translate_inline_check_constraint( $table_name, $tokens, $index, $check_names ); + $check = $this->translate_inline_check_constraint( $table_name, $tokens, $index, $check_names ); if ( null !== $check['sql'] ) { $constraints[] = $check['sql']; } - $checks[] = $check['metadata']; + $checks[] = $check['metadata']; break; case WP_MySQL_Lexer::REFERENCES_SYMBOL: if ( ! $include_inline_constraints ) { @@ -10498,7 +10498,7 @@ private function translate_inline_check_constraint( string $table_name, array $t throw new WP_DuckDB_Driver_Exception( 'CHECK constraint requires an expression in the DuckDB driver.' ); } - $index = $expression_end; + $index = $expression_end; $enforced = 'YES'; if ( isset( $tokens[ $index ] ) ) { if ( @@ -10950,7 +10950,7 @@ private function translate_table_check_constraint( string $table_name, array $to throw new WP_DuckDB_Driver_Exception( 'CHECK constraint requires an expression in the DuckDB driver.' ); } - $index = $expression_end; + $index = $expression_end; $enforced = 'YES'; if ( isset( $tokens[ $index ] ) ) { if ( From a086b3f5adf5e1043cd4b74c8162637b1807d52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:03:11 +0000 Subject: [PATCH 102/317] Add DuckDB inline ALTER guard snapshots --- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 212 ++++++++++++++++-- 1 file changed, 189 insertions(+), 23 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 34cdcd22a..d218c31f5 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -9254,23 +9254,50 @@ public function test_alter_table_change_modify_rejects_protected_definitions(): 'database' => 'wp', ) ); - $driver->query( 'CREATE TABLE change_pk (id INT NOT NULL, note VARCHAR(20), PRIMARY KEY (id))' ); - $driver->query( 'CREATE TABLE change_auto (id BIGINT NOT NULL AUTO_INCREMENT, note VARCHAR(20))' ); - $driver->query( 'CREATE TABLE change_inline_unique (name VARCHAR(20))' ); + $driver->query( 'CREATE TABLE change_pk (id INT NOT NULL, note VARCHAR(20), UNIQUE KEY note_unique (note), PRIMARY KEY (id))' ); + $driver->query( "INSERT INTO change_pk (id, note) VALUES (1, 'one'), (2, 'two')" ); + $driver->query( 'CREATE TABLE change_auto (id BIGINT NOT NULL AUTO_INCREMENT, note VARCHAR(20), UNIQUE KEY note_unique (note))' ); + $driver->query( "INSERT INTO change_auto (note) VALUES ('one'), ('two')" ); + $driver->query( 'CREATE TABLE change_inline_auto (id BIGINT NOT NULL, note VARCHAR(20), UNIQUE KEY note_unique (note))' ); + $driver->query( "INSERT INTO change_inline_auto (id, note) VALUES (1, 'one'), (2, 'two')" ); + $driver->query( 'CREATE TABLE change_inline_primary (id INT NOT NULL, note VARCHAR(20), KEY note_idx (note))' ); + $driver->query( "INSERT INTO change_inline_primary (id, note) VALUES (1, 'one'), (2, 'two')" ); + $driver->query( 'CREATE TABLE change_inline_unique (id INT NOT NULL, name VARCHAR(20), PRIMARY KEY (id))' ); + $driver->query( "INSERT INTO change_inline_unique (id, name) VALUES (1, 'one'), (2, 'two')" ); foreach ( array( - 'ALTER TABLE change_pk CHANGE id item_id INT NOT NULL' => 'primary key column requires a table rebuild', - 'ALTER TABLE change_auto MODIFY id BIGINT NOT NULL' => 'AUTO_INCREMENT column requires a table rebuild', - 'ALTER TABLE change_inline_unique MODIFY name VARCHAR(20) UNIQUE' => 'inline UNIQUE is not supported', - ) as $sql => $message + 'change_pk' => array( + 'sql' => 'ALTER TABLE change_pk CHANGE id item_id INT NOT NULL', + 'message' => 'primary key column requires a table rebuild', + ), + 'change_auto' => array( + 'sql' => 'ALTER TABLE change_auto MODIFY id BIGINT NOT NULL', + 'message' => 'AUTO_INCREMENT column requires a table rebuild', + ), + 'change_inline_auto' => array( + 'sql' => 'ALTER TABLE change_inline_auto MODIFY id BIGINT NOT NULL AUTO_INCREMENT', + 'message' => 'CHANGE/MODIFY AUTO_INCREMENT requires a table rebuild', + ), + 'change_inline_primary' => array( + 'sql' => 'ALTER TABLE change_inline_primary CHANGE id item_id INT NOT NULL PRIMARY KEY', + 'message' => 'CHANGE/MODIFY inline PRIMARY KEY is not supported', + ), + 'change_inline_unique' => array( + 'sql' => 'ALTER TABLE change_inline_unique MODIFY name VARCHAR(20) UNIQUE', + 'message' => 'CHANGE/MODIFY inline UNIQUE is not supported', + ), + ) as $table_name => $case ) { - try { - $driver->query( $sql ); - $this->fail( 'Expected CHANGE/MODIFY protection to reject SQL: ' . $sql ); - } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( $message, $e->getMessage() ); - } + $before = $this->alter_table_inline_constraint_guard_snapshot( $driver, $table_name ); + + $this->assertDriverQueryRejected( $driver, $case['sql'], $case['message'] ); + $this->assertSame( + $before, + $this->alter_table_inline_constraint_guard_snapshot( $driver, $table_name ), + 'CHANGE/MODIFY rejection mutated state for SQL: ' . $case['sql'] + ); + $this->assert_duckdb_connection_usable( $driver ); } } @@ -9513,12 +9540,109 @@ public function test_show_create_table_denies_information_schema_tables(): void public function test_unsupported_alter_table_add_column_constraints_throw_driver_exception(): void { $this->requireDuckDBRuntime(); - $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); - $driver->query( 'CREATE TABLE users (name VARCHAR(100))' ); + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE add_column_primary_guard ( + name VARCHAR(100) NOT NULL, + slug VARCHAR(100), + UNIQUE KEY name_unique (name), + KEY slug_idx (slug) + )' + ); + $driver->query( "INSERT INTO add_column_primary_guard (name, slug) VALUES ('Ada', 'ada'), ('Grace', 'grace')" ); - $this->expectException( WP_DuckDB_Driver_Exception::class ); - $this->expectExceptionMessage( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN PRIMARY KEY is not supported.' ); - $driver->query( 'ALTER TABLE users ADD COLUMN id INT PRIMARY KEY' ); + $before = $this->alter_table_inline_constraint_guard_snapshot( $driver, 'add_column_primary_guard' ); + $this->assertDriverQueryRejected( + $driver, + 'ALTER TABLE add_column_primary_guard ADD COLUMN id INT PRIMARY KEY', + 'ADD COLUMN PRIMARY KEY is not supported' + ); + + $this->assertSame( $before, $this->alter_table_inline_constraint_guard_snapshot( $driver, 'add_column_primary_guard' ) ); + $this->assert_duckdb_connection_usable( $driver ); + } + + public function test_alter_table_add_column_inline_unique_updates_metadata_and_enforces_uniqueness(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE add_column_unique_inline (id INT NOT NULL, name VARCHAR(20), PRIMARY KEY (id))' ); + $driver->query( "INSERT INTO add_column_unique_inline (id, name) VALUES (1, 'one'), (2, 'two')" ); + + $result = $driver->query( 'ALTER TABLE add_column_unique_inline ADD COLUMN slug VARCHAR(20) UNIQUE' ); + + $this->assertSame( 0, $result->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'one', + 'slug' => null, + ), + array( + 'id' => 2, + 'name' => 'two', + 'slug' => null, + ), + ), + $driver->query( 'SELECT id, name, slug FROM add_column_unique_inline ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + 'id' => 'PRI', + 'name' => '', + 'slug' => 'UNI', + ), + array_column( $driver->query( 'SHOW COLUMNS FROM add_column_unique_inline' )->fetchAll( PDO::FETCH_ASSOC ), 'Key', 'Field' ) + ); + + $snapshot = $this->alter_table_inline_constraint_guard_snapshot( $driver, 'add_column_unique_inline' ); + $this->assertContains( 'slug', array_column( $snapshot['indexes'], 'Key_name' ) ); + $this->assertStringContainsString( 'UNIQUE KEY `slug` (`slug`)', $snapshot['show_create'][0]['Create Table'] ); + $this->assertContains( + array( + 'CONSTRAINT_NAME' => 'slug', + 'CONSTRAINT_TYPE' => 'UNIQUE', + 'ENFORCED' => 'YES', + ), + $snapshot['table_constraints'] + ); + $this->assertContains( + array( + 'INDEX_NAME' => 'slug', + 'NON_UNIQUE' => 0, + 'SEQ_IN_INDEX' => 1, + 'COLUMN_NAME' => 'slug', + ), + $snapshot['statistics'] + ); + $this->assertContains( + array( + 'CONSTRAINT_NAME' => 'slug', + 'COLUMN_NAME' => 'slug', + 'ORDINAL_POSITION' => 1, + ), + $snapshot['key_column_usage'] + ); + + $driver->query( "UPDATE add_column_unique_inline SET slug = 'one' WHERE id = 1" ); + $driver->query( "UPDATE add_column_unique_inline SET slug = 'two' WHERE id = 2" ); + $this->assertDriverQueryRejected( + $driver, + "INSERT INTO add_column_unique_inline (id, name, slug) VALUES (3, 'duplicate', 'one')", + 'Failed to execute DuckDB INSERT' + ); + $this->assert_duckdb_connection_usable( $driver ); } public function test_alter_table_add_check_constraint_rebuilds_table_metadata_and_indexes(): void { @@ -11481,12 +11605,31 @@ public function test_unsupported_inline_references_shapes_throw_before_mutation( public function test_unsupported_alter_table_add_auto_increment_throws_driver_exception(): void { $this->requireDuckDBRuntime(); - $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); - $driver->query( 'CREATE TABLE users (name VARCHAR(100))' ); + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE add_column_auto_guard ( + name VARCHAR(100) NOT NULL, + slug VARCHAR(100), + UNIQUE KEY name_unique (name), + KEY slug_idx (slug) + )' + ); + $driver->query( "INSERT INTO add_column_auto_guard (name, slug) VALUES ('Ada', 'ada'), ('Grace', 'grace')" ); - $this->expectException( WP_DuckDB_Driver_Exception::class ); - $this->expectExceptionMessage( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN AUTO_INCREMENT is not supported.' ); - $driver->query( 'ALTER TABLE users ADD COLUMN id BIGINT AUTO_INCREMENT' ); + $before = $this->alter_table_inline_constraint_guard_snapshot( $driver, 'add_column_auto_guard' ); + $this->assertDriverQueryRejected( + $driver, + 'ALTER TABLE add_column_auto_guard ADD COLUMN id BIGINT AUTO_INCREMENT', + 'ADD COLUMN AUTO_INCREMENT is not supported' + ); + + $this->assertSame( $before, $this->alter_table_inline_constraint_guard_snapshot( $driver, 'add_column_auto_guard' ) ); + $this->assert_duckdb_connection_usable( $driver ); } public function test_unsupported_alter_table_add_not_null_without_default_on_non_empty_table_throws_driver_exception(): void { @@ -12078,6 +12221,18 @@ private function alter_table_auto_increment_snapshot( WP_DuckDB_Driver $driver, ); } + private function alter_table_inline_constraint_guard_snapshot( WP_DuckDB_Driver $driver, string $table_name ): array { + $snapshot = $this->alter_table_unique_constraint_snapshot( $driver, $table_name ); + $snapshot['auto_increment'] = $driver->query( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = '{$table_name}'" + )->fetchAll( PDO::FETCH_ASSOC ); + $snapshot['status'] = $driver->query( "SHOW TABLE STATUS LIKE '{$table_name}'" )->fetchAll( PDO::FETCH_ASSOC ); + + return $snapshot; + } + private function alter_table_foreign_key_lifecycle_snapshot( WP_DuckDB_Driver $driver, string $table_name ): array { return array( 'columns' => $driver->query( 'SHOW COLUMNS FROM ' . $table_name )->fetchAll( PDO::FETCH_ASSOC ), @@ -12238,6 +12393,17 @@ private function assertDriverQueryRejected( WP_DuckDB_Driver $driver, string $sq } } + private function assert_duckdb_connection_usable( WP_DuckDB_Driver $driver ): void { + $this->assertSame( + array( + array( + 'still_usable' => 1, + ), + ), + $driver->query( 'SELECT 1 AS still_usable' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + private function lastDuckDBQuery( WP_DuckDB_Driver $driver ): string { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid $queries = $driver->get_last_duckdb_queries(); return $queries[ count( $queries ) - 1 ]; From 592c9dd666250d5ff64a2638b42119d15da330fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:04:42 +0000 Subject: [PATCH 103/317] Add DuckDB national character aliases --- .../src/duckdb/class-wp-duckdb-driver.php | 163 +++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 48 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 354 ++++++++++++++++++ 3 files changed, 553 insertions(+), 12 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 8c1dab4d5..61d1389fc 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -10278,15 +10278,11 @@ private function translate_create_table_column( string $table_name, array $token $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; - if ( ! isset( $tokens[ $index ] ) || ! isset( self::DATA_TYPE_MAP[ $tokens[ $index ]->id ] ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported MySQL column type for DuckDB column: ' . $column_name . '.' ); - } - $type_index = $index; - $type_token = $tokens[ $index ]; - $duck_type = self::DATA_TYPE_MAP[ $type_token->id ]; + $duck_type = $this->duckdb_type_for_mysql_column_type( $tokens, $type_index, $column_name ); + $type_token = $tokens[ $type_index ]; ++$index; - $index = $this->skip_type_modifiers( $tokens, $index ); + $index = $this->skip_type_modifiers( $tokens, $index, $type_index ); $not_null = false; $primary_key = false; @@ -10593,14 +10589,44 @@ private function translate_inline_foreign_key_constraint( string $table_name, st ); } + /** + * Resolve the DuckDB storage type for a MySQL column type. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $type_index Index of the type token. + * @param string $column_name Column name. + * @return string DuckDB storage type. + */ + private function duckdb_type_for_mysql_column_type( array $tokens, int $type_index, string $column_name ): string { + if ( ! isset( $tokens[ $type_index ] ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported MySQL column type for DuckDB column: ' . $column_name . '.' ); + } + + if ( isset( self::DATA_TYPE_MAP[ $tokens[ $type_index ]->id ] ) ) { + return self::DATA_TYPE_MAP[ $tokens[ $type_index ]->id ]; + } + + if ( null !== $this->national_character_column_type( $tokens, $type_index ) ) { + return 'VARCHAR'; + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported MySQL column type for DuckDB column: ' . $column_name . '.' ); + } + /** * Skip MySQL type display widths and modifiers. * - * @param WP_Parser_Token[] $tokens Token stream. - * @param int $index Current index. + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index. + * @param int $type_index Index of the type token. * @return int New index. */ - private function skip_type_modifiers( array $tokens, int $index ): int { + private function skip_type_modifiers( array $tokens, int $index, int $type_index ): int { + $national_type = $this->national_character_column_type( $tokens, $type_index ); + if ( null !== $national_type ) { + $index = $national_type['attributes_index']; + } + while ( $index < count( $tokens ) ) { $token = $tokens[ $index ]; if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { @@ -10635,8 +10661,14 @@ private function skip_type_modifiers( array $tokens, int $index ): int { * @return string MySQL column type. */ private function mysql_column_type_from_tokens( array $tokens, int $type_index ): string { - $pieces = array( $tokens[ $type_index ]->get_bytes() ); - $index = $type_index + 1; + $national_type = $this->national_character_column_type( $tokens, $type_index ); + if ( null !== $national_type ) { + $pieces = array( $national_type['data_type'] ); + $index = $national_type['attributes_index']; + } else { + $pieces = array( $tokens[ $type_index ]->get_bytes() ); + $index = $type_index + 1; + } while ( $index < count( $tokens ) ) { if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { @@ -10715,11 +10747,93 @@ private function canonical_mysql_column_type( string $column_type, WP_Parser_Tok case WP_MySQL_Lexer::VARBINARY_SYMBOL: return $this->mysql_column_type_with_base( $column_type, 'varbinary' ); + + case WP_MySQL_Lexer::NCHAR_SYMBOL: + case WP_MySQL_Lexer::NATIONAL_SYMBOL: + case WP_MySQL_Lexer::NVARCHAR_SYMBOL: + if ( 0 === strpos( $column_type, 'varchar' ) ) { + return $this->mysql_column_type_with_base( $column_type, 'varchar' ); + } + + return $this->mysql_column_type_with_default_attributes( + $this->mysql_column_type_with_base( $column_type, 'char' ), + '(1)' + ); } return $column_type; } + /** + * Resolve national character aliases to their canonical MySQL type family. + * + * @param WP_Parser_Token[] $tokens Column definition tokens. + * @param int $type_index Index of the type token. + * @return array{data_type:string,attributes_index:int}|null National type details. + */ + private function national_character_column_type( array $tokens, int $type_index ): ?array { + if ( ! isset( $tokens[ $type_index ] ) ) { + return null; + } + + switch ( $tokens[ $type_index ]->id ) { + case WP_MySQL_Lexer::NCHAR_SYMBOL: + if ( + isset( $tokens[ $type_index + 1 ] ) + && in_array( $tokens[ $type_index + 1 ]->id, array( WP_MySQL_Lexer::VARCHAR_SYMBOL, WP_MySQL_Lexer::VARYING_SYMBOL ), true ) + ) { + return array( + 'data_type' => 'varchar', + 'attributes_index' => $type_index + 2, + ); + } + + return array( + 'data_type' => 'char', + 'attributes_index' => $type_index + 1, + ); + + case WP_MySQL_Lexer::NVARCHAR_SYMBOL: + return array( + 'data_type' => 'varchar', + 'attributes_index' => $type_index + 1, + ); + + case WP_MySQL_Lexer::NATIONAL_SYMBOL: + if ( ! isset( $tokens[ $type_index + 1 ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::VARCHAR_SYMBOL === $tokens[ $type_index + 1 ]->id ) { + return array( + 'data_type' => 'varchar', + 'attributes_index' => $type_index + 2, + ); + } + + if ( WP_MySQL_Lexer::CHAR_SYMBOL !== $tokens[ $type_index + 1 ]->id ) { + return null; + } + + if ( + isset( $tokens[ $type_index + 2 ] ) + && WP_MySQL_Lexer::VARYING_SYMBOL === $tokens[ $type_index + 2 ]->id + ) { + return array( + 'data_type' => 'varchar', + 'attributes_index' => $type_index + 3, + ); + } + + return array( + 'data_type' => 'char', + 'attributes_index' => $type_index + 2, + ); + } + + return null; + } + /** * Replace the leading type name in a MySQL column type. * @@ -10770,6 +10884,10 @@ private function mysql_column_collation_from_tokens( array $tokens, WP_Parser_To return null; } + if ( $this->mysql_type_uses_national_charset( $type_token ) ) { + return 'utf8_general_ci'; + } + return null !== $default_collation_name && '' !== $default_collation_name ? $default_collation_name : 'utf8mb4_0900_ai_ci'; } @@ -10847,6 +10965,27 @@ private function mysql_type_has_collation( WP_Parser_Token $type_token ): bool { WP_MySQL_Lexer::TINYTEXT_SYMBOL, WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL, WP_MySQL_Lexer::LONGTEXT_SYMBOL, + WP_MySQL_Lexer::NCHAR_SYMBOL, + WP_MySQL_Lexer::NATIONAL_SYMBOL, + WP_MySQL_Lexer::NVARCHAR_SYMBOL, + ), + true + ); + } + + /** + * Check whether a MySQL type uses the national character set by default. + * + * @param WP_Parser_Token $type_token Type token. + * @return bool Whether the type is national-character based. + */ + private function mysql_type_uses_national_charset( WP_Parser_Token $type_token ): bool { + return in_array( + $type_token->id, + array( + WP_MySQL_Lexer::NCHAR_SYMBOL, + WP_MySQL_Lexer::NATIONAL_SYMBOL, + WP_MySQL_Lexer::NVARCHAR_SYMBOL, ), true ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index baa1788ae..06da8bd10 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -3402,6 +3402,54 @@ public function test_alias_storage_type_family_metadata_matches_sqlite(): void { ); } + public function test_national_character_type_family_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE national_character_types ( + id INT PRIMARY KEY, + plain_nchar NCHAR DEFAULT 'a', + nchar_len NCHAR(10) DEFAULT 'bee', + national_plain NATIONAL CHAR DEFAULT 'c', + national_len NATIONAL CHAR (10) DEFAULT 'dee', + nchar_varchar NCHAR VARCHAR(255) DEFAULT 'echo', + nchar_varying NCHAR VARYING(32) DEFAULT 'foxtrot', + nvarchar_col NVARCHAR(20) DEFAULT 'golf', + national_varchar NATIONAL VARCHAR(30) DEFAULT 'hotel', + national_char_varying NATIONAL CHAR VARYING(40) DEFAULT 'india', + national_character_varying NATIONAL CHARACTER VARYING(50) DEFAULT 'juliet' + )", + 'INSERT INTO national_character_types (id) VALUES (1)', + "INSERT INTO national_character_types + (id, plain_nchar, nchar_len, national_plain, national_len, + nchar_varchar, nchar_varying, nvarchar_col, national_varchar, + national_char_varying, national_character_varying) + VALUES + (2, 'aa', 'bb', 'cc', 'dd', 'ee', 'ff', 'gg', 'hh', 'ii', 'jj')", + ) + ); + + $this->assertParityRows( 'SHOW COLUMNS FROM national_character_types' ); + $this->assertParityRows( 'SHOW FULL COLUMNS FROM national_character_types' ); + $this->assertParityRows( 'DESCRIBE national_character_types' ); + $this->assertParityRows( 'SHOW CREATE TABLE national_character_types' ); + $this->assertParityRows( + "SELECT COLUMN_NAME, COLUMN_DEFAULT, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, + CHARACTER_OCTET_LENGTH, CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'national_character_types' + AND column_name <> 'id' + ORDER BY ordinal_position" + ); + $this->assertParityRows( + 'SELECT id, plain_nchar, nchar_len, national_plain, national_len, + nchar_varchar, nchar_varying, nvarchar_col, national_varchar, + national_char_varying, national_character_varying + FROM national_character_types + ORDER BY id' + ); + } + public function test_bit_type_family_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index d218c31f5..e15743184 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -4190,6 +4190,360 @@ public function test_alias_storage_type_family_metadata_and_writes(): void { } } + public function test_national_character_type_family_metadata_defaults_and_writes(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE national_character_types ( + id INT PRIMARY KEY, + plain_nchar NCHAR DEFAULT 'a', + nchar_len NCHAR(10) DEFAULT 'bee', + national_plain NATIONAL CHAR DEFAULT 'c', + national_len NATIONAL CHAR (10) DEFAULT 'dee', + nchar_varchar NCHAR VARCHAR(255) DEFAULT 'echo', + nchar_varying NCHAR VARYING(32) DEFAULT 'foxtrot', + nvarchar_col NVARCHAR(20) DEFAULT 'golf', + national_varchar NATIONAL VARCHAR(30) DEFAULT 'hotel', + national_char_varying NATIONAL CHAR VARYING(40) DEFAULT 'india', + national_character_varying NATIONAL CHARACTER VARYING(50) DEFAULT 'juliet' + )" + ); + $driver->query( 'INSERT INTO national_character_types (id) VALUES (1)' ); + $driver->query( + "INSERT INTO national_character_types + (id, plain_nchar, nchar_len, national_plain, national_len, + nchar_varchar, nchar_varying, nvarchar_col, national_varchar, + national_char_varying, national_character_varying) + VALUES + (2, 'aa', 'bb', 'cc', 'dd', 'ee', 'ff', 'gg', 'hh', 'ii', 'jj')" + ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'plain_nchar' => 'a', + 'nchar_len' => 'bee', + 'national_plain' => 'c', + 'national_len' => 'dee', + 'nchar_varchar' => 'echo', + 'nchar_varying' => 'foxtrot', + 'nvarchar_col' => 'golf', + 'national_varchar' => 'hotel', + 'national_char_varying' => 'india', + 'national_character_varying' => 'juliet', + ), + array( + 'id' => 2, + 'plain_nchar' => 'aa', + 'nchar_len' => 'bb', + 'national_plain' => 'cc', + 'national_len' => 'dd', + 'nchar_varchar' => 'ee', + 'nchar_varying' => 'ff', + 'nvarchar_col' => 'gg', + 'national_varchar' => 'hh', + 'national_char_varying' => 'ii', + 'national_character_varying' => 'jj', + ), + ), + $driver->query( + 'SELECT id, plain_nchar, nchar_len, national_plain, national_len, + nchar_varchar, nchar_varying, nvarchar_col, national_varchar, + national_char_varying, national_character_varying + FROM national_character_types + ORDER BY id' + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $show_columns = array_slice( $driver->query( 'SHOW COLUMNS FROM national_character_types' )->fetchAll( PDO::FETCH_ASSOC ), 1 ); + $this->assertSame( + array( + array( + 'Field' => 'plain_nchar', + 'Type' => 'char(1)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'a', + 'Extra' => '', + ), + array( + 'Field' => 'nchar_len', + 'Type' => 'char(10)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'bee', + 'Extra' => '', + ), + array( + 'Field' => 'national_plain', + 'Type' => 'char(1)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'c', + 'Extra' => '', + ), + array( + 'Field' => 'national_len', + 'Type' => 'char(10)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'dee', + 'Extra' => '', + ), + array( + 'Field' => 'nchar_varchar', + 'Type' => 'varchar(255)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'echo', + 'Extra' => '', + ), + array( + 'Field' => 'nchar_varying', + 'Type' => 'varchar(32)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'foxtrot', + 'Extra' => '', + ), + array( + 'Field' => 'nvarchar_col', + 'Type' => 'varchar(20)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'golf', + 'Extra' => '', + ), + array( + 'Field' => 'national_varchar', + 'Type' => 'varchar(30)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'hotel', + 'Extra' => '', + ), + array( + 'Field' => 'national_char_varying', + 'Type' => 'varchar(40)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'india', + 'Extra' => '', + ), + array( + 'Field' => 'national_character_varying', + 'Type' => 'varchar(50)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'juliet', + 'Extra' => '', + ), + ), + $show_columns + ); + $this->assertSame( + $show_columns, + array_slice( $driver->query( 'DESCRIBE national_character_types' )->fetchAll( PDO::FETCH_ASSOC ), 1 ) + ); + + $this->assertSame( + array_fill( 0, 10, 'utf8_general_ci' ), + array_column( + array_slice( $driver->query( 'SHOW FULL COLUMNS FROM national_character_types' )->fetchAll( PDO::FETCH_ASSOC ), 1 ), + 'Collation' + ) + ); + + $this->assertSame( + array( + array( + 'COLUMN_NAME' => 'plain_nchar', + 'COLUMN_DEFAULT' => 'a', + 'DATA_TYPE' => 'char', + 'CHARACTER_MAXIMUM_LENGTH' => 1, + 'CHARACTER_OCTET_LENGTH' => 3, + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + 'COLUMN_TYPE' => 'char(1)', + ), + array( + 'COLUMN_NAME' => 'nchar_len', + 'COLUMN_DEFAULT' => 'bee', + 'DATA_TYPE' => 'char', + 'CHARACTER_MAXIMUM_LENGTH' => 10, + 'CHARACTER_OCTET_LENGTH' => 30, + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + 'COLUMN_TYPE' => 'char(10)', + ), + array( + 'COLUMN_NAME' => 'national_plain', + 'COLUMN_DEFAULT' => 'c', + 'DATA_TYPE' => 'char', + 'CHARACTER_MAXIMUM_LENGTH' => 1, + 'CHARACTER_OCTET_LENGTH' => 3, + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + 'COLUMN_TYPE' => 'char(1)', + ), + array( + 'COLUMN_NAME' => 'national_len', + 'COLUMN_DEFAULT' => 'dee', + 'DATA_TYPE' => 'char', + 'CHARACTER_MAXIMUM_LENGTH' => 10, + 'CHARACTER_OCTET_LENGTH' => 30, + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + 'COLUMN_TYPE' => 'char(10)', + ), + array( + 'COLUMN_NAME' => 'nchar_varchar', + 'COLUMN_DEFAULT' => 'echo', + 'DATA_TYPE' => 'varchar', + 'CHARACTER_MAXIMUM_LENGTH' => 255, + 'CHARACTER_OCTET_LENGTH' => 765, + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + 'COLUMN_TYPE' => 'varchar(255)', + ), + array( + 'COLUMN_NAME' => 'nchar_varying', + 'COLUMN_DEFAULT' => 'foxtrot', + 'DATA_TYPE' => 'varchar', + 'CHARACTER_MAXIMUM_LENGTH' => 32, + 'CHARACTER_OCTET_LENGTH' => 96, + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + 'COLUMN_TYPE' => 'varchar(32)', + ), + array( + 'COLUMN_NAME' => 'nvarchar_col', + 'COLUMN_DEFAULT' => 'golf', + 'DATA_TYPE' => 'varchar', + 'CHARACTER_MAXIMUM_LENGTH' => 20, + 'CHARACTER_OCTET_LENGTH' => 60, + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + 'COLUMN_TYPE' => 'varchar(20)', + ), + array( + 'COLUMN_NAME' => 'national_varchar', + 'COLUMN_DEFAULT' => 'hotel', + 'DATA_TYPE' => 'varchar', + 'CHARACTER_MAXIMUM_LENGTH' => 30, + 'CHARACTER_OCTET_LENGTH' => 90, + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + 'COLUMN_TYPE' => 'varchar(30)', + ), + array( + 'COLUMN_NAME' => 'national_char_varying', + 'COLUMN_DEFAULT' => 'india', + 'DATA_TYPE' => 'varchar', + 'CHARACTER_MAXIMUM_LENGTH' => 40, + 'CHARACTER_OCTET_LENGTH' => 120, + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + 'COLUMN_TYPE' => 'varchar(40)', + ), + array( + 'COLUMN_NAME' => 'national_character_varying', + 'COLUMN_DEFAULT' => 'juliet', + 'DATA_TYPE' => 'varchar', + 'CHARACTER_MAXIMUM_LENGTH' => 50, + 'CHARACTER_OCTET_LENGTH' => 150, + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + 'COLUMN_TYPE' => 'varchar(50)', + ), + ), + $driver->query( + "SELECT COLUMN_NAME, COLUMN_DEFAULT, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, + CHARACTER_OCTET_LENGTH, CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'national_character_types' + AND column_name <> 'id' + ORDER BY ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $result = $driver->query( + 'SELECT plain_nchar, nchar_len, nchar_varchar, nvarchar_col + FROM national_character_types + WHERE 0 = 1' + ); + $expected_metadata = array( + array( + 'name' => 'plain_nchar', + 'native_type' => 'STRING', + 'len' => 4, + 'precision' => 0, + 'duckdb:decl_type' => 'char(1)', + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 254, + ), + array( + 'name' => 'nchar_len', + 'native_type' => 'STRING', + 'len' => 40, + 'precision' => 0, + 'duckdb:decl_type' => 'char(10)', + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 254, + ), + array( + 'name' => 'nchar_varchar', + 'native_type' => 'VAR_STRING', + 'len' => 1020, + 'precision' => 0, + 'duckdb:decl_type' => 'varchar(255)', + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 253, + ), + array( + 'name' => 'nvarchar_col', + 'native_type' => 'VAR_STRING', + 'len' => 80, + 'precision' => 0, + 'duckdb:decl_type' => 'varchar(20)', + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 253, + ), + ); + foreach ( $expected_metadata as $index => $expected ) { + $metadata = $result->getColumnMeta( $index ); + foreach ( $expected as $key => $value ) { + $this->assertSame( $value, $metadata[ $key ], $expected['name'] . ' metadata key ' . $key ); + } + } + + $create_sql = $driver->query( 'SHOW CREATE TABLE national_character_types' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + foreach ( + array( + "`plain_nchar` char(1) DEFAULT 'a'", + "`nchar_len` char(10) DEFAULT 'bee'", + "`national_plain` char(1) DEFAULT 'c'", + "`national_len` char(10) DEFAULT 'dee'", + "`nchar_varchar` varchar(255) DEFAULT 'echo'", + "`nchar_varying` varchar(32) DEFAULT 'foxtrot'", + "`nvarchar_col` varchar(20) DEFAULT 'golf'", + "`national_varchar` varchar(30) DEFAULT 'hotel'", + "`national_char_varying` varchar(40) DEFAULT 'india'", + "`national_character_varying` varchar(50) DEFAULT 'juliet'", + ) as $expected_fragment + ) { + $this->assertStringContainsString( $expected_fragment, $create_sql ); + } + } + public function test_bit_type_family_metadata_defaults_and_writes(): void { $this->requireDuckDBRuntime(); From 0ed613027158e5f2c4ac422241136fe37b362796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:07:16 +0000 Subject: [PATCH 104/317] Document DuckDB seeded RAND context limits --- .../WP_DuckDB_Differential_TestCase.php | 34 +++++++++++++++++++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 23 +++++++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 27 +++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php index 80a8bce6d..13bb39a28 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php @@ -95,6 +95,40 @@ protected function assertParityErrorContains( string $sql, string $needle ): voi $this->assertStringContainsString( $needle, $duckdb_message, 'DuckDB error mismatch for SQL: ' . $sql ); } + /** + * Assert SQLite accepts a statement while DuckDB explicitly rejects it. + * + * @param string $sql SQL query. + * @param string $needle Expected DuckDB message fragment. + */ + protected function assertDuckDBRejectsWhileSqliteAccepts( string $sql, string $needle ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $sqlite_message = $this->query_sqlite_error_message( $sql ); + $duckdb_message = $this->query_duckdb_error_message( $sql ); + + $this->assertNull( $sqlite_message, 'SQLite query should have succeeded for SQL: ' . $sql ); + $this->assertIsString( $duckdb_message, 'DuckDB query should have failed for SQL: ' . $sql ); + $this->assertStringContainsString( $needle, $duckdb_message, 'DuckDB error mismatch for SQL: ' . $sql ); + } + + /** + * Assert a rejected DuckDB statement leaves observable rows unchanged. + * + * @param string $sql SQL query. + * @param string $needle Expected DuckDB message fragment. + * @param string $state_sql Query used to observe DuckDB state before and after. + */ + protected function assertDuckDBRejectsWithoutMutatingRows( string $sql, string $needle, string $state_sql ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $before = $this->normalize_rows( $this->query_duckdb_rows( $state_sql ) ); + + $this->assertDuckDBRejectsWhileSqliteAccepts( $sql, $needle ); + + $this->assertSame( + $before, + $this->normalize_rows( $this->query_duckdb_rows( $state_sql ) ), + 'DuckDB rows changed after rejected SQL: ' . $sql + ); + } + /** * Execute setup SQL on both engines. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 06da8bd10..30da084ca 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -89,6 +89,29 @@ public function test_insert_values_seeded_rand_literals_match_sqlite(): void { $this->assertParityRows( 'SELECT id, value, other FROM seeded_rand_out ORDER BY id' ); } + public function test_seeded_rand_where_and_order_by_contexts_have_explicit_rejection_contract(): void { + $this->runParitySetup( + array( + 'CREATE TABLE seeded_rand_ctx (id INT, value DOUBLE)', + 'INSERT INTO seeded_rand_ctx (id, value) VALUES (1, 0.0), (2, 0.0), (3, 0.0)', + ) + ); + + $this->assertDuckDBRejectsWhileSqliteAccepts( + 'SELECT id FROM seeded_rand_ctx WHERE RAND(1) < 0.5 ORDER BY id', + 'top-level SELECT expression' + ); + $this->assertDuckDBRejectsWhileSqliteAccepts( + 'SELECT id FROM seeded_rand_ctx ORDER BY RAND(1)', + 'top-level SELECT expression' + ); + $this->assertDuckDBRejectsWithoutMutatingRows( + 'UPDATE seeded_rand_ctx SET value = 9 WHERE RAND(1) < 0.5', + 'top-level SELECT expression', + 'SELECT id, value FROM seeded_rand_ctx ORDER BY id' + ); + } + public function test_select_cast_convert_binary_expressions_match_sqlite(): void { foreach ( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index e15743184..1d113ade3 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -285,6 +285,10 @@ public function test_select_seeded_rand_unsupported_shapes_are_rejected(): void 'sql' => 'SELECT RAND(1) AS r FROM t ORDER BY RAND(1)', 'message' => 'top-level SELECT expression', ), + array( + 'sql' => 'SELECT id FROM t ORDER BY RAND(1)', + 'message' => 'top-level SELECT expression', + ), array( 'sql' => 'SELECT id FROM t WHERE RAND(1) < 1', 'message' => 'top-level SELECT expression', @@ -312,6 +316,29 @@ public function test_select_seeded_rand_unsupported_shapes_are_rejected(): void } } + public function test_seeded_rand_where_rejection_does_not_mutate_rows_or_state(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE seeded_rand_where_reject (id INT, value DOUBLE)' ); + $driver->query( 'INSERT INTO seeded_rand_where_reject (id, value) VALUES (1, 0.0), (2, 0.0), (3, 0.0)' ); + + $before = $driver->query( 'SELECT id, value FROM seeded_rand_where_reject ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + + try { + $driver->query( 'UPDATE seeded_rand_where_reject SET value = 9 WHERE RAND(1) < 0.5' ); + $this->fail( 'Expected unsupported seeded RAND() WHERE context to fail.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'top-level SELECT expression', $e->getMessage() ); + } + + $after = $driver->query( 'SELECT id, value FROM seeded_rand_where_reject ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( $before, $after ); + + $row = $driver->query( 'SELECT RAND(1) AS r' )->fetch( PDO::FETCH_ASSOC ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $row['r'], 1e-12 ); + } + public function test_date_format_function_is_emulated(): void { $this->requireDuckDBRuntime(); From a3b56b8b5b58c581b9fa3bf08c73ca2a9cf127d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:10:30 +0000 Subject: [PATCH 105/317] Add DuckDB wpdb col_info metadata breadth --- .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index f62135ed8..a11bcfd4a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -180,7 +180,7 @@ public function test_duckdb_wpdb_col_info_uses_statement_metadata(): void { $this->assertTrue( $result['connected'] ); $this->assertSame( 0, $result['select_return'] ); - $this->assertCount( 2, $result['select_col_info'] ); + $this->assertCount( 3, $result['select_col_info'] ); $this->assertSame( 'ID', $result['select_col_info'][0]['name'] ); $this->assertSame( 'ID', $result['select_col_info'][0]['orgname'] ); @@ -199,6 +199,17 @@ public function test_duckdb_wpdb_col_info_uses_statement_metadata(): void { $this->assertSame( 764, $result['select_col_info'][1]['length'] ); $this->assertSame( 255, $result['select_col_info'][1]['charsetnr'] ); $this->assertSame( 253, $result['select_col_info'][1]['type'] ); + + $this->assertSame( 'price', $result['select_col_info'][2]['name'] ); + $this->assertSame( 'price', $result['select_col_info'][2]['orgname'] ); + $this->assertSame( 'wp_posts', $result['select_col_info'][2]['table'] ); + $this->assertSame( 'wp_posts', $result['select_col_info'][2]['orgtable'] ); + $this->assertSame( 'wordpress_test', $result['select_col_info'][2]['db'] ); + $this->assertSame( 12, $result['select_col_info'][2]['length'] ); + $this->assertSame( 63, $result['select_col_info'][2]['charsetnr'] ); + $this->assertSame( 32768, $result['select_col_info'][2]['flags'] ); + $this->assertSame( 246, $result['select_col_info'][2]['type'] ); + $this->assertSame( 2, $result['select_col_info'][2]['decimals'] ); $this->assertSame( 0, $result['update_return'] ); $this->assertSame( array(), $result['update_col_info'] ); } @@ -877,9 +888,9 @@ public function query( string $sql ): WP_DuckDB_Result_Statement { return new WP_DuckDB_Result_Statement( array(), array(), 0 ); } - if ( 'SELECT ID, post_title FROM wp_posts WHERE ID = 0' === $sql ) { + if ( 'SELECT ID, post_title, price FROM wp_posts WHERE ID = 0' === $sql ) { return new WP_DuckDB_Result_Statement( - array( 'ID', 'post_title' ), + array( 'ID', 'post_title', 'price' ), array(), 0, array( @@ -909,6 +920,19 @@ public function query( string $sql ): WP_DuckDB_Result_Statement { 'mysqli:flags' => 0, 'mysqli:type' => 253, ), + array( + 'name' => 'price', + 'native_type' => 'NEWDECIMAL', + 'table' => 'wp_posts', + 'len' => 12, + 'precision' => 2, + 'mysqli:orgname' => 'price', + 'mysqli:orgtable' => 'wp_posts', + 'mysqli:db' => 'wordpress_test', + 'mysqli:charsetnr' => 63, + 'mysqli:flags' => 32768, + 'mysqli:type' => 246, + ), ) ); } @@ -936,7 +960,7 @@ function ( $column ) { $GLOBALS['@duckdb_driver'] = new WP_DuckDB_Plugin_Col_Info_Test_Driver(); $db = new WP_DuckDB_Plugin_Col_Info_Test_DB( 'wordpress_test' ); $connected = $db->db_connect( false ); -$select_return = $db->query( 'SELECT ID, post_title FROM wp_posts WHERE ID = 0' ); +$select_return = $db->query( 'SELECT ID, post_title, price FROM wp_posts WHERE ID = 0' ); $select_col_info = $db->exported_col_info(); $update_return = $db->query( 'UPDATE wp_posts SET ID = ID WHERE ID = 0' ); $update_col_info = $db->exported_col_info(); From 696fe858964add04403343a4117356c99f6edfda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:13:42 +0000 Subject: [PATCH 106/317] Harden DuckDB foreign key limitation tests --- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 1d113ade3..1b15007f6 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -11956,6 +11956,74 @@ public function test_unsupported_create_table_foreign_key_actions_throw_before_m ); } + public function test_unsupported_table_level_foreign_key_shapes_throw_before_mutation(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE parents (id INT PRIMARY KEY, other_id INT)' ); + $driver->query( 'INSERT INTO parents (id, other_id) VALUES (1, 10)' ); + + foreach ( + array( + 'CREATE TABLE child_schema_fk ( + parent_id INT, + CONSTRAINT fk_schema FOREIGN KEY (parent_id) REFERENCES wp.parents (id) + )' => 'Schema-qualified references are not supported', + 'CREATE TABLE child_composite_fk ( + parent_id INT, + other_id INT, + CONSTRAINT fk_composite FOREIGN KEY (parent_id, other_id) REFERENCES parents (id, other_id) + )' => 'Only single-column foreign keys are supported', + 'CREATE TABLE child_composite_ref_fk ( + parent_id INT, + CONSTRAINT fk_composite_ref FOREIGN KEY (parent_id) REFERENCES parents (id, other_id) + )' => 'Only single-column foreign keys are supported', + 'CREATE TABLE child_missing_ref_list ( + parent_id INT, + CONSTRAINT fk_missing_ref FOREIGN KEY (parent_id) REFERENCES parents + )' => 'Expected FOREIGN KEY referenced column list', + ) as $sql => $message + ) { + $before = array( + 'tables' => $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ), + 'parent_rows' => $driver->query( 'SELECT id, other_id FROM parents ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'foreign_key_metadata' => $this->foreign_key_rejection_metadata_snapshot( $driver ), + ); + + try { + $driver->query( $sql ); + $this->fail( 'Expected unsupported table-level FOREIGN KEY shape to reject SQL: ' . $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( $message, $e->getMessage() ); + } + + $this->assertSame( + $before, + array( + 'tables' => $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ), + 'parent_rows' => $driver->query( 'SELECT id, other_id FROM parents ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'foreign_key_metadata' => $this->foreign_key_rejection_metadata_snapshot( $driver ), + ), + 'Unsupported table-level FOREIGN KEY shape created schema, metadata, or data for SQL: ' . $sql + ); + } + + $this->assertSame( + array( array( 'Tables_in_wp' => 'parents' ) ), + $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'other_id' => 10, + ), + ), + $driver->query( 'SELECT id, other_id FROM parents ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assert_duckdb_connection_usable( $driver ); + } + public function test_unsupported_inline_references_shapes_throw_before_mutation(): void { $this->requireDuckDBRuntime(); From 8a349713a2a7c0098ea58a46f7e3c2bb4f84157f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:18:18 +0000 Subject: [PATCH 107/317] Harden DuckDB inline FK rejection tests --- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 1b15007f6..14a688a88 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -12029,6 +12029,7 @@ public function test_unsupported_inline_references_shapes_throw_before_mutation( $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); $driver->query( 'CREATE TABLE parents (id INT PRIMARY KEY, other_id INT)' ); + $driver->query( 'INSERT INTO parents (id, other_id) VALUES (1, 10)' ); foreach ( array( @@ -12037,18 +12038,44 @@ public function test_unsupported_inline_references_shapes_throw_before_mutation( 'CREATE TABLE child_inline_missing_list (parent_id INT REFERENCES parents)' => 'Expected FOREIGN KEY referenced column list', ) as $sql => $message ) { + $before = array( + 'tables' => $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ), + 'parent_rows' => $driver->query( 'SELECT id, other_id FROM parents ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'foreign_key_metadata' => $this->foreign_key_rejection_metadata_snapshot( $driver ), + ); + try { $driver->query( $sql ); $this->fail( 'Expected unsupported inline REFERENCES shape to reject SQL: ' . $sql ); } catch ( WP_DuckDB_Driver_Exception $e ) { $this->assertStringContainsString( $message, $e->getMessage() ); } + + $this->assertSame( + $before, + array( + 'tables' => $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ), + 'parent_rows' => $driver->query( 'SELECT id, other_id FROM parents ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), + 'foreign_key_metadata' => $this->foreign_key_rejection_metadata_snapshot( $driver ), + ), + 'Unsupported inline REFERENCES shape created schema, metadata, or data for SQL: ' . $sql + ); } $this->assertSame( array( array( 'Tables_in_wp' => 'parents' ) ), $driver->query( 'SHOW TABLES' )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( + array( + array( + 'id' => 1, + 'other_id' => 10, + ), + ), + $driver->query( 'SELECT id, other_id FROM parents ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assert_duckdb_connection_usable( $driver ); } public function test_unsupported_alter_table_add_auto_increment_throws_driver_exception(): void { From 8f218742bdf152817491d5fd106f982f99f6e8fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:37:32 +0000 Subject: [PATCH 108/317] Add DuckDB local WordPress smoke command --- bin/wp-duckdb-local-smoke.sh | 135 +++++++++++++++++++++++++++++++++++ composer.json | 3 + 2 files changed, 138 insertions(+) create mode 100755 bin/wp-duckdb-local-smoke.sh diff --git a/bin/wp-duckdb-local-smoke.sh b/bin/wp-duckdb-local-smoke.sh new file mode 100755 index 000000000..26265b639 --- /dev/null +++ b/bin/wp-duckdb-local-smoke.sh @@ -0,0 +1,135 @@ +#!/bin/bash + +## +# Run a non-Docker smoke test against the generated WordPress DuckDB drop-in. +## + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd -P)" +WP_DIR="$ROOT_DIR/wordpress" +DUCKDB_PHP_AUTOLOAD="${DUCKDB_PHP_AUTOLOAD:-}" + +if [ -z "$DUCKDB_PHP_AUTOLOAD" ]; then + echo 'Error: DUCKDB_PHP_AUTOLOAD must point to the DuckDB PHP runtime autoload file.' >&2 + exit 1 +fi + +case "$DUCKDB_PHP_AUTOLOAD" in + /*) + ;; + *) + echo 'Error: DUCKDB_PHP_AUTOLOAD must be an absolute path.' >&2 + exit 1 + ;; +esac + +if [ ! -f "$DUCKDB_PHP_AUTOLOAD" ]; then + echo "Error: DUCKDB_PHP_AUTOLOAD must point to an existing file: $DUCKDB_PHP_AUTOLOAD" >&2 + exit 1 +fi + +DUCKDB_PHP_AUTOLOAD="$(cd "$(dirname "$DUCKDB_PHP_AUTOLOAD")" && pwd -P)/$(basename "$DUCKDB_PHP_AUTOLOAD")" + +if [ ! -f "$WP_DIR/src/wp-load.php" ] || \ + [ ! -f "$WP_DIR/src/wp-content/db.php" ] || \ + ! grep -q "define( 'DB_ENGINE', 'duckdb' );" "$WP_DIR/src/wp-content/db.php" 2>/dev/null || \ + ! grep -Fq "$DUCKDB_PHP_AUTOLOAD" "$WP_DIR/src/wp-content/db.php" 2>/dev/null; then + DUCKDB_PHP_AUTOLOAD="$DUCKDB_PHP_AUTOLOAD" WP_TEST_DB_ENGINE=duckdb "$ROOT_DIR/wp-setup.sh" +fi + +if [ ! -f "$WP_DIR/src/wp-includes/class-wpdb.php" ]; then + echo 'Error: WordPress checkout is incomplete after setup.' >&2 + exit 1 +fi + +PLUGIN_DIR="$WP_DIR/src/wp-content/plugins/sqlite-database-integration" +rm -rf "$PLUGIN_DIR" +mkdir -p "$(dirname "$PLUGIN_DIR")" +cp -R "$ROOT_DIR/packages/plugin-sqlite-database-integration" "$PLUGIN_DIR" +rm -rf "$PLUGIN_DIR/wp-includes/database" +ln -s "$ROOT_DIR/packages/mysql-on-sqlite/src" "$PLUGIN_DIR/wp-includes/database" + +rm -rf \ + "$WP_DIR/src/wp-content/database/.ht.duckdb" \ + "$WP_DIR/src/wp-content/database/.ht.duckdb.wal" + +WP_DUCKDB_SMOKE_ROOT="$ROOT_DIR" php -d ffi.enable=1 <<'PHP' +db_connect( false ); +if ( true !== $connected ) { + fwrite( STDERR, "Error: WP_DuckDB_DB did not connect.\n" ); + exit( 1 ); +} + +$GLOBALS['wpdb']->query( 'DROP TABLE IF EXISTS wp_duckdb_local_smoke' ); +$create = $GLOBALS['wpdb']->query( 'CREATE TABLE wp_duckdb_local_smoke (id INTEGER, note VARCHAR(20))' ); +$insert = $GLOBALS['wpdb']->query( "INSERT INTO wp_duckdb_local_smoke VALUES (1, 'ok')" ); +$rows = $GLOBALS['wpdb']->get_results( 'SELECT id, note FROM wp_duckdb_local_smoke ORDER BY id', ARRAY_A ); + +$expected_rows = array( + array( + 'id' => 1, + 'note' => 'ok', + ), +); + +if ( true !== $create || 1 !== $insert || $expected_rows !== $rows || '' !== $GLOBALS['wpdb']->last_error ) { + fwrite( STDERR, "Error: DuckDB local smoke query failed.\n" ); + fwrite( + STDERR, + json_encode( + array( + 'create' => $create, + 'insert' => $insert, + 'rows' => $rows, + 'last_error' => $GLOBALS['wpdb']->last_error, + ), + JSON_UNESCAPED_SLASHES + ) . "\n" + ); + exit( 1 ); +} + +echo json_encode( + array( + 'wpdb_class' => get_class( $GLOBALS['wpdb'] ), + 'connect' => $connected, + 'create' => $create, + 'insert' => $insert, + 'rows' => $rows, + 'ready' => $GLOBALS['wpdb']->ready, + 'last_error' => $GLOBALS['wpdb']->last_error, + ), + JSON_UNESCAPED_SLASHES +) . "\n"; +PHP diff --git a/composer.json b/composer.json index f15e37602..21177296e 100644 --- a/composer.json +++ b/composer.json @@ -82,6 +82,9 @@ "rm -rf wordpress/src/wp-content/database/.ht.duckdb wordpress/src/wp-content/database/.ht.duckdb.wal @no_additional_args", "npm --prefix wordpress run test:php -- @additional_args" ], + "wp-smoke-duckdb-local": [ + "./bin/wp-duckdb-local-smoke.sh" + ], "wp-test-e2e": [ "@wp-test-ensure-env @no_additional_args", "npm --prefix wordpress run test:e2e -- @additional_args" From a9768588cb90a9192bb54677315601a5d65dc328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:42:54 +0000 Subject: [PATCH 109/317] Wire DuckDB WordPress PHPUnit through wrapper --- .github/workflows/duckdb-phpunit-tests.yml | 63 ++++++++++++++++++++++ .github/workflows/wp-tests-phpunit-run.js | 14 +++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index 44797ce35..4ed223c89 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -6,6 +6,9 @@ on: - trunk paths: - '.github/workflows/duckdb-phpunit-tests.yml' + - '.github/workflows/wp-tests-phpunit-run.js' + - 'composer.json' + - 'wp-setup.sh' - 'packages/mysql-on-sqlite/composer.json' - 'packages/mysql-on-sqlite/composer.lock' - 'packages/mysql-on-sqlite/src/load.php' @@ -18,6 +21,9 @@ on: pull_request: paths: - '.github/workflows/duckdb-phpunit-tests.yml' + - '.github/workflows/wp-tests-phpunit-run.js' + - 'composer.json' + - 'wp-setup.sh' - 'packages/mysql-on-sqlite/composer.json' - 'packages/mysql-on-sqlite/composer.lock' - 'packages/mysql-on-sqlite/src/load.php' @@ -84,3 +90,60 @@ jobs: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php run: php -d ffi.enable=true ./vendor/bin/phpunit -c ./phpunit.xml.dist --group duckdb working-directory: packages/mysql-on-sqlite + + wp-duckdb-test: + name: WordPress PHPUnit / DuckDB + runs-on: ubuntu-latest + timeout-minutes: 40 + permissions: + contents: read # Required to clone the repo. + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set UID and GID for PHP in WordPress images + run: | + echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV + echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: ffi, pdo_sqlite + ini-values: ffi.enable=true + coverage: none + + - name: Install Composer dependencies + uses: ramsey/composer-install@v3 + with: + working-directory: packages/mysql-on-sqlite + ignore-cache: "yes" + composer-options: "--optimize-autoloader" + + - name: Install DuckDB PHP client + run: | + composer config --no-plugins allow-plugins.satur.io/duckdb-auto true + composer require --dev --no-plugins --no-interaction --with-all-dependencies satur.io/duckdb-auto + composer dump-autoload + ./vendor/bin/install-c-lib + working-directory: packages/mysql-on-sqlite + + - name: Verify DuckDB runtime + run: php -d ffi.enable=true -r "require 'vendor/autoload.php'; require 'src/load.php'; WP_DuckDB_Runtime::assert_available( true );" + working-directory: packages/mysql-on-sqlite + + - name: Run WordPress PHPUnit tests with DuckDB + env: + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_SQLITE_PHPUNIT_COMMAND: composer run wp-test-php-duckdb -- --log-junit=phpunit-duckdb-results.xml --verbose + WP_SQLITE_PHPUNIT_JUNIT_PATH: wordpress/phpunit-duckdb-results.xml + run: node .github/workflows/wp-tests-phpunit-run.js + + - name: Stop Docker containers + if: always() + run: composer run wp-test-clean diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index ae4f5f6a3..af412c5fc 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -9,7 +9,13 @@ const { execSync } = require( 'child_process' ); const fs = require( 'fs' ); const path = require( 'path' ); +const repoRoot = path.join( __dirname, '..', '..' ); const requiresNativeParserExtension = process.env.WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION === '1'; +const phpunitCommand = process.env.WP_SQLITE_PHPUNIT_COMMAND || 'composer run wp-test-php -- --log-junit=phpunit-results.xml --verbose'; +const junitOutputPath = process.env.WP_SQLITE_PHPUNIT_JUNIT_PATH || 'wordpress/phpunit-results.xml'; +const junitOutputFile = path.isAbsolute( junitOutputPath ) + ? junitOutputPath + : path.join( repoRoot, junitOutputPath ); const expectedErrors = [ 'Tests_DB_Charset::test_invalid_characters_in_query', @@ -94,6 +100,8 @@ console.log( 'Running WordPress PHPUnit tests with expected failures tracking... if ( requiresNativeParserExtension ) { console.log( 'Native parser extension is required for this PHPUnit run.' ); } +console.log( 'PHPUnit command:', phpunitCommand ); +console.log( 'JUnit output:', junitOutputFile ); console.log( 'Expected errors:', expectedErrors ); console.log( 'Expected failures:', expectedFailures ); @@ -117,17 +125,13 @@ try { } try { - execSync( - `composer run wp-test-php -- --log-junit=phpunit-results.xml --verbose`, - { stdio: 'inherit' } - ); + execSync( phpunitCommand, { stdio: 'inherit' } ); console.log( '\n⚠️ All tests passed, checking if expected errors/failures occurred...' ); } catch ( error ) { console.log( '\n⚠️ Some tests errored/failed (expected). Analyzing results...' ); } // Read the JUnit XML test output: - const junitOutputFile = path.join( __dirname, '..', '..', 'wordpress', 'phpunit-results.xml' ); if ( ! fs.existsSync( junitOutputFile ) ) { console.error( 'Error: JUnit output file not found!' ); process.exit( 1 ); From 5c41c27222efbe178645f2b23ac5ebc52f62b77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:50:37 +0000 Subject: [PATCH 110/317] Add DuckDB WordPress E2E acceptance path --- .github/workflows/duckdb-phpunit-tests.yml | 60 +++++++++++++++++++++- composer.json | 4 ++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index 4ed223c89..f6f9b0d0a 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -1,4 +1,4 @@ -name: DuckDB PHPUnit Tests +name: DuckDB Tests on: push: @@ -147,3 +147,61 @@ jobs: - name: Stop Docker containers if: always() run: composer run wp-test-clean + + wp-duckdb-e2e-test: + name: WordPress E2E / DuckDB + runs-on: ubuntu-latest + timeout-minutes: 40 + permissions: + contents: read # Required to clone the repo. + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set UID and GID for PHP in WordPress images + run: | + echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV + echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: ffi, pdo_sqlite + ini-values: ffi.enable=true + coverage: none + + - name: Install Composer dependencies + uses: ramsey/composer-install@v3 + with: + working-directory: packages/mysql-on-sqlite + ignore-cache: "yes" + composer-options: "--optimize-autoloader" + + - name: Install DuckDB PHP client + run: | + composer config --no-plugins allow-plugins.satur.io/duckdb-auto true + composer require --dev --no-plugins --no-interaction --with-all-dependencies satur.io/duckdb-auto + composer dump-autoload + ./vendor/bin/install-c-lib + working-directory: packages/mysql-on-sqlite + + - name: Verify DuckDB runtime + run: php -d ffi.enable=true -r "require 'vendor/autoload.php'; require 'src/load.php'; WP_DuckDB_Runtime::assert_available( true );" + working-directory: packages/mysql-on-sqlite + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Run WordPress end-to-end tests with DuckDB + env: + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + run: composer run wp-test-e2e-duckdb + + - name: Stop Docker containers + if: always() + run: composer run wp-test-clean diff --git a/composer.json b/composer.json index 21177296e..912fc41d4 100644 --- a/composer.json +++ b/composer.json @@ -89,6 +89,10 @@ "@wp-test-ensure-env @no_additional_args", "npm --prefix wordpress run test:e2e -- @additional_args" ], + "wp-test-e2e-duckdb": [ + "@wp-test-ensure-env-duckdb @no_additional_args", + "npm --prefix wordpress run test:e2e -- @additional_args" + ], "wp-test-clean": [ "npm --prefix wordpress run env:clean", "rm -rf wordpress/src/wp-content/database/.ht.sqlite wordpress/src/wp-content/database/.ht.duckdb wordpress/src/wp-content/database/.ht.duckdb.wal" From fe9b74e887612576acf71d43958a27bbc8832b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:52:02 +0000 Subject: [PATCH 111/317] Align DuckDB wpdb error diagnostics --- .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 88 +++++++++++++++++++ .../wp-includes/duckdb/class-wp-duckdb-db.php | 70 +++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index a11bcfd4a..3de626cdc 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -408,6 +408,25 @@ public function test_duckdb_wpdb_last_error_surface_provider(): void { $this->assertSame( 13, $cases['insert_after_failure']['insert_id'] ); } + public function test_duckdb_wpdb_suppressed_errors_are_recorded_for_ezsql(): void { + $result = $this->run_error_diagnostic_state_script(); + + $this->assertTrue( $result['connected'] ); + $this->assertFalse( $result['old_suppress_errors'] ); + $this->assertTrue( $result['suppress_errors'] ); + $this->assertFalse( $result['failed_return'] ); + $this->assertSame( 'Synthetic select failure.', $result['last_error'] ); + $this->assertSame( 'SELECT BROKEN', $result['last_query'] ); + $this->assertCount( 1, $result['ezsql_error'] ); + $this->assertSame( + array( + 'query' => 'SELECT BROKEN', + 'error_str' => 'Synthetic select failure.', + ), + $result['ezsql_error'][0] + ); + } + public function test_duckdb_wpdb_failure_diagnostics_clear_metadata_and_preserve_non_insert_id(): void { $result = $this->run_query_surface_state_script(); $cases = $result['cases']; @@ -565,6 +584,67 @@ public function query( string $sql ): WP_DuckDB_Result_Statement { return $this->run_isolated_php( $code ); } + private function run_error_diagnostic_state_script(): array { + $plugin_dir = $this->get_plugin_dir(); + $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; + $code = $this->get_wordpress_stub_code(); + $code .= "\nrequire_once " . var_export( $driver_load, true ) . ";\n"; + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= <<<'PHP' + +class WP_DuckDB_Plugin_Error_Diagnostic_Test_Driver extends WP_DuckDB_Driver { + public function __construct() {} + + public function query( string $sql ): WP_DuckDB_Result_Statement { + if ( 'SELECT @@SESSION.sql_mode' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( '@@SESSION.sql_mode' ), + array( + array( 'NO_ENGINE_SUBSTITUTION' ), + ), + 0 + ); + } + + if ( "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'" === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( 'SELECT BROKEN' === $sql ) { + throw new WP_DuckDB_Driver_Exception( + 'Synthetic select failure.', + 'HY000', + new RuntimeException( 'Native synthetic select failure.' ) + ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } +} + +$EZSQL_ERROR = array(); +$GLOBALS['@duckdb_driver'] = new WP_DuckDB_Plugin_Error_Diagnostic_Test_Driver(); +$db = new WP_DuckDB_DB( 'wordpress_test' ); +$connected = $db->db_connect( false ); +$old_suppress_errors = $db->suppress_errors( true ); +$failed_return = $db->query( 'SELECT BROKEN' ); + +echo json_encode( + array( + 'connected' => $connected, + 'old_suppress_errors' => $old_suppress_errors, + 'suppress_errors' => $db->suppress_errors, + 'failed_return' => $failed_return, + 'last_error' => $db->last_error, + 'last_query' => $db->last_query, + 'ezsql_error' => $EZSQL_ERROR, + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + private function run_raw_query_state_script(): array { $plugin_dir = $this->get_plugin_dir(); $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; @@ -730,6 +810,7 @@ public function get_insert_id(): int { $GLOBALS['@duckdb_driver'] = new WP_DuckDB_Plugin_Insert_Id_Test_Driver(); $db = new WP_DuckDB_DB( 'wordpress_test' ); $connected = $db->db_connect( false ); +$db->suppress_errors( true ); $insert_return = $db->query( "INSERT INTO t (name) VALUES ('first')" ); $insert_id = $db->insert_id; $replace_return = $db->query( "REPLACE INTO t (name) VALUES ('second')" ); @@ -1349,6 +1430,7 @@ function wp_duckdb_plugin_query_surface_case( WP_DuckDB_Plugin_Query_Surface_Tes $GLOBALS['@duckdb_driver'] = new WP_DuckDB_Plugin_Query_Surface_Test_Driver(); $db = new WP_DuckDB_Plugin_Query_Surface_Test_DB( 'wordpress_test' ); $connected = $db->db_connect( false ); +$db->suppress_errors( true ); $cases = array( 'select_one_row' => wp_duckdb_plugin_query_surface_case( $db, 'SELECT 42 AS answer' ), 'select_zero_rows' => wp_duckdb_plugin_query_surface_case( $db, 'SELECT ID, post_title FROM wp_posts WHERE ID = 0' ), @@ -1513,6 +1595,12 @@ public function show_errors( $show = true ) { return $old_show_errors; } + public function suppress_errors( $suppress = true ) { + $old_suppress_errors = $this->suppress_errors; + $this->suppress_errors = (bool) $suppress; + return $old_suppress_errors; + } + public function timer_start() { $this->time_start = microtime( true ); return true; diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index fe1c179d1..aba14b6f1 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -139,6 +139,76 @@ public function _real_escape( $data ) { return $this->add_placeholder_escape( addslashes( $data ) ); } + /** + * Prints SQL/DB error. + * + * This overrides wpdb::print_error() while avoiding its mysqli error + * fallback for non-mysqli database handles. + * + * @global array $EZSQL_ERROR Stores error information of query and error string. + * + * @param string $str The error to display. + * @return void|false Void if the showing of errors is enabled, false if disabled. + */ + public function print_error( $str = '' ) { + global $EZSQL_ERROR; + + if ( ! $str ) { + $str = $this->last_error; + } + + $EZSQL_ERROR[] = array( + 'query' => $this->last_query, + 'error_str' => $str, + ); + + if ( $this->suppress_errors ) { + return false; + } + + $caller = $this->get_caller(); + if ( $caller ) { + // Not translated, as this will only appear in the error log. + $error_str = sprintf( 'WordPress database error %1$s for query %2$s made by %3$s', $str, $this->last_query, $caller ); + } else { + $error_str = sprintf( 'WordPress database error %1$s for query %2$s', $str, $this->last_query ); + } + + error_log( $error_str ); + + if ( ! $this->show_errors ) { + return false; + } + + wp_load_translations_early(); + + if ( is_multisite() ) { + $msg = sprintf( + "%s [%s]\n%s\n", + __( 'WordPress database error:' ), + $str, + $this->last_query + ); + + if ( defined( 'ERRORLOGFILE' ) ) { + error_log( $msg, 3, ERRORLOGFILE ); + } + if ( defined( 'DIEONDBERROR' ) ) { + wp_die( $msg ); + } + } else { + $str = htmlspecialchars( $str, ENT_QUOTES ); + $query = htmlspecialchars( $this->last_query, ENT_QUOTES ); + + printf( + '

%s [%s]
%s

', + __( 'WordPress database error:' ), + $str, + $query + ); + } + } + /** * Flush cached query state. */ From 37fb8249b5458e644bcbf60a28a6fefcbbdab753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 09:52:44 +0000 Subject: [PATCH 112/317] Add DuckDB JSON type storage parity --- .../src/duckdb/class-wp-duckdb-driver.php | 7 +- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 33 +++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 95 +++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 61d1389fc..4763eaa7b 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -88,6 +88,7 @@ class WP_DuckDB_Driver { WP_MySQL_Lexer::TINYTEXT_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::LONGTEXT_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::JSON_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::DATE_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::TIME_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::DATETIME_SYMBOL => 'VARCHAR', @@ -1440,6 +1441,10 @@ private function mysql_result_column_precision( array $type_attributes, ?int $de * @return int MySQLi charset number. */ private function mysql_result_column_charsetnr( string $data_type, $collation_name ): int { + if ( 'json' === $data_type ) { + return 255; + } + $charset = $this->character_set_from_collation( $collation_name ); if ( null !== $charset @@ -14385,7 +14390,7 @@ private function coerce_write_value_for_column_sql( array $metadata, array $valu * @return bool Whether the type is character-backed. */ private function is_character_write_data_type( string $data_type ): bool { - return in_array( $data_type, array( 'char', 'varchar', 'text', 'tinytext', 'mediumtext', 'longtext' ), true ); + return in_array( $data_type, array( 'char', 'varchar', 'text', 'tinytext', 'mediumtext', 'longtext', 'json' ), true ); } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 30da084ca..98b14678c 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -3425,6 +3425,39 @@ public function test_alias_storage_type_family_metadata_matches_sqlite(): void { ); } + public function test_json_type_family_storage_and_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE json_type_family ( + id INT PRIMARY KEY, + data JSON, + required JSON NOT NULL DEFAULT 'null' + )", + ) + ); + + $this->assertParityRowCount( "INSERT INTO json_type_family (id, data, required) VALUES (1, '{\"a\":1}', '{\"required\":true}')" ); + $this->assertParityRowCount( 'INSERT INTO json_type_family (id, data) VALUES (2, TRUE)' ); + $this->assertParityRowCount( 'INSERT INTO json_type_family (id, data) VALUES (3, 0x62)' ); + $this->assertParityRowCount( "INSERT INTO json_type_family (id, data) VALUES (4, x'63')" ); + $this->assertParityRowCount( 'INSERT INTO json_type_family (id) VALUES (5)' ); + $this->assertParityRowCount( 'UPDATE json_type_family SET data = 123.456 WHERE id = 5' ); + + $this->assertParityRows( 'SELECT id, data, required FROM json_type_family ORDER BY id' ); + $this->assertParityRows( 'SHOW COLUMNS FROM json_type_family' ); + $this->assertParityRows( 'SHOW FULL COLUMNS FROM json_type_family' ); + $this->assertParityRows( 'SHOW CREATE TABLE json_type_family' ); + $this->assertParityRows( + "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, + CHARACTER_OCTET_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, + CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'json_type_family' + ORDER BY ordinal_position" + ); + } + public function test_national_character_type_family_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 14a688a88..a96cd32db 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3856,6 +3856,101 @@ public function test_direct_column_metadata_type_matrix_for_supported_mysql_type $this->assertFalse( $result->getColumnMeta( 13 ) ); } + public function test_json_column_storage_and_direct_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( + "CREATE TABLE json_type_metadata ( + id INT PRIMARY KEY, + data JSON, + required JSON NOT NULL DEFAULT 'null' + )" + ); + $driver->query( "INSERT INTO json_type_metadata (id, data, required) VALUES (1, '{\"a\":1}', '{\"required\":true}')" ); + $driver->query( 'INSERT INTO json_type_metadata (id, data) VALUES (2, TRUE)' ); + $driver->query( 'INSERT INTO json_type_metadata (id, data) VALUES (3, 0x62)' ); + $driver->query( "INSERT INTO json_type_metadata (id, data) VALUES (4, x'63')" ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'data' => '{"a":1}', + 'required' => '{"required":true}', + ), + array( + 'id' => 2, + 'data' => '1', + 'required' => 'null', + ), + array( + 'id' => 3, + 'data' => 'b', + 'required' => 'null', + ), + array( + 'id' => 4, + 'data' => 'c', + 'required' => 'null', + ), + ), + $driver->query( 'SELECT id, data, required FROM json_type_metadata ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $result = $driver->query( 'SELECT data FROM json_type_metadata WHERE id = 1' ); + $metadata = $result->getColumnMeta( 0 ); + $this->assertSame( array( array( 'data' => '{"a":1}' ) ), $result->fetchAll( PDO::FETCH_ASSOC ) ); + + $expected = array( + 'native_type' => 'BLOB', + 'table' => 'json_type_metadata', + 'name' => 'data', + 'len' => 4294967295, + 'precision' => 0, + 'duckdb:decl_type' => 'json', + 'mysqli:orgname' => 'data', + 'mysqli:orgtable' => 'json_type_metadata', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 245, + ); + + foreach ( $expected as $key => $value ) { + $this->assertArrayHasKey( $key, $metadata ); + $this->assertSame( $value, $metadata[ $key ], 'JSON metadata key ' . $key ); + } + + $this->assertSame( + array( + array( + 'COLUMN_NAME' => 'data', + 'DATA_TYPE' => 'json', + 'CHARACTER_MAXIMUM_LENGTH' => null, + 'CHARACTER_OCTET_LENGTH' => null, + 'CHARACTER_SET_NAME' => null, + 'COLLATION_NAME' => null, + 'COLUMN_TYPE' => 'json', + ), + ), + $driver->query( + "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, + CHARACTER_OCTET_LENGTH, CHARACTER_SET_NAME, COLLATION_NAME, + COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'json_type_metadata' + AND column_name = 'data'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_type_default_charset_metadata_rows_for_supported_mysql_types(): void { $this->requireDuckDBRuntime(); From 2e28aa5574e6fbda0ee2486ebb48a99e67004b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 10:00:04 +0000 Subject: [PATCH 113/317] Harden DuckDB wpdb col_info fallback metadata --- .../duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index 3de626cdc..ce9ee605e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -267,6 +267,7 @@ public function test_duckdb_wpdb_col_info_defaults_for_name_only_metadata_provid 'analyze_table' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), 'optimize_table' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), 'repair_table' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + 'complex_join' => array( 'ID', 'post_title', 'meta_value', 'post_title' ), ); foreach ( $expected_cases as $case_name => $expected_names ) { @@ -1086,6 +1087,8 @@ public function query( string $sql ): WP_DuckDB_Result_Statement { return new WP_DuckDB_Result_Statement( array(), array(), 0 ); } + $complex_join_sql = 'SELECT p.*, m.meta_value, LENGTH(p.post_title) AS post_title ' . + 'FROM wp_posts AS p LEFT JOIN wp_postmeta AS m ON m.post_id = p.ID'; $statement_columns = array( 'SELECT COUNT(*) AS post_count' => array( 'post_count' ), 'SELECT FOUND_ROWS() AS found_rows' => array( 'found_rows' ), @@ -1133,6 +1136,12 @@ public function query( string $sql ): WP_DuckDB_Result_Statement { 'ANALYZE TABLE wp_posts' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), 'OPTIMIZE TABLE wp_posts' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), 'REPAIR TABLE wp_posts' => array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + $complex_join_sql => array( + 'ID', + 'post_title', + 'meta_value', + 'post_title', + ), ); if ( isset( $statement_columns[ $sql ] ) ) { @@ -1170,6 +1179,8 @@ function wp_duckdb_plugin_col_info_fallback_case( WP_DuckDB_Plugin_Col_Info_Fall $GLOBALS['@duckdb_driver'] = new WP_DuckDB_Plugin_Col_Info_Fallback_Test_Driver(); $db = new WP_DuckDB_Plugin_Col_Info_Fallback_Test_DB( 'wordpress_test' ); $connected = $db->db_connect( false ); +$complex_join_sql = 'SELECT p.*, m.meta_value, LENGTH(p.post_title) AS post_title ' . + 'FROM wp_posts AS p LEFT JOIN wp_postmeta AS m ON m.post_id = p.ID'; $cases = array( 'select_count' => wp_duckdb_plugin_col_info_fallback_case( $db, 'SELECT COUNT(*) AS post_count' ), 'found_rows' => wp_duckdb_plugin_col_info_fallback_case( $db, 'SELECT FOUND_ROWS() AS found_rows' ), @@ -1182,6 +1193,7 @@ function wp_duckdb_plugin_col_info_fallback_case( WP_DuckDB_Plugin_Col_Info_Fall 'analyze_table' => wp_duckdb_plugin_col_info_fallback_case( $db, 'ANALYZE TABLE wp_posts' ), 'optimize_table' => wp_duckdb_plugin_col_info_fallback_case( $db, 'OPTIMIZE TABLE wp_posts' ), 'repair_table' => wp_duckdb_plugin_col_info_fallback_case( $db, 'REPAIR TABLE wp_posts' ), + 'complex_join' => wp_duckdb_plugin_col_info_fallback_case( $db, $complex_join_sql ), ); echo json_encode( From 91a856331d897f394b7d74dfb0a213b4bd8f3dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 10:00:33 +0000 Subject: [PATCH 114/317] Add DuckDB YEAR type storage parity --- .../src/duckdb/class-wp-duckdb-driver.php | 1 + .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 4763eaa7b..6166ad9c4 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -93,6 +93,7 @@ class WP_DuckDB_Driver { WP_MySQL_Lexer::TIME_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::DATETIME_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::TIMESTAMP_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::YEAR_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::BLOB_SYMBOL => 'BLOB', WP_MySQL_Lexer::TINYBLOB_SYMBOL => 'BLOB', WP_MySQL_Lexer::MEDIUMBLOB_SYMBOL => 'BLOB', diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 98b14678c..78c2a6f01 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -3458,6 +3458,37 @@ public function test_json_type_family_storage_and_metadata_matches_sqlite(): voi ); } + public function test_year_type_family_storage_and_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE year_type_family ( + id INT PRIMARY KEY, + observed YEAR, + required YEAR NOT NULL DEFAULT '0000' + )", + ) + ); + + $this->assertParityRowCount( "INSERT INTO year_type_family (id, observed, required) VALUES (1, '2024', '2020')" ); + $this->assertParityRowCount( 'INSERT INTO year_type_family (id, observed) VALUES (2, 2025)' ); + $this->assertParityRowCount( "UPDATE year_type_family SET observed = '2026' WHERE id = 2" ); + + $this->assertParityRows( 'SELECT id, observed, required FROM year_type_family ORDER BY id' ); + $this->assertParityRows( 'SHOW COLUMNS FROM year_type_family' ); + $this->assertParityRows( 'SHOW FULL COLUMNS FROM year_type_family' ); + $this->assertParityRows( 'DESCRIBE year_type_family' ); + $this->assertParityRows( 'SHOW CREATE TABLE year_type_family' ); + $this->assertParityRows( + "SELECT COLUMN_NAME, COLUMN_DEFAULT, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, + CHARACTER_OCTET_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, + DATETIME_PRECISION, CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'year_type_family' + ORDER BY ordinal_position" + ); + } + public function test_national_character_type_family_matches_sqlite(): void { $this->runParitySetup( array( From e9c993b9082a22164edbe23ad49a4fc96a8fc200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 10:01:23 +0000 Subject: [PATCH 115/317] Add DuckDB sql_warnings session parity --- .../src/duckdb/class-wp-duckdb-driver.php | 3 ++- .../tests/duckdb/WP_DuckDB_Connection_Tests.php | 12 ++++++++---- .../tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php | 5 +++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 6166ad9c4..436ae5401 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -42,6 +42,7 @@ class WP_DuckDB_Driver { 'big_tables' => true, 'foreign_key_checks' => true, 'sql_mode' => true, + 'sql_warnings' => true, 'unique_checks' => true, ); @@ -4058,7 +4059,7 @@ private function normalize_supported_session_system_variable_name( string $name throw new WP_DuckDB_Driver_Exception( 'Unsupported SET session variable in DuckDB driver: ' . $name - . '. Only autocommit, big_tables, foreign_key_checks, sql_mode, and unique_checks are supported.' + . '. Only autocommit, big_tables, foreign_key_checks, sql_mode, sql_warnings, and unique_checks are supported.' ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index ae2bfb506..607324522 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -641,7 +641,7 @@ public function query( string $sql ) { $this->assertSame( array( '00000', null, null ), $stmt->errorInfo() ); } - public function test_sql_warnings_policy_is_explicitly_unsupported(): void { + public function test_sql_warnings_session_variable_is_supported_without_warning_lists(): void { $empty_result = $this->createDuckDBResult( array(), array() ); $duckdb = new class( $empty_result ) { public $queries = array(); @@ -662,10 +662,14 @@ public function query( string $sql ) { ) ); + $this->assertSame( 0, $driver->query( 'SET sql_warnings = ON' )->rowCount() ); + $stmt = $driver->query( 'SELECT @@sql_warnings' ); + $this->assertSame( 1, $stmt->columnCount() ); + $this->assertSame( array( '@@sql_warnings' => 1 ), $stmt->fetch( PDO::FETCH_ASSOC ) ); + $cases = array( - 'SET sql_warnings = ON' => 'Unsupported SET session variable in DuckDB driver: sql_warnings.', - 'SHOW WARNINGS' => 'Unsupported SHOW statement in DuckDB driver.', - 'SHOW ERRORS' => 'Unsupported SHOW statement in DuckDB driver.', + 'SHOW WARNINGS' => 'Unsupported SHOW statement in DuckDB driver.', + 'SHOW ERRORS' => 'Unsupported SHOW statement in DuckDB driver.', ); foreach ( $cases as $sql => $message ) { diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 78c2a6f01..638224d79 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -608,6 +608,11 @@ public function test_session_variable_sql_matches_sqlite(): void { $this->assertParityRowCount( 'SET big_tables = ON' ); $this->assertParityRows( 'SELECT @@big_tables' ); + $this->assertParityRowCount( 'SET sql_warnings = ON' ); + $this->assertParityRows( 'SELECT @@sql_warnings' ); + $this->assertParityRowCount( 'SET @@session.sql_warnings = OFF' ); + $this->assertParityRows( 'SELECT @@sql_warnings, @@SESSION.sql_warnings' ); + $this->assertParityRowCount( 'SET SESSION autocommit = 0' ); $this->assertParityRowCount( 'SET @@session.big_tables = 1' ); $this->assertParityRows( From 750026d5076ee1eaa27c305d8f9a93ae37cbdee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 10:13:42 +0000 Subject: [PATCH 116/317] Add DuckDB ENUM SET storage parity --- .../src/duckdb/class-wp-duckdb-driver.php | 147 +++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 46 ++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 225 ++++++++++++++++++ 3 files changed, 405 insertions(+), 13 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 436ae5401..b603349f1 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -90,6 +90,8 @@ class WP_DuckDB_Driver { WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::LONGTEXT_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::JSON_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::ENUM_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::SET_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::DATE_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::TIME_SYMBOL => 'VARCHAR', WP_MySQL_Lexer::DATETIME_SYMBOL => 'VARCHAR', @@ -110,6 +112,11 @@ class WP_DuckDB_Driver { 'timestamp' => '0000-00-00 00:00:00', ); + const STRING_IMPLICIT_DEFAULT_MAP = array( + 'enum' => '', + 'set' => '', + ); + /** * @var WP_Parser_Grammar|null */ @@ -422,6 +429,22 @@ private function tokenize_and_validate( string $query ): array { return array_values( $tokens ); } + /** + * Tokenize a MySQL fragment without requiring it to be a complete statement. + * + * @param string $fragment MySQL fragment. + * @return WP_Parser_Token[] + */ + private function tokenize_fragment( string $fragment ): array { + $lexer = new WP_MySQL_Lexer( $fragment, $this->mysql_version ); + $raw_tokens = class_exists( 'WP_MySQL_Native_Lexer', false ) && $lexer instanceof WP_MySQL_Native_Lexer + ? $lexer->native_token_stream() + : $lexer->remaining_tokens(); + $tokens = is_array( $raw_tokens ) ? array_values( $raw_tokens ) : array_values( iterator_to_array( $raw_tokens ) ); + + return array_values( $this->without_eof( $tokens ) ); + } + /** * Ensure the token stream contains at most one trailing semicolon. * @@ -1352,6 +1375,8 @@ private function mysql_result_column_type_info( string $data_type, string $colum 'mediumtext' => array( 'BLOB', 252, null, 0 ), 'longtext' => array( 'BLOB', 252, null, 0 ), 'json' => array( 'BLOB', 245, 4294967295, 0 ), + 'enum' => array( 'STRING', 254, null, 0 ), + 'set' => array( 'STRING', 254, null, 0 ), 'date' => array( 'DATE', 10, 10, 0 ), 'time' => array( 'TIME', 11, 10, 0 ), 'datetime' => array( 'DATETIME', 12, 19, 0 ), @@ -10766,6 +10791,11 @@ private function canonical_mysql_column_type( string $column_type, WP_Parser_Tok $this->mysql_column_type_with_base( $column_type, 'char' ), '(1)' ); + + case WP_MySQL_Lexer::ENUM_SYMBOL: + case WP_MySQL_Lexer::SET_SYMBOL: + $canonical = preg_replace( '/^(enum|set)\s+\(/', '$1(', $column_type ); + return null === $canonical ? $column_type : $canonical; } return $column_type; @@ -10972,6 +11002,8 @@ private function mysql_type_has_collation( WP_Parser_Token $type_token ): bool { WP_MySQL_Lexer::TINYTEXT_SYMBOL, WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL, WP_MySQL_Lexer::LONGTEXT_SYMBOL, + WP_MySQL_Lexer::ENUM_SYMBOL, + WP_MySQL_Lexer::SET_SYMBOL, WP_MySQL_Lexer::NCHAR_SYMBOL, WP_MySQL_Lexer::NATIONAL_SYMBOL, WP_MySQL_Lexer::NVARCHAR_SYMBOL, @@ -13181,7 +13213,7 @@ private function parse_insert_select_target_shape( array $tokens, int $table_ind } $omitted_defaults = $explicit_columns - ? $this->omitted_non_strict_temporal_default_writes( $table_name, $temporary, $target_columns ) + ? $this->omitted_non_strict_implicit_default_writes( $table_name, $temporary, $target_columns ) : array(); return array( @@ -13719,7 +13751,7 @@ private function translate_insert_set_tokens_to_duckdb_sql( array $tokens, int $ $values[] = $value_sql; } - foreach ( $this->omitted_non_strict_temporal_default_writes( $reference['table_name'], $reference['temporary'], $supplied ) as $default_write ) { + foreach ( $this->omitted_non_strict_implicit_default_writes( $reference['table_name'], $reference['temporary'], $supplied ) as $default_write ) { $columns[] = $this->connection->quote_identifier( $default_write['column_name'] ); $values[] = $default_write['value_sql']; } @@ -13897,7 +13929,7 @@ private function parse_on_duplicate_insert_shape( array $tokens, int $table_inde } $values_by_column[ strtolower( $column_name ) ] = $value_sql; } - foreach ( $this->omitted_non_strict_temporal_default_writes( $reference['table_name'], $reference['temporary'], $columns ) as $default_write ) { + foreach ( $this->omitted_non_strict_implicit_default_writes( $reference['table_name'], $reference['temporary'], $columns ) as $default_write ) { $values_by_column[ strtolower( $default_write['column_name'] ) ] = $default_write['value_sql']; } @@ -14016,7 +14048,7 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde } $omitted_defaults = $coerce_for_storage - ? $this->omitted_non_strict_temporal_default_writes( $table_name, $temporary, $columns ) + ? $this->omitted_non_strict_implicit_default_writes( $table_name, $temporary, $columns ) : array(); $storage_columns = $columns; foreach ( $omitted_defaults as $default_write ) { @@ -14248,14 +14280,14 @@ private function write_column_metadata_map( string $table_name, bool $temporary } /** - * Return omitted non-strict temporal default writes in table ordinal order. + * Return omitted non-strict implicit default writes in table ordinal order. * * @param string $table_name Resolved DuckDB table name. * @param bool $temporary Whether the target is temporary. * @param string[] $supplied_columns MySQL-facing supplied column names. * @return array */ - private function omitted_non_strict_temporal_default_writes( string $table_name, bool $temporary, array $supplied_columns ): array { + private function omitted_non_strict_implicit_default_writes( string $table_name, bool $temporary, array $supplied_columns ): array { if ( $this->is_strict_sql_mode_active() ) { return array(); } @@ -14284,12 +14316,8 @@ private function omitted_non_strict_temporal_default_writes( string $table_name, continue; } - $data_type = $this->mysql_column_data_type( $metadata ); - if ( ! $this->is_temporal_write_data_type( $data_type ) ) { - continue; - } - - $implicit_default = $this->temporal_implicit_default( $data_type ); + $data_type = $this->mysql_column_data_type( $metadata ); + $implicit_default = $this->non_strict_implicit_default( $data_type ); if ( null === $implicit_default ) { continue; } @@ -14392,7 +14420,7 @@ private function coerce_write_value_for_column_sql( array $metadata, array $valu * @return bool Whether the type is character-backed. */ private function is_character_write_data_type( string $data_type ): bool { - return in_array( $data_type, array( 'char', 'varchar', 'text', 'tinytext', 'mediumtext', 'longtext', 'json' ), true ); + return in_array( $data_type, array( 'char', 'varchar', 'text', 'tinytext', 'mediumtext', 'longtext', 'json', 'enum', 'set' ), true ); } /** @@ -14963,6 +14991,20 @@ private function temporal_implicit_default( string $data_type ): ?string { return self::TEMPORAL_IMPLICIT_DEFAULT_MAP[ $data_type ] ?? null; } + /** + * Return an implicit default for non-strict omitted NOT NULL writes. + * + * @param string $data_type MySQL data type. + * @return string|null Implicit default, or null for unsupported types. + */ + private function non_strict_implicit_default( string $data_type ): ?string { + if ( isset( self::STRING_IMPLICIT_DEFAULT_MAP[ $data_type ] ) ) { + return self::STRING_IMPLICIT_DEFAULT_MAP[ $data_type ]; + } + + return $this->temporal_implicit_default( $data_type ); + } + /** * Check whether a value token list is exactly DEFAULT. * @@ -19440,6 +19482,12 @@ private function column_type_attributes( string $column_type, ?string $collation } elseif ( 'longtext' === $data_type || 'longblob' === $data_type ) { $char_length = 4294967295; $octet_length = 4294967295; + } elseif ( 'enum' === $data_type || 'set' === $data_type ) { + $values = $this->column_type_string_list_values( $normalized, $data_type ); + $char_length = $this->enum_set_character_maximum_length( $data_type, $values ); + if ( null !== $char_length ) { + $octet_length = $char_length * $this->charset_max_bytes( $charset ); + } } list( $numeric_precision, $numeric_scale ) = $this->numeric_attributes_from_data_type( $data_type, $normalized ); @@ -19493,6 +19541,79 @@ private function column_type_length( string $column_type ): ?int { return null; } + /** + * Extract decoded string-list values from an enum/set column type. + * + * @param string $column_type MySQL-facing column type. + * @param string $data_type Normalized data type. + * @return string[]|null Decoded string list, or null when the type shape is unsupported. + */ + private function column_type_string_list_values( string $column_type, string $data_type ): ?array { + $tokens = $this->tokenize_fragment( $column_type ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || ! in_array( $tokens[0]->id, array( WP_MySQL_Lexer::ENUM_SYMBOL, WP_MySQL_Lexer::SET_SYMBOL ), true ) + || strtolower( $tokens[0]->get_bytes() ) !== $data_type + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + list( $items, $index ) = $this->collect_parenthesized_items( $tokens, 2 ); + if ( count( $tokens ) !== $index ) { + return null; + } + + $values = array(); + foreach ( $items as $item ) { + if ( + 1 !== count( $item ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $item[0]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $item[0]->id + ) + ) { + return null; + } + + $values[] = $item[0]->get_value(); + } + + return $values; + } + + /** + * Match SQLite-driver enum/set maximum character length derivation. + * + * @param string $data_type Normalized data type. + * @param string[]|null $values Decoded enum/set values. + * @return int|null Maximum character length, or null when unavailable. + */ + private function enum_set_character_maximum_length( string $data_type, ?array $values ): ?int { + if ( null === $values ) { + return null; + } + + $length = 0; + foreach ( $values as $value ) { + if ( 'enum' === $data_type ) { + $length = max( $length, strlen( $value ) ); + } else { + $length += strlen( $value ); + } + } + + if ( 'set' === $data_type ) { + if ( 2 === count( $values ) ) { + $length += 1; + } elseif ( count( $values ) > 2 ) { + $length += 2; + } + } + + return $length; + } + /** * Derive numeric precision and scale. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 638224d79..cbdddb5f2 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -3494,6 +3494,52 @@ public function test_year_type_family_storage_and_metadata_matches_sqlite(): voi ); } + public function test_enum_set_type_family_storage_and_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE enum_set_type_family ( + id INT PRIMARY KEY, + status ENUM('red', 'green', 'blue') NOT NULL DEFAULT 'green', + flags SET('read', 'write', 'exec') DEFAULT 'read,exec', + quoted ENUM('plain', 'b''b', 'longer') DEFAULT 'b''b' + )", + ) + ); + + $this->assertParityRowCount( 'INSERT INTO enum_set_type_family (id) VALUES (1)' ); + $this->assertParityRowCount( "INSERT INTO enum_set_type_family (id, status, flags, quoted) VALUES (2, 'purple', 'read,bogus', 'plain')" ); + $this->assertParityRowCount( "INSERT INTO enum_set_type_family (id, status, flags, quoted) VALUES (3, '', '', 'longer')" ); + $this->assertParityRowCount( "UPDATE enum_set_type_family SET status = 'cerulean', flags = 'anything' WHERE id = 3" ); + + $this->assertParityRows( 'SELECT id, status, flags, quoted FROM enum_set_type_family ORDER BY id' ); + $this->assertParityRows( 'SHOW COLUMNS FROM enum_set_type_family' ); + $this->assertParityRows( 'SHOW FULL COLUMNS FROM enum_set_type_family' ); + $this->assertParityRows( 'DESCRIBE enum_set_type_family' ); + $this->assertParityRows( 'SHOW CREATE TABLE enum_set_type_family' ); + $this->assertParityRows( + "SELECT COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE, + CHARACTER_MAXIMUM_LENGTH, CHARACTER_OCTET_LENGTH, + CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'enum_set_type_family' + ORDER BY ordinal_position" + ); + + $this->assertParityRowCount( "SET SESSION sql_mode = ''" ); + $this->runParitySetup( + array( + "CREATE TABLE enum_set_implicit_defaults ( + id INT PRIMARY KEY, + status ENUM('red', 'green') NOT NULL, + flags SET('read', 'write') NOT NULL + )", + ) + ); + $this->assertParityRowCount( 'INSERT INTO enum_set_implicit_defaults (id) VALUES (1)' ); + $this->assertParityRows( 'SELECT id, status, flags FROM enum_set_implicit_defaults ORDER BY id' ); + } + public function test_national_character_type_family_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index a96cd32db..120f66f76 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -3951,6 +3951,231 @@ public function test_json_column_storage_and_direct_metadata(): void { ); } + public function test_enum_set_storage_and_direct_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE enum_set_type_metadata ( + id INT PRIMARY KEY, + status ENUM('red', 'green', 'blue') NOT NULL DEFAULT 'green', + flags SET('read', 'write', 'exec') DEFAULT 'read,exec', + quoted ENUM('plain', 'b''b', 'longer') DEFAULT 'b''b' + )" + ); + + $driver->query( 'INSERT INTO enum_set_type_metadata (id) VALUES (1)' ); + $driver->query( "INSERT INTO enum_set_type_metadata (id, status, flags, quoted) VALUES (2, 'purple', 'read,bogus', 'plain')" ); + $driver->query( "INSERT INTO enum_set_type_metadata (id, status, flags, quoted) VALUES (3, '', '', 'longer')" ); + $driver->query( "UPDATE enum_set_type_metadata SET status = 'cerulean', flags = 'anything' WHERE id = 3" ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'status' => 'green', + 'flags' => 'read,exec', + 'quoted' => "b'b", + ), + array( + 'id' => 2, + 'status' => 'purple', + 'flags' => 'read,bogus', + 'quoted' => 'plain', + ), + array( + 'id' => 3, + 'status' => 'cerulean', + 'flags' => 'anything', + 'quoted' => 'longer', + ), + ), + $driver->query( 'SELECT id, status, flags, quoted FROM enum_set_type_metadata ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + array( + 'Field' => 'status', + 'Type' => "enum('red','green','blue')", + 'Null' => 'NO', + 'Key' => '', + 'Default' => 'green', + 'Extra' => '', + ), + array( + 'Field' => 'flags', + 'Type' => "set('read','write','exec')", + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'read,exec', + 'Extra' => '', + ), + array( + 'Field' => 'quoted', + 'Type' => "enum('plain','b''b','longer')", + 'Null' => 'YES', + 'Key' => '', + 'Default' => "b'b", + 'Extra' => '', + ), + ), + array_slice( $driver->query( 'SHOW COLUMNS FROM enum_set_type_metadata' )->fetchAll( PDO::FETCH_ASSOC ), 1 ) + ); + + $this->assertSame( + array( + array( + 'Field' => 'status', + 'Type' => "enum('red','green','blue')", + 'Collation' => 'utf8mb4_0900_ai_ci', + 'Null' => 'NO', + 'Key' => '', + 'Default' => 'green', + 'Extra' => '', + 'Privileges' => 'select,insert,update,references', + 'Comment' => '', + ), + array( + 'Field' => 'flags', + 'Type' => "set('read','write','exec')", + 'Collation' => 'utf8mb4_0900_ai_ci', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'read,exec', + 'Extra' => '', + 'Privileges' => 'select,insert,update,references', + 'Comment' => '', + ), + array( + 'Field' => 'quoted', + 'Type' => "enum('plain','b''b','longer')", + 'Collation' => 'utf8mb4_0900_ai_ci', + 'Null' => 'YES', + 'Key' => '', + 'Default' => "b'b", + 'Extra' => '', + 'Privileges' => 'select,insert,update,references', + 'Comment' => '', + ), + ), + array_slice( $driver->query( 'SHOW FULL COLUMNS FROM enum_set_type_metadata' )->fetchAll( PDO::FETCH_ASSOC ), 1 ) + ); + + $this->assertSame( + array( + array( + 'COLUMN_NAME' => 'status', + 'COLUMN_DEFAULT' => 'green', + 'IS_NULLABLE' => 'NO', + 'DATA_TYPE' => 'enum', + 'CHARACTER_MAXIMUM_LENGTH' => 5, + 'CHARACTER_OCTET_LENGTH' => 20, + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + 'COLUMN_TYPE' => "enum('red','green','blue')", + ), + array( + 'COLUMN_NAME' => 'flags', + 'COLUMN_DEFAULT' => 'read,exec', + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'set', + 'CHARACTER_MAXIMUM_LENGTH' => 15, + 'CHARACTER_OCTET_LENGTH' => 60, + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + 'COLUMN_TYPE' => "set('read','write','exec')", + ), + array( + 'COLUMN_NAME' => 'quoted', + 'COLUMN_DEFAULT' => "b'b", + 'IS_NULLABLE' => 'YES', + 'DATA_TYPE' => 'enum', + 'CHARACTER_MAXIMUM_LENGTH' => 6, + 'CHARACTER_OCTET_LENGTH' => 24, + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + 'COLUMN_TYPE' => "enum('plain','b''b','longer')", + ), + ), + $driver->query( + "SELECT COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE, + CHARACTER_MAXIMUM_LENGTH, CHARACTER_OCTET_LENGTH, + CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'enum_set_type_metadata' + AND column_name IN ('status', 'flags', 'quoted') + ORDER BY ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create_sql = $driver->query( 'SHOW CREATE TABLE enum_set_type_metadata' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + $this->assertStringContainsString( "`status` enum('red','green','blue') NOT NULL DEFAULT 'green'", $create_sql ); + $this->assertStringContainsString( "`flags` set('read','write','exec') DEFAULT 'read,exec'", $create_sql ); + $this->assertStringContainsString( "`quoted` enum('plain','b''b','longer') DEFAULT 'b''b'", $create_sql ); + $this->assertStringNotContainsString( 'VARCHAR', $create_sql ); + + $result = $driver->query( 'SELECT status, flags, quoted FROM enum_set_type_metadata WHERE 0 = 1' ); + $this->assertSame( 3, $result->columnCount() ); + $this->assertSame( array(), $result->fetchAll( PDO::FETCH_ASSOC ) ); + + $expected_metadata = array( + array( + 'native_type' => 'STRING', + 'table' => 'enum_set_type_metadata', + 'name' => 'status', + 'len' => 20, + 'precision' => 0, + 'duckdb:decl_type' => "enum('red','green','blue')", + 'mysqli:orgname' => 'status', + 'mysqli:orgtable' => 'enum_set_type_metadata', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 254, + ), + array( + 'native_type' => 'STRING', + 'table' => 'enum_set_type_metadata', + 'name' => 'flags', + 'len' => 60, + 'precision' => 0, + 'duckdb:decl_type' => "set('read','write','exec')", + 'mysqli:orgname' => 'flags', + 'mysqli:orgtable' => 'enum_set_type_metadata', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 254, + ), + array( + 'native_type' => 'STRING', + 'table' => 'enum_set_type_metadata', + 'name' => 'quoted', + 'len' => 24, + 'precision' => 0, + 'duckdb:decl_type' => "enum('plain','b''b','longer')", + 'mysqli:orgname' => 'quoted', + 'mysqli:orgtable' => 'enum_set_type_metadata', + 'mysqli:db' => 'wp', + 'mysqli:charsetnr' => 255, + 'mysqli:type' => 254, + ), + ); + + foreach ( $expected_metadata as $index => $expected ) { + $metadata = $result->getColumnMeta( $index ); + foreach ( $expected as $key => $value ) { + $this->assertArrayHasKey( $key, $metadata, $expected['name'] . ' metadata key ' . $key ); + $this->assertSame( $value, $metadata[ $key ], $expected['name'] . ' metadata key ' . $key ); + } + } + } + public function test_type_default_charset_metadata_rows_for_supported_mysql_types(): void { $this->requireDuckDBRuntime(); From 62b8f528cf8e90a8a7962d78e389bde452dea26b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 10:31:17 +0000 Subject: [PATCH 117/317] Add DuckDB SERIAL and spatial type parity --- .../src/duckdb/class-wp-duckdb-driver.php | 176 +++++++++++------- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 81 ++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 39 ++++ 3 files changed, 224 insertions(+), 72 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index b603349f1..c27478620 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -67,42 +67,52 @@ class WP_DuckDB_Driver { ); const DATA_TYPE_MAP = array( - WP_MySQL_Lexer::BIT_SYMBOL => 'BIGINT', - WP_MySQL_Lexer::BOOL_SYMBOL => 'BOOLEAN', - WP_MySQL_Lexer::BOOLEAN_SYMBOL => 'BOOLEAN', - WP_MySQL_Lexer::TINYINT_SYMBOL => 'TINYINT', - WP_MySQL_Lexer::SMALLINT_SYMBOL => 'SMALLINT', - WP_MySQL_Lexer::MEDIUMINT_SYMBOL => 'INTEGER', - WP_MySQL_Lexer::INT_SYMBOL => 'INTEGER', - WP_MySQL_Lexer::INTEGER_SYMBOL => 'INTEGER', - WP_MySQL_Lexer::BIGINT_SYMBOL => 'BIGINT', - WP_MySQL_Lexer::FLOAT_SYMBOL => 'FLOAT', - WP_MySQL_Lexer::DOUBLE_SYMBOL => 'DOUBLE', - WP_MySQL_Lexer::REAL_SYMBOL => 'DOUBLE', - WP_MySQL_Lexer::DECIMAL_SYMBOL => 'DECIMAL', - WP_MySQL_Lexer::DEC_SYMBOL => 'DECIMAL', - WP_MySQL_Lexer::FIXED_SYMBOL => 'DECIMAL', - WP_MySQL_Lexer::NUMERIC_SYMBOL => 'DECIMAL', - WP_MySQL_Lexer::CHAR_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::VARCHAR_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::TEXT_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::TINYTEXT_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::LONGTEXT_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::JSON_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::ENUM_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::SET_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::DATE_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::TIME_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::DATETIME_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::TIMESTAMP_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::YEAR_SYMBOL => 'VARCHAR', - WP_MySQL_Lexer::BLOB_SYMBOL => 'BLOB', - WP_MySQL_Lexer::TINYBLOB_SYMBOL => 'BLOB', - WP_MySQL_Lexer::MEDIUMBLOB_SYMBOL => 'BLOB', - WP_MySQL_Lexer::LONGBLOB_SYMBOL => 'BLOB', - WP_MySQL_Lexer::BINARY_SYMBOL => 'BLOB', - WP_MySQL_Lexer::VARBINARY_SYMBOL => 'BLOB', + WP_MySQL_Lexer::BIT_SYMBOL => 'BIGINT', + WP_MySQL_Lexer::BOOL_SYMBOL => 'BOOLEAN', + WP_MySQL_Lexer::BOOLEAN_SYMBOL => 'BOOLEAN', + WP_MySQL_Lexer::TINYINT_SYMBOL => 'TINYINT', + WP_MySQL_Lexer::SMALLINT_SYMBOL => 'SMALLINT', + WP_MySQL_Lexer::MEDIUMINT_SYMBOL => 'INTEGER', + WP_MySQL_Lexer::INT_SYMBOL => 'INTEGER', + WP_MySQL_Lexer::INTEGER_SYMBOL => 'INTEGER', + WP_MySQL_Lexer::BIGINT_SYMBOL => 'BIGINT', + WP_MySQL_Lexer::FLOAT_SYMBOL => 'FLOAT', + WP_MySQL_Lexer::DOUBLE_SYMBOL => 'DOUBLE', + WP_MySQL_Lexer::REAL_SYMBOL => 'DOUBLE', + WP_MySQL_Lexer::DECIMAL_SYMBOL => 'DECIMAL', + WP_MySQL_Lexer::DEC_SYMBOL => 'DECIMAL', + WP_MySQL_Lexer::FIXED_SYMBOL => 'DECIMAL', + WP_MySQL_Lexer::NUMERIC_SYMBOL => 'DECIMAL', + WP_MySQL_Lexer::CHAR_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::VARCHAR_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::TEXT_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::TINYTEXT_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::MEDIUMTEXT_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::LONGTEXT_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::JSON_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::ENUM_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::SET_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::DATE_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::TIME_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::DATETIME_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::TIMESTAMP_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::YEAR_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::BLOB_SYMBOL => 'BLOB', + WP_MySQL_Lexer::TINYBLOB_SYMBOL => 'BLOB', + WP_MySQL_Lexer::MEDIUMBLOB_SYMBOL => 'BLOB', + WP_MySQL_Lexer::LONGBLOB_SYMBOL => 'BLOB', + WP_MySQL_Lexer::BINARY_SYMBOL => 'BLOB', + WP_MySQL_Lexer::VARBINARY_SYMBOL => 'BLOB', + WP_MySQL_Lexer::SERIAL_SYMBOL => 'BIGINT', + WP_MySQL_Lexer::GEOMETRY_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::POINT_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::LINESTRING_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::POLYGON_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::MULTIPOINT_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::MULTILINESTRING_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::MULTIPOLYGON_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::GEOMCOLLECTION_SYMBOL => 'VARCHAR', + WP_MySQL_Lexer::GEOMETRYCOLLECTION_SYMBOL => 'VARCHAR', ); const TEMPORAL_IMPLICIT_DEFAULT_MAP = array( @@ -1359,35 +1369,43 @@ private function mysql_result_column_metadata( string $table_name, string $table */ private function mysql_result_column_type_info( string $data_type, string $column_type ): array { $type_map = array( - 'bit' => array( 'BIT', 16, 1, 0 ), - 'tinyint' => array( 'TINY', 1, 4, 0 ), - 'smallint' => array( 'SHORT', 2, 6, 0 ), - 'mediumint' => array( 'INT24', 9, 9, 0 ), - 'int' => array( 'LONG', 3, 11, 0 ), - 'bigint' => array( 'LONGLONG', 8, 20, 0 ), - 'float' => array( 'FLOAT', 4, 12, 31 ), - 'double' => array( 'DOUBLE', 5, 22, 31 ), - 'decimal' => array( 'NEWDECIMAL', 246, null, null ), - 'char' => array( 'STRING', 254, null, 0 ), - 'varchar' => array( 'VAR_STRING', 253, null, 0 ), - 'tinytext' => array( 'BLOB', 252, null, 0 ), - 'text' => array( 'BLOB', 252, null, 0 ), - 'mediumtext' => array( 'BLOB', 252, null, 0 ), - 'longtext' => array( 'BLOB', 252, null, 0 ), - 'json' => array( 'BLOB', 245, 4294967295, 0 ), - 'enum' => array( 'STRING', 254, null, 0 ), - 'set' => array( 'STRING', 254, null, 0 ), - 'date' => array( 'DATE', 10, 10, 0 ), - 'time' => array( 'TIME', 11, 10, 0 ), - 'datetime' => array( 'DATETIME', 12, 19, 0 ), - 'timestamp' => array( 'TIMESTAMP', 7, 19, 0 ), - 'year' => array( 'YEAR', 13, 4, 0 ), - 'binary' => array( 'BLOB', 254, null, 0 ), - 'varbinary' => array( 'BLOB', 253, null, 0 ), - 'tinyblob' => array( 'BLOB', 252, null, 0 ), - 'blob' => array( 'BLOB', 252, null, 0 ), - 'mediumblob' => array( 'BLOB', 252, null, 0 ), - 'longblob' => array( 'BLOB', 252, null, 0 ), + 'bit' => array( 'BIT', 16, 1, 0 ), + 'tinyint' => array( 'TINY', 1, 4, 0 ), + 'smallint' => array( 'SHORT', 2, 6, 0 ), + 'mediumint' => array( 'INT24', 9, 9, 0 ), + 'int' => array( 'LONG', 3, 11, 0 ), + 'bigint' => array( 'LONGLONG', 8, 20, 0 ), + 'float' => array( 'FLOAT', 4, 12, 31 ), + 'double' => array( 'DOUBLE', 5, 22, 31 ), + 'decimal' => array( 'NEWDECIMAL', 246, null, null ), + 'char' => array( 'STRING', 254, null, 0 ), + 'varchar' => array( 'VAR_STRING', 253, null, 0 ), + 'tinytext' => array( 'BLOB', 252, null, 0 ), + 'text' => array( 'BLOB', 252, null, 0 ), + 'mediumtext' => array( 'BLOB', 252, null, 0 ), + 'longtext' => array( 'BLOB', 252, null, 0 ), + 'json' => array( 'BLOB', 245, 4294967295, 0 ), + 'enum' => array( 'STRING', 254, null, 0 ), + 'set' => array( 'STRING', 254, null, 0 ), + 'date' => array( 'DATE', 10, 10, 0 ), + 'time' => array( 'TIME', 11, 10, 0 ), + 'datetime' => array( 'DATETIME', 12, 19, 0 ), + 'timestamp' => array( 'TIMESTAMP', 7, 19, 0 ), + 'year' => array( 'YEAR', 13, 4, 0 ), + 'binary' => array( 'BLOB', 254, null, 0 ), + 'varbinary' => array( 'BLOB', 253, null, 0 ), + 'tinyblob' => array( 'BLOB', 252, null, 0 ), + 'blob' => array( 'BLOB', 252, null, 0 ), + 'mediumblob' => array( 'BLOB', 252, null, 0 ), + 'longblob' => array( 'BLOB', 252, null, 0 ), + 'geometry' => array( 'GEOMETRY', 255, 4294967295, 0 ), + 'point' => array( 'GEOMETRY', 255, 4294967295, 0 ), + 'linestring' => array( 'GEOMETRY', 255, 4294967295, 0 ), + 'polygon' => array( 'GEOMETRY', 255, 4294967295, 0 ), + 'multipoint' => array( 'GEOMETRY', 255, 4294967295, 0 ), + 'multilinestring' => array( 'GEOMETRY', 255, 4294967295, 0 ), + 'multipolygon' => array( 'GEOMETRY', 255, 4294967295, 0 ), + 'geomcollection' => array( 'GEOMETRY', 255, 4294967295, 0 ), ); $type_info = $type_map[ $data_type ] ?? array( 'VAR_STRING', 253, null, 0 ); @@ -10427,6 +10445,11 @@ private function translate_create_table_column( string $table_name, array $token } } + if ( WP_MySQL_Lexer::SERIAL_SYMBOL === $type_token->id ) { + $not_null = true; + $auto_increment = true; + } + if ( $auto_increment && null !== $default_sql ) { throw new WP_DuckDB_Driver_Exception( 'AUTO_INCREMENT columns cannot also declare DEFAULT in the DuckDB driver.' ); } @@ -10494,7 +10517,7 @@ private function translate_create_table_column( string $table_name, array $token 'column_name' => $column_name, 'column_type' => $this->mysql_column_type_from_tokens( $tokens, $type_index ), 'is_nullable' => $not_null ? 'NO' : 'YES', - 'column_key' => $primary_key ? 'PRI' : ( $unique_key ? 'UNI' : '' ), + 'column_key' => $primary_key || WP_MySQL_Lexer::SERIAL_SYMBOL === $type_token->id ? 'PRI' : ( $unique_key ? 'UNI' : '' ), 'column_default' => $column_default, 'extra' => $auto_increment ? 'auto_increment' : '', 'collation_name' => $metadata_collation_name, @@ -10796,6 +10819,13 @@ private function canonical_mysql_column_type( string $column_type, WP_Parser_Tok case WP_MySQL_Lexer::SET_SYMBOL: $canonical = preg_replace( '/^(enum|set)\s+\(/', '$1(', $column_type ); return null === $canonical ? $column_type : $canonical; + + case WP_MySQL_Lexer::SERIAL_SYMBOL: + return 'bigint unsigned'; + + case WP_MySQL_Lexer::GEOMCOLLECTION_SYMBOL: + case WP_MySQL_Lexer::GEOMETRYCOLLECTION_SYMBOL: + return 'geomcollection'; } return $column_type; @@ -19516,12 +19546,14 @@ private function data_type_from_column_type( string $column_type ): string { } $map = array( - 'integer' => 'int', - 'boolean' => 'tinyint', - 'real' => 'double', - 'dec' => 'decimal', - 'fixed' => 'decimal', - 'numeric' => 'decimal', + 'integer' => 'int', + 'boolean' => 'tinyint', + 'real' => 'double', + 'dec' => 'decimal', + 'fixed' => 'decimal', + 'numeric' => 'decimal', + 'serial' => 'bigint', + 'geometrycollection' => 'geomcollection', ); return $map[ $data_type ] ?? $data_type; diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index cbdddb5f2..63a21b55e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -3379,6 +3379,87 @@ public function test_information_schema_columns_metadata_matches_sqlite(): void ); } + public function test_serial_alias_storage_metadata_and_insert_id_shape_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE serial_type_family ( + id SERIAL, + name VARCHAR(20) + )', + ) + ); + + $this->assertParityRowCount( "INSERT INTO serial_type_family (name) VALUES ('first')" ); + $this->assertParityRowCount( "INSERT INTO serial_type_family (name) VALUES ('second')" ); + $this->assertParityRows( 'SELECT id, name FROM serial_type_family ORDER BY id' ); + $this->assertParityRows( 'SHOW COLUMNS FROM serial_type_family' ); + $this->assertParityRows( 'DESCRIBE serial_type_family' ); + $this->assertParityRows( 'SHOW CREATE TABLE serial_type_family' ); + $this->assertParityRowColumns( + 'SHOW INDEX FROM serial_type_family', + array( 'Table', 'Non_unique', 'Key_name', 'Seq_in_index', 'Column_name', 'Sub_part' ) + ); + $this->assertParityRows( + "SELECT COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE, + CHARACTER_MAXIMUM_LENGTH, CHARACTER_OCTET_LENGTH, + NUMERIC_PRECISION, NUMERIC_SCALE, COLUMN_TYPE, COLUMN_KEY, EXTRA + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'serial_type_family' + ORDER BY ordinal_position" + ); + } + + public function test_spatial_type_family_storage_and_metadata_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE spatial_type_family ( + id INT PRIMARY KEY, + geom GEOMETRY, + pt POINT, + line LINESTRING, + poly POLYGON, + mpt MULTIPOINT, + mline MULTILINESTRING, + mpoly MULTIPOLYGON, + gc GEOMETRYCOLLECTION + )', + ) + ); + + $this->assertParityRowCount( + "INSERT INTO spatial_type_family + (id, geom, pt, line, poly, mpt, mline, mpoly, gc) + VALUES + ( + 1, + 'POINT(1 1)', + 'POINT(2 2)', + 'LINESTRING(0 0, 1 1)', + 'POLYGON((0 0, 1 0, 0 1, 0 0))', + 'MULTIPOINT(1 1, 2 2)', + 'MULTILINESTRING((0 0, 1 1), (2 2, 3 3))', + 'MULTIPOLYGON(((0 0, 1 0, 0 1, 0 0)))', + 'GEOMETRYCOLLECTION(POINT(1 1), LINESTRING(0 0, 1 1))' + )" + ); + + $this->assertParityRows( 'SELECT id, geom, pt, line, poly, mpt, mline, mpoly, gc FROM spatial_type_family ORDER BY id' ); + $this->assertParityRows( 'SHOW COLUMNS FROM spatial_type_family' ); + $this->assertParityRows( 'SHOW FULL COLUMNS FROM spatial_type_family' ); + $this->assertParityRows( 'DESCRIBE spatial_type_family' ); + $this->assertParityRows( 'SHOW CREATE TABLE spatial_type_family' ); + $this->assertParityRows( + "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, + CHARACTER_OCTET_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, + CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_TYPE + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'spatial_type_family' + ORDER BY ordinal_position" + ); + } + public function test_alias_storage_type_family_metadata_matches_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 120f66f76..047b77399 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -5805,6 +5805,45 @@ public function test_insert_id_tracks_generated_auto_increment_values(): void { $this->fail( 'Expected duplicate insert to fail.' ); } + public function test_serial_alias_tracks_generated_insert_id_and_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE serial_items (id SERIAL, name VARCHAR(20))' ); + + $driver->query( "INSERT INTO serial_items (name) VALUES ('first')" ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $driver->query( "INSERT INTO serial_items (name) VALUES ('second')" ); + $this->assertSame( 2, $driver->get_insert_id() ); + + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'first', + ), + array( + 'id' => 2, + 'name' => 'second', + ), + ), + $driver->query( 'SELECT id, name FROM serial_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + 'Field' => 'id', + 'Type' => 'bigint unsigned', + 'Null' => 'NO', + 'Key' => 'PRI', + 'Default' => null, + 'Extra' => 'auto_increment', + ), + $driver->query( 'SHOW COLUMNS FROM serial_items WHERE Field = "id"' )->fetch( PDO::FETCH_ASSOC ) + ); + } + public function test_insert_id_tracks_on_duplicate_key_update_policy(): void { $this->requireDuckDBRuntime(); From c53a1dd7a346a6f7048d4f5283272f9ccd476caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 10:31:25 +0000 Subject: [PATCH 118/317] Enable DuckDB WordPress Docker FFI runtime --- .github/workflows/duckdb-phpunit-tests.yml | 2 + bin/wp-test-ensure-env-duckdb.sh | 141 +++++++++++++++++++++ composer.json | 4 +- 3 files changed, 144 insertions(+), 3 deletions(-) create mode 100755 bin/wp-test-ensure-env-duckdb.sh diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index f6f9b0d0a..7effcbaae 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -7,6 +7,7 @@ on: paths: - '.github/workflows/duckdb-phpunit-tests.yml' - '.github/workflows/wp-tests-phpunit-run.js' + - 'bin/wp-test-ensure-env-duckdb.sh' - 'composer.json' - 'wp-setup.sh' - 'packages/mysql-on-sqlite/composer.json' @@ -22,6 +23,7 @@ on: paths: - '.github/workflows/duckdb-phpunit-tests.yml' - '.github/workflows/wp-tests-phpunit-run.js' + - 'bin/wp-test-ensure-env-duckdb.sh' - 'composer.json' - 'wp-setup.sh' - 'packages/mysql-on-sqlite/composer.json' diff --git a/bin/wp-test-ensure-env-duckdb.sh b/bin/wp-test-ensure-env-duckdb.sh new file mode 100755 index 000000000..a48bb5c42 --- /dev/null +++ b/bin/wp-test-ensure-env-duckdb.sh @@ -0,0 +1,141 @@ +#!/bin/bash + +## +# Ensure the WordPress Docker test environment is configured for DuckDB. +## + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd -P)" +WP_DIR="$ROOT_DIR/wordpress" +COMPOSE_OVERRIDE="$WP_DIR/docker-compose.override.yml" + +PHP_BASE_IMAGE="wordpressdevelop/php@sha256:c0ba85936a9d1ac2c98bf3da2d62ceb0e5787a6b11e383630df0c5a5bf2534b5" +CLI_BASE_IMAGE="wordpressdevelop/cli@sha256:85ad7d7a9c3bd9a8775fc83aea7f7dfc0aad25b2bc4f7d740696b28cd2a0ef89" +PHP_FFI_IMAGE="sqlite-duckdb-wordpress-php:8.3.10-ffi" +CLI_FFI_IMAGE="sqlite-duckdb-wordpress-cli:8.3.10-ffi" + +needs_duckdb_setup() { + if [ ! -f "$WP_DIR/src/wp-load.php" ]; then + return 0 + fi + + if ! grep -q "define( 'DB_ENGINE', 'duckdb' );" "$WP_DIR/src/wp-content/db.php" 2> /dev/null; then + return 0 + fi + + if [ ! -f "$COMPOSE_OVERRIDE" ]; then + return 0 + fi + + return 1 +} + +ensure_duckdb_setup() { + if needs_duckdb_setup; then + ( + cd "$ROOT_DIR" + composer run wp-setup-duckdb + ) + fi +} + +ensure_docker_available() { + if ! command -v docker > /dev/null; then + echo 'Error: Docker is required to run WordPress DuckDB tests.' >&2 + exit 1 + fi + + docker info > /dev/null +} + +write_ffi_dockerfile() { + local image_dir="$WP_DIR/duckdb-php-ffi-image" + + mkdir -p "$image_dir" + cat > "$image_dir/Dockerfile" <<'EOF' +ARG BASE_IMAGE +FROM ${BASE_IMAGE} + +USER root + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends libffi-dev; \ + docker-php-ext-install ffi; \ + rm -rf /var/lib/apt/lists/* +EOF +} + +build_ffi_images() { + local image_dir="$WP_DIR/duckdb-php-ffi-image" + + write_ffi_dockerfile + if ! docker image inspect "$PHP_FFI_IMAGE" > /dev/null 2>&1; then + docker build --build-arg "BASE_IMAGE=$PHP_BASE_IMAGE" --tag "$PHP_FFI_IMAGE" "$image_dir" + fi + if ! docker image inspect "$CLI_FFI_IMAGE" > /dev/null 2>&1; then + docker build --build-arg "BASE_IMAGE=$CLI_BASE_IMAGE" --tag "$CLI_FFI_IMAGE" "$image_dir" + fi +} + +use_ffi_images_in_compose_override() { + sed -i.bak \ + -e "s#image: $PHP_BASE_IMAGE#image: $PHP_FFI_IMAGE#g" \ + -e "s#image: $CLI_BASE_IMAGE#image: $CLI_FFI_IMAGE#g" \ + "$COMPOSE_OVERRIDE" + rm -f "$COMPOSE_OVERRIDE.bak" + + if ! grep -q "image: $PHP_FFI_IMAGE" "$COMPOSE_OVERRIDE"; then + echo 'Error: Could not configure the WordPress PHP container to use the DuckDB FFI image.' >&2 + exit 1 + fi + + if ! grep -q "image: $CLI_FFI_IMAGE" "$COMPOSE_OVERRIDE"; then + echo 'Error: Could not configure the WordPress CLI container to use the DuckDB FFI image.' >&2 + exit 1 + fi +} + +start_wordpress_environment() { + export COMPOSE_IGNORE_ORPHANS=true + + if [ -n "$(cd "$WP_DIR" && node tools/local-env/scripts/docker.js ps -q)" ]; then + ( + cd "$WP_DIR" + docker compose -f docker-compose.yml -f docker-compose.override.yml up -d --no-deps --force-recreate php + ) + return + fi + + ( + cd "$ROOT_DIR" + composer run wp-test-start + ) +} + +verify_service_ffi() { + local service="$1" + + if ! ( cd "$WP_DIR" && docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm "$service" php -m ) | grep -qi '^FFI$'; then + echo "Error: PHP FFI extension is not loaded in the WordPress $service container." >&2 + exit 1 + fi + + if ! ( cd "$WP_DIR" && docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm "$service" php -i ) | grep -Eiq '^ffi\.enable => (1|on|true) => (1|on|true)$'; then + echo "Error: PHP FFI is not enabled in the WordPress $service container." >&2 + exit 1 + fi +} + +verify_ffi_runtime() { + verify_service_ffi php + verify_service_ffi cli +} + +ensure_duckdb_setup +ensure_docker_available +build_ffi_images +use_ffi_images_in_compose_override +start_wordpress_environment +verify_ffi_runtime diff --git a/composer.json b/composer.json index 912fc41d4..262e91e00 100644 --- a/composer.json +++ b/composer.json @@ -68,9 +68,7 @@ "cd wordpress && if [ -z \"$(node tools/local-env/scripts/docker.js ps -q)\" ]; then cd ..; composer run wp-test-start; fi" ], "wp-test-ensure-env-duckdb": [ - "if [ ! -f wordpress/src/wp-load.php ] || ! grep -q \"define( 'DB_ENGINE', 'duckdb' );\" wordpress/src/wp-content/db.php 2>/dev/null; then composer run wp-setup-duckdb; fi", - "@putenv COMPOSE_IGNORE_ORPHANS=true", - "cd wordpress && if [ -z \"$(node tools/local-env/scripts/docker.js ps -q)\" ]; then cd ..; composer run wp-test-start; fi" + "./bin/wp-test-ensure-env-duckdb.sh @no_additional_args" ], "wp-test-php": [ "@wp-test-ensure-env @no_additional_args", From d9b4907edb2f198738926fe9d4b65a16548ba748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 10:31:34 +0000 Subject: [PATCH 119/317] Document DuckDB verification paths --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 514bc466b..417d30be3 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ composer run wp-test-start # Start WordPress environment (Docker) composer run wp-test-php # Run WordPress PHPUnit tests composer run wp-test-e2e # Run WordPress E2E tests (Playwright) composer run wp-test-clean # Clean up WordPress environment (Docker and DB) + +# Optional DuckDB verification +DUCKDB_PHP_AUTOLOAD=/tmp/wp-duckdb-php/vendor/autoload.php composer run wp-smoke-duckdb-local +DUCKDB_PHP_AUTOLOAD=/tmp/wp-duckdb-php/vendor/autoload.php composer run wp-test-php-duckdb +DUCKDB_PHP_AUTOLOAD=/tmp/wp-duckdb-php/vendor/autoload.php composer run wp-test-e2e-duckdb ``` ## Optional: Native MySQL Parser Extension @@ -70,12 +75,12 @@ Latest local measurement (Apple Silicon macOS, PHP 8.4.5 CLI, 2026-05-26): the n ## Optional: DuckDB backend -DuckDB support is experimental and is not part of the default SQLite runtime or -default CI matrix. It uses the third-party DuckDB PHP client documented by -DuckDB. That client uses FFI, requires PHP 8.3 or newer and `ext-ffi`, and -DuckDB recommends the automatic Composer install with `satur.io/duckdb-auto`. -See the [DuckDB PHP client docs](https://duckdb.org/docs/lts/clients/php) and -the [saturio/duckdb-php installation docs](https://duckdb-php.readthedocs.io/en/latest/installation/). +DuckDB support is experimental and is not part of the default SQLite runtime. It +uses the third-party DuckDB PHP client documented by DuckDB. That client uses +FFI, requires PHP 8.3 or newer and `ext-ffi`, and DuckDB recommends the +automatic Composer install with `satur.io/duckdb-auto`. See the +[DuckDB PHP client docs](https://duckdb.org/docs/lts/clients/php) and the +[saturio/duckdb-php installation docs](https://duckdb-php.readthedocs.io/en/latest/installation/). Install the DuckDB PHP client outside the default project dependency graph, then point this project at that Composer autoloader. The commands below install @@ -106,7 +111,7 @@ define( 'DUCKDB_PHP_AUTOLOAD', '/tmp/wp-duckdb-php/vendor/autoload.php' ); If your local WordPress launcher maps environment variables into `wp-config.php`, set `DB_ENGINE=duckdb` and `DUCKDB_PHP_AUTOLOAD=/tmp/wp-duckdb-php/vendor/autoload.php`. -Exact local verification commands for this branch: +Exact verification commands for this branch: ```bash cd packages/mysql-on-sqlite @@ -118,13 +123,46 @@ DUCKDB_PHP_AUTOLOAD=/tmp/wp-duckdb-php/vendor/autoload.php \ composer run test -- --group duckdb ``` +The root WordPress scripts exercise the generated DuckDB drop-in path: + +```bash +DUCKDB_PHP_AUTOLOAD=/tmp/wp-duckdb-php/vendor/autoload.php \ +composer run wp-smoke-duckdb-local + +DUCKDB_PHP_AUTOLOAD=/tmp/wp-duckdb-php/vendor/autoload.php \ +composer run wp-test-php-duckdb + +DUCKDB_PHP_AUTOLOAD=/tmp/wp-duckdb-php/vendor/autoload.php \ +composer run wp-test-e2e-duckdb +``` + +`wp-smoke-duckdb-local` is a non-Docker smoke for the generated WordPress +drop-in and a simple `$wpdb` query. The WordPress PHPUnit and E2E scripts use +`wp-test-ensure-env-duckdb`, which creates or reuses a DuckDB-configured +WordPress checkout and starts the local Docker environment before running the +tests. + +DuckDB CI is isolated in `.github/workflows/duckdb-phpunit-tests.yml`. It +installs the optional DuckDB PHP client at runtime, verifies the FFI-backed +runtime, and runs three paths: + +- package PHPUnit: `php -d ffi.enable=true ./vendor/bin/phpunit -c ./phpunit.xml.dist --group duckdb`; +- WordPress PHPUnit: `node .github/workflows/wp-tests-phpunit-run.js` with + `WP_SQLITE_PHPUNIT_COMMAND` set to `composer run wp-test-php-duckdb -- --log-junit=phpunit-duckdb-results.xml --verbose`; +- WordPress E2E: `composer run wp-test-e2e-duckdb`. + Current limitations: - DuckDB support is a first-stage adapter. It is intended for targeted local verification and isolated CI, not production WordPress traffic. - The branch currently verifies runtime gating, connections, query execution, persistence, result handling, prepared statements, a focused MySQL-to-DuckDB - driver subset, and WordPress-style schema DDL including secondary indexes. It - does not yet run the full WordPress PHPUnit or E2E suites against DuckDB. + driver subset, WordPress-style schema DDL including secondary indexes, and + dedicated WordPress PHPUnit/E2E CI paths. Local full WordPress acceptance + still requires Docker; when Docker is unavailable, only package PHPUnit and + the non-Docker smoke can be proven locally. +- Remaining DuckDB parity gaps include savepoints, broader foreign-key, view, + and spatial function semantics, seeded `RAND()` in complex query contexts, + richer origin metadata, and `SHOW WARNINGS`/`SHOW ERRORS`. - DuckDB's concurrency model allows one process to read and write, or multiple read-only processes. The DuckDB docs state that automatic writes from multiple processes are not supported and that many small transactions are not its From 1fc7a24db839634863f8068273cf81f477359709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 10:38:00 +0000 Subject: [PATCH 120/317] Fix DuckDB FFI container verification --- bin/wp-test-ensure-env-duckdb.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/wp-test-ensure-env-duckdb.sh b/bin/wp-test-ensure-env-duckdb.sh index a48bb5c42..3cb17f6d4 100755 --- a/bin/wp-test-ensure-env-duckdb.sh +++ b/bin/wp-test-ensure-env-duckdb.sh @@ -117,12 +117,12 @@ start_wordpress_environment() { verify_service_ffi() { local service="$1" - if ! ( cd "$WP_DIR" && docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm "$service" php -m ) | grep -qi '^FFI$'; then + if ! ( cd "$WP_DIR" && docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm "$service" php -r 'exit( extension_loaded( "ffi" ) ? 0 : 1 );' ); then echo "Error: PHP FFI extension is not loaded in the WordPress $service container." >&2 exit 1 fi - if ! ( cd "$WP_DIR" && docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm "$service" php -i ) | grep -Eiq '^ffi\.enable => (1|on|true) => (1|on|true)$'; then + if ! ( cd "$WP_DIR" && docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm "$service" php -r '$value = strtolower( (string) ini_get( "ffi.enable" ) ); exit( in_array( $value, array( "1", "on", "true" ), true ) ? 0 : 1 );' ); then echo "Error: PHP FFI is not enabled in the WordPress $service container." >&2 exit 1 fi From 30b139e88802850b6e2c055c7c6f7f77edaaf262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 10:55:30 +0000 Subject: [PATCH 121/317] Fix DuckDB WordPress setup SQL blockers --- .../src/duckdb/class-wp-duckdb-driver.php | 199 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 5 + .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 95 +++++++++ 3 files changed, 290 insertions(+), 9 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index c27478620..899d27a0e 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -38,12 +38,13 @@ class WP_DuckDB_Driver { const INFO_SCHEMA_CHECK_CONSTRAINTS_TABLE = '__wp_duckdb_information_schema_check_constraints'; const SUPPORTED_SESSION_SYSTEM_VARIABLES = array( - 'autocommit' => true, - 'big_tables' => true, - 'foreign_key_checks' => true, - 'sql_mode' => true, - 'sql_warnings' => true, - 'unique_checks' => true, + 'autocommit' => true, + 'big_tables' => true, + 'default_storage_engine' => true, + 'foreign_key_checks' => true, + 'sql_mode' => true, + 'sql_warnings' => true, + 'unique_checks' => true, ); const READ_ONLY_SYSTEM_VARIABLES = array( @@ -2529,7 +2530,7 @@ function () use ( $shape ): WP_DuckDB_Result_Statement { . $shape['from_sql']; $where_clauses = array(); if ( count( $shape['where_tokens'] ) > 0 ) { - $where_clauses[] = $this->translate_tokens_to_duckdb_sql( $shape['where_tokens'] ); + $where_clauses[] = $this->translate_multi_table_delete_where_tokens_to_duckdb_sql( $shape['where_tokens'] ); } foreach ( $shape['join_predicates'] as $predicate ) { $where_clauses[] = $this->joined_dml_predicate_sql( $predicate ); @@ -2583,6 +2584,27 @@ private function joined_dml_predicate_sql( $predicate ): string { return $this->translate_tokens_to_duckdb_sql( $predicate ); } + /** + * Translate the WHERE clause used while collecting multi-table DELETE targets. + * + * @param WP_Parser_Token[] $tokens WHERE tokens. + * @return string DuckDB SQL. + */ + private function translate_multi_table_delete_where_tokens_to_duckdb_sql( array $tokens ): string { + return $this->translate_tokens_to_duckdb_sql( + $tokens, + false, + false, + false, + false, + false, + false, + false, + array(), + true + ); + } + /** * Parse a multi-table DELETE target alias list. * @@ -3891,6 +3913,9 @@ private function normalize_set_session_system_variable_value( string $name, WP_P if ( 'sql_mode' === $name ) { return $this->normalize_set_sql_mode_value( $token ); } + if ( 'default_storage_engine' === $name ) { + return $this->normalize_set_default_storage_engine_value( $token ); + } $value = $token->get_value(); $lower = strtolower( $value ); @@ -3926,6 +3951,28 @@ private function normalize_set_session_system_variable_value( string $name, WP_P throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); } + /** + * Normalize a SET default_storage_engine value for driver-local readback. + * + * @param WP_Parser_Token $token Value token. + * @return string Normalized storage engine value. + */ + private function normalize_set_default_storage_engine_value( WP_Parser_Token $token ): string { + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $token->get_value(); + } + + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id || $this->is_non_identifier_token( $token ) ) { + throw $this->new_unsupported_set_session_system_variable_value_exception( 'default_storage_engine' ); + } + + if ( 'default' === strtolower( $token->get_value() ) ) { + return 'DEFAULT'; + } + + return $token->get_value(); + } + /** * Normalize a restored dump check variable value. * @@ -4071,6 +4118,11 @@ private function new_unsupported_set_session_system_variable_value_exception( st 'Unsupported SET value for sql_mode in DuckDB driver. Only string literals, bare mode names, and DEFAULT are supported.' ); } + if ( 'default_storage_engine' === $name ) { + return new WP_DuckDB_Driver_Exception( + 'Unsupported SET value for default_storage_engine in DuckDB driver. Only string literals, bare engine names, and DEFAULT are supported.' + ); + } return new WP_DuckDB_Driver_Exception( 'Unsupported SET value for ' @@ -4102,7 +4154,7 @@ private function normalize_supported_session_system_variable_name( string $name throw new WP_DuckDB_Driver_Exception( 'Unsupported SET session variable in DuckDB driver: ' . $name - . '. Only autocommit, big_tables, foreign_key_checks, sql_mode, sql_warnings, and unique_checks are supported.' + . '. Only autocommit, big_tables, default_storage_engine, foreign_key_checks, sql_mode, sql_warnings, and unique_checks are supported.' ); } @@ -12045,7 +12097,8 @@ private function translate_tokens_to_duckdb_sql( bool $rewrite_information_schema_key_column_usage = false, bool $rewrite_information_schema_referential_constraints = false, bool $rewrite_information_schema_check_constraints = false, - array $seeded_rand_rewrites = array() + array $seeded_rand_rewrites = array(), + bool $rewrite_option_value_numeric_literal_comparisons = false ): string { $pieces = array(); @@ -12296,6 +12349,14 @@ private function translate_tokens_to_duckdb_sql( continue; } + if ( $rewrite_option_value_numeric_literal_comparisons ) { + $option_value_comparison = $this->translate_option_value_numeric_literal_comparison( $tokens, $index ); + if ( null !== $option_value_comparison ) { + $pieces[] = $option_value_comparison; + continue; + } + } + $like_binary_predicate = $this->translate_like_binary_predicate( $tokens, $index ); if ( null !== $like_binary_predicate ) { $pieces[] = $like_binary_predicate; @@ -16392,6 +16453,126 @@ private function translate_unix_timestamp_comparison( array $tokens, int &$index . ' CAST(epoch(current_timestamp) AS BIGINT)'; } + /** + * Translate WordPress transient timeout string comparisons in multi-table DELETE collection. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index, advanced on match. + * @return string|null Translated comparison, or null when the pattern does not match. + */ + private function translate_option_value_numeric_literal_comparison( array $tokens, int &$index ): ?string { + $operand = $this->option_value_numeric_comparison_operand_sql( $tokens, $index ); + if ( null === $operand ) { + return null; + } + + $operator_index = $operand['next_index']; + $literal_index = $operator_index + 1; + if ( + ! isset( $tokens[ $literal_index ] ) + || ! $this->is_numeric_comparison_operator_token( $tokens[ $operator_index ] ?? null ) + || ! $this->is_integer_number_token( $tokens[ $literal_index ] ) + ) { + return null; + } + + if ( + isset( $tokens[ $literal_index + 1 ] ) + && ! $this->is_option_value_numeric_comparison_boundary_token( $tokens[ $literal_index + 1 ] ) + ) { + return null; + } + + $index = $literal_index; + return 'TRY_CAST(' + . $operand['sql'] + . ' AS BIGINT) ' + . $tokens[ $operator_index ]->get_bytes() + . ' ' + . $tokens[ $literal_index ]->get_bytes(); + } + + /** + * Build an option_value operand SQL fragment for a bounded numeric comparison. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index. + * @return array{sql:string,next_index:int}|null Operand SQL and next token index, or null when not matched. + */ + private function option_value_numeric_comparison_operand_sql( array $tokens, int $index ): ?array { + if ( ! isset( $tokens[ $index ] ) || $this->is_non_identifier_token( $tokens[ $index ] ) ) { + return null; + } + + if ( + isset( $tokens[ $index + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && ! $this->is_non_identifier_token( $tokens[ $index + 2 ] ) + ) { + $column_name = $this->identifier_value( $tokens[ $index + 2 ] ); + if ( 0 !== strcasecmp( $column_name, 'option_value' ) ) { + return null; + } + + return array( + 'sql' => $this->connection->quote_identifier( $this->identifier_value( $tokens[ $index ] ) ) + . '.' + . $this->connection->quote_identifier( $column_name ), + 'next_index' => $index + 3, + ); + } + + $column_name = $this->identifier_value( $tokens[ $index ] ); + if ( 0 !== strcasecmp( $column_name, 'option_value' ) ) { + return null; + } + + return array( + 'sql' => $this->connection->quote_identifier( $column_name ), + 'next_index' => $index + 1, + ); + } + + /** + * Check whether a token is a comparison operator that coerces strings numerically in MySQL. + * + * @param WP_Parser_Token|null $token Token. + * @return bool Whether the token is a supported comparison operator. + */ + private function is_numeric_comparison_operator_token( $token ): bool { + return $token instanceof WP_Parser_Token + && in_array( + $token->id, + array( + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_THAN_OPERATOR, + WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::EQUAL_OPERATOR, + ), + true + ); + } + + /** + * Check whether a token can end an option_value numeric literal predicate. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token is a predicate boundary. + */ + private function is_option_value_numeric_comparison_boundary_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::XOR_SYMBOL, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + ), + true + ); + } + /** * Translate MySQL's default backslash escape for simple LIKE predicates. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 63a21b55e..da7427c05 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -603,6 +603,11 @@ public function test_session_variable_sql_matches_sqlite(): void { $this->assertParityRows( 'SELECT @@autocommit, @@big_tables' ); } + $this->assertParityRowCount( 'SET default_storage_engine = InnoDB' ); + $this->assertParityRows( 'SELECT @@default_storage_engine, @@SESSION.default_storage_engine' ); + $this->assertParityRowCount( "SET @@session.default_storage_engine = 'MyISAM'" ); + $this->assertParityRows( 'SELECT @@default_storage_engine' ); + $this->assertParityRowCount( 'SET autocommit = OFF' ); $this->assertParityRows( 'SELECT @@autocommit' ); $this->assertParityRowCount( 'SET big_tables = ON' ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 047b77399..31ac016c0 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1182,6 +1182,43 @@ public function test_session_variable_default_matches_sqlite_current_behavior(): $this->assertSame( array(), $driver->get_last_duckdb_queries() ); } + public function test_default_storage_engine_session_variable_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + $set = $driver->query( 'SET default_storage_engine = InnoDB' ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $read = $driver->query( 'SELECT @@default_storage_engine, @@SESSION.default_storage_engine' ); + $this->assertSame( array( 'name' => '@@default_storage_engine' ), $read->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => '@@SESSION.default_storage_engine' ), $read->getColumnMeta( 1 ) ); + $this->assertSame( + array( + '@@default_storage_engine' => 'InnoDB', + '@@SESSION.default_storage_engine' => 'InnoDB', + ), + $read->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $driver->query( "SET @@session.default_storage_engine = 'MyISAM'" ); + $this->assertSame( + array( '@@default_storage_engine' => 'MyISAM' ), + $driver->query( 'SELECT @@default_storage_engine' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $driver->query( 'SET SESSION default_storage_engine = DEFAULT' ); + $this->assertSame( + array( '@@default_storage_engine' => 'DEFAULT' ), + $driver->query( 'SELECT @@default_storage_engine' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + public function test_sql_mode_bootstrap_statements_are_emulated(): void { $this->requireDuckDBRuntime(); @@ -2913,6 +2950,64 @@ public function test_multi_table_delete_removes_expired_transient_alias_rows(): ); } + public function test_multi_table_delete_removes_expired_transient_alias_rows_with_literal_timeout(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + "CREATE TABLE wp_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wp_options (option_name, option_value, autoload) VALUES + ('_transient_tag4', 'tag4', 'no'), + ('_transient_timeout_tag4', '1', 'no'), + ('_transient_tag5', 'tag5', 'no'), + ('_transient_timeout_tag5', '9999999999', 'no'), + ('rss_1', 'rss', 'yes')" + ); + + $delete = $driver->query( + "DELETE a, b FROM wp_options a, wp_options b + WHERE a.option_name LIKE '\_transient\_%' + AND a.option_name NOT LIKE '\_transient\_timeout_%' + AND b.option_name = CONCAT( '_transient_timeout_', SUBSTRING( a.option_name, 12 ) ) + AND b.option_value < 1782556962" + ); + $delete_queries = $driver->get_last_duckdb_queries(); + + $uses_numeric_cast = false; + foreach ( $delete_queries as $query ) { + if ( false !== strpos( $query, 'TRY_CAST("b"."option_value" AS BIGINT) < 1782556962' ) ) { + $uses_numeric_cast = true; + break; + } + } + + $this->assertSame( 2, $delete->rowCount() ); + $this->assertTrue( $uses_numeric_cast ); + $this->assertSame( + array( + array( 'option_name' => '_transient_tag5' ), + array( 'option_name' => '_transient_timeout_tag5' ), + array( 'option_name' => 'rss_1' ), + ), + $driver->query( 'SELECT option_name FROM wp_options ORDER BY option_id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_multi_table_delete_using_form_and_single_target_are_rewritten(): void { $this->requireDuckDBRuntime(); From b61fe3dffc6c2c5defdc327dbcb7c12cc31bc441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 11:06:03 +0000 Subject: [PATCH 122/317] Ensure compatible WordPress PHPUnit runner --- .github/workflows/duckdb-phpunit-tests.yml | 1 + .github/workflows/wp-tests-phpunit-run.js | 61 ++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index 7effcbaae..6363e5dfa 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -142,6 +142,7 @@ jobs: - name: Run WordPress PHPUnit tests with DuckDB env: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY: '1' WP_SQLITE_PHPUNIT_COMMAND: composer run wp-test-php-duckdb -- --log-junit=phpunit-duckdb-results.xml --verbose WP_SQLITE_PHPUNIT_JUNIT_PATH: wordpress/phpunit-duckdb-results.xml run: node .github/workflows/wp-tests-phpunit-run.js diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index af412c5fc..53ea03254 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -12,6 +12,10 @@ const path = require( 'path' ); const repoRoot = path.join( __dirname, '..', '..' ); const requiresNativeParserExtension = process.env.WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION === '1'; const phpunitCommand = process.env.WP_SQLITE_PHPUNIT_COMMAND || 'composer run wp-test-php -- --log-junit=phpunit-results.xml --verbose'; +const phpunitEnsureEnvironmentCommand = process.env.WP_SQLITE_PHPUNIT_ENSURE_ENV_COMMAND || getDefaultEnsureEnvironmentCommand(); +const ensurePhpunitCompatibility = process.env.WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY === '1'; +const phpunitCompatibilityConstraint = process.env.WP_SQLITE_PHPUNIT_COMPATIBILITY_CONSTRAINT || '^9.6'; +const skipPhpunitCompatibilityCheck = process.env.WP_SQLITE_SKIP_PHPUNIT_COMPATIBILITY_CHECK === '1'; const junitOutputPath = process.env.WP_SQLITE_PHPUNIT_JUNIT_PATH || 'wordpress/phpunit-results.xml'; const junitOutputFile = path.isAbsolute( junitOutputPath ) ? junitOutputPath @@ -105,6 +109,12 @@ console.log( 'JUnit output:', junitOutputFile ); console.log( 'Expected errors:', expectedErrors ); console.log( 'Expected failures:', expectedFailures ); +function getDefaultEnsureEnvironmentCommand() { + return phpunitCommand.includes( 'wp-test-php-duckdb' ) + ? 'composer run wp-test-ensure-env-duckdb' + : 'composer run wp-test-ensure-env'; +} + function verifyNativeParserExtension() { const verifier = path.join( __dirname, '..', '..', 'wordpress', 'native-verify-extension.php' ); if ( ! fs.existsSync( verifier ) ) { @@ -119,11 +129,62 @@ function verifyNativeParserExtension() { ); } +function ensureCompatiblePhpunitRunner() { + if ( ! ensurePhpunitCompatibility ) { + return; + } + + if ( skipPhpunitCompatibilityCheck ) { + console.log( 'Skipping WordPress PHPUnit runner compatibility check.' ); + return; + } + + console.log( 'Ensuring WordPress PHPUnit test environment is available...' ); + execSync( phpunitEnsureEnvironmentCommand, { stdio: 'inherit' } ); + + if ( hasCompatiblePhpunitRunner() ) { + return; + } + + console.log( + `PHPUnit\\TextUI\\TestRunner::run() is unavailable; constraining PHPUnit to ${ phpunitCompatibilityConstraint }...` + ); + execSync( + `cd wordpress && node tools/local-env/scripts/docker.js run --rm php composer update --no-interaction --no-progress --with ${ shellQuote( `phpunit/phpunit:${ phpunitCompatibilityConstraint }` ) }`, + { stdio: 'inherit' } + ); + + if ( ! hasCompatiblePhpunitRunner() ) { + console.error( 'Error: WordPress PHPUnit runner is still incompatible after Composer update.' ); + process.exit( 1 ); + } +} + +function hasCompatiblePhpunitRunner() { + const check = "require 'vendor/autoload.php'; exit( method_exists( 'PHPUnit\\\\TextUI\\\\TestRunner', 'run' ) ? 0 : 1 );"; + + try { + execSync( + `cd wordpress && node tools/local-env/scripts/docker.js run --rm php php -r ${ shellQuote( check ) }`, + { stdio: 'ignore' } + ); + return true; + } catch ( error ) { + return false; + } +} + +function shellQuote( value ) { + return `'${ String( value ).replace( /'/g, "'\\''" ) }'`; +} + try { if ( requiresNativeParserExtension ) { verifyNativeParserExtension(); } + ensureCompatiblePhpunitRunner(); + try { execSync( phpunitCommand, { stdio: 'inherit' } ); console.log( '\n⚠️ All tests passed, checking if expected errors/failures occurred...' ); From b456880a86b4255e288bafe4b24c58e6b10a4aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 11:06:13 +0000 Subject: [PATCH 123/317] Add DuckDB primary key GROUP BY wildcard parity --- .../src/duckdb/class-wp-duckdb-driver.php | 303 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 20 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 48 +++ 3 files changed, 366 insertions(+), 5 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 899d27a0e..4c2ac18a1 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -538,6 +538,7 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $has_sql_calc_found_rows = $this->has_top_level_sql_calc_found_rows( $tokens ); $tokens = $this->normalize_select_helper_tokens( $tokens ); $column_meta = $this->simple_select_column_metadata( $tokens ); + $group_by_expansion = $this->primary_key_group_by_wildcard_expansion( $tokens ); $seeded_rand_expressions = $this->parse_seeded_rand_select_expressions( $tokens ); $seeded_rand_rewrites = $this->seeded_rand_rewrite_map( $seeded_rand_expressions ); @@ -579,7 +580,9 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_key_column_usage, $rewrite_information_schema_referential_constraints, $rewrite_information_schema_check_constraints, - $seeded_rand_rewrites + $seeded_rand_rewrites, + false, + $group_by_expansion ); if ( $has_sql_calc_found_rows ) { @@ -593,7 +596,8 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_key_column_usage, $rewrite_information_schema_referential_constraints, $rewrite_information_schema_check_constraints, - $seeded_rand_rewrites + $seeded_rand_rewrites, + $group_by_expansion ); $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); $result = $this->apply_result_column_metadata( $result, $column_meta ); @@ -1096,6 +1100,229 @@ private function simple_select_column_metadata( array $tokens ): ?array { return $column_meta; } + /** + * Build a bounded GROUP BY expansion for SELECT table.* grouped by that table's full primary key. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return array{group_index:int,group_end:int,table_alias:string,columns:string[]}|null Expansion data, or null when outside the supported slice. + */ + private function primary_key_group_by_wildcard_expansion( array $tokens ): ?array { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + if ( + null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::HAVING_SYMBOL ) + || null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::UNION_SYMBOL ) + || $this->contains_top_level_aggregate_function_call( $tokens ) + ) { + return null; + } + + $from_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::FROM_SYMBOL ); + $group_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::GROUP_SYMBOL ); + if ( + null === $from_index + || null === $group_index + || $group_index <= $from_index + || ! isset( $tokens[ $group_index + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_index + 1 ]->id + ) { + return null; + } + + $select_items = $this->split_top_level_comma_items( array_slice( $tokens, 1, $from_index - 1 ) ); + if ( 1 !== count( $select_items ) ) { + return null; + } + + $table_tokens = array_slice( + $tokens, + $from_index + 1, + $this->simple_select_from_clause_end( $tokens, $from_index + 1 ) - $from_index - 1 + ); + $table = $this->parse_simple_select_table_reference( $table_tokens ); + if ( null === $table ) { + return null; + } + + $wildcard = $this->parse_simple_select_column_reference( $select_items[0] ); + if ( + null === $wildcard + || ! $wildcard['wildcard'] + || ! $this->simple_select_column_qualifier_matches_table( $wildcard['qualifier'], $table ) + ) { + return null; + } + + $primary_key_columns = $this->primary_key_columns_for_table( $table['table_name'] ); + if ( count( $primary_key_columns ) === 0 ) { + return null; + } + + $group_end = $this->primary_key_group_by_clause_end( $tokens, $group_index + 2 ); + $group_tokens = array_slice( $tokens, $group_index + 2, $group_end - $group_index - 2 ); + if ( count( $group_tokens ) === 0 ) { + return null; + } + + $grouped_columns = array(); + foreach ( $this->split_top_level_comma_items( $group_tokens ) as $item ) { + $column = $this->parse_group_by_column_reference( $item ); + if ( + null === $column + || ! $this->simple_select_column_qualifier_matches_table( $column['qualifier'], $table ) + ) { + return null; + } + $grouped_columns[ strtolower( $column['column_name'] ) ] = true; + } + + foreach ( $primary_key_columns as $primary_key_column ) { + if ( ! isset( $grouped_columns[ strtolower( $primary_key_column ) ] ) ) { + return null; + } + } + + $columns = array(); + foreach ( $this->table_column_metadata_rows( $table['table_name'], $table['temporary'] ) as $metadata ) { + $column_name = (string) $metadata['column_name']; + if ( ! isset( $grouped_columns[ strtolower( $column_name ) ] ) ) { + $columns[] = $column_name; + } + } + + if ( count( $columns ) === 0 ) { + return null; + } + + return array( + 'group_index' => $group_index, + 'group_end' => $group_end, + 'table_alias' => $table['alias'], + 'columns' => $columns, + ); + } + + /** + * Find the end of the supported top-level GROUP BY clause. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $start First token after GROUP BY. + * @return int End offset, exclusive. + */ + private function primary_key_group_by_clause_end( array $tokens, int $start ): int { + $clause_tokens = array( + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::WINDOW_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ); + $depth = 0; + + for ( $index = $start; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + continue; + } + if ( 0 === $depth && in_array( $tokens[ $index ]->id, $clause_tokens, true ) ) { + return $index; + } + } + + return count( $tokens ); + } + + /** + * Check whether a token stream contains a top-level aggregate function call. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return bool Whether a top-level aggregate call is present. + */ + private function contains_top_level_aggregate_function_call( array $tokens ): bool { + $aggregate_names = array( + 'AVG', + 'BIT_AND', + 'BIT_OR', + 'BIT_XOR', + 'COUNT', + 'GROUP_CONCAT', + 'MAX', + 'MIN', + 'STD', + 'STDDEV', + 'SUM', + 'VARIANCE', + ); + $depth = 0; + + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + continue; + } + if ( + 0 === $depth + && isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index + 1 ]->id + && ! $this->is_non_identifier_token( $tokens[ $index ] ) + && in_array( strtoupper( $tokens[ $index ]->get_value() ), $aggregate_names, true ) + ) { + return true; + } + } + + return false; + } + + /** + * Parse a simple GROUP BY column reference. + * + * @param WP_Parser_Token[] $tokens GROUP BY item tokens. + * @return array{column_name:string,qualifier:string|null}|null Column reference, or null when unsupported. + */ + private function parse_group_by_column_reference( array $tokens ): ?array { + $count = count( $tokens ); + if ( 1 === $count ) { + $name = $this->metadata_identifier_value( $tokens[0] ); + if ( null === $name ) { + return null; + } + + return array( + 'column_name' => $name, + 'qualifier' => null, + ); + } + + if ( 3 === $count && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[1]->id ) { + $qualifier = $this->metadata_identifier_value( $tokens[0] ); + $name = $this->metadata_identifier_value( $tokens[2] ); + if ( null === $qualifier || null === $name ) { + return null; + } + + return array( + 'column_name' => $name, + 'qualifier' => $qualifier, + ); + } + + return null; + } + /** * Check whether a SELECT-list qualifier belongs to the one supported table. * @@ -1576,7 +1803,8 @@ private function count_select_rows( bool $rewrite_information_schema_key_column_usage, bool $rewrite_information_schema_referential_constraints, bool $rewrite_information_schema_check_constraints, - array $seeded_rand_rewrites = array() + array $seeded_rand_rewrites = array(), + ?array $group_by_expansion = null ): int { $sql = $this->translate_tokens_to_duckdb_sql( $tokens, @@ -1587,7 +1815,9 @@ private function count_select_rows( $rewrite_information_schema_key_column_usage, $rewrite_information_schema_referential_constraints, $rewrite_information_schema_check_constraints, - $seeded_rand_rewrites + $seeded_rand_rewrites, + false, + $group_by_expansion ); return (int) $this->execute_duckdb_query( @@ -12082,6 +12312,52 @@ private function translate_bit_default_literal( array $tokens, int &$index ): ar return $literal; } + /** + * Render a GROUP BY clause expanded with selected wildcard columns. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param array{group_index:int,group_end:int,table_alias:string,columns:string[]} $group_by_expansion Expansion data. + * @return string DuckDB GROUP BY SQL. + */ + private function expanded_primary_key_group_by_clause_sql( + array $tokens, + array $group_by_expansion, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints + ): string { + $group_tokens = array_slice( + $tokens, + $group_by_expansion['group_index'] + 2, + $group_by_expansion['group_end'] - $group_by_expansion['group_index'] - 2 + ); + + $items = array( + $this->translate_tokens_to_duckdb_sql( + $group_tokens, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ), + ); + + foreach ( $group_by_expansion['columns'] as $column_name ) { + $items[] = $this->connection->quote_identifier( $group_by_expansion['table_alias'] ) + . '.' + . $this->connection->quote_identifier( $column_name ); + } + + return 'GROUP BY ' . implode( ', ', $items ); + } + /** * Convert MySQL tokens to DuckDB SQL. * @@ -12098,13 +12374,30 @@ private function translate_tokens_to_duckdb_sql( bool $rewrite_information_schema_referential_constraints = false, bool $rewrite_information_schema_check_constraints = false, array $seeded_rand_rewrites = array(), - bool $rewrite_option_value_numeric_literal_comparisons = false + bool $rewrite_option_value_numeric_literal_comparisons = false, + ?array $group_by_expansion = null ): string { $pieces = array(); for ( $index = 0; $index < count( $tokens ); ++$index ) { $token = $tokens[ $index ]; + if ( null !== $group_by_expansion && $index === $group_by_expansion['group_index'] ) { + $pieces[] = $this->expanded_primary_key_group_by_clause_sql( + $tokens, + $group_by_expansion, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + $index = $group_by_expansion['group_end'] - 1; + continue; + } + if ( $this->is_configured_database_select_table_qualifier( $tokens, $index ) ) { ++$index; continue; diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index da7427c05..24e6e04c7 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -853,6 +853,26 @@ public function test_failed_selects_reset_found_rows_state_match_sqlite(): void $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); } + public function test_select_posts_wildcard_group_by_primary_key_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wp_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_author BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + post_title TEXT NOT NULL, + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wp_posts (ID, post_author, post_title) VALUES + (1, 10, 'first'), + (2, 20, 'second')", + ) + ); + + $this->assertParityRows( + 'SELECT wp_posts.* FROM wp_posts GROUP BY wp_posts.ID ORDER BY wp_posts.ID' + ); + } + public function test_select_index_hints_match_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 31ac016c0..be6e9cb0f 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -152,6 +152,54 @@ public function test_select_mysql_functions_are_emulated(): void { $this->assertLessThan( time() + 60, $row['unix_time'] ); } + public function test_select_posts_wildcard_group_by_primary_key_expands_group_by(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_author BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + post_title TEXT NOT NULL, + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wp_posts (ID, post_author, post_title) VALUES + (1, 10, 'first'), + (2, 20, 'second')" + ); + + $rows = $driver->query( + 'SELECT wp_posts.* FROM wp_posts GROUP BY wp_posts.ID ORDER BY wp_posts.ID' + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + array( + 'ID' => 1, + 'post_author' => 10, + 'post_title' => 'first', + ), + array( + 'ID' => 2, + 'post_author' => 20, + 'post_title' => 'second', + ), + ), + $rows + ); + + $duckdb_queries = $driver->get_last_duckdb_queries(); + $select_sql = end( $duckdb_queries ); + + $this->assertIsString( $select_sql ); + $this->assertStringContainsString( + 'GROUP BY wp_posts.ID, "wp_posts"."post_author", "wp_posts"."post_title"', + $select_sql + ); + } + public function test_select_seeded_rand_literals_are_emulated(): void { $this->requireDuckDBRuntime(); From 874f5bb07894f4e0a5b9332df7a7790828ab3dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 11:24:30 +0000 Subject: [PATCH 124/317] Fix DuckDB WordPress CI acceptance loop --- .github/workflows/duckdb-phpunit-tests.yml | 160 +++++++++++++++++++++ .github/workflows/wp-tests-phpunit-run.js | 24 +++- 2 files changed, 179 insertions(+), 5 deletions(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index 6363e5dfa..5db2381f6 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -147,6 +147,86 @@ jobs: WP_SQLITE_PHPUNIT_JUNIT_PATH: wordpress/phpunit-duckdb-results.xml run: node .github/workflows/wp-tests-phpunit-run.js + - name: Dump WordPress DuckDB failure diagnostics + if: failure() + shell: bash + run: | + set +e + + if [ ! -d wordpress ]; then + echo "wordpress directory is not present; skipping Docker and debug log diagnostics." + exit 0 + fi + + echo "::group::Docker compose status" + ( + cd wordpress || exit 0 + docker ps -a --format 'table {{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Names}}' || true + docker compose -f docker-compose.yml -f docker-compose.override.yml ps || true + docker compose -f docker-compose.yml -f docker-compose.override.yml config --services || true + ) || true + echo "::endgroup::" + + echo "::group::Docker compose logs" + ( + cd wordpress || exit 0 + docker compose -f docker-compose.yml -f docker-compose.override.yml logs --no-color --tail=200 || true + ) || true + echo "::endgroup::" + + echo "::group::WordPress container debug logs" + ( + cd wordpress || exit 0 + services="$(docker compose -f docker-compose.yml -f docker-compose.override.yml config --services 2>/dev/null || true)" + for service in php cli wordpress-develop; do + if printf '%s\n' "$services" | grep -qx "$service"; then + echo "===== $service debug.log tails =====" + docker compose -f docker-compose.yml -f docker-compose.override.yml exec -T "$service" sh -lc 'for log_file in /var/www/src/wp-content/debug.log /var/www/build/wp-content/debug.log /var/www/html/wp-content/debug.log; do if [ -f "$log_file" ]; then echo "===== $log_file ====="; tail -n 200 "$log_file"; fi; done' || true + fi + done + ) || true + echo "::endgroup::" + + echo "::group::Local WordPress debug logs" + mapfile -t debug_logs < <( + find wordpress \ + -path '*/node_modules' -prune -o \ + -path '*/vendor' -prune -o \ + -name 'debug.log' -type f -print 2>/dev/null \ + | sort \ + | head -20 + ) + if [ "${#debug_logs[@]}" -eq 0 ]; then + echo "No debug.log files found under wordpress/." + fi + for log_file in "${debug_logs[@]}"; do + echo "===== $log_file =====" + tail -n 200 "$log_file" || true + done + echo "::endgroup::" + + echo "::group::WordPress DuckDB runtime probes" + ( + cd wordpress || exit 0 + services="$(docker compose -f docker-compose.yml -f docker-compose.override.yml config --services 2>/dev/null || true)" + if printf '%s\n' "$services" | grep -qx 'php'; then + docker compose -f docker-compose.yml -f docker-compose.override.yml exec -T php sh -lc 'for file in /var/www/src/wp-config.php /var/www/src/wp-content/db.php; do if [ -f "$file" ]; then echo "===== $file selected constants ====="; grep -nE "DB_ENGINE|DATABASE_TYPE|DUCKDB|WP_DEBUG" "$file" || true; fi; done' || true + fi + if printf '%s\n' "$services" | grep -qx 'cli'; then + docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm cli wp --path=/var/www/src eval 'global $wpdb; echo "wpdb_class=" . get_class( $wpdb ) . PHP_EOL; echo "db_engine=" . ( defined( "DB_ENGINE" ) ? DB_ENGINE : "" ) . PHP_EOL; echo "last_error=" . $wpdb->last_error . PHP_EOL;' || true + fi + curl -i -sS -X POST \ + --data-urlencode 'log=admin' \ + --data-urlencode 'pwd=password' \ + --data-urlencode 'wp-submit=Log In' \ + http://localhost:8889/wp-login.php \ + | sed -E 's/(Set-Cookie: [^=]+=)[^;]*/\1/Ig' \ + | sed -n '1,180p' || true + ) || true + echo "::endgroup::" + + true + - name: Stop Docker containers if: always() run: composer run wp-test-clean @@ -205,6 +285,86 @@ jobs: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php run: composer run wp-test-e2e-duckdb + - name: Dump WordPress DuckDB failure diagnostics + if: failure() + shell: bash + run: | + set +e + + if [ ! -d wordpress ]; then + echo "wordpress directory is not present; skipping Docker and debug log diagnostics." + exit 0 + fi + + echo "::group::Docker compose status" + ( + cd wordpress || exit 0 + docker ps -a --format 'table {{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Names}}' || true + docker compose -f docker-compose.yml -f docker-compose.override.yml ps || true + docker compose -f docker-compose.yml -f docker-compose.override.yml config --services || true + ) || true + echo "::endgroup::" + + echo "::group::Docker compose logs" + ( + cd wordpress || exit 0 + docker compose -f docker-compose.yml -f docker-compose.override.yml logs --no-color --tail=200 || true + ) || true + echo "::endgroup::" + + echo "::group::WordPress container debug logs" + ( + cd wordpress || exit 0 + services="$(docker compose -f docker-compose.yml -f docker-compose.override.yml config --services 2>/dev/null || true)" + for service in php cli wordpress-develop; do + if printf '%s\n' "$services" | grep -qx "$service"; then + echo "===== $service debug.log tails =====" + docker compose -f docker-compose.yml -f docker-compose.override.yml exec -T "$service" sh -lc 'for log_file in /var/www/src/wp-content/debug.log /var/www/build/wp-content/debug.log /var/www/html/wp-content/debug.log; do if [ -f "$log_file" ]; then echo "===== $log_file ====="; tail -n 200 "$log_file"; fi; done' || true + fi + done + ) || true + echo "::endgroup::" + + echo "::group::Local WordPress debug logs" + mapfile -t debug_logs < <( + find wordpress \ + -path '*/node_modules' -prune -o \ + -path '*/vendor' -prune -o \ + -name 'debug.log' -type f -print 2>/dev/null \ + | sort \ + | head -20 + ) + if [ "${#debug_logs[@]}" -eq 0 ]; then + echo "No debug.log files found under wordpress/." + fi + for log_file in "${debug_logs[@]}"; do + echo "===== $log_file =====" + tail -n 200 "$log_file" || true + done + echo "::endgroup::" + + echo "::group::WordPress DuckDB runtime probes" + ( + cd wordpress || exit 0 + services="$(docker compose -f docker-compose.yml -f docker-compose.override.yml config --services 2>/dev/null || true)" + if printf '%s\n' "$services" | grep -qx 'php'; then + docker compose -f docker-compose.yml -f docker-compose.override.yml exec -T php sh -lc 'for file in /var/www/src/wp-config.php /var/www/src/wp-content/db.php; do if [ -f "$file" ]; then echo "===== $file selected constants ====="; grep -nE "DB_ENGINE|DATABASE_TYPE|DUCKDB|WP_DEBUG" "$file" || true; fi; done' || true + fi + if printf '%s\n' "$services" | grep -qx 'cli'; then + docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm cli wp --path=/var/www/src eval 'global $wpdb; echo "wpdb_class=" . get_class( $wpdb ) . PHP_EOL; echo "db_engine=" . ( defined( "DB_ENGINE" ) ? DB_ENGINE : "" ) . PHP_EOL; echo "last_error=" . $wpdb->last_error . PHP_EOL;' || true + fi + curl -i -sS -X POST \ + --data-urlencode 'log=admin' \ + --data-urlencode 'pwd=password' \ + --data-urlencode 'wp-submit=Log In' \ + http://localhost:8889/wp-login.php \ + | sed -E 's/(Set-Cookie: [^=]+=)[^;]*/\1/Ig' \ + | sed -n '1,180p' || true + ) || true + echo "::endgroup::" + + true + - name: Stop Docker containers if: always() run: composer run wp-test-clean diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 53ea03254..9c4067d20 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -149,17 +149,31 @@ function ensureCompatiblePhpunitRunner() { console.log( `PHPUnit\\TextUI\\TestRunner::run() is unavailable; constraining PHPUnit to ${ phpunitCompatibilityConstraint }...` ); - execSync( - `cd wordpress && node tools/local-env/scripts/docker.js run --rm php composer update --no-interaction --no-progress --with ${ shellQuote( `phpunit/phpunit:${ phpunitCompatibilityConstraint }` ) }`, - { stdio: 'inherit' } - ); + ensureWordPressGitSafeDirectory(); + installCompatiblePhpunitRunner(); if ( ! hasCompatiblePhpunitRunner() ) { - console.error( 'Error: WordPress PHPUnit runner is still incompatible after Composer update.' ); + console.error( 'Error: WordPress PHPUnit runner is still incompatible after Composer require.' ); process.exit( 1 ); } } +function ensureWordPressGitSafeDirectory() { + execSync( + `cd wordpress && node tools/local-env/scripts/docker.js run --rm php sh -lc ${ shellQuote( 'if command -v git >/dev/null 2>&1; then git config --global --add safe.directory /var/www; fi' ) }`, + { stdio: 'inherit' } + ); +} + +function installCompatiblePhpunitRunner() { + const phpunitPackage = `phpunit/phpunit:${ phpunitCompatibilityConstraint }`; + + execSync( + `cd wordpress && node tools/local-env/scripts/docker.js run --rm php composer require --dev --no-interaction --no-progress --with-all-dependencies ${ shellQuote( phpunitPackage ) }`, + { stdio: 'inherit' } + ); +} + function hasCompatiblePhpunitRunner() { const check = "require 'vendor/autoload.php'; exit( method_exists( 'PHPUnit\\\\TextUI\\\\TestRunner', 'run' ) ? 0 : 1 );"; From 6d7379e5655a14bd4d335150e5abf1f21fd42c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 11:40:36 +0000 Subject: [PATCH 125/317] Fix DuckDB WordPress CI blockers --- .github/workflows/wp-tests-phpunit-run.js | 52 +++++- .../src/duckdb/class-wp-duckdb-driver.php | 169 ++++++++++++++++-- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 48 +++++ 3 files changed, 244 insertions(+), 25 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 9c4067d20..c8148e342 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -159,8 +159,15 @@ function ensureCompatiblePhpunitRunner() { } function ensureWordPressGitSafeDirectory() { - execSync( - `cd wordpress && node tools/local-env/scripts/docker.js run --rm php sh -lc ${ shellQuote( 'if command -v git >/dev/null 2>&1; then git config --global --add safe.directory /var/www; fi' ) }`, + runWordPressDockerCompose( + [ + 'run', + '--rm', + 'php', + 'sh', + '-lc', + 'if command -v git >/dev/null 2>&1; then git config --global --add safe.directory /var/www; fi', + ], { stdio: 'inherit' } ); } @@ -168,8 +175,19 @@ function ensureWordPressGitSafeDirectory() { function installCompatiblePhpunitRunner() { const phpunitPackage = `phpunit/phpunit:${ phpunitCompatibilityConstraint }`; - execSync( - `cd wordpress && node tools/local-env/scripts/docker.js run --rm php composer require --dev --no-interaction --no-progress --with-all-dependencies ${ shellQuote( phpunitPackage ) }`, + runWordPressDockerCompose( + [ + 'run', + '--rm', + 'php', + 'composer', + 'require', + '--dev', + '--no-interaction', + '--no-progress', + '--with-all-dependencies', + phpunitPackage, + ], { stdio: 'inherit' } ); } @@ -178,16 +196,34 @@ function hasCompatiblePhpunitRunner() { const check = "require 'vendor/autoload.php'; exit( method_exists( 'PHPUnit\\\\TextUI\\\\TestRunner', 'run' ) ? 0 : 1 );"; try { - execSync( - `cd wordpress && node tools/local-env/scripts/docker.js run --rm php php -r ${ shellQuote( check ) }`, - { stdio: 'ignore' } - ); + runWordPressDockerCompose( [ 'run', '--rm', 'php', 'php', '-r', check ], { stdio: 'ignore' } ); return true; } catch ( error ) { return false; } } +function runWordPressDockerCompose( args, options ) { + const command = [ + 'cd wordpress &&', + 'COMPOSE_IGNORE_ORPHANS=true docker compose', + ...getWordPressComposeArgs().map( shellQuote ), + ...args.map( shellQuote ), + ].join( ' ' ); + + execSync( command, options ); +} + +function getWordPressComposeArgs() { + const composeArgs = [ '-f', 'docker-compose.yml' ]; + + if ( fs.existsSync( path.join( repoRoot, 'wordpress', 'docker-compose.override.yml' ) ) ) { + composeArgs.push( '-f', 'docker-compose.override.yml' ); + } + + return composeArgs; +} + function shellQuote( value ) { return `'${ String( value ).replace( /'/g, "'\\''" ) }'`; } diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 4c2ac18a1..1bc8cfacb 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -538,7 +538,7 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $has_sql_calc_found_rows = $this->has_top_level_sql_calc_found_rows( $tokens ); $tokens = $this->normalize_select_helper_tokens( $tokens ); $column_meta = $this->simple_select_column_metadata( $tokens ); - $group_by_expansion = $this->primary_key_group_by_wildcard_expansion( $tokens ); + $group_by_expansion = $this->primary_key_group_by_expansion( $tokens ); $seeded_rand_expressions = $this->parse_seeded_rand_select_expressions( $tokens ); $seeded_rand_rewrites = $this->seeded_rand_rewrite_map( $seeded_rand_expressions ); @@ -1101,12 +1101,12 @@ private function simple_select_column_metadata( array $tokens ): ?array { } /** - * Build a bounded GROUP BY expansion for SELECT table.* grouped by that table's full primary key. + * Build a bounded GROUP BY expansion for simple SELECTs grouped by a table's full primary key. * * @param WP_Parser_Token[] $tokens MySQL tokens. * @return array{group_index:int,group_end:int,table_alias:string,columns:string[]}|null Expansion data, or null when outside the supported slice. */ - private function primary_key_group_by_wildcard_expansion( array $tokens ): ?array { + private function primary_key_group_by_expansion( array $tokens ): ?array { if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { return null; } @@ -1131,7 +1131,7 @@ private function primary_key_group_by_wildcard_expansion( array $tokens ): ?arra } $select_items = $this->split_top_level_comma_items( array_slice( $tokens, 1, $from_index - 1 ) ); - if ( 1 !== count( $select_items ) ) { + if ( count( $select_items ) === 0 ) { return null; } @@ -1145,13 +1145,23 @@ private function primary_key_group_by_wildcard_expansion( array $tokens ): ?arra return null; } - $wildcard = $this->parse_simple_select_column_reference( $select_items[0] ); - if ( - null === $wildcard - || ! $wildcard['wildcard'] - || ! $this->simple_select_column_qualifier_matches_table( $wildcard['qualifier'], $table ) - ) { - return null; + $selected_columns = array(); + $select_has_wildcard = false; + foreach ( $select_items as $item ) { + $column = $this->parse_simple_select_column_reference( $item ); + if ( + null === $column + || ! $this->simple_select_column_qualifier_matches_table( $column['qualifier'], $table ) + ) { + return null; + } + + if ( $column['wildcard'] ) { + $select_has_wildcard = true; + continue; + } + + $selected_columns[ strtolower( $column['column_name'] ) ] = true; } $primary_key_columns = $this->primary_key_columns_for_table( $table['table_name'] ); @@ -1183,14 +1193,43 @@ private function primary_key_group_by_wildcard_expansion( array $tokens ): ?arra } } - $columns = array(); - foreach ( $this->table_column_metadata_rows( $table['table_name'], $table['temporary'] ) as $metadata ) { - $column_name = (string) $metadata['column_name']; - if ( ! isset( $grouped_columns[ strtolower( $column_name ) ] ) ) { - $columns[] = $column_name; + $metadata_rows = $this->table_column_metadata_rows( $table['table_name'], $table['temporary'] ); + $metadata_by_column = array(); + foreach ( $metadata_rows as $metadata ) { + $metadata_by_column[ strtolower( (string) $metadata['column_name'] ) ] = $metadata; + } + + $columns_by_key = array(); + if ( $select_has_wildcard ) { + foreach ( $metadata_rows as $metadata ) { + $column_name = (string) $metadata['column_name']; + $key = strtolower( $column_name ); + if ( ! isset( $grouped_columns[ $key ] ) ) { + $columns_by_key[ $key ] = $column_name; + } } } + foreach ( array_keys( $selected_columns ) as $column_key ) { + if ( ! isset( $metadata_by_column[ $column_key ] ) ) { + return null; + } + if ( ! isset( $grouped_columns[ $column_key ] ) ) { + $columns_by_key[ $column_key ] = (string) $metadata_by_column[ $column_key ]['column_name']; + } + } + + foreach ( $this->primary_key_group_by_order_columns( $tokens, $group_end, $table, $metadata_by_column ) as $column_key => $column_name ) { + if ( ! isset( $grouped_columns[ $column_key ] ) ) { + $columns_by_key[ $column_key ] = $column_name; + } + } + + $columns = array(); + foreach ( $columns_by_key as $column_name ) { + $columns[] = $column_name; + } + if ( count( $columns ) === 0 ) { return null; } @@ -1241,6 +1280,102 @@ private function primary_key_group_by_clause_end( array $tokens, int $start ): i return count( $tokens ); } + /** + * Extract simple ORDER BY columns that are functionally dependent on a full primary-key GROUP BY. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $group_end End of the GROUP BY clause. + * @param array{table_name:string,alias:string,temporary:bool} $table Parsed table reference. + * @param array> $metadata_by_column Table metadata keyed by lower-case column name. + * @return array Column names keyed by lower-case column name. + */ + private function primary_key_group_by_order_columns( array $tokens, int $group_end, array $table, array $metadata_by_column ): array { + if ( + ! isset( $tokens[ $group_end ] ) + || WP_MySQL_Lexer::ORDER_SYMBOL !== $tokens[ $group_end ]->id + || ! isset( $tokens[ $group_end + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_end + 1 ]->id + ) { + return array(); + } + + $order_end = $this->primary_key_order_by_clause_end( $tokens, $group_end + 2 ); + $order_tokens = array_slice( $tokens, $group_end + 2, $order_end - $group_end - 2 ); + if ( count( $order_tokens ) === 0 ) { + return array(); + } + + $columns = array(); + foreach ( $this->split_top_level_comma_items( $order_tokens ) as $item ) { + $column = $this->parse_order_by_column_reference( $item ); + if ( + null === $column + || ! $this->simple_select_column_qualifier_matches_table( $column['qualifier'], $table ) + ) { + continue; + } + + $column_key = strtolower( $column['column_name'] ); + if ( isset( $metadata_by_column[ $column_key ] ) ) { + $columns[ $column_key ] = (string) $metadata_by_column[ $column_key ]['column_name']; + } + } + + return $columns; + } + + /** + * Find the end of the supported top-level ORDER BY clause. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $start First token after ORDER BY. + * @return int End offset, exclusive. + */ + private function primary_key_order_by_clause_end( array $tokens, int $start ): int { + $clause_tokens = array( + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ); + $depth = 0; + + for ( $index = $start; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + continue; + } + if ( 0 === $depth && in_array( $tokens[ $index ]->id, $clause_tokens, true ) ) { + return $index; + } + } + + return count( $tokens ); + } + + /** + * Parse a simple ORDER BY column reference. + * + * @param WP_Parser_Token[] $tokens ORDER BY item tokens. + * @return array{column_name:string,qualifier:string|null}|null Column reference, or null when unsupported. + */ + private function parse_order_by_column_reference( array $tokens ): ?array { + if ( count( $tokens ) > 0 ) { + $last = $tokens[ count( $tokens ) - 1 ]; + if ( WP_MySQL_Lexer::ASC_SYMBOL === $last->id || WP_MySQL_Lexer::DESC_SYMBOL === $last->id ) { + array_pop( $tokens ); + } + } + + return $this->parse_group_by_column_reference( $tokens ); + } + /** * Check whether a token stream contains a top-level aggregate function call. * @@ -12313,7 +12448,7 @@ private function translate_bit_default_literal( array $tokens, int &$index ): ar } /** - * Render a GROUP BY clause expanded with selected wildcard columns. + * Render a GROUP BY clause expanded with functionally dependent columns. * * @param WP_Parser_Token[] $tokens MySQL tokens. * @param array{group_index:int,group_end:int,table_alias:string,columns:string[]} $group_by_expansion Expansion data. diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index be6e9cb0f..3b854bb5e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -200,6 +200,54 @@ public function test_select_posts_wildcard_group_by_primary_key_expands_group_by ); } + public function test_select_primary_key_group_by_orders_by_functionally_dependent_column(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_date DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wp_posts (ID, post_date, post_type, post_status) VALUES + (1, '2026-01-01 00:00:00', 'wp_global_styles', 'publish'), + (2, '2026-02-01 00:00:00', 'wp_global_styles', 'publish'), + (3, '2026-03-01 00:00:00', 'post', 'publish')" + ); + + $rows = $driver->query( + "SELECT wp_posts.ID + FROM wp_posts + WHERE 1=1 + AND wp_posts.post_type = 'wp_global_styles' + AND ((wp_posts.post_status = 'publish')) + GROUP BY wp_posts.ID + ORDER BY wp_posts.post_date DESC + LIMIT 0, 1" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + array( 'ID' => 2 ), + ), + $rows + ); + + $duckdb_queries = $driver->get_last_duckdb_queries(); + $select_sql = end( $duckdb_queries ); + + $this->assertIsString( $select_sql ); + $this->assertStringContainsString( + 'GROUP BY wp_posts.ID, "wp_posts"."post_date"', + $select_sql + ); + } + public function test_select_seeded_rand_literals_are_emulated(): void { $this->requireDuckDBRuntime(); From feb8b4791db744304f6c9732e2c8aa7138741063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 12:00:56 +0000 Subject: [PATCH 126/317] Fix DuckDB joined GROUP BY WordPress blockers --- .github/workflows/wp-tests-phpunit-run.js | 137 ++++++++++++- .../src/duckdb/class-wp-duckdb-driver.php | 190 +++++++++++++++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 114 +++++++++++ 3 files changed, 435 insertions(+), 6 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index c8148e342..66bf86b30 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -20,6 +20,10 @@ const junitOutputPath = process.env.WP_SQLITE_PHPUNIT_JUNIT_PATH || 'wordpress/p const junitOutputFile = path.isAbsolute( junitOutputPath ) ? junitOutputPath : path.join( repoRoot, junitOutputPath ); +const phpunitCompatibilityPrependPath = path.join( repoRoot, 'wordpress', 'phpunit-runner-compat-prepend.php' ); +const duckdbAutoloadCompatibilityWrapperPath = path.join( repoRoot, 'wordpress', 'phpunit-duckdb-autoload-wrapper.php' ); +const phpunitCompatibilityPrependContainerPath = '/var/www/phpunit-runner-compat-prepend.php'; +const duckdbAutoloadCompatibilityWrapperContainerPath = '/var/www/phpunit-duckdb-autoload-wrapper.php'; const expectedErrors = [ 'Tests_DB_Charset::test_invalid_characters_in_query', @@ -115,6 +119,132 @@ function getDefaultEnsureEnvironmentCommand() { : 'composer run wp-test-ensure-env'; } +function preparePhpunitCommand() { + if ( ! shouldPreloadCompatiblePhpunitRunner() ) { + return phpunitCommand; + } + + writePhpunitCompatibilityFiles(); + verifyPhpunitCompatibilityFiles(); + + const effectivePhpunitCommand = addPhpunitPrependArgument( + phpunitCommand, + phpunitCompatibilityPrependContainerPath + ); + + if ( effectivePhpunitCommand !== phpunitCommand ) { + console.log( 'PHPUnit compatibility prepend:', phpunitCompatibilityPrependContainerPath ); + console.log( 'Effective PHPUnit command:', effectivePhpunitCommand ); + } + + return effectivePhpunitCommand; +} + +function shouldPreloadCompatiblePhpunitRunner() { + return ( + ensurePhpunitCompatibility && + ! skipPhpunitCompatibilityCheck && + phpunitCommand.includes( 'wp-test-php-duckdb' ) + ); +} + +function writePhpunitCompatibilityFiles() { + if ( ! process.env.DUCKDB_PHP_AUTOLOAD ) { + console.error( 'Error: DUCKDB_PHP_AUTOLOAD is required for the DuckDB PHPUnit compatibility wrapper.' ); + process.exit( 1 ); + } + + fs.writeFileSync( + phpunitCompatibilityPrependPath, + `unregister(); +\t} + +\t$GLOBALS['wp_sqlite_phpunit_autoloader']->register( true ); +} + +if ( ! method_exists( 'PHPUnit\\\\TextUI\\\\TestRunner', 'run' ) ) { +\tfwrite( STDERR, "Error: DuckDB autoload changed the active PHPUnit runner away from the WordPress-compatible runner.\\n" ); +\texit( 1 ); +} +` + ); +} + +function verifyPhpunitCompatibilityFiles() { + const check = [ + `require ${ phpSingleQuote( phpunitCompatibilityPrependContainerPath ) };`, + 'require_once DUCKDB_PHP_AUTOLOAD;', + "exit( method_exists( 'PHPUnit\\\\TextUI\\\\TestRunner', 'run' ) ? 0 : 1 );", + ].join( ' ' ); + + runWordPressDockerCompose( [ 'run', '--rm', 'php', 'php', '-r', check ], { stdio: 'inherit' } ); +} + +function addPhpunitPrependArgument( command, prependPath ) { + if ( command.includes( '--prepend' ) ) { + return command; + } + + const prependArgument = `--prepend=${ prependPath }`; + const composerArgumentSeparator = ' -- '; + + if ( command.includes( composerArgumentSeparator ) ) { + return command.replace( + composerArgumentSeparator, + `${ composerArgumentSeparator }${ shellQuote( prependArgument ) } ` + ); + } + + if ( command.includes( 'composer run wp-test-php-duckdb' ) ) { + return `${ command } -- ${ shellQuote( prependArgument ) }`; + } + + return `${ command } ${ shellQuote( prependArgument ) }`; +} + function verifyNativeParserExtension() { const verifier = path.join( __dirname, '..', '..', 'wordpress', 'native-verify-extension.php' ); if ( ! fs.existsSync( verifier ) ) { @@ -228,15 +358,20 @@ function shellQuote( value ) { return `'${ String( value ).replace( /'/g, "'\\''" ) }'`; } +function phpSingleQuote( value ) { + return `'${ String( value ).replace( /\\/g, '\\\\' ).replace( /'/g, "\\'" ) }'`; +} + try { if ( requiresNativeParserExtension ) { verifyNativeParserExtension(); } ensureCompatiblePhpunitRunner(); + const effectivePhpunitCommand = preparePhpunitCommand(); try { - execSync( phpunitCommand, { stdio: 'inherit' } ); + execSync( effectivePhpunitCommand, { stdio: 'inherit' } ); console.log( '\n⚠️ All tests passed, checking if expected errors/failures occurred...' ); } catch ( error ) { console.log( '\n⚠️ Some tests errored/failed (expected). Analyzing results...' ); diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 1bc8cfacb..3645a2e4c 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1140,7 +1140,7 @@ private function primary_key_group_by_expansion( array $tokens ): ?array { $from_index + 1, $this->simple_select_from_clause_end( $tokens, $from_index + 1 ) - $from_index - 1 ); - $table = $this->parse_simple_select_table_reference( $table_tokens ); + $table = $this->parse_primary_key_group_by_from_clause( $table_tokens ); if ( null === $table ) { return null; } @@ -1151,7 +1151,7 @@ private function primary_key_group_by_expansion( array $tokens ): ?array { $column = $this->parse_simple_select_column_reference( $item ); if ( null === $column - || ! $this->simple_select_column_qualifier_matches_table( $column['qualifier'], $table ) + || ! $this->primary_key_group_by_column_qualifier_matches_table( $column['qualifier'], $table ) ) { return null; } @@ -1180,7 +1180,7 @@ private function primary_key_group_by_expansion( array $tokens ): ?array { $column = $this->parse_group_by_column_reference( $item ); if ( null === $column - || ! $this->simple_select_column_qualifier_matches_table( $column['qualifier'], $table ) + || ! $this->primary_key_group_by_column_qualifier_matches_table( $column['qualifier'], $table ) ) { return null; } @@ -1242,6 +1242,186 @@ private function primary_key_group_by_expansion( array $tokens ): ?array { ); } + /** + * Parse the FROM clause supported by primary-key GROUP BY expansion. + * + * Joined tables are accepted only as direct INNER JOIN sources. Projected, + * grouped, and ordered columns must still qualify the primary table. + * + * @param WP_Parser_Token[] $tokens FROM clause tokens. + * @return array{table_name:string,alias:string,temporary:bool,joined:bool}|null Primary table reference, or null when unsupported. + */ + private function parse_primary_key_group_by_from_clause( array $tokens ): ?array { + if ( + count( $tokens ) === 0 + || $this->contains_top_level_token_id( $tokens, WP_MySQL_Lexer::COMMA_SYMBOL ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id + ) { + return null; + } + + $join_index = $this->find_primary_key_group_by_join_index( $tokens, 0 ); + if ( null === $join_index ) { + $table = $this->parse_simple_select_table_reference( $tokens ); + if ( null === $table ) { + return null; + } + $table['joined'] = false; + return $table; + } + + $table = $this->parse_simple_select_table_reference( array_slice( $tokens, 0, $join_index ) ); + if ( null === $table || ! $this->primary_key_group_by_join_chain_is_supported( $tokens, $join_index ) ) { + return null; + } + + $table['joined'] = true; + return $table; + } + + /** + * Check whether the joined-table tail is narrow enough for GROUP BY expansion. + * + * @param WP_Parser_Token[] $tokens FROM clause tokens. + * @param int $index First join token. + * @return bool Whether the join chain is supported. + */ + private function primary_key_group_by_join_chain_is_supported( array $tokens, int $index ): bool { + while ( $index < count( $tokens ) ) { + if ( WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { + return false; + } + } elseif ( WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { + return false; + } + + ++$index; + $table_end = $this->primary_key_group_by_join_table_factor_end( $tokens, $index ); + if ( + $table_end === $index + || null === $this->parse_simple_select_table_reference( array_slice( $tokens, $index, $table_end - $index ) ) + ) { + return false; + } + + $index = $table_end; + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $index ]->id ) { + return false; + } + + ++$index; + $predicate_end = $this->find_primary_key_group_by_join_index( $tokens, $index ); + if ( null === $predicate_end ) { + $predicate_end = count( $tokens ); + } + if ( $predicate_end === $index ) { + return false; + } + + $index = $predicate_end; + } + + return true; + } + + /** + * Find the end of a joined table factor. + * + * @param WP_Parser_Token[] $tokens FROM clause tokens. + * @param int $start First table-factor token. + * @return int End index, exclusive. + */ + private function primary_key_group_by_join_table_factor_end( array $tokens, int $start ): int { + $depth = 0; + for ( $index = $start; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + continue; + } + if ( + 0 === $depth + && ( + WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $index ]->id + || WP_MySQL_Lexer::USING_SYMBOL === $tokens[ $index ]->id + || $this->is_primary_key_group_by_join_start_token( $tokens[ $index ] ) + ) + ) { + return $index; + } + } + + return count( $tokens ); + } + + /** + * Find the next join operator token in a FROM clause. + * + * @param WP_Parser_Token[] $tokens FROM clause tokens. + * @param int $start First token to scan. + * @return int|null Join token index, or null when absent. + */ + private function find_primary_key_group_by_join_index( array $tokens, int $start ): ?int { + $depth = 0; + for ( $index = $start; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + continue; + } + if ( 0 === $depth && $this->is_primary_key_group_by_join_start_token( $tokens[ $index ] ) ) { + return $index; + } + } + + return null; + } + + /** + * Check whether a token can start a join operator. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token starts a join operator. + */ + private function is_primary_key_group_by_join_start_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::INNER_SYMBOL, + WP_MySQL_Lexer::LEFT_SYMBOL, + WP_MySQL_Lexer::RIGHT_SYMBOL, + WP_MySQL_Lexer::NATURAL_SYMBOL, + WP_MySQL_Lexer::CROSS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ), + true + ); + } + + /** + * Check whether a GROUP BY expansion column qualifier belongs to the primary table. + * + * @param string|null $qualifier Optional column qualifier. + * @param array{table_name:string,alias:string,temporary:bool,joined?:bool} $table Parsed primary table reference. + * @return bool Whether the qualifier matches the primary table. + */ + private function primary_key_group_by_column_qualifier_matches_table( ?string $qualifier, array $table ): bool { + if ( isset( $table['joined'] ) && $table['joined'] ) { + return null !== $qualifier && 0 === strcasecmp( $qualifier, $table['alias'] ); + } + + return $this->simple_select_column_qualifier_matches_table( $qualifier, $table ); + } + /** * Find the end of the supported top-level GROUP BY clause. * @@ -1285,7 +1465,7 @@ private function primary_key_group_by_clause_end( array $tokens, int $start ): i * * @param WP_Parser_Token[] $tokens MySQL tokens. * @param int $group_end End of the GROUP BY clause. - * @param array{table_name:string,alias:string,temporary:bool} $table Parsed table reference. + * @param array{table_name:string,alias:string,temporary:bool,joined?:bool} $table Parsed table reference. * @param array> $metadata_by_column Table metadata keyed by lower-case column name. * @return array Column names keyed by lower-case column name. */ @@ -1310,7 +1490,7 @@ private function primary_key_group_by_order_columns( array $tokens, int $group_e $column = $this->parse_order_by_column_reference( $item ); if ( null === $column - || ! $this->simple_select_column_qualifier_matches_table( $column['qualifier'], $table ) + || ! $this->primary_key_group_by_column_qualifier_matches_table( $column['qualifier'], $table ) ) { continue; } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 3b854bb5e..7444221c1 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -248,6 +248,82 @@ public function test_select_primary_key_group_by_orders_by_functionally_dependen ); } + public function test_select_posts_wildcard_joined_group_by_primary_key_expands_group_by(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $this->create_wordpress_joined_group_by_tables( $driver ); + + $rows = $driver->query( + "SELECT wptests_posts.* + FROM wptests_posts + INNER JOIN wptests_postmeta + ON (wptests_posts.ID = wptests_postmeta.post_id) + WHERE 1=1 + AND wptests_posts.post_type = 'page' + AND wptests_postmeta.meta_key = '_wp_page_template' + AND wptests_postmeta.meta_value = 'default' + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + array( + 'ID' => 2, + 'post_author' => 20, + 'post_date' => '2026-02-01 00:00:00', + 'post_title' => 'second', + 'post_type' => 'page', + ), + array( + 'ID' => 1, + 'post_author' => 10, + 'post_date' => '2026-01-01 00:00:00', + 'post_title' => 'first', + 'post_type' => 'page', + ), + ), + $rows + ); + + $duckdb_queries = $driver->get_last_duckdb_queries(); + $select_sql = end( $duckdb_queries ); + + $this->assertIsString( $select_sql ); + $this->assertStringContainsString( + 'GROUP BY wptests_posts.ID, "wptests_posts"."post_author", "wptests_posts"."post_date", "wptests_posts"."post_title", "wptests_posts"."post_type"', + $select_sql + ); + } + + public function test_select_joined_group_by_primary_key_rejects_joined_table_projection(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $this->create_wordpress_joined_group_by_tables( $driver ); + + try { + $driver->query( + "SELECT wptests_posts.ID, wptests_postmeta.meta_value + FROM wptests_posts + INNER JOIN wptests_postmeta + ON (wptests_posts.ID = wptests_postmeta.post_id) + WHERE wptests_postmeta.meta_key = '_wp_page_template' + GROUP BY wptests_posts.ID" + ); + $this->fail( 'Expected WP_DuckDB_Driver_Exception.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Unsupported DuckDB MySQL-emulation SELECT statement', $e->getMessage() ); + } + + $duckdb_queries = $driver->get_last_duckdb_queries(); + $select_sql = end( $duckdb_queries ); + + $this->assertIsString( $select_sql ); + $this->assertStringNotContainsString( '"wptests_posts"."post_author"', $select_sql ); + } + public function test_select_seeded_rand_literals_are_emulated(): void { $this->requireDuckDBRuntime(); @@ -13446,6 +13522,44 @@ private function lifecycleTableSql( string $table_name ): string { // phpcs:igno ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"; } + private function create_wordpress_joined_group_by_tables( WP_DuckDB_Driver $driver ): void { + $driver->query( + "CREATE TABLE wptests_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_author BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + post_date DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + post_title TEXT NOT NULL, + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "CREATE TABLE wptests_postmeta ( + meta_id BIGINT(20) UNSIGNED NOT NULL, + post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + meta_key VARCHAR(255) DEFAULT NULL, + meta_value LONGTEXT, + PRIMARY KEY (meta_id), + KEY post_id (post_id), + KEY meta_key (meta_key(191)) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wptests_posts (ID, post_author, post_date, post_title, post_type) VALUES + (1, 10, '2026-01-01 00:00:00', 'first', 'page'), + (2, 20, '2026-02-01 00:00:00', 'second', 'page'), + (3, 30, '2026-03-01 00:00:00', 'third', 'post')" + ); + $driver->query( + "INSERT INTO wptests_postmeta (meta_id, post_id, meta_key, meta_value) VALUES + (1, 1, '_wp_page_template', 'default'), + (2, 1, '_wp_page_template', 'default'), + (3, 2, '_wp_page_template', 'default'), + (4, 3, '_wp_page_template', 'default'), + (5, 2, '_edit_lock', 'ignored')" + ); + } + /** * WordPress-style schema statements that exercise core DDL shapes. * From 399442b7f8fa7195a1469f1de323402414c51133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 12:51:12 +0000 Subject: [PATCH 127/317] Focus DuckDB WordPress CI diagnostics --- .github/workflows/duckdb-phpunit-tests.yml | 102 +++++++++++++++++++-- .github/workflows/wp-tests-phpunit-run.js | 28 +++++- 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index 5db2381f6..498455613 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -94,7 +94,7 @@ jobs: working-directory: packages/mysql-on-sqlite wp-duckdb-test: - name: WordPress PHPUnit / DuckDB + name: WordPress PHPUnit wpdb / DuckDB runs-on: ubuntu-latest timeout-minutes: 40 permissions: @@ -139,16 +139,17 @@ jobs: run: php -d ffi.enable=true -r "require 'vendor/autoload.php'; require 'src/load.php'; WP_DuckDB_Runtime::assert_available( true );" working-directory: packages/mysql-on-sqlite - - name: Run WordPress PHPUnit tests with DuckDB + - name: Run WordPress wpdb PHPUnit tests with DuckDB env: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY: '1' - WP_SQLITE_PHPUNIT_COMMAND: composer run wp-test-php-duckdb -- --log-junit=phpunit-duckdb-results.xml --verbose + WP_SQLITE_IGNORE_MISSING_EXPECTED_RESULTS: '1' + WP_SQLITE_PHPUNIT_COMMAND: composer run wp-test-php-duckdb -- --group=wpdb --log-junit=phpunit-duckdb-results.xml --verbose WP_SQLITE_PHPUNIT_JUNIT_PATH: wordpress/phpunit-duckdb-results.xml run: node .github/workflows/wp-tests-phpunit-run.js - name: Dump WordPress DuckDB failure diagnostics - if: failure() + if: failure() || cancelled() shell: bash run: | set +e @@ -280,13 +281,102 @@ jobs: - name: Install Playwright browsers run: npx playwright install --with-deps + - name: Prepare WordPress DuckDB end-to-end environment + env: + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + run: composer run wp-test-ensure-env-duckdb + + - name: Install WordPress 500 response diagnostics + run: | + cat > wordpress/src/wp-content/duckdb-e2e-500-diagnostics.php <<'PHP' + $status ) { + return; + } + + global $wpdb; + + $diagnostics = array( + 'status' => $status, + 'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : '', + 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '', + 'wpdb_class' => is_object( $wpdb ) ? get_class( $wpdb ) : gettype( $wpdb ), + 'last_error' => '', + 'last_query' => '', + 'num_queries' => null, + 'queries_tail' => array(), + 'php_error' => error_get_last(), + ); + + if ( is_object( $wpdb ) ) { + if ( isset( $wpdb->last_error ) ) { + $diagnostics['last_error'] = (string) $wpdb->last_error; + } + if ( isset( $wpdb->last_query ) ) { + $diagnostics['last_query'] = (string) $wpdb->last_query; + } + if ( isset( $wpdb->num_queries ) ) { + $diagnostics['num_queries'] = (int) $wpdb->num_queries; + } + if ( isset( $wpdb->queries ) && is_array( $wpdb->queries ) ) { + foreach ( array_slice( $wpdb->queries, -5 ) as $query ) { + if ( is_array( $query ) ) { + $diagnostics['queries_tail'][] = array( + 'query' => isset( $query[0] ) ? (string) $query[0] : '', + 'time' => isset( $query[1] ) ? $query[1] : null, + 'caller' => isset( $query[2] ) ? (string) $query[2] : '', + ); + continue; + } + + $diagnostics['queries_tail'][] = (string) $query; + } + } + } + + $encoded = function_exists( 'wp_json_encode' ) + ? wp_json_encode( $diagnostics, JSON_UNESCAPED_SLASHES ) + : json_encode( $diagnostics ); + + error_log( '[duckdb-e2e-500] ' . $encoded ); + } + ); + PHP + php -l wordpress/src/wp-content/duckdb-e2e-500-diagnostics.php + php -r ' + $file = $argv[1]; + $contents = file_get_contents( $file ); + $needle = "require_once \$sqlite_plugin_implementation_folder_path . " . chr( 39 ) . "/wp-includes/db.php" . chr( 39 ) . ";"; + $insert = "require_once __DIR__ . " . chr( 39 ) . "/duckdb-e2e-500-diagnostics.php" . chr( 39 ) . ";\n"; + + if ( false === strpos( $contents, $needle ) ) { + fwrite( STDERR, "Could not find DuckDB db.php insertion point.\n" ); + exit( 1 ); + } + + if ( false === strpos( $contents, $insert ) ) { + file_put_contents( $file, str_replace( $needle, $insert . $needle, $contents ) ); + } + ' wordpress/src/wp-content/db.php + - name: Run WordPress end-to-end tests with DuckDB env: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php - run: composer run wp-test-e2e-duckdb + run: npm --prefix wordpress run test:e2e -- - name: Dump WordPress DuckDB failure diagnostics - if: failure() + if: failure() || cancelled() shell: bash run: | set +e diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 66bf86b30..cf8cff70a 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -16,6 +16,7 @@ const phpunitEnsureEnvironmentCommand = process.env.WP_SQLITE_PHPUNIT_ENSURE_ENV const ensurePhpunitCompatibility = process.env.WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY === '1'; const phpunitCompatibilityConstraint = process.env.WP_SQLITE_PHPUNIT_COMPATIBILITY_CONSTRAINT || '^9.6'; const skipPhpunitCompatibilityCheck = process.env.WP_SQLITE_SKIP_PHPUNIT_COMPATIBILITY_CHECK === '1'; +const ignoreMissingExpectedResults = process.env.WP_SQLITE_IGNORE_MISSING_EXPECTED_RESULTS === '1'; const junitOutputPath = process.env.WP_SQLITE_PHPUNIT_JUNIT_PATH || 'wordpress/phpunit-results.xml'; const junitOutputFile = path.isAbsolute( junitOutputPath ) ? junitOutputPath @@ -112,6 +113,9 @@ console.log( 'PHPUnit command:', phpunitCommand ); console.log( 'JUnit output:', junitOutputFile ); console.log( 'Expected errors:', expectedErrors ); console.log( 'Expected failures:', expectedFailures ); +if ( ignoreMissingExpectedResults ) { + console.log( 'Expected errors/failures outside the selected PHPUnit tests will be ignored.' ); +} function getDefaultEnsureEnvironmentCommand() { return phpunitCommand.includes( 'wp-test-php-duckdb' ) @@ -385,6 +389,7 @@ try { const junitXml = fs.readFileSync( junitOutputFile, 'utf8' ); // Extract test info from the XML: + const actualTests = []; const actualErrors = []; const actualFailures = []; for ( const testcase of junitXml.matchAll( /]*)\/>|]*)>([\s\S]*?)<\/testcase>/g ) ) { @@ -396,6 +401,8 @@ try { const content = testcase[3] ?? ''; const fqn = attributes.class ? `${attributes.class}::${attributes.name}` : attributes.name; + actualTests.push( fqn ); + const hasError = content.includes( ' ! actualErrors.includes( test ) ); + const expectedErrorsInScope = filterExpectedResultsInScope( expectedErrors, actualTests ); + const unexpectedNonErrors = expectedErrorsInScope.filter( test => ! actualErrors.includes( test ) ); if ( unexpectedNonErrors.length > 0 ) { console.error( '\n❌ The following tests were expected to error but did not:' ); unexpectedNonErrors.forEach( test => console.error( ` - ${test}` ) ); @@ -419,13 +427,21 @@ try { } // Check if all expected failures actually failed - const unexpectedPasses = expectedFailures.filter( test => ! actualFailures.includes( test ) ); + const expectedFailuresInScope = filterExpectedResultsInScope( expectedFailures, actualTests ); + const unexpectedPasses = expectedFailuresInScope.filter( test => ! actualFailures.includes( test ) ); if ( unexpectedPasses.length > 0 ) { console.error( '\n❌ The following tests were expected to fail but passed:' ); unexpectedPasses.forEach( test => console.error( ` - ${test}` ) ); isSuccess = false; } + if ( ignoreMissingExpectedResults ) { + const ignoredExpectedResults = expectedErrors.concat( expectedFailures ).filter( test => ! actualTests.includes( test ) ); + if ( ignoredExpectedResults.length > 0 ) { + console.log( `\nℹ️ Ignored ${ ignoredExpectedResults.length } expected errors/failures outside the selected PHPUnit tests.` ); + } + } + // Check for unexpected errors const unexpectedErrors = actualErrors.filter( test => ! expectedErrors.includes( test ) ); if ( unexpectedErrors.length > 0 ) { @@ -453,3 +469,11 @@ try { console.error( '\n❌ Script execution error:', error.message ); process.exit( 1 ); } + +function filterExpectedResultsInScope( expectedResults, actualTests ) { + if ( ! ignoreMissingExpectedResults ) { + return expectedResults; + } + + return expectedResults.filter( test => actualTests.includes( test ) ); +} From 5b2225411838971530a97cb3ecca0b43472d3af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 13:08:44 +0000 Subject: [PATCH 128/317] Fix DuckDB WordPress acceptance blockers --- .github/workflows/duckdb-phpunit-tests.yml | 29 ++++++++++ .../wp-includes/duckdb/class-wp-duckdb-db.php | 57 ++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index 498455613..edc779ace 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -286,6 +286,35 @@ jobs: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php run: composer run wp-test-ensure-env-duckdb + - name: Serialize WordPress PHP-FPM for DuckDB end-to-end tests + run: | + set -euo pipefail + cd wordpress + cat > docker-compose.duckdb-e2e.yml <<'YAML' + services: + php: + command: + - /bin/sh + - -c + - | + if [ "$$LOCAL_PHP_MEMCACHED" = true ]; then + cp -n /var/www/tests/phpunit/includes/object-cache.php /var/www/src/wp-content/object-cache.php + else + rm -f /var/www/src/wp-content/object-cache.php + fi + pool_config=/usr/local/etc/php-fpm.d/www.conf + sed -ri 's/^[[:space:]]*pm[[:space:]]*=.*/pm = static/' "$pool_config" + sed -ri 's/^[[:space:]]*pm\.max_children[[:space:]]*=.*/pm.max_children = 1/' "$pool_config" + grep -Eq '^[[:space:]]*pm[[:space:]]*=[[:space:]]*static' "$pool_config" + grep -Eq '^[[:space:]]*pm\.max_children[[:space:]]*=[[:space:]]*1' "$pool_config" + php-fpm -tt + exec php-fpm + YAML + docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.duckdb-e2e.yml config --quiet + docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.duckdb-e2e.yml up -d --no-deps --force-recreate php + docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.duckdb-e2e.yml exec -T php sh -lc 'grep -Eq "^[[:space:]]*pm[[:space:]]*=[[:space:]]*static" /usr/local/etc/php-fpm.d/www.conf && grep -Eq "^[[:space:]]*pm\\.max_children[[:space:]]*=[[:space:]]*1" /usr/local/etc/php-fpm.d/www.conf' + docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.duckdb-e2e.yml exec -T php php -r 'exit( extension_loaded( "ffi" ) ? 0 : 1 );' + - name: Install WordPress 500 response diagnostics run: | cat > wordpress/src/wp-content/duckdb-e2e-500-diagnostics.php <<'PHP' diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index aba14b6f1..d0e081270 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -21,6 +21,16 @@ class WP_DuckDB_DB extends wpdb { */ protected $dbh; + /** + * Backward compatibility, see wpdb::$allow_unsafe_unquoted_parameters. + * + * This property mirrors "wpdb::$allow_unsafe_unquoted_parameters" because + * some tests access it externally using PHP reflection. + * + * @var bool + */ + private $allow_unsafe_unquoted_parameters = true; + /** * Last DuckDB statement. * @@ -293,6 +303,33 @@ public function check_connection( $allow_bail = true ) { return true; } + /** + * Prepares a SQL query for safe execution. + * + * See "wpdb::prepare()". This override only fixes a WPDB test issue. + * + * @param string $query Query statement with `sprintf()`-like placeholders. + * @param array|mixed $args The array of variables or the first variable to substitute. + * @param mixed ...$args Further variables to substitute when using individual arguments. + * @return string|void Sanitized query string, if there is a query to prepare. + */ + public function prepare( $query, ...$args ) { + /* + * Sync "$allow_unsafe_unquoted_parameters" with the WPDB parent property. + * This is only needed because some WPDB tests access the private property + * externally via PHP reflection. + */ + $wpdb_allow_unsafe_unquoted_parameters = $this->__get( 'allow_unsafe_unquoted_parameters' ); + if ( $wpdb_allow_unsafe_unquoted_parameters !== $this->allow_unsafe_unquoted_parameters ) { + $property = new ReflectionProperty( 'wpdb', 'allow_unsafe_unquoted_parameters' ); + $property->setAccessible( true ); + $property->setValue( $this, $this->allow_unsafe_unquoted_parameters ); + $property->setAccessible( false ); + } + + return parent::prepare( $query, ...$args ); + } + /** * Perform a query. * @@ -355,7 +392,7 @@ private function _do_query( $query ) { try { $this->last_statement = $this->dbh->query( $query ); if ( $this->last_statement->columnCount() > 0 ) { - $this->result = $this->last_statement->fetchAll( PDO::FETCH_OBJ ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + $this->result = $this->normalize_result_rows( $this->last_statement->fetchAll( PDO::FETCH_OBJ ) ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO } else { $this->result = null; } @@ -376,6 +413,24 @@ private function _do_query( $query ) { } } + /** + * Normalize fetched DuckDB rows to MySQL/PDO-style scalar values. + * + * @param array $rows Result rows. + * @return array Normalized result rows. + */ + private function normalize_result_rows( array $rows ) { + foreach ( $rows as $row ) { + foreach ( get_object_vars( $row ) as $name => $value ) { + if ( is_bool( $value ) ) { + $row->$name = $value ? '1' : '0'; + } + } + } + + return $rows; + } + /** * Load column metadata. */ From b23607610c7eb03aff6d5d473204cc20292b5386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 13:31:36 +0000 Subject: [PATCH 129/317] Advance DuckDB WordPress parity blockers --- .github/workflows/duckdb-phpunit-tests.yml | 8 +- .../src/duckdb/class-wp-duckdb-driver.php | 213 ++++++++++++------ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 113 ++++++++++ 3 files changed, 260 insertions(+), 74 deletions(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index edc779ace..9e9a6cc2c 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -303,10 +303,10 @@ jobs: rm -f /var/www/src/wp-content/object-cache.php fi pool_config=/usr/local/etc/php-fpm.d/www.conf - sed -ri 's/^[[:space:]]*pm[[:space:]]*=.*/pm = static/' "$pool_config" - sed -ri 's/^[[:space:]]*pm\.max_children[[:space:]]*=.*/pm.max_children = 1/' "$pool_config" - grep -Eq '^[[:space:]]*pm[[:space:]]*=[[:space:]]*static' "$pool_config" - grep -Eq '^[[:space:]]*pm\.max_children[[:space:]]*=[[:space:]]*1' "$pool_config" + sed -ri 's/^[[:space:]]*pm[[:space:]]*=.*/pm = static/' "$$pool_config" + sed -ri 's/^[[:space:]]*pm\.max_children[[:space:]]*=.*/pm.max_children = 1/' "$$pool_config" + grep -Eq '^[[:space:]]*pm[[:space:]]*=[[:space:]]*static' "$$pool_config" + grep -Eq '^[[:space:]]*pm\.max_children[[:space:]]*=[[:space:]]*1' "$$pool_config" php-fpm -tt exec php-fpm YAML diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 3645a2e4c..5419af5a2 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -2615,11 +2615,13 @@ private function execute_create_index( array $tokens ): WP_DuckDB_Result_Stateme ++$index; } + $index_type = 'BTREE'; if ( isset( $tokens[ $index ] ) && ( WP_MySQL_Lexer::FULLTEXT_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::SPATIAL_SYMBOL === $tokens[ $index ]->id ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE INDEX type in DuckDB driver: ' . $tokens[ $index ]->get_bytes() . '.' ); + $index_type = strtoupper( $tokens[ $index ]->get_bytes() ); + ++$index; } $this->expect_token( $tokens, $index, WP_MySQL_Lexer::INDEX_SYMBOL, 'Expected INDEX in CREATE INDEX statement.' ); @@ -2644,7 +2646,7 @@ private function execute_create_index( array $tokens ): WP_DuckDB_Result_Stateme list( $columns, $column_metadata, $index ) = $this->translate_index_column_list( $tokens, $index ); $this->assert_supported_index_options( $tokens, $index ); - $index_definition = $this->build_secondary_index_definition( $table_name, $mysql_index_name, $unique, $columns, $column_metadata, $table_reference['temporary'] ); + $index_definition = $this->build_secondary_index_definition( $table_name, $mysql_index_name, $unique, $columns, $column_metadata, $table_reference['temporary'], $index_type ); $result = $this->execute_duckdb_query( $index_definition['sql'], 'Failed to create DuckDB index' ); $this->record_index_metadata( $index_definition ); @@ -7983,7 +7985,7 @@ function ( string $column ): array { /** * Build SHOW CREATE index groups from secondary index definitions. * - * @param array}> $index_definitions Index definitions. + * @param array}> $index_definitions Index definitions. * @return array}> */ private function show_create_table_index_groups_from_definitions( array $index_definitions ): array { @@ -7992,7 +7994,7 @@ private function show_create_table_index_groups_from_definitions( array $index_d $groups[] = array( 'name' => $index_definition['index_name'], 'non_unique' => $index_definition['unique'] ? 0 : 1, - 'index_type' => 'BTREE', + 'index_type' => $index_definition['index_type'] ?? 'BTREE', 'index_comment' => '', 'columns' => array_map( function ( array $column ): array { @@ -8620,9 +8622,9 @@ private function index_definition_contains_column( array $index_definition, stri * * @param string $table_name Table name. * @param string $column_name Dropped column name. - * @param array}> $index_definitions Current index definitions. + * @param array}> $index_definitions Current index definitions. * @param bool $temporary Whether the target is a temporary table. - * @return array}> + * @return array}> */ private function secondary_index_definitions_after_column_drop( string $table_name, string $column_name, array $index_definitions, bool $temporary = false ): array { $rebuilt_indexes = array(); @@ -8657,7 +8659,8 @@ function ( array $column ): string { $column_metadata ), $column_metadata, - $temporary + $temporary, + $index_definition['index_type'] ?? 'BTREE' ); } @@ -8915,9 +8918,9 @@ private function canonical_duckdb_type( string $type ): string { * @param string $table_name Table name. * @param string $old_column_name Old column name. * @param string $new_column_name New column name. - * @param array}> $index_definitions Current index definitions. + * @param array}> $index_definitions Current index definitions. * @param bool $temporary Whether the target is a temporary table. - * @return array}> + * @return array}> */ private function secondary_index_definitions_after_column_rename( string $table_name, string $old_column_name, string $new_column_name, array $index_definitions, bool $temporary = false ): array { $rebuilt_indexes = array(); @@ -8944,7 +8947,8 @@ function ( array $column ): string { $column_metadata ), $column_metadata, - $temporary + $temporary, + $index_definition['index_type'] ?? 'BTREE' ); } @@ -10312,9 +10316,23 @@ private function execute_show_index( array $tokens ): WP_DuckDB_Result_Statement } $requested_reference = $this->parse_metadata_table_reference( $tokens, 3, true ); - if ( count( $tokens ) !== $requested_reference['next_index'] ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW INDEX statement in DuckDB driver. Use SHOW INDEX FROM table.' ); - } + $columns = array( + 'Table', + 'Non_unique', + 'Key_name', + 'Seq_in_index', + 'Column_name', + 'Collation', + 'Cardinality', + 'Sub_part', + 'Packed', + 'Null', + 'Index_type', + 'Comment', + 'Index_comment', + 'Visible', + 'Expression', + ); $rows = array(); if ( 0 === strcasecmp( $requested_reference['database'], $this->database ) ) { @@ -10322,24 +10340,16 @@ private function execute_show_index( array $tokens ): WP_DuckDB_Result_Statement $rows = null === $table_reference ? array() : $this->index_rows_for_table( $table_reference['table_name'], $table_reference['temporary'] ); } + if ( count( $tokens ) !== $requested_reference['next_index'] ) { + if ( count( $rows ) === 0 ) { + $this->consume_show_like_or_where_clause( $tokens, $requested_reference['next_index'], 'SHOW INDEX', 'Key_name', $columns ); + } else { + return $this->execute_static_show_metadata_statement( $columns, $rows, 'Key_name', $tokens, $requested_reference['next_index'], 'SHOW INDEX' ); + } + } + return new WP_DuckDB_Result_Statement( - array( - 'Table', - 'Non_unique', - 'Key_name', - 'Seq_in_index', - 'Column_name', - 'Collation', - 'Cardinality', - 'Sub_part', - 'Packed', - 'Null', - 'Index_type', - 'Comment', - 'Index_comment', - 'Visible', - 'Expression', - ), + $columns, $rows ); } @@ -10512,7 +10522,14 @@ function ( array $column ): string { if ( 'PRIMARY' === $index_group['name'] ) { $sql = ' PRIMARY KEY (' . implode( ', ', $columns ) . ')'; } else { - $sql = ' ' . ( 0 === $index_group['non_unique'] ? 'UNIQUE KEY ' : 'KEY ' ); + $index_type = strtoupper( $index_group['index_type'] ); + if ( 0 === $index_group['non_unique'] ) { + $sql = ' UNIQUE KEY '; + } elseif ( 'FULLTEXT' === $index_type || 'SPATIAL' === $index_type ) { + $sql = ' ' . $index_type . ' KEY '; + } else { + $sql = ' KEY '; + } $sql .= $this->quote_mysql_identifier( $index_group['name'] ); $sql .= ' (' . implode( ', ', $columns ) . ')'; } @@ -10561,7 +10578,7 @@ private function secondary_index_rows( string $table_name, bool $temporary = fal $this->ensure_index_metadata_table( $temporary ); $stmt = $this->execute_duckdb_query( - 'SELECT index_name, non_unique, seq_in_index, column_name, sub_part FROM ' + 'SELECT index_name, non_unique, seq_in_index, column_name, sub_part, index_type FROM ' . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) @@ -10577,7 +10594,8 @@ private function secondary_index_rows( string $table_name, bool $temporary = fal (string) $row['index_name'], (int) $row['seq_in_index'], (string) $row['column_name'], - null === $row['sub_part'] ? null : (int) $row['sub_part'] + null === $row['sub_part'] ? null : (int) $row['sub_part'], + null === $row['index_type'] ? 'BTREE' : (string) $row['index_type'] ); } @@ -10593,9 +10611,10 @@ private function secondary_index_rows( string $table_name, bool $temporary = fal * @param int $seq_in_index Sequence in index. * @param string $column_name Column name. * @param int|null $sub_part Optional prefix length. + * @param string $index_type MySQL-facing index type. * @return array */ - private function show_index_row( string $table_name, int $non_unique, string $key_name, int $seq_in_index, string $column_name, ?int $sub_part ): array { + private function show_index_row( string $table_name, int $non_unique, string $key_name, int $seq_in_index, string $column_name, ?int $sub_part, string $index_type = 'BTREE' ): array { return array( $table_name, $non_unique, @@ -10607,7 +10626,7 @@ private function show_index_row( string $table_name, int $non_unique, string $ke $sub_part, null, '', - 'BTREE', + $index_type, '', '', 'YES', @@ -12235,14 +12254,16 @@ private function is_create_index_statement( array $tokens ): bool { * * @param string $table_name Table name. * @param WP_Parser_Token[] $tokens Index definition tokens. - * @return array{sql:string,table_name:string,index_name:string,unique:bool,columns:array} + * @return array{sql:string,table_name:string,index_name:string,unique:bool,temporary:bool,index_type:string,columns:array} */ private function translate_create_table_index( string $table_name, array $tokens, bool $temporary = false ): array { - $index = 0; - $unique = false; + $index = 0; + $unique = false; + $index_type = 'BTREE'; if ( WP_MySQL_Lexer::FULLTEXT_SYMBOL === $tokens[0]->id || WP_MySQL_Lexer::SPATIAL_SYMBOL === $tokens[0]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE index type in DuckDB driver: ' . $tokens[0]->get_bytes() . '.' ); + $index_type = strtoupper( $tokens[0]->get_bytes() ); + ++$index; } if ( WP_MySQL_Lexer::UNIQUE_SYMBOL === $tokens[ $index ]->id ) { @@ -12277,7 +12298,7 @@ private function translate_create_table_index( string $table_name, array $tokens $mysql_index_name = 'unnamed_' . substr( hash( 'sha256', serialize( $column_metadata ) ), 0, 8 ); } - return $this->build_secondary_index_definition( $table_name, $mysql_index_name, $unique, $columns, $column_metadata, $temporary ); + return $this->build_secondary_index_definition( $table_name, $mysql_index_name, $unique, $columns, $column_metadata, $temporary, $index_type ); } /** @@ -12288,9 +12309,13 @@ private function translate_create_table_index( string $table_name, array $tokens * @param bool $unique Whether the index is unique. * @param string[] $columns DuckDB column SQL fragments. * @param array $column_metadata MySQL column metadata. - * @return array{sql:string,table_name:string,index_name:string,unique:bool,temporary:bool,columns:array} + * @param bool $temporary Whether the index is for a temporary table. + * @param string $index_type MySQL-facing index type. + * @return array{sql:string,table_name:string,index_name:string,unique:bool,temporary:bool,index_type:string,columns:array} */ - private function build_secondary_index_definition( string $table_name, string $mysql_index_name, bool $unique, array $columns, array $column_metadata, bool $temporary = false ): array { + private function build_secondary_index_definition( string $table_name, string $mysql_index_name, bool $unique, array $columns, array $column_metadata, bool $temporary = false, string $index_type = 'BTREE' ): array { + $index_type = strtoupper( $index_type ); + return array( 'sql' => 'CREATE ' . ( $unique ? 'UNIQUE ' : '' ) @@ -12305,6 +12330,7 @@ private function build_secondary_index_definition( string $table_name, string $m 'index_name' => $mysql_index_name, 'unique' => $unique, 'temporary' => $temporary, + 'index_type' => $index_type, 'columns' => $column_metadata, ); } @@ -17189,21 +17215,12 @@ private function is_option_value_numeric_comparison_boundary_token( WP_Parser_To * @return string|null Translated predicate, or null when the pattern does not match. */ private function translate_like_escape_predicate( array $tokens, int &$index ): ?string { - if ( ! isset( $tokens[ $index ] ) || $this->is_non_identifier_token( $tokens[ $index ] ) ) { + $left_operand = $this->like_escape_left_operand_sql( $tokens, $index ); + if ( null === $left_operand ) { return null; } - $operator_index = $index + 1; - $left_tokens = array( $tokens[ $index ] ); - if ( - isset( $tokens[ $index + 2 ] ) - && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id - && ! $this->is_non_identifier_token( $tokens[ $index + 2 ] ) - ) { - $left_tokens = array( $tokens[ $index ], $tokens[ $index + 2 ] ); - $operator_index = $index + 3; - } - + $operator_index = $left_operand['next_index']; if ( ! isset( $tokens[ $operator_index ] ) ) { return null; } @@ -17235,20 +17252,56 @@ private function translate_like_escape_predicate( array $tokens, int &$index ): return null; } - $left_sql = 1 === count( $left_tokens ) - ? $this->connection->quote_identifier( $this->identifier_value( $left_tokens[0] ) ) - : $this->connection->quote_identifier( $this->identifier_value( $left_tokens[0] ) ) - . '.' - . $this->connection->quote_identifier( $this->identifier_value( $left_tokens[1] ) ); - $index = $pattern_index; - return $left_sql + return $left_operand['sql'] . ( $is_not_like ? ' NOT LIKE ' : ' LIKE ' ) . $this->connection->quote( $tokens[ $pattern_index ]->get_value() ) . ' ESCAPE ' . $this->connection->quote( '\\' ); } + /** + * Build the left operand for a simple MySQL LIKE predicate. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Operand start index. + * @return array{sql:string,next_index:int}|null Operand SQL and next token index. + */ + private function like_escape_left_operand_sql( array $tokens, int $index ): ?array { + if ( ! isset( $tokens[ $index ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $tokens[ $index ]->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[ $index ]->id ) { + return array( + 'sql' => $this->connection->quote( $tokens[ $index ]->get_value() ), + 'next_index' => $index + 1, + ); + } + + if ( $this->is_non_identifier_token( $tokens[ $index ] ) ) { + return null; + } + + if ( + isset( $tokens[ $index + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + && ! $this->is_non_identifier_token( $tokens[ $index + 2 ] ) + ) { + return array( + 'sql' => $this->connection->quote_identifier( $this->identifier_value( $tokens[ $index ] ) ) + . '.' + . $this->connection->quote_identifier( $this->identifier_value( $tokens[ $index + 2 ] ) ), + 'next_index' => $index + 3, + ); + } + + return array( + 'sql' => $this->connection->quote_identifier( $this->identifier_value( $tokens[ $index ] ) ), + 'next_index' => $index + 1, + ); + } + /** * Check if a token starts an empty function call by name. * @@ -17733,14 +17786,29 @@ private function execute_duckdb_query( string $sql, string $context ): WP_DuckDB * Ensure the internal index metadata table exists. */ private function ensure_index_metadata_table( bool $temporary = false ): void { + $table_name = $this->index_metadata_table_name( $temporary ); $this->execute_duckdb_query( 'CREATE ' . ( $temporary ? 'TEMP ' : '' ) . 'TABLE IF NOT EXISTS ' - . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) - . ' (table_name VARCHAR, index_name VARCHAR, non_unique INTEGER, seq_in_index INTEGER, column_name VARCHAR, sub_part INTEGER)', + . $this->connection->quote_identifier( $table_name ) + . ' (table_name VARCHAR, index_name VARCHAR, non_unique INTEGER, seq_in_index INTEGER, column_name VARCHAR, sub_part INTEGER, index_type VARCHAR)', 'Failed to initialize DuckDB index metadata' ); + + $columns = $this->execute_duckdb_query( + 'SELECT name FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ") WHERE name = 'index_type'", + 'Failed to inspect DuckDB index metadata schema' + )->fetchAll( PDO::FETCH_ASSOC ); + + if ( count( $columns ) === 0 ) { + $this->execute_duckdb_query( + 'ALTER TABLE ' + . $this->connection->quote_identifier( $table_name ) + . " ADD COLUMN index_type VARCHAR DEFAULT 'BTREE'", + 'Failed to upgrade DuckDB index metadata' + ); + } } /** @@ -17852,7 +17920,7 @@ private function foreign_key_metadata_table_name( bool $temporary ): string { /** * Record MySQL index metadata for SHOW INDEX. * - * @param array{table_name:string,index_name:string,unique:bool,temporary?:bool,columns:array} $index_definition Index definition. + * @param array{table_name:string,index_name:string,unique:bool,temporary?:bool,index_type?:string,columns:array} $index_definition Index definition. */ private function record_index_metadata( array $index_definition ): void { $temporary = isset( $index_definition['temporary'] ) && (bool) $index_definition['temporary']; @@ -17860,6 +17928,7 @@ private function record_index_metadata( array $index_definition ): void { $table_name = $index_definition['table_name']; $index_name = $index_definition['index_name']; + $index_type = $index_definition['index_type'] ?? 'BTREE'; $this->execute_duckdb_query( 'DELETE FROM ' @@ -17875,7 +17944,7 @@ private function record_index_metadata( array $index_definition ): void { $this->execute_duckdb_query( 'INSERT INTO ' . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) - . ' (table_name, index_name, non_unique, seq_in_index, column_name, sub_part) VALUES (' + . ' (table_name, index_name, non_unique, seq_in_index, column_name, sub_part, index_type) VALUES (' . $this->connection->quote( $table_name ) . ', ' . $this->connection->quote( $index_name ) @@ -17887,6 +17956,8 @@ private function record_index_metadata( array $index_definition ): void { . $this->connection->quote( $column['name'] ) . ', ' . $this->connection->quote( $column['sub_part'] ) + . ', ' + . $this->connection->quote( $index_type ) . ')', 'Failed to store DuckDB index metadata' ); @@ -17929,13 +18000,13 @@ private function execute_with_secondary_indexes_rebuilt( string $table_name, cal * Read recorded secondary index definitions for a table. * * @param string $table_name Table name. - * @return array}> + * @return array}> */ private function secondary_index_definitions_for_table( string $table_name, bool $temporary = false ): array { $this->ensure_index_metadata_table( $temporary ); $stmt = $this->execute_duckdb_query( - 'SELECT index_name, non_unique, seq_in_index, column_name, sub_part FROM ' + 'SELECT index_name, non_unique, seq_in_index, column_name, sub_part, index_type FROM ' . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) @@ -17948,8 +18019,9 @@ private function secondary_index_definitions_for_table( string $table_name, bool $index_name = (string) $row['index_name']; if ( ! isset( $grouped[ $index_name ] ) ) { $grouped[ $index_name ] = array( - 'unique' => 0 === (int) $row['non_unique'], - 'columns' => array(), + 'unique' => 0 === (int) $row['non_unique'], + 'index_type' => null === $row['index_type'] ? 'BTREE' : (string) $row['index_type'], + 'columns' => array(), ); } $grouped[ $index_name ]['columns'][] = array( @@ -17971,7 +18043,8 @@ function ( array $column ): string { $definition['columns'] ), $definition['columns'], - $temporary + $temporary, + $definition['index_type'] ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 7444221c1..b26439e14 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -2763,6 +2763,85 @@ public function test_escaped_like_predicates_use_mysql_backslash_semantics(): vo $this->assertStringContainsString( " ESCAPE '\\'", $this->lastDuckDBQuery( $driver ) ); } + public function test_escaped_like_literal_predicates_are_translated_without_duckdb_runtime(): void { + $duckdb = new class() { + public function query( string $sql ) { + return new class() { + public function columnNames(): ArrayIterator { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return new ArrayIterator( array( 'matched' ) ); + } + + public function rows( bool $assoc ): array { + return array( array( 'matched' => 1 ) ); + } + }; + } + }; + $driver = new WP_DuckDB_Driver( + array( + 'connection' => new WP_DuckDB_Connection( array( 'duckdb' => $duckdb ) ), + ) + ); + + $backslash = chr( 92 ); + $cases = array( + array( + 'mysql' => "SELECT 'a{$backslash}{$backslash}aa' LIKE 'a{$backslash}{$backslash}{$backslash}{$backslash}aa' AS matched", + 'duckdb' => "SELECT 'a{$backslash}aa' LIKE 'a{$backslash}{$backslash}aa' ESCAPE '{$backslash}' AS matched", + ), + array( + 'mysql' => "SELECT 'a%aa' LIKE 'a{$backslash}{$backslash}%aa' AS matched", + 'duckdb' => "SELECT 'a%aa' LIKE 'a{$backslash}%aa' ESCAPE '{$backslash}' AS matched", + ), + array( + 'mysql' => "SELECT 'a_aa' LIKE 'a{$backslash}{$backslash}_aa' AS matched", + 'duckdb' => "SELECT 'a_aa' LIKE 'a{$backslash}_aa' ESCAPE '{$backslash}' AS matched", + ), + ); + + foreach ( $cases as $case ) { + $driver->query( $case['mysql'] ); + + $this->assertSame( array( $case['duckdb'] ), $driver->get_last_duckdb_queries() ); + } + } + + public function test_escaped_like_literal_predicates_use_mysql_backslash_semantics(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $backslash = chr( 92 ); + $cases = array( + 'literal backslash matches itself' => array( + "SELECT 'a{$backslash}{$backslash}aa' LIKE 'a{$backslash}{$backslash}{$backslash}{$backslash}aa' AS matched", + true, + ), + 'literal percent matches itself' => array( + "SELECT 'a%aa' LIKE 'a{$backslash}{$backslash}%aa' AS matched", + true, + ), + 'literal percent is not wildcard' => array( + "SELECT 'aaaa' LIKE 'a{$backslash}{$backslash}%aa' AS matched", + false, + ), + 'literal underscore matches itself' => array( + "SELECT 'a_aa' LIKE 'a{$backslash}{$backslash}_aa' AS matched", + true, + ), + 'literal underscore is not wildcard' => array( + "SELECT 'aaaa' LIKE 'a{$backslash}{$backslash}_aa' AS matched", + false, + ), + ); + + foreach ( $cases as $message => $case ) { + $row = $driver->query( $case[0] )->fetch( PDO::FETCH_ASSOC ); + + $this->assertSame( $case[1], (bool) $row['matched'], $message ); + $this->assertStringContainsString( " ESCAPE '\\'", $this->lastDuckDBQuery( $driver ), $message ); + } + } + public function test_sql_calc_found_rows_and_found_rows_are_emulated(): void { $this->requireDuckDBRuntime(); @@ -6444,6 +6523,40 @@ function ( string $sql ): bool { $this->assertSame( 191, $indexes[1]['Sub_part'] ); } + public function test_mysql_fulltext_spatial_and_show_index_where_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE wp_search ( + id BIGINT(20) UNSIGNED NOT NULL, + content LONGTEXT NOT NULL, + shape GEOMETRYCOLLECTION NOT NULL, + KEY a_key (content(191)), + FULLTEXT KEY content_fulltext (content), + SPATIAL KEY shape_spatial (shape) + )' + ); + + $indexes = $driver->query( 'SHOW INDEX FROM wp_search' )->fetchAll( PDO::FETCH_ASSOC ); + $index_types = array_column( $indexes, 'Index_type', 'Key_name' ); + $index_prefix = array_column( $indexes, 'Sub_part', 'Key_name' ); + + $this->assertSame( 'BTREE', $index_types['a_key'] ); + $this->assertSame( 'FULLTEXT', $index_types['content_fulltext'] ); + $this->assertSame( 'SPATIAL', $index_types['shape_spatial'] ); + $this->assertSame( 191, $index_prefix['a_key'] ); + + $filtered = $driver->query( "SHOW INDEXES FROM wp_search WHERE Key_name='a_key';" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertCount( 1, $filtered ); + $this->assertSame( 'a_key', $filtered[0]['Key_name'] ); + $this->assertSame( 191, $filtered[0]['Sub_part'] ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wp_search' )->fetch( PDO::FETCH_ASSOC ); + $this->assertStringContainsString( 'FULLTEXT KEY `content_fulltext` (`content`)', $create_table['Create Table'] ); + $this->assertStringContainsString( 'SPATIAL KEY `shape_spatial` (`shape`)', $create_table['Create Table'] ); + } + public function test_alter_table_add_unique_index_is_supported(): void { $this->requireDuckDBRuntime(); From e36ab4ae01c8c0e03ff1029eedce01e1de03ca80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 14:09:53 +0000 Subject: [PATCH 130/317] Fix DuckDB wpdb insert and charset parity --- .../src/duckdb/class-wp-duckdb-driver.php | 491 +++++++++++++++--- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 332 ++++++++++++ .../wp-includes/duckdb/class-wp-duckdb-db.php | 4 +- 3 files changed, 760 insertions(+), 67 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 5419af5a2..67a6a434d 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -2542,6 +2542,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme false, $temporary, $auto_increment_seed, + $table_metadata['column_default_collation'], $table_metadata['table_collation'], $check_names, $foreign_key_names @@ -6064,9 +6065,6 @@ private function validate_alter_table_supported_action_shapes( array $actions ): WP_MySQL_Lexer::CHANGE_SYMBOL === $action[0]->id || WP_MySQL_Lexer::MODIFY_SYMBOL === $action[0]->id ) { - if ( $this->contains_auto_increment_token( $action ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY AUTO_INCREMENT requires a table rebuild.' ); - } continue; } @@ -6281,8 +6279,21 @@ private function is_alter_table_change_modify_column_rebuild_action( string $tab return false; } - list( , $sequence_sql, , $metadata ) = $this->translate_create_table_column( $table_name, $definition_tokens, false, true, $temporary, null, $this->table_default_collation( $table_name, $temporary ) ); - if ( null !== $sequence_sql || 'auto_increment' === $metadata['extra'] || 'PRI' === $metadata['column_key'] || 'UNI' === $metadata['column_key'] ) { + list( , $sequence_sql, , $metadata ) = $this->translate_create_table_column( + $table_name, + $definition_tokens, + false, + true, + $temporary, + null, + $this->table_default_column_collation( $table_name, $temporary ), + $this->table_default_collation( $table_name, $temporary ) + ); + $definition_has_auto_increment = $this->alter_table_change_modify_definition_has_auto_increment( $sequence_sql, $metadata ); + if ( $definition_has_auto_increment ) { + return true; + } + if ( 'PRI' === $metadata['column_key'] || 'UNI' === $metadata['column_key'] ) { return false; } @@ -6294,12 +6305,8 @@ private function is_alter_table_change_modify_column_rebuild_action( string $tab return false; } - $auto_increment = $this->auto_increment_metadata_for_table( $table_name, $temporary ); - if ( - ( null !== $auto_increment && 0 === strcasecmp( $auto_increment['column_name'], $current_column_name ) ) - || 'auto_increment' === $current_column['extra'] - ) { - return false; + if ( $this->alter_table_change_modify_column_is_auto_increment( $table_name, $current_column, $temporary ) ) { + return true; } $physical_column = $this->physical_column_info_row( $table_name, $current_column_name ); @@ -8822,8 +8829,9 @@ private function resolve_alter_table_change_column_metadata( string $table_name, * @param array> $metadata_rows Current table metadata rows. * @param string $new_column_name Requested new column name. * @param bool $temporary Whether the target is a temporary table. + * @param bool $allow_auto_increment_rebuild Whether same-name AUTO_INCREMENT rebuild is allowed. */ - private function assert_alter_table_change_column_supported( string $table_name, array $current_column, array $metadata_rows, string $new_column_name, bool $temporary = false ): void { + private function assert_alter_table_change_column_supported( string $table_name, array $current_column, array $metadata_rows, string $new_column_name, bool $temporary = false, bool $allow_auto_increment_rebuild = false ): void { $current_column_name = (string) $current_column['column_name']; foreach ( $metadata_rows as $column ) { if ( @@ -8843,11 +8851,7 @@ private function assert_alter_table_change_column_supported( string $table_name, } } - $auto_increment = $this->auto_increment_metadata_for_table( $table_name, $temporary ); - if ( - ( null !== $auto_increment && 0 === strcasecmp( $auto_increment['column_name'], $current_column_name ) ) - || 'auto_increment' === $current_column['extra'] - ) { + if ( ! $allow_auto_increment_rebuild && $this->alter_table_change_modify_column_is_auto_increment( $table_name, $current_column, $temporary ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY on an AUTO_INCREMENT column requires a table rebuild.' ); } } @@ -8979,6 +8983,61 @@ private function alter_table_change_modify_column_targets_key( string $table_nam return false; } + /** + * Check whether a CHANGE/MODIFY definition declares AUTO_INCREMENT. + * + * @param string|null $sequence_sql AUTO_INCREMENT sequence DDL, if any. + * @param array $metadata Planned column metadata. + * @return bool Whether AUTO_INCREMENT is declared. + */ + private function alter_table_change_modify_definition_has_auto_increment( ?string $sequence_sql, array $metadata ): bool { + return null !== $sequence_sql || 'auto_increment' === $metadata['extra']; + } + + /** + * Check whether the current CHANGE/MODIFY target is AUTO_INCREMENT. + * + * @param string $table_name Table name. + * @param array $current_column Current column metadata. + * @param bool $temporary Whether the target is a temporary table. + * @return bool Whether the current column is AUTO_INCREMENT. + */ + private function alter_table_change_modify_column_is_auto_increment( string $table_name, array $current_column, bool $temporary = false ): bool { + $current_column_name = (string) $current_column['column_name']; + $auto_increment = $this->auto_increment_metadata_for_table( $table_name, $temporary ); + + return ( null !== $auto_increment && 0 === strcasecmp( $auto_increment['column_name'], $current_column_name ) ) + || 'auto_increment' === $current_column['extra']; + } + + /** + * Validate support for a CHANGE/MODIFY AUTO_INCREMENT rebuild. + * + * @param string $table_name Table name. + * @param array $current_column Current column metadata. + * @param string $new_column_name Requested new column name. + * @param array $metadata Planned column metadata. + * @param array $inline_indexes Planned inline indexes. + * @param bool $temporary Whether the target is a temporary table. + * @return bool Whether the action should rebuild for AUTO_INCREMENT. + */ + private function assert_alter_table_change_modify_auto_increment_rebuild_supported( string $table_name, array $current_column, string $new_column_name, array $metadata, array $inline_indexes, bool $temporary = false ): bool { + if ( ! $this->alter_table_change_modify_column_is_auto_increment( $table_name, $current_column, $temporary ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY AUTO_INCREMENT requires a table rebuild.' ); + } + + if ( + 0 !== strcasecmp( (string) $current_column['column_name'], $new_column_name ) + || 'PRI' === $metadata['column_key'] + || 'UNI' === $metadata['column_key'] + || count( $inline_indexes ) > 0 + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY AUTO_INCREMENT requires a table rebuild.' ); + } + + return true; + } + /** * Return column metadata rows after a CHANGE/MODIFY column rebuild. * @@ -9135,7 +9194,16 @@ private function execute_alter_table_add_column( string $table_name, array $toke $tokens = $items[0]; } - list( $column_sql, $sequence_sql, $indexes, $metadata ) = $this->translate_create_table_column( $table_name, $tokens, false, true, $temporary, null, $this->table_default_collation( $table_name, $temporary ) ); + list( $column_sql, $sequence_sql, $indexes, $metadata ) = $this->translate_create_table_column( + $table_name, + $tokens, + false, + true, + $temporary, + null, + $this->table_default_column_collation( $table_name, $temporary ), + $this->table_default_collation( $table_name, $temporary ) + ); if ( 'PRI' === $metadata['column_key'] ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD COLUMN PRIMARY KEY is not supported.' ); } @@ -9261,24 +9329,35 @@ private function execute_alter_table_modify_column( string $table_name, array $t * @return WP_DuckDB_Result_Statement */ private function execute_alter_table_change_or_modify_column( string $table_name, string $old_column_name, array $definition_tokens, bool $temporary = false ): WP_DuckDB_Result_Statement { - list( , $sequence_sql, $inline_indexes, $metadata ) = $this->translate_create_table_column( $table_name, $definition_tokens, false, true, $temporary, null, $this->table_default_collation( $table_name, $temporary ) ); - if ( null !== $sequence_sql || 'auto_increment' === $metadata['extra'] ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY AUTO_INCREMENT requires a table rebuild.' ); - } - if ( 'PRI' === $metadata['column_key'] ) { + list( , $sequence_sql, $inline_indexes, $metadata ) = $this->translate_create_table_column( + $table_name, + $definition_tokens, + false, + true, + $temporary, + null, + $this->table_default_column_collation( $table_name, $temporary ), + $this->table_default_collation( $table_name, $temporary ) + ); + + $definition_has_auto_increment = $this->alter_table_change_modify_definition_has_auto_increment( $sequence_sql, $metadata ); + if ( ! $definition_has_auto_increment && 'PRI' === $metadata['column_key'] ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY inline PRIMARY KEY is not supported.' ); } - if ( 'UNI' === $metadata['column_key'] || count( $inline_indexes ) > 0 ) { + if ( ! $definition_has_auto_increment && ( 'UNI' === $metadata['column_key'] || count( $inline_indexes ) > 0 ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. CHANGE/MODIFY inline UNIQUE is not supported.' ); } - $stored_metadata_rows = $this->column_metadata_rows( $table_name, $temporary ); - $metadata_rows = count( $stored_metadata_rows ) > 0 ? $stored_metadata_rows : $this->pragma_column_metadata_rows( $table_name ); - $current_column = $this->resolve_alter_table_change_column_metadata( $table_name, $old_column_name, $metadata_rows ); - $current_column_name = (string) $current_column['column_name']; - $new_column_name = (string) $metadata['column_name']; + $stored_metadata_rows = $this->column_metadata_rows( $table_name, $temporary ); + $metadata_rows = count( $stored_metadata_rows ) > 0 ? $stored_metadata_rows : $this->pragma_column_metadata_rows( $table_name ); + $current_column = $this->resolve_alter_table_change_column_metadata( $table_name, $old_column_name, $metadata_rows ); + $current_column_name = (string) $current_column['column_name']; + $new_column_name = (string) $metadata['column_name']; + $auto_increment_rebuild = $definition_has_auto_increment + ? $this->assert_alter_table_change_modify_auto_increment_rebuild_supported( $table_name, $current_column, $new_column_name, $metadata, $inline_indexes, $temporary ) + : false; - $this->assert_alter_table_change_column_supported( $table_name, $current_column, $metadata_rows, $new_column_name, $temporary ); + $this->assert_alter_table_change_column_supported( $table_name, $current_column, $metadata_rows, $new_column_name, $temporary, $auto_increment_rebuild ); $physical_column = $this->physical_column_info_row( $table_name, $current_column_name ); $rename_column = 0 !== strcasecmp( $current_column_name, $new_column_name ); @@ -9292,7 +9371,7 @@ private function execute_alter_table_change_or_modify_column( string $table_name $has_physical_changes = $rename_column || $type_change || $default_change || $nullability_change; $targets_key_column = $this->alter_table_change_modify_column_targets_key( $table_name, $current_column_name, $index_definitions ); - if ( ! $rename_column && $has_physical_changes && $targets_key_column ) { + if ( ! $rename_column && ( $auto_increment_rebuild || ( $has_physical_changes && $targets_key_column ) ) ) { $this->assert_alter_table_change_modify_column_rebuild_supported( $table_name, $current_column_name, @@ -10934,12 +11013,13 @@ private function split_top_level_comma_items( array $tokens ): array { * @param bool $allow_position_options Whether to accept FIRST/AFTER position hints. * @param bool $temporary Whether the target is a temporary table. * @param int|null $auto_increment_seed Optional AUTO_INCREMENT table option. - * @param string|null $default_collation_name Effective table collation for text columns without an explicit collation. + * @param string|null $default_collation_name Default MySQL metadata collation for text columns without an explicit collation. + * @param string|null $physical_default_collation_name Effective physical collation for text columns without an explicit collation. * @param array|null $check_names Existing MySQL-facing CHECK names, keyed lowercase. * @param array|null $foreign_key_names Existing FOREIGN KEY names, keyed lowercase. * @return array{0:string,1:string|null,2:array}>,3:array,4:array,5:array,6:array} */ - private function translate_create_table_column( string $table_name, array $tokens, bool $include_inline_constraints = true, bool $allow_position_options = false, bool $temporary = false, ?int $auto_increment_seed = null, ?string $default_collation_name = null, ?array &$check_names = null, ?array &$foreign_key_names = null ): array { + private function translate_create_table_column( string $table_name, array $tokens, bool $include_inline_constraints = true, bool $allow_position_options = false, bool $temporary = false, ?int $auto_increment_seed = null, ?string $default_collation_name = null, ?string $physical_default_collation_name = null, ?array &$check_names = null, ?array &$foreign_key_names = null ): array { $index = 0; $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); ++$index; @@ -11078,8 +11158,8 @@ private function translate_create_table_column( string $table_name, array $token $duck_type = 'BIGINT'; } - $metadata_collation_name = $this->mysql_column_collation_from_tokens( $tokens, $type_token ); - $physical_collation_name = $this->mysql_column_collation_from_tokens( $tokens, $type_token, $default_collation_name ); + $metadata_collation_name = $this->mysql_column_collation_from_tokens( $tokens, $type_token, $default_collation_name ); + $physical_collation_name = $this->mysql_column_collation_from_tokens( $tokens, $type_token, null !== $physical_default_collation_name ? $physical_default_collation_name : $default_collation_name ); $column_sql = $this->connection->quote_identifier( $column_name ) . ' ' . $duck_type; $sequence = null; @@ -11557,23 +11637,87 @@ private function mysql_column_type_with_default_attributes( string $column_type, * @return string|null Collation name. */ private function mysql_column_collation_from_tokens( array $tokens, WP_Parser_Token $type_token, ?string $default_collation_name = null ): ?string { + if ( ! $this->mysql_type_has_collation( $type_token ) ) { + return null; + } + for ( $index = 0; $index < count( $tokens ); ++$index ) { if ( WP_MySQL_Lexer::COLLATE_SYMBOL === $tokens[ $index ]->id ) { - return $this->option_value( $tokens, $index + 1 ); + return $this->normalize_mysql_metadata_identifier( + $this->option_value( $tokens, $index + 1 ), + 'column COLLATE' + ); } } - if ( ! $this->mysql_type_has_collation( $type_token ) ) { - return null; - } - if ( $this->mysql_type_uses_national_charset( $type_token ) ) { return 'utf8_general_ci'; } + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::CHARSET_SYMBOL === $tokens[ $index ]->id ) { + return $this->mysql_default_collation_for_charset( + $this->option_value( $tokens, $index + 1 ), + 'column CHARSET' + ); + } + + if ( + ( WP_MySQL_Lexer::CHAR_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::CHARACTER_SYMBOL === $tokens[ $index ]->id ) + && isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $index + 1 ]->id + ) { + return $this->mysql_default_collation_for_charset( + $this->option_value( $tokens, $index + 2 ), + 'column CHARACTER SET' + ); + } + } + return null !== $default_collation_name && '' !== $default_collation_name ? $default_collation_name : 'utf8mb4_0900_ai_ci'; } + /** + * Resolve a MySQL default collation for charset-only declarations. + * + * @param string|null $charset Character set option value. + * @param string $context User-facing parser context. + * @return string Collation name. + */ + private function mysql_default_collation_for_charset( ?string $charset, string $context ): string { + $charset = $this->normalize_mysql_metadata_identifier( $charset, $context ); + + $default_collations = array( + 'ascii' => 'ascii_general_ci', + 'big5' => 'big5_chinese_ci', + 'binary' => 'binary', + 'cp1251' => 'cp1251_general_ci', + 'koi8r' => 'koi8r_general_ci', + 'latin1' => 'latin1_swedish_ci', + 'utf8' => 'utf8_general_ci', + 'utf8mb3' => 'utf8_general_ci', + 'utf8mb4' => 'utf8mb4_0900_ai_ci', + ); + + return $default_collations[ $charset ] ?? $charset . '_general_ci'; + } + + /** + * Normalize MySQL charset/collation metadata identifiers. + * + * @param string|null $identifier Identifier option value. + * @param string $context User-facing parser context. + * @return string Normalized identifier. + */ + private function normalize_mysql_metadata_identifier( ?string $identifier, string $context ): string { + $identifier = strtolower( trim( (string) $identifier ) ); + if ( '' === $identifier || ! preg_match( '/^[a-z0-9_]+$/', $identifier ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $context . ' value in DuckDB driver.' ); + } + + return $identifier; + } + /** * Check whether a MySQL column should use DuckDB's case-insensitive collation. * @@ -12464,14 +12608,15 @@ private function assert_supported_index_options( array $tokens, int $index ): vo * Parse supported CREATE TABLE tail options into MySQL-facing metadata. * * @param WP_Parser_Token[] $tokens Tail tokens after the column list. - * @return array{engine:string,row_format:string,table_collation:string,table_comment:string,create_options:string,auto_increment:int|null} + * @return array{engine:string,row_format:string,table_collation:string,column_default_collation:string,table_comment:string,create_options:string,auto_increment:int|null} */ private function parse_create_table_options( array $tokens ): array { - $engine = 'InnoDB'; - $table_collation = 'utf8mb4_0900_ai_ci'; - $table_comment = ''; - $auto_increment = null; - $index = 0; + $engine = 'InnoDB'; + $table_collation = 'utf8mb4_0900_ai_ci'; + $column_default_collation = 'utf8mb4_0900_ai_ci'; + $table_comment = ''; + $auto_increment = null; + $index = 0; while ( $index < count( $tokens ) ) { $token = $tokens[ $index ]; if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { @@ -12486,7 +12631,10 @@ private function parse_create_table_options( array $tokens ): array { } if ( WP_MySQL_Lexer::COLLATE_SYMBOL === $token->id ) { - $table_collation = strtolower( (string) $this->option_value( $tokens, $index + 1 ) ); + $table_collation = $this->normalize_mysql_metadata_identifier( + $this->option_value( $tokens, $index + 1 ), + 'CREATE TABLE COLLATE' + ); $index = $this->skip_option_value( $tokens, $index + 1 ); continue; } @@ -12497,10 +12645,17 @@ private function parse_create_table_options( array $tokens ): array { continue; } - if ( - WP_MySQL_Lexer::CHARSET_SYMBOL === $token->id - || WP_MySQL_Lexer::ROW_FORMAT_SYMBOL === $token->id - ) { + if ( WP_MySQL_Lexer::CHARSET_SYMBOL === $token->id ) { + $column_default_collation = $this->mysql_default_collation_for_charset( + $this->option_value( $tokens, $index + 1 ), + 'CREATE TABLE CHARSET' + ); + $table_collation = $column_default_collation; + $index = $this->skip_option_value( $tokens, $index + 1 ); + continue; + } + + if ( WP_MySQL_Lexer::ROW_FORMAT_SYMBOL === $token->id ) { $index = $this->skip_option_value( $tokens, $index + 1 ); continue; } @@ -12515,19 +12670,25 @@ private function parse_create_table_options( array $tokens ): array { if ( ! isset( $tokens[ $index + 1 ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $index + 1 ]->id ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE option in DuckDB driver: ' . $token->get_bytes() . '.' ); } - $index = $this->skip_option_value( $tokens, $index + 2 ); + $column_default_collation = $this->mysql_default_collation_for_charset( + $this->option_value( $tokens, $index + 2 ), + 'CREATE TABLE CHARACTER SET' + ); + $table_collation = $column_default_collation; + $index = $this->skip_option_value( $tokens, $index + 2 ); continue; } throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE TABLE option in DuckDB driver: ' . $token->get_bytes() . '.' ); } return array( - 'engine' => $engine, - 'row_format' => 'MyISAM' === $engine ? 'Fixed' : 'Dynamic', - 'table_collation' => $table_collation, - 'table_comment' => $table_comment, - 'create_options' => '', - 'auto_increment' => $auto_increment, + 'engine' => $engine, + 'row_format' => 'MyISAM' === $engine ? 'Fixed' : 'Dynamic', + 'table_collation' => $table_collation, + 'column_default_collation' => $column_default_collation, + 'table_comment' => $table_comment, + 'create_options' => '', + 'auto_increment' => $auto_increment, ); } @@ -17444,6 +17605,33 @@ private function execute_auto_increment_write( string $table_name, string $sql, ? null : $this->explicit_auto_increment_value_for_write( $tokens, $table_index, $metadata['column_name'] ); $before = null === $sequence_name ? null : $this->sequence_currval( $sequence_name ); + + $insert_ignore_explicit_ids = array(); + $insert_ignore_explicit_ids_existed = array(); + $tracks_insert_ignore_explicit_values = null !== $metadata + && null !== $table_reference + && null !== $table_index + && $this->is_insert_ignore_write( $tokens, $table_index ); + if ( $tracks_insert_ignore_explicit_values ) { + $insert_ignore_explicit_ids = $this->explicit_auto_increment_values_for_write( $tokens, $table_index, $metadata['column_name'] ); + foreach ( $insert_ignore_explicit_ids as $insert_ignore_explicit_id ) { + $key = (string) $insert_ignore_explicit_id; + if ( ! array_key_exists( $key, $insert_ignore_explicit_ids_existed ) ) { + $insert_ignore_explicit_ids_existed[ $key ] = $this->auto_increment_value_exists( + $table_reference['table_name'], + $metadata['column_name'], + $insert_ignore_explicit_id + ); + } + } + } + $column_was_omitted = null !== $metadata && null !== $table_index + ? $this->auto_increment_column_omitted_from_write( $tokens, $table_index, $metadata['column_name'] ) + : false; + $before_max = null; + if ( null === $before && $column_was_omitted && null !== $table_reference ) { + $before_max = $this->max_auto_increment_column_value( $table_reference['table_name'], $metadata['column_name'], $table_reference['temporary'] ); + } if ( null !== $metadata && null !== $explicit_insert_id @@ -17459,13 +17647,149 @@ private function execute_auto_increment_write( string $table_name, string $sql, if ( null !== $after && $after !== $before ) { $this->last_insert_id = $after; } elseif ( null !== $explicit_insert_id ) { - $this->last_insert_id = $explicit_insert_id; + if ( $tracks_insert_ignore_explicit_values ) { + $explicit_insert_id = $this->inserted_explicit_auto_increment_value_for_insert_ignore( + $table_reference['table_name'], + $metadata['column_name'], + $insert_ignore_explicit_ids, + $insert_ignore_explicit_ids_existed, + $table_reference['temporary'] + ); + } + if ( null !== $explicit_insert_id ) { + $this->last_insert_id = $explicit_insert_id; + } + } elseif ( null !== $before_max && $column_was_omitted ) { + $after_max = $this->max_auto_increment_column_value( $table_reference['table_name'], $metadata['column_name'], $table_reference['temporary'] ); + if ( $after_max > $before_max ) { + $this->last_insert_id = $after_max; + } } } return $result; } + /** + * Check whether an INSERT write uses IGNORE. + * + * @param WP_Parser_Token[] $tokens MySQL token stream. + * @param int $table_index Index of the table token. + * @return bool Whether the statement is INSERT IGNORE. + */ + private function is_insert_ignore_write( array $tokens, int $table_index ): bool { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + for ( $index = 1; $index < $table_index; ++$index ) { + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[ $index ]->id ) { + return true; + } + } + + return false; + } + + /** + * Find the last explicit AUTO_INCREMENT value inserted by INSERT IGNORE. + * + * @param string $table_name Table name. + * @param string $column_name AUTO_INCREMENT column name. + * @param int[] $explicit_ids Explicit AUTO_INCREMENT IDs in statement order. + * @param array $ids_existed Whether each explicit ID existed before the write. + * @param bool $temporary Whether the target is a temporary table. + * @return int|null Last inserted explicit AUTO_INCREMENT value. + */ + private function inserted_explicit_auto_increment_value_for_insert_ignore( string $table_name, string $column_name, array $explicit_ids, array $ids_existed, bool $temporary = false ): ?int { + for ( $index = count( $explicit_ids ) - 1; $index >= 0; --$index ) { + $explicit_id = $explicit_ids[ $index ]; + if ( ! empty( $ids_existed[ (string) $explicit_id ] ) ) { + continue; + } + if ( $this->auto_increment_value_exists( $table_name, $column_name, $explicit_id ) ) { + return $explicit_id; + } + } + + return null; + } + + /** + * Check whether an INSERT/REPLACE omits the AUTO_INCREMENT column. + * + * @param WP_Parser_Token[] $tokens MySQL token stream. + * @param int $table_index Index of the table token. + * @param string $column_name AUTO_INCREMENT column name. + * @return bool Whether the statement omits the AUTO_INCREMENT column. + */ + private function auto_increment_column_omitted_from_write( array $tokens, int $table_index, string $column_name ): bool { + $set_index = $this->find_insert_set_index( $tokens, $table_index ); + if ( null !== $set_index ) { + return ! $this->insert_set_assignments_include_column( array_slice( $tokens, $set_index + 1 ), $column_name ); + } + + $index = $table_index + 1; + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index ]->id ) { + return false; + } + + list( $column_items ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); + foreach ( $column_items as $column_tokens ) { + if ( 1 === count( $column_tokens ) && 0 === strcasecmp( $this->identifier_value( $column_tokens[0] ), $column_name ) ) { + return false; + } + } + + return true; + } + + /** + * Check whether an INSERT ... SET assignment list includes a column. + * + * @param WP_Parser_Token[] $tokens Assignment tokens after SET. + * @param string $column_name Column name. + * @return bool Whether the column is assigned. + */ + private function insert_set_assignments_include_column( array $tokens, string $column_name ): bool { + $index = 0; + + while ( $index < count( $tokens ) ) { + $current_column = $this->identifier_value( $tokens[ $index ] ?? null ); + if ( 0 === strcasecmp( $current_column, $column_name ) ) { + return true; + } + ++$index; + + if ( + ! isset( $tokens[ $index ] ) + || ( WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $index ]->id && WP_MySQL_Lexer::ASSIGN_OPERATOR !== $tokens[ $index ]->id ) + ) { + return false; + } + ++$index; + + $depth = 0; + while ( $index < count( $tokens ) ) { + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + break; + } + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + } elseif ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + --$depth; + } + ++$index; + } + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + } + + return false; + } + /** * Check whether an AUTO_INCREMENT column still has a visible primary or unique key. * @@ -17587,14 +17911,32 @@ private function drop_auto_increment_sequences( array $sequence_names ): void { * @return int|null Explicit value, or null when the statement uses generated/default values. */ private function explicit_auto_increment_value_for_write( array $tokens, int $table_index, string $column_name ): ?int { + $explicit_values = $this->explicit_auto_increment_values_for_write( $tokens, $table_index, $column_name ); + if ( count( $explicit_values ) > 0 ) { + return $explicit_values[ count( $explicit_values ) - 1 ]; + } + + return null; + } + + /** + * Parse explicit literal values assigned to an AUTO_INCREMENT column. + * + * @param WP_Parser_Token[] $tokens MySQL token stream. + * @param int $table_index Index of the table token. + * @param string $column_name AUTO_INCREMENT column name. + * @return int[] Explicit values in statement order. + */ + private function explicit_auto_increment_values_for_write( array $tokens, int $table_index, string $column_name ): array { $set_index = $this->find_insert_set_index( $tokens, $table_index ); if ( null !== $set_index ) { - return $this->explicit_auto_increment_value_for_set_assignments( array_slice( $tokens, $set_index + 1 ), $column_name ); + $explicit_value = $this->explicit_auto_increment_value_for_set_assignments( array_slice( $tokens, $set_index + 1 ), $column_name ); + return null === $explicit_value ? array() : array( $explicit_value ); } $index = $table_index + 1; if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index ]->id ) { - return null; + return array(); } list( $column_items, $index ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); @@ -17607,17 +17949,17 @@ private function explicit_auto_increment_value_for_write( array $tokens, int $ta } if ( null === $column_offset || ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $index ]->id ) { - return null; + return array(); } ++$index; - $explicit_value = null; + $explicit_values = array(); while ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { list( $value_items, $index ) = $this->collect_parenthesized_items( $tokens, $index + 1 ); if ( isset( $value_items[ $column_offset ] ) ) { $value = $this->integer_literal_value( $value_items[ $column_offset ] ); if ( null !== $value ) { - $explicit_value = $value; + $explicit_values[] = $value; } } @@ -17628,7 +17970,7 @@ private function explicit_auto_increment_value_for_write( array $tokens, int $ta break; } - return $explicit_value; + return $explicit_values; } /** @@ -19160,6 +19502,22 @@ private function table_metadata_by_table( bool $temporary = false ): array { * @param bool $temporary Whether the target is a temporary table. * @return string MySQL collation name. */ + private function table_default_column_collation( string $table_name, bool $temporary = false ): string { + $charset = $this->character_set_from_collation( $this->table_default_collation( $table_name, $temporary ) ); + if ( null === $charset ) { + return 'utf8mb4_0900_ai_ci'; + } + + return $this->mysql_default_collation_for_charset( $charset, 'table default charset' ); + } + + /** + * Read the effective default table collation for physical text columns. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + * @return string MySQL collation name. + */ private function table_default_collation( string $table_name, bool $temporary = false ): string { $metadata = $this->table_metadata_by_table( $temporary ); if ( isset( $metadata[ $table_name ]['table_collation'] ) && '' !== $metadata[ $table_name ]['table_collation'] ) { @@ -20578,6 +20936,9 @@ private function charset_max_bytes( ?string $charset ): int { if ( 'utf8' === $charset || 'utf8mb3' === $charset ) { return 3; } + if ( 'big5' === $charset ) { + return 2; + } return 1; } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index b26439e14..801ee5683 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -6,6 +6,83 @@ * @group duckdb */ class WP_DuckDB_Driver_Tests extends WP_DuckDB_TestCase { + public function test_auto_increment_insert_id_falls_back_to_max_when_currval_is_unavailable(): void { + $connection = new class() extends WP_DuckDB_Connection { + public $queries = array(); + + private $max_reads = 0; + + public function __construct() {} + + public function query( string $sql, array $params = array() ): WP_DuckDB_Result_Statement { + $this->queries[] = $sql; + + if ( 0 === strpos( $sql, 'CREATE OR REPLACE MACRO ' ) ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( false !== strpos( $sql, "table_type = 'LOCAL TEMPORARY'" ) ) { + return new WP_DuckDB_Result_Statement( array( 'table_name' ), array() ); + } + + if ( 0 === strpos( $sql, 'SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()' ) ) { + return new WP_DuckDB_Result_Statement( array( 'table_name' ), array( array( 'wp_users' ) ) ); + } + + if ( 0 === strpos( $sql, 'SELECT column_name FROM "__wp_duckdb_column_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( array( 'column_name' ), array( array( 'ID' ) ) ); + } + + if ( 0 === strpos( $sql, 'SELECT currval(' ) ) { + throw new WP_DuckDB_Driver_Exception( 'currval unavailable' ); + } + + if ( 'SELECT MAX("ID") AS max_value FROM "wp_users"' === $sql ) { + ++$this->max_reads; + return new WP_DuckDB_Result_Statement( + array( 'max_value' ), + array( array( 1 === $this->max_reads ? 1 : 2 ) ) + ); + } + + if ( 'INSERT INTO "wp_users" ("display_name") VALUES (\'Walter Sobchak\')' === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } + }; + $driver = new WP_DuckDB_Driver( array( 'connection' => $connection ) ); + $mysql_sql = "INSERT INTO wp_users (display_name) VALUES ('Walter Sobchak')"; + $duckdb_sql = 'INSERT INTO "wp_users" ("display_name") VALUES (\'Walter Sobchak\')'; + + $tokenize = new ReflectionMethod( WP_DuckDB_Driver::class, 'tokenize_and_validate' ); + if ( PHP_VERSION_ID < 80100 ) { + $tokenize->setAccessible( true ); + } + $tokens = $tokenize->invoke( $driver, $mysql_sql ); + + $execute = new ReflectionMethod( WP_DuckDB_Driver::class, 'execute_auto_increment_write' ); + if ( PHP_VERSION_ID < 80100 ) { + $execute->setAccessible( true ); + } + $result = $execute->invoke( + $driver, + 'wp_users', + $duckdb_sql, + 'Failed to execute DuckDB INSERT', + $tokens, + 2 + ); + + $this->assertSame( 1, $result->rowCount() ); + $this->assertSame( 2, $driver->get_insert_id() ); + $this->assertSame( + 2, + substr_count( implode( "\n", $connection->queries ), 'SELECT MAX("ID") AS max_value FROM "wp_users"' ) + ); + } + public function test_record_found_rows_from_result_preserves_column_metadata(): void { $driver = ( new ReflectionClass( WP_DuckDB_Driver::class ) )->newInstanceWithoutConstructor(); $source = new WP_DuckDB_Result_Statement( @@ -6151,6 +6228,93 @@ public function test_insert_id_tracks_generated_auto_increment_values(): void { $this->fail( 'Expected duplicate insert to fail.' ); } + public function test_insert_ignore_explicit_auto_increment_insert_id_skips_ignored_rows(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE ignore_primary_before ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) UNIQUE + )' + ); + $driver->query( + 'CREATE TABLE ignore_primary_after ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) UNIQUE + )' + ); + $driver->query( + 'CREATE TABLE ignore_secondary_after ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) UNIQUE + )' + ); + + $driver->query( "INSERT INTO ignore_primary_before (id, name) VALUES (3, 'existing')" ); + $inserted_before_ignored = $driver->query( + "INSERT IGNORE INTO ignore_primary_before (id, name) + VALUES (2, 'inserted'), (3, 'ignored-primary')" + ); + $this->assertSame( 1, $inserted_before_ignored->rowCount() ); + $this->assertSame( 2, $driver->get_insert_id() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'name' => 'inserted', + ), + array( + 'id' => 3, + 'name' => 'existing', + ), + ), + $driver->query( 'SELECT id, name FROM ignore_primary_before ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( "INSERT INTO ignore_primary_after (id, name) VALUES (1, 'existing')" ); + $inserted_after_ignored = $driver->query( + "INSERT IGNORE INTO ignore_primary_after (id, name) + VALUES (4, 'inserted'), (1, 'ignored-primary')" + ); + $this->assertSame( 1, $inserted_after_ignored->rowCount() ); + $this->assertSame( 4, $driver->get_insert_id() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'existing', + ), + array( + 'id' => 4, + 'name' => 'inserted', + ), + ), + $driver->query( 'SELECT id, name FROM ignore_primary_after ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( "INSERT INTO ignore_secondary_after (id, name) VALUES (1, 'taken')" ); + $inserted_before_secondary_conflict = $driver->query( + "INSERT IGNORE INTO ignore_secondary_after (id, name) + VALUES (5, 'inserted'), (6, 'taken')" + ); + $this->assertSame( 1, $inserted_before_secondary_conflict->rowCount() ); + $this->assertSame( 5, $driver->get_insert_id() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'name' => 'taken', + ), + array( + 'id' => 5, + 'name' => 'inserted', + ), + ), + $driver->query( 'SELECT id, name FROM ignore_secondary_after ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_serial_alias_tracks_generated_insert_id_and_metadata(): void { $this->requireDuckDBRuntime(); @@ -7041,6 +7205,100 @@ public function test_information_schema_columns_exposes_mysql_shaped_metadata(): $this->assertSame( array(), $internal ); } + public function test_create_table_charset_metadata_tracks_table_and_column_declarations(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE charset_metadata ( + id INT, + ascii_default VARCHAR(10), + big5_col VARCHAR(50) CHARACTER SET big5, + koi8r_col TEXT CHARACTER SET koi8r, + utf8_col VARCHAR(50) CHARSET utf8, + utf8mb4_col VARCHAR(50) CHARACTER SET utf8mb4, + binary_col BINARY, + blob_col BLOB + ) DEFAULT CHARSET=ascii' + ); + $driver->query( + 'CREATE TABLE charset_collate_metadata ( + name VARCHAR(10), + explicit_name VARCHAR(10) COLLATE utf8mb4_unicode_ci + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci' + ); + $driver->query( 'ALTER TABLE charset_collate_metadata ADD COLUMN added_name VARCHAR(10)' ); + + $this->assertSame( + array( + 'id' => null, + 'ascii_default' => 'ascii_general_ci', + 'big5_col' => 'big5_chinese_ci', + 'koi8r_col' => 'koi8r_general_ci', + 'utf8_col' => 'utf8_general_ci', + 'utf8mb4_col' => 'utf8mb4_0900_ai_ci', + 'binary_col' => null, + 'blob_col' => null, + ), + array_column( + $driver->query( 'SHOW FULL COLUMNS FROM charset_metadata' )->fetchAll( PDO::FETCH_ASSOC ), + 'Collation', + 'Field' + ) + ); + $this->assertSame( + array( + array( + 'TABLE_COLLATION' => 'utf8mb4_unicode_ci', + ), + array( + 'TABLE_COLLATION' => 'ascii_general_ci', + ), + ), + $driver->query( + "SELECT table_collation + FROM information_schema.tables + WHERE table_schema = 'wp' + AND table_name IN ('charset_metadata', 'charset_collate_metadata') + ORDER BY table_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'COLUMN_NAME' => 'name', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + ), + array( + 'COLUMN_NAME' => 'explicit_name', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_unicode_ci', + ), + array( + 'COLUMN_NAME' => 'added_name', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + ), + ), + $driver->query( + "SELECT column_name, character_set_name, collation_name + FROM information_schema.columns + WHERE table_schema = 'wp' + AND table_name = 'charset_collate_metadata' + ORDER BY ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create_sql = $driver->query( 'SHOW CREATE TABLE charset_metadata' )->fetch( PDO::FETCH_ASSOC )['Create Table']; + $this->assertStringContainsString( 'DEFAULT CHARSET=ascii COLLATE=ascii_general_ci', $create_sql ); + } + public function test_information_schema_statistics_exposes_mysql_shaped_index_metadata(): void { $this->requireDuckDBRuntime(); @@ -10296,6 +10554,80 @@ function ( array $row ): array { } } + public function test_alter_table_change_existing_auto_increment_column_rebuilds(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE dbdelta_auto_increment ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + slug VARCHAR(50) NOT NULL, + payload VARCHAR(50), + PRIMARY KEY (id), + UNIQUE KEY slug_unique (slug) + ) AUTO_INCREMENT=5' + ); + $driver->query( "INSERT INTO dbdelta_auto_increment (slug, payload) VALUES ('alpha', 'one'), ('bravo', 'two')" ); + + $result = $driver->query( 'ALTER TABLE dbdelta_auto_increment CHANGE COLUMN id id int(11) NOT NULL AUTO_INCREMENT' ); + $queries = implode( "\n", $driver->get_last_duckdb_queries() ); + $driver->query( "INSERT INTO dbdelta_auto_increment (slug, payload) VALUES ('charlie', 'three')" ); + + $this->assertSame( 0, $result->rowCount() ); + $this->assertStringContainsString( 'CREATE TEMP TABLE "__wp_duckdb_rebuild_', $queries ); + $this->assertSame( + array( + array( + 'id' => 5, + 'slug' => 'alpha', + 'payload' => 'one', + ), + array( + 'id' => 6, + 'slug' => 'bravo', + 'payload' => 'two', + ), + array( + 'id' => 7, + 'slug' => 'charlie', + 'payload' => 'three', + ), + ), + $driver->query( 'SELECT id, slug, payload FROM dbdelta_auto_increment ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $columns = array_column( $driver->query( 'SHOW FULL COLUMNS FROM dbdelta_auto_increment' )->fetchAll( PDO::FETCH_ASSOC ), null, 'Field' ); + $this->assertSame( 'int(11)', $columns['id']['Type'] ); + $this->assertSame( 'NO', $columns['id']['Null'] ); + $this->assertSame( 'PRI', $columns['id']['Key'] ); + $this->assertSame( 'auto_increment', $columns['id']['Extra'] ); + + $this->assertSame( + array( array( 'AUTO_INCREMENT' => 8 ) ), + $driver->query( + "SELECT `AUTO_INCREMENT` + FROM information_schema.tables + WHERE table_schema = 'wp' AND table_name = 'dbdelta_auto_increment'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertStringContainsString( + '`id` int(11) NOT NULL AUTO_INCREMENT', + $driver->query( 'SHOW CREATE TABLE dbdelta_auto_increment' )->fetch( PDO::FETCH_ASSOC )['Create Table'] + ); + + try { + $driver->query( "INSERT INTO dbdelta_auto_increment (slug, payload) VALUES ('alpha', 'duplicate')" ); + $this->fail( 'Expected rebuilt UNIQUE index to remain enforced after AUTO_INCREMENT CHANGE COLUMN.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); + } + } + public function test_alter_table_change_modify_key_rebuild_rejects_unsafe_conversion_before_mutation(): void { $this->requireDuckDBRuntime(); diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index d0e081270..ea81c84b5 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -65,10 +65,10 @@ public function set_charset( $dbh, $charset = null, $collate = null ) { * * @param string $table Table name. * @param string $column Column name. - * @return string + * @return string|false|WP_Error */ public function get_col_charset( $table, $column ) { - return 'utf8mb4'; + return parent::get_col_charset( $table, $column ); } /** From 88fd7c029b90f4221ebf58d87a5d825b1d24152b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 14:24:26 +0000 Subject: [PATCH 131/317] Advance DuckDB wpdb and E2E acceptance --- .github/workflows/duckdb-phpunit-tests.yml | 31 +++++++++++ .../src/duckdb/class-wp-duckdb-driver.php | 18 +++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 54 +++++++++++++++++-- .../wp-includes/duckdb/class-wp-duckdb-db.php | 5 +- 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index 9e9a6cc2c..1c8fe0b7a 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -343,8 +343,13 @@ jobs: 'wpdb_class' => is_object( $wpdb ) ? get_class( $wpdb ) : gettype( $wpdb ), 'last_error' => '', 'last_query' => '', + 'rows_affected' => null, + 'insert_id' => null, + 'driver_class' => null, + 'driver_insert_id' => null, 'num_queries' => null, 'queries_tail' => array(), + 'duckdb_queries_tail' => array(), 'php_error' => error_get_last(), ); @@ -355,9 +360,35 @@ jobs: if ( isset( $wpdb->last_query ) ) { $diagnostics['last_query'] = (string) $wpdb->last_query; } + if ( isset( $wpdb->rows_affected ) ) { + $diagnostics['rows_affected'] = (int) $wpdb->rows_affected; + } + if ( isset( $wpdb->insert_id ) ) { + $diagnostics['insert_id'] = (int) $wpdb->insert_id; + } if ( isset( $wpdb->num_queries ) ) { $diagnostics['num_queries'] = (int) $wpdb->num_queries; } + try { + $reflection = new ReflectionObject( $wpdb ); + if ( $reflection->hasProperty( 'dbh' ) ) { + $property = $reflection->getProperty( 'dbh' ); + $property->setAccessible( true ); + $driver = $property->getValue( $wpdb ); + + if ( is_object( $driver ) ) { + $diagnostics['driver_class'] = get_class( $driver ); + if ( method_exists( $driver, 'get_insert_id' ) ) { + $diagnostics['driver_insert_id'] = (int) $driver->get_insert_id(); + } + if ( method_exists( $driver, 'get_last_duckdb_queries' ) ) { + $diagnostics['duckdb_queries_tail'] = array_slice( $driver->get_last_duckdb_queries(), -5 ); + } + } + } + } catch ( Throwable $e ) { + $diagnostics['driver_probe_error'] = $e->getMessage(); + } if ( isset( $wpdb->queries ) && is_array( $wpdb->queries ) ) { foreach ( array_slice( $wpdb->queries, -5 ) as $query ) { if ( is_array( $query ) ) { diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 67a6a434d..d3109588f 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -9371,6 +9371,24 @@ private function execute_alter_table_change_or_modify_column( string $table_name $has_physical_changes = $rename_column || $type_change || $default_change || $nullability_change; $targets_key_column = $this->alter_table_change_modify_column_targets_key( $table_name, $current_column_name, $index_definitions ); + if ( ! $rename_column && $auto_increment_rebuild && ! $has_physical_changes ) { + $metadata['column_key'] = '' === $metadata['column_key'] + ? $current_column['column_key'] + : $metadata['column_key']; + $this->replace_changed_column_metadata( + $table_name, + $current_column_name, + $metadata, + $metadata_rows, + count( $stored_metadata_rows ) > 0, + $temporary + ); + $this->refresh_column_key_metadata( $table_name, $temporary ); + $this->invalidate_information_schema_compatibility_tables(); + + return $this->empty_ddl_result(); + } + if ( ! $rename_column && ( $auto_increment_rebuild || ( $has_physical_changes && $targets_key_column ) ) ) { $this->assert_alter_table_change_modify_column_rebuild_supported( $table_name, diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 801ee5683..8c6ac5476 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -10554,7 +10554,7 @@ function ( array $row ): array { } } - public function test_alter_table_change_existing_auto_increment_column_rebuilds(): void { + public function test_alter_table_change_existing_auto_increment_column_updates_metadata(): void { $this->requireDuckDBRuntime(); $driver = new WP_DuckDB_Driver( @@ -10579,7 +10579,7 @@ public function test_alter_table_change_existing_auto_increment_column_rebuilds( $driver->query( "INSERT INTO dbdelta_auto_increment (slug, payload) VALUES ('charlie', 'three')" ); $this->assertSame( 0, $result->rowCount() ); - $this->assertStringContainsString( 'CREATE TEMP TABLE "__wp_duckdb_rebuild_', $queries ); + $this->assertStringNotContainsString( 'CREATE TEMP TABLE "__wp_duckdb_rebuild_', $queries ); $this->assertSame( array( array( @@ -10622,12 +10622,60 @@ public function test_alter_table_change_existing_auto_increment_column_rebuilds( try { $driver->query( "INSERT INTO dbdelta_auto_increment (slug, payload) VALUES ('alpha', 'duplicate')" ); - $this->fail( 'Expected rebuilt UNIQUE index to remain enforced after AUTO_INCREMENT CHANGE COLUMN.' ); + $this->fail( 'Expected UNIQUE index to remain enforced after AUTO_INCREMENT CHANGE COLUMN.' ); } catch ( WP_DuckDB_Driver_Exception $e ) { $this->assertStringContainsString( 'Failed to execute DuckDB INSERT', $e->getMessage() ); } } + public function test_alter_table_change_existing_auto_increment_noop_runs_inside_transaction(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE dbdelta_auto_increment_noop ( + id INT(11) NOT NULL AUTO_INCREMENT, + slug VARCHAR(50) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO dbdelta_auto_increment_noop (slug) VALUES ('alpha')" ); + + $driver->query( 'BEGIN' ); + $result = $driver->query( 'ALTER TABLE dbdelta_auto_increment_noop CHANGE COLUMN `id` id int(11) NOT NULL AUTO_INCREMENT' ); + $queries = implode( "\n", $driver->get_last_duckdb_queries() ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $driver->query( "INSERT INTO dbdelta_auto_increment_noop (slug) VALUES ('bravo')" ); + $driver->query( 'COMMIT' ); + + $this->assertSame( 0, $result->rowCount() ); + $this->assertStringNotContainsString( 'CREATE TEMP TABLE "__wp_duckdb_rebuild_', $queries ); + $this->assertSame( + array( + array( + 'id' => 1, + 'slug' => 'alpha', + ), + array( + 'id' => 2, + 'slug' => 'bravo', + ), + ), + $driver->query( 'SELECT id, slug FROM dbdelta_auto_increment_noop ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $columns = array_column( $driver->query( 'SHOW FULL COLUMNS FROM dbdelta_auto_increment_noop' )->fetchAll( PDO::FETCH_ASSOC ), null, 'Field' ); + $this->assertSame( 'int(11)', $columns['id']['Type'] ); + $this->assertSame( 'NO', $columns['id']['Null'] ); + $this->assertSame( 'PRI', $columns['id']['Key'] ); + $this->assertSame( 'auto_increment', $columns['id']['Extra'] ); + } + public function test_alter_table_change_modify_key_rebuild_rejects_unsafe_conversion_before_mutation(): void { $this->requireDuckDBRuntime(); diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index ea81c84b5..38db4ec4e 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -369,7 +369,10 @@ public function query( $query ) { if ( preg_match( '/^\s*(insert|delete|update|replace)\s/i', $query ) ) { $this->rows_affected = $this->last_statement ? $this->last_statement->rowCount() : 0; if ( preg_match( '/^\s*(insert|replace)\s/i', $query ) && method_exists( $this->dbh, 'get_insert_id' ) ) { - $this->insert_id = $this->dbh->get_insert_id(); + $this->insert_id = (int) $this->dbh->get_insert_id(); + if ( 0 === $this->rows_affected && $this->insert_id > 0 ) { + $this->rows_affected = 1; + } } return $this->rows_affected; } From b82c1fc82a450d0f821ce8891912ab96eb3c2601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 14:41:59 +0000 Subject: [PATCH 132/317] Fix DuckDB WordPress insert and charset blockers --- .../src/duckdb/class-wp-duckdb-driver.php | 112 +++++++++--- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 170 ++++++++++++++++++ .../wp-includes/duckdb/class-wp-duckdb-db.php | 112 +++++++++++- 3 files changed, 369 insertions(+), 25 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index d3109588f..454350eac 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -13194,29 +13194,29 @@ private function translate_tokens_to_duckdb_sql( if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { $identifier = null; if ( $rewrite_information_schema_tables ) { - $identifier = $this->information_schema_tables_column_name( $token->get_value() ); + $identifier = $this->information_schema_tables_column_name( $this->token_value( $token ) ); } if ( null === $identifier && $rewrite_information_schema_statistics ) { - $identifier = $this->information_schema_statistics_column_name( $token->get_value() ); + $identifier = $this->information_schema_statistics_column_name( $this->token_value( $token ) ); } if ( null === $identifier && $rewrite_information_schema_table_constraints ) { - $identifier = $this->information_schema_table_constraints_column_name( $token->get_value() ); + $identifier = $this->information_schema_table_constraints_column_name( $this->token_value( $token ) ); } if ( null === $identifier && $rewrite_information_schema_key_column_usage ) { - $identifier = $this->information_schema_key_column_usage_column_name( $token->get_value() ); + $identifier = $this->information_schema_key_column_usage_column_name( $this->token_value( $token ) ); } if ( null === $identifier && $rewrite_information_schema_referential_constraints ) { - $identifier = $this->information_schema_referential_constraints_column_name( $token->get_value() ); + $identifier = $this->information_schema_referential_constraints_column_name( $this->token_value( $token ) ); } if ( null === $identifier && $rewrite_information_schema_check_constraints ) { - $identifier = $this->information_schema_check_constraints_column_name( $token->get_value() ); + $identifier = $this->information_schema_check_constraints_column_name( $this->token_value( $token ) ); } - $pieces[] = $this->connection->quote_identifier( $identifier ?? $token->get_value() ); + $pieces[] = $this->connection->quote_identifier( $identifier ?? $this->token_value( $token ) ); continue; } if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { - $pieces[] = $this->connection->quote( $token->get_value() ); + $pieces[] = $this->connection->quote( $this->token_value( $token ) ); continue; } @@ -16559,11 +16559,11 @@ private function translate_regexp_predicate( array &$pieces, WP_Parser_Token $pa */ private function translate_token_to_duckdb_sql( WP_Parser_Token $token ): string { if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { - return $this->connection->quote_identifier( $token->get_value() ); + return $this->connection->quote_identifier( $this->token_value( $token ) ); } if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { - return $this->connection->quote( $token->get_value() ); + return $this->connection->quote( $this->token_value( $token ) ); } return $token->get_bytes(); @@ -17425,7 +17425,7 @@ private function translate_like_escape_predicate( array $tokens, int &$index ): WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $pattern_index ]->id && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $pattern_index ]->id ) - || false === strpos( $tokens[ $pattern_index ]->get_value(), '\\' ) + || false === strpos( $this->token_value( $tokens[ $pattern_index ] ), '\\' ) || ( isset( $tokens[ $pattern_index + 1 ] ) && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern_index + 1 ]->id ) ) { return null; @@ -17434,7 +17434,7 @@ private function translate_like_escape_predicate( array $tokens, int &$index ): $index = $pattern_index; return $left_operand['sql'] . ( $is_not_like ? ' NOT LIKE ' : ' LIKE ' ) - . $this->connection->quote( $tokens[ $pattern_index ]->get_value() ) + . $this->connection->quote( $this->token_value( $tokens[ $pattern_index ] ) ) . ' ESCAPE ' . $this->connection->quote( '\\' ); } @@ -17453,7 +17453,7 @@ private function like_escape_left_operand_sql( array $tokens, int $index ): ?arr if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $tokens[ $index ]->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[ $index ]->id ) { return array( - 'sql' => $this->connection->quote( $tokens[ $index ]->get_value() ), + 'sql' => $this->connection->quote( $this->token_value( $tokens[ $index ] ) ), 'next_index' => $index + 1, ); } @@ -17624,12 +17624,13 @@ private function execute_auto_increment_write( string $table_name, string $sql, : $this->explicit_auto_increment_value_for_write( $tokens, $table_index, $metadata['column_name'] ); $before = null === $sequence_name ? null : $this->sequence_currval( $sequence_name ); - $insert_ignore_explicit_ids = array(); - $insert_ignore_explicit_ids_existed = array(); - $tracks_insert_ignore_explicit_values = null !== $metadata + $insert_ignore_write = null !== $metadata && null !== $table_reference && null !== $table_index && $this->is_insert_ignore_write( $tokens, $table_index ); + $insert_ignore_explicit_ids = array(); + $insert_ignore_explicit_ids_existed = array(); + $tracks_insert_ignore_explicit_values = $insert_ignore_write; if ( $tracks_insert_ignore_explicit_values ) { $insert_ignore_explicit_ids = $this->explicit_auto_increment_values_for_write( $tokens, $table_index, $metadata['column_name'] ); foreach ( $insert_ignore_explicit_ids as $insert_ignore_explicit_id ) { @@ -17660,10 +17661,13 @@ private function execute_auto_increment_write( string $table_name, string $sql, } $result = $this->execute_duckdb_query( $sql, $context ); - if ( null !== $sequence_name && $result->rowCount() > 0 ) { + if ( null !== $sequence_name && ( $result->rowCount() > 0 || ! $insert_ignore_write ) ) { $after = $this->sequence_currval( $sequence_name ); if ( null !== $after && $after !== $before ) { $this->last_insert_id = $after; + if ( 0 === $result->rowCount() ) { + $result = new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } } elseif ( null !== $explicit_insert_id ) { if ( $tracks_insert_ignore_explicit_values ) { $explicit_insert_id = $this->inserted_explicit_auto_increment_value_for_insert_ignore( @@ -17677,10 +17681,15 @@ private function execute_auto_increment_write( string $table_name, string $sql, if ( null !== $explicit_insert_id ) { $this->last_insert_id = $explicit_insert_id; } - } elseif ( null !== $before_max && $column_was_omitted ) { - $after_max = $this->max_auto_increment_column_value( $table_reference['table_name'], $metadata['column_name'], $table_reference['temporary'] ); - if ( $after_max > $before_max ) { - $this->last_insert_id = $after_max; + } + } + + if ( 0 === $this->last_insert_id && null !== $before_max && $column_was_omitted ) { + $after_max = $this->max_auto_increment_column_value( $table_reference['table_name'], $metadata['column_name'], $table_reference['temporary'] ); + if ( $after_max > $before_max ) { + $this->last_insert_id = $after_max; + if ( 0 === $result->rowCount() ) { + $result = new WP_DuckDB_Result_Statement( array(), array(), 1 ); } } } @@ -20991,6 +21000,67 @@ private function identifier_value( $token ): string { return $token->get_value(); } + /** + * Return a token value with a byte-safe fallback for invalid string bytes. + * + * The shared MySQL token unescaper intentionally uses a Unicode regex. Invalid + * byte sequences make that regex return null, which violates get_value()'s + * string return type before the DuckDB driver can report or translate the SQL. + * + * @param WP_Parser_Token $token MySQL token. + * @return string Token value. + */ + private function token_value( WP_Parser_Token $token ): string { + try { + return $token->get_value(); + } catch ( TypeError $e ) { + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $token->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $token->id + && WP_MySQL_Lexer::BACK_TICK_QUOTED_ID !== $token->id + ) { + throw $e; + } + } + + return $this->quoted_token_value_from_bytes( $token ); + } + + /** + * Unescape a quoted token without requiring valid UTF-8. + * + * @param WP_Parser_Token $token Quoted token. + * @return string Unquoted token value. + */ + private function quoted_token_value_from_bytes( WP_Parser_Token $token ): string { + $value = $token->get_bytes(); + if ( strlen( $value ) < 2 ) { + return $value; + } + + $quote = $value[0]; + $value = substr( $value, 1, -1 ); + + $backslash = chr( 92 ); + $replacements = array( + ( $backslash . '0' ) => chr( 0 ), + ( $backslash . "'" ) => chr( 39 ), + ( $backslash . '"' ) => chr( 34 ), + ( $backslash . 'b' ) => chr( 8 ), + ( $backslash . 'n' ) => chr( 10 ), + ( $backslash . 'r' ) => chr( 13 ), + ( $backslash . 't' ) => chr( 9 ), + ( $backslash . 'Z' ) => chr( 26 ), + ( $backslash . '%' ) => $backslash . $backslash . '%', + ( $backslash . '_' ) => $backslash . $backslash . '_', + ( $backslash . $backslash ) => $backslash . $backslash, + ( $quote . $quote ) => $quote, + ); + + $value = strtr( $value, $replacements ); + return preg_replace( '/' . preg_quote( $backslash, '/' ) . '(.)/s', '$1', $value ); + } + /** * Quote an identifier for MySQL-facing SHOW CREATE TABLE output. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 8c6ac5476..c13abb44a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -6,6 +6,62 @@ * @group duckdb */ class WP_DuckDB_Driver_Tests extends WP_DuckDB_TestCase { + public function test_invalid_byte_string_literals_translate_without_token_value_type_error(): void { + $connection = new class() extends WP_DuckDB_Connection { + public function __construct() {} + }; + $driver = ( new ReflectionClass( WP_DuckDB_Driver::class ) )->newInstanceWithoutConstructor(); + + foreach ( + array( + 'mysql_version' => WP_DuckDB_Driver::DEFAULT_MYSQL_VERSION, + 'connection' => $connection, + 'database' => 'wp', + 'current_database' => 'wp', + ) as $property => $value + ) { + $reflection_property = new ReflectionProperty( WP_DuckDB_Driver::class, $property ); + if ( PHP_VERSION_ID < 80100 ) { + $reflection_property->setAccessible( true ); + } + $reflection_property->setValue( $driver, $value ); + } + + $grammar = new ReflectionProperty( WP_DuckDB_Driver::class, 'mysql_grammar' ); + if ( PHP_VERSION_ID < 80100 ) { + $grammar->setAccessible( true ); + } + $grammar->setValue( null, new WP_Parser_Grammar( require WP_DuckDB_Driver::MYSQL_GRAMMAR_PATH ) ); + + $tokenize = new ReflectionMethod( WP_DuckDB_Driver::class, 'tokenize_and_validate' ); + $translate = new ReflectionMethod( WP_DuckDB_Driver::class, 'translate_tokens_to_duckdb_sql' ); + if ( PHP_VERSION_ID < 80100 ) { + $tokenize->setAccessible( true ); + $translate->setAccessible( true ); + } + + $invalid_byte = "\xA1"; + $cases = array( + array( + "SELECT CONVERT( LEFT( CONVERT( '{$invalid_byte}ord{$invalid_byte}ress' USING binary ), 100 ) USING big5 ) AS x_0", + "SELECT LEFT('{$invalid_byte}ord{$invalid_byte}ress', 100) AS x_0", + ), + array( + "INSERT INTO wptests_posts (post_status) VALUES ('{$invalid_byte}')", + "INSERT INTO wptests_posts(post_status) VALUES ('{$invalid_byte}')", + ), + array( + "DELETE FROM `wptests_posts` WHERE `post_status` = '{$invalid_byte}'", + "DELETE FROM \"wptests_posts\" WHERE \"post_status\" = '{$invalid_byte}'", + ), + ); + + foreach ( $cases as $case ) { + $tokens = $tokenize->invoke( $driver, $case[0] ); + $this->assertSame( bin2hex( $case[1] ), bin2hex( $translate->invoke( $driver, $tokens ) ) ); + } + } + public function test_auto_increment_insert_id_falls_back_to_max_when_currval_is_unavailable(): void { $connection = new class() extends WP_DuckDB_Connection { public $queries = array(); @@ -83,6 +139,83 @@ public function query( string $sql, array $params = array() ): WP_DuckDB_Result_ ); } + public function test_auto_increment_insert_id_recovers_when_row_count_is_zero(): void { + $connection = new class() extends WP_DuckDB_Connection { + public $queries = array(); + + private $max_reads = 0; + + public function __construct() {} + + public function query( string $sql, array $params = array() ): WP_DuckDB_Result_Statement { + $this->queries[] = $sql; + + if ( 0 === strpos( $sql, 'CREATE OR REPLACE MACRO ' ) ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( false !== strpos( $sql, "table_type = 'LOCAL TEMPORARY'" ) ) { + return new WP_DuckDB_Result_Statement( array( 'table_name' ), array() ); + } + + if ( 0 === strpos( $sql, 'SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()' ) ) { + return new WP_DuckDB_Result_Statement( array( 'table_name' ), array( array( 'wp_usermeta' ) ) ); + } + + if ( 0 === strpos( $sql, 'SELECT column_name FROM "__wp_duckdb_column_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( array( 'column_name' ), array( array( 'umeta_id' ) ) ); + } + + if ( 0 === strpos( $sql, 'SELECT currval(' ) ) { + throw new WP_DuckDB_Driver_Exception( 'currval unavailable' ); + } + + if ( 'SELECT MAX("umeta_id") AS max_value FROM "wp_usermeta"' === $sql ) { + ++$this->max_reads; + return new WP_DuckDB_Result_Statement( + array( 'max_value' ), + array( array( 1 === $this->max_reads ? 0 : 1 ) ) + ); + } + + if ( 'INSERT INTO "wp_usermeta" ("user_id", "meta_key", "meta_value") VALUES (1, \'wp_persisted_preferences\', \'a:0:{}\')' === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } + }; + $driver = new WP_DuckDB_Driver( array( 'connection' => $connection ) ); + $mysql_sql = "INSERT INTO `wp_usermeta` (`user_id`, `meta_key`, `meta_value`) VALUES (1, 'wp_persisted_preferences', 'a:0:{}')"; + $duckdb_sql = 'INSERT INTO "wp_usermeta" ("user_id", "meta_key", "meta_value") VALUES (1, \'wp_persisted_preferences\', \'a:0:{}\')'; + + $tokenize = new ReflectionMethod( WP_DuckDB_Driver::class, 'tokenize_and_validate' ); + if ( PHP_VERSION_ID < 80100 ) { + $tokenize->setAccessible( true ); + } + $tokens = $tokenize->invoke( $driver, $mysql_sql ); + + $execute = new ReflectionMethod( WP_DuckDB_Driver::class, 'execute_auto_increment_write' ); + if ( PHP_VERSION_ID < 80100 ) { + $execute->setAccessible( true ); + } + $result = $execute->invoke( + $driver, + 'wp_usermeta', + $duckdb_sql, + 'Failed to execute DuckDB INSERT', + $tokens, + 2 + ); + + $this->assertSame( 1, $result->rowCount() ); + $this->assertSame( 1, $driver->get_insert_id() ); + $this->assertSame( + 2, + substr_count( implode( "\n", $connection->queries ), 'SELECT MAX("umeta_id") AS max_value FROM "wp_usermeta"' ) + ); + } + public function test_record_found_rows_from_result_preserves_column_metadata(): void { $driver = ( new ReflectionClass( WP_DuckDB_Driver::class ) )->newInstanceWithoutConstructor(); $source = new WP_DuckDB_Result_Statement( @@ -6228,6 +6361,43 @@ public function test_insert_id_tracks_generated_auto_increment_values(): void { $this->fail( 'Expected duplicate insert to fail.' ); } + public function test_insert_id_tracks_wordpress_usermeta_shape(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE wp_usermeta ( + umeta_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + meta_key VARCHAR(255) DEFAULT NULL, + meta_value LONGTEXT, + PRIMARY KEY (umeta_id), + KEY user_id (user_id), + KEY meta_key (meta_key(191)) + )' + ); + + $insert = $driver->query( + "INSERT INTO `wp_usermeta` (`user_id`, `meta_key`, `meta_value`) + VALUES (1, 'wp_persisted_preferences', 'a:0:{}')" + ); + + $this->assertSame( 1, $insert->rowCount() ); + $this->assertSame( 1, $driver->get_insert_id() ); + $this->assertSame( + array( + array( + 'umeta_id' => 1, + 'user_id' => 1, + 'meta_key' => 'wp_persisted_preferences', + 'meta_value' => 'a:0:{}', + ), + ), + $driver->query( 'SELECT umeta_id, user_id, meta_key, meta_value FROM wp_usermeta' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + } + public function test_insert_ignore_explicit_auto_increment_insert_id_skips_ignored_rows(): void { $this->requireDuckDBRuntime(); diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index 38db4ec4e..bedf400ac 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -51,13 +51,51 @@ public function __construct( $dbname ) { } /** - * Noop charset setter. + * Determine the best charset and collation to use. + * + * This mirrors wpdb::determine_charset() without requiring a mysqli handle. + * + * @param string $charset Character set. + * @param string $collate Collation. + * @return array{charset:string,collate:string} + */ + public function determine_charset( $charset, $collate ) { + if ( 'utf8' === $charset ) { + $charset = 'utf8mb4'; + } + + if ( 'utf8mb4' === $charset ) { + if ( ! $collate || 'utf8_general_ci' === $collate ) { + $collate = 'utf8mb4_unicode_ci'; + } else { + $collate = str_replace( 'utf8_', 'utf8mb4_', $collate ); + } + } + + if ( $this->has_cap( 'utf8mb4_520' ) && 'utf8mb4_unicode_ci' === $collate ) { + $collate = 'utf8mb4_unicode_520_ci'; + } + + return compact( 'charset', 'collate' ); + } + + /** + * Track connection charset state without calling mysqli functions. * * @param resource $dbh Database handle. * @param string $charset Optional charset. * @param string $collate Optional collation. */ public function set_charset( $dbh, $charset = null, $collate = null ) { + if ( ! isset( $charset ) ) { + $charset = $this->charset; + } + if ( ! isset( $collate ) ) { + $collate = $this->collate; + } + + $this->charset = $charset; + $this->collate = $collate ? $collate : $this->get_default_collation_for_charset( $charset ); } /** @@ -282,7 +320,8 @@ public function db_connect( $allow_bail = true ) { return false; } - $this->ready = true; + $this->is_mysql = true; + $this->ready = true; try { $this->set_sql_mode(); } catch ( Throwable $e ) { @@ -393,7 +432,11 @@ private function _do_query( $query ) { } try { - $this->last_statement = $this->dbh->query( $query ); + $this->last_statement = $this->get_connection_collation_statement( $query ); + if ( ! $this->last_statement ) { + $this->last_statement = $this->dbh->query( $query ); + } + if ( $this->last_statement->columnCount() > 0 ) { $this->result = $this->normalize_result_rows( $this->last_statement->fetchAll( PDO::FETCH_OBJ ) ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO } else { @@ -479,7 +522,18 @@ protected function load_col_info() { * @return bool */ public function has_cap( $db_cap ) { - return 'subqueries' === strtolower( $db_cap ); + switch ( strtolower( $db_cap ) ) { + case 'collation': + case 'group_concat': + case 'identifier_placeholders': + case 'set_charset': + case 'subqueries': + case 'utf8mb4': + case 'utf8mb4_520': + return true; + } + + return false; } /** @@ -534,6 +588,56 @@ private function get_default_incompatible_sql_modes() { ); } + /** + * Return a wpdb-shaped result for the connection collation variable. + * + * The DuckDB driver currently accepts SET NAMES as a bootstrap no-op and + * returns an empty SHOW VARIABLES result. WordPress core asks for this + * variable directly after set_charset(). + * + * @param string $query SQL query. + * @return WP_DuckDB_Result_Statement|null Result statement if handled. + */ + private function get_connection_collation_statement( $query ) { + if ( + ! preg_match( + "/^\\s*SHOW\\s+(?:GLOBAL\\s+|LOCAL\\s+|SESSION\\s+)?VARIABLES\\s+WHERE\\s+Variable_name\\s*=\\s*(['\"])collation_connection\\1\\s*$/i", + $query + ) + ) { + return null; + } + + return new WP_DuckDB_Result_Statement( + array( 'Variable_name', 'Value' ), + array( + array( + 'collation_connection', + $this->collate ? $this->collate : $this->get_default_collation_for_charset( $this->charset ), + ), + ) + ); + } + + /** + * Get the default collation name for a charset. + * + * @param string $charset Character set. + * @return string Collation name. + */ + private function get_default_collation_for_charset( $charset ) { + switch ( $charset ) { + case 'utf8': + return 'utf8_general_ci'; + case 'utf8mb3': + return 'utf8mb3_general_ci'; + case 'utf8mb4': + return 'utf8mb4_unicode_ci'; + } + + return $charset . '_general_ci'; + } + /** * Ensure database directory exists and is protected. * From f938104b03bb16b4e2aa05f9fe07eb79ff7d0331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 14:57:46 +0000 Subject: [PATCH 133/317] Tighten DuckDB wpdb diagnostics and charset handling --- .github/workflows/duckdb-phpunit-tests.yml | 3 + .../src/duckdb/class-wp-duckdb-driver.php | 10 +- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 48 ++++++++ .../wp-includes/duckdb/class-wp-duckdb-db.php | 104 ++++++++++++++++++ 4 files changed, 160 insertions(+), 5 deletions(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index 1c8fe0b7a..5f08ea970 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -326,6 +326,9 @@ jobs: if ( ! defined( 'SAVEQUERIES' ) ) { define( 'SAVEQUERIES', true ); } + if ( ! defined( 'WP_DUCKDB_E2E_DIAGNOSTICS' ) ) { + define( 'WP_DUCKDB_E2E_DIAGNOSTICS', true ); + } register_shutdown_function( static function () { diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 454350eac..4a123acb4 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -12784,7 +12784,7 @@ private function translate_default_literal( array $tokens, int &$index ): string ++$index; if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { - return $this->connection->quote( $token->get_value() ); + return $this->connection->quote( $this->token_value( $token ) ); } if ( $this->is_number_token( $token ) ) { return $token->get_bytes(); @@ -15508,7 +15508,7 @@ private function bit_literal_sql_and_default_from_tokens( array $value_tokens ): } if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { - return $this->bit_literal_sql_and_default_from_numeric_string( $token->get_value() ); + return $this->bit_literal_sql_and_default_from_numeric_string( $this->token_value( $token ) ); } if ( WP_MySQL_Lexer::BIN_NUMBER === $token->id ) { @@ -15930,7 +15930,7 @@ private function write_value_display_sql( array $value_tokens, string $value_sql if ( 1 === count( $value_tokens ) ) { $token = $value_tokens[0]; if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { - return $this->connection->quote( $token->get_value() ); + return $this->connection->quote( $this->token_value( $token ) ); } if ( $this->is_number_token( $token ) ) { return $this->connection->quote( $token->get_bytes() ); @@ -18073,9 +18073,9 @@ private function integer_literal_value( array $tokens ): ?int { } if ( ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) - && preg_match( '/^[+-]?\d+$/', $token->get_value() ) + && preg_match( '/^[+-]?\d+$/', $this->token_value( $token ) ) ) { - return (int) $token->get_value(); + return (int) $this->token_value( $token ); } } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index c13abb44a..12dea3a40 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -62,6 +62,54 @@ public function __construct() {} } } + public function test_invalid_byte_write_display_sql_uses_byte_safe_token_value(): void { + $connection = new class() extends WP_DuckDB_Connection { + public function __construct() {} + }; + $driver = ( new ReflectionClass( WP_DuckDB_Driver::class ) )->newInstanceWithoutConstructor(); + + foreach ( + array( + 'mysql_version' => WP_DuckDB_Driver::DEFAULT_MYSQL_VERSION, + 'connection' => $connection, + 'database' => 'wp', + 'current_database' => 'wp', + ) as $property => $value + ) { + $reflection_property = new ReflectionProperty( WP_DuckDB_Driver::class, $property ); + if ( PHP_VERSION_ID < 80100 ) { + $reflection_property->setAccessible( true ); + } + $reflection_property->setValue( $driver, $value ); + } + + $grammar = new ReflectionProperty( WP_DuckDB_Driver::class, 'mysql_grammar' ); + if ( PHP_VERSION_ID < 80100 ) { + $grammar->setAccessible( true ); + } + $grammar->setValue( null, new WP_Parser_Grammar( require WP_DuckDB_Driver::MYSQL_GRAMMAR_PATH ) ); + + $tokenize = new ReflectionMethod( WP_DuckDB_Driver::class, 'tokenize_and_validate' ); + $display = new ReflectionMethod( WP_DuckDB_Driver::class, 'write_value_display_sql' ); + if ( PHP_VERSION_ID < 80100 ) { + $tokenize->setAccessible( true ); + $display->setAccessible( true ); + } + + $invalid_byte = "\xA1"; + $tokens = $tokenize->invoke( $driver, "INSERT INTO wptests_posts (post_status) VALUES ('{$invalid_byte}')" ); + $value_tokens = array_values( + array_filter( + $tokens, + function ( WP_Parser_Token $token ): bool { + return WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id; + } + ) + ); + + $this->assertSame( bin2hex( "'{$invalid_byte}'" ), bin2hex( $display->invoke( $driver, $value_tokens, "'{$invalid_byte}'" ) ) ); + } + public function test_auto_increment_insert_id_falls_back_to_max_when_currval_is_unavailable(): void { $connection = new class() extends WP_DuckDB_Connection { public $queries = array(); diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index bedf400ac..57cab7db8 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -60,6 +60,10 @@ public function __construct( $dbname ) { * @return array{charset:string,collate:string} */ public function determine_charset( $charset, $collate ) { + if ( ! $this->ready || ! ( $this->dbh instanceof WP_DuckDB_Driver ) ) { + return compact( 'charset', 'collate' ); + } + if ( 'utf8' === $charset ) { $charset = 'utf8mb4'; } @@ -161,6 +165,13 @@ public function set_sql_mode( $modes = array() ) { * @return bool */ public function close() { + if ( ! $this->ready ) { + return false; + } + + $this->ready = false; + $this->has_connected = false; + return true; } @@ -339,6 +350,11 @@ public function db_connect( $allow_bail = true ) { * @return bool */ public function check_connection( $allow_bail = true ) { + if ( $this->dbh instanceof WP_DuckDB_Driver ) { + $this->ready = true; + $this->has_connected = true; + } + return true; } @@ -413,6 +429,9 @@ public function query( $query ) { $this->rows_affected = 1; } } + if ( defined( 'WP_DUCKDB_E2E_DIAGNOSTICS' ) && WP_DUCKDB_E2E_DIAGNOSTICS && $this->is_persisted_preferences_usermeta_insert( $query ) ) { + $this->log_persisted_preferences_insert_diagnostic( $query ); + } return $this->rows_affected; } @@ -421,6 +440,71 @@ public function query( $query ) { return $this->num_rows; } + /** + * Check whether a query inserts the persisted preferences user meta row. + * + * @param string $query Query to inspect. + * @return bool + */ + private function is_persisted_preferences_usermeta_insert( $query ) { + return preg_match( '/^\s*insert\s+into\s+`?wp_usermeta`?\s/i', $query ) + && false !== strpos( $query, 'wp_persisted_preferences' ); + } + + /** + * Log immediate DuckDB state after the persisted preferences insert. + * + * @param string $query Insert query. + */ + private function log_persisted_preferences_insert_diagnostic( $query ) { + $diagnostics = array( + 'query' => $query, + 'last_error' => (string) $this->last_error, + 'statement_row_count' => $this->last_statement ? (int) $this->last_statement->rowCount() : null, + 'rows_affected' => (int) $this->rows_affected, + 'insert_id' => (int) $this->insert_id, + 'driver_class' => is_object( $this->dbh ) ? get_class( $this->dbh ) : gettype( $this->dbh ), + 'driver_insert_id' => null, + 'duckdb_queries_tail' => array(), + 'wp_usermeta_columns' => array(), + 'wp_usermeta_row_counts' => array(), + 'wp_usermeta_latest' => array(), + ); + + try { + if ( is_object( $this->dbh ) && method_exists( $this->dbh, 'get_insert_id' ) ) { + $diagnostics['driver_insert_id'] = (int) $this->dbh->get_insert_id(); + } + if ( is_object( $this->dbh ) && method_exists( $this->dbh, 'get_last_duckdb_queries' ) ) { + $diagnostics['duckdb_queries_tail'] = array_slice( $this->dbh->get_last_duckdb_queries(), -5 ); + } + if ( is_object( $this->dbh ) && method_exists( $this->dbh, 'get_connection' ) ) { + $connection = $this->dbh->get_connection(); + $table = $connection->quote_identifier( 'wp_usermeta' ); + + $diagnostics['wp_usermeta_columns'] = $connection + ->query( 'SELECT cid, name, type, "notnull", dflt_value, pk FROM pragma_table_info(' . $connection->quote( 'wp_usermeta' ) . ') ORDER BY cid' ) + ->fetchAll( PDO::FETCH_ASSOC ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + + $diagnostics['wp_usermeta_row_counts'] = $connection + ->query( 'SELECT COUNT(*) AS total_rows, MAX(umeta_id) AS max_umeta_id FROM ' . $table ) + ->fetchAll( PDO::FETCH_ASSOC ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + + $diagnostics['wp_usermeta_latest'] = $connection + ->query( 'SELECT umeta_id, user_id, meta_key, LENGTH(meta_value) AS meta_value_length FROM ' . $table . " WHERE meta_key = 'wp_persisted_preferences' ORDER BY umeta_id DESC LIMIT 3" ) + ->fetchAll( PDO::FETCH_ASSOC ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + } + } catch ( Throwable $e ) { + $diagnostics['probe_error'] = $e->getMessage(); + } + + $encoded = function_exists( 'wp_json_encode' ) + ? wp_json_encode( $diagnostics, JSON_UNESCAPED_SLASHES ) + : json_encode( $diagnostics ); + + error_log( '[duckdb-persisted-preferences-insert] ' . $encoded ); + } + /** * Internal query runner. * @@ -559,6 +643,26 @@ public function db_server_info() { } } + /** + * Strip invalid text without falling back to mysqli for the connection charset. + * + * @param array $data Values to strip. + * @return array|WP_Error Stripped values, or error. + */ + protected function strip_invalid_text( $data ) { + if ( '' !== $this->charset ) { + return parent::strip_invalid_text( $data ); + } + + $this->charset = 'utf8mb4'; + + try { + return parent::strip_invalid_text( $data ); + } finally { + $this->charset = ''; + } + } + /** * Get the WordPress-incompatible SQL modes for filtering. * From 0abd1381ac18fb8b6b75c088df1f2169a2af2156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 15:11:00 +0000 Subject: [PATCH 134/317] Tighten DuckDB wpdb failure handling --- .github/workflows/wp-tests-phpunit-run.js | 36 ---------- .../src/duckdb/class-wp-duckdb-driver.php | 51 ++++++++++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 71 +++++++++++++++++++ .../wp-includes/duckdb/class-wp-duckdb-db.php | 61 +++++++++++++--- 4 files changed, 174 insertions(+), 45 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index cf8cff70a..9f36962e7 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -28,7 +28,6 @@ const duckdbAutoloadCompatibilityWrapperContainerPath = '/var/www/phpunit-duckdb const expectedErrors = [ 'Tests_DB_Charset::test_invalid_characters_in_query', - 'Tests_DB_Charset::test_set_charset_changes_the_connection_collation', ]; const expectedFailures = [ @@ -36,31 +35,6 @@ const expectedFailures = [ 'Tests_Admin_wpSiteHealth::test_object_cache_thresholds with data set #3', 'Tests_Comment::test_wp_new_comment_respects_comment_field_lengths', 'Tests_Comment::test_wp_update_comment', - 'Tests_DB_Charset::test_get_column_charset with data set #0', - 'Tests_DB_Charset::test_get_column_charset with data set #1', - 'Tests_DB_Charset::test_get_column_charset with data set #2', - 'Tests_DB_Charset::test_get_column_charset with data set #3', - 'Tests_DB_Charset::test_get_column_charset with data set #4', - 'Tests_DB_Charset::test_get_column_charset with data set #5', - 'Tests_DB_Charset::test_get_column_charset with data set #6', - 'Tests_DB_Charset::test_get_column_charset with data set #7', - 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #0', - 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #1', - 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #2', - 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #3', - 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #4', - 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #5', - 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #6', - 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #7', - 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #0', - 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #1', - 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #2', - 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #3', - 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #4', - 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #5', - 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #6', - 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #7', - 'Tests_DB_Charset::test_process_field_charsets_on_nonexistent_table', 'Tests_DB_Charset::test_strip_invalid_text with data set #21', 'Tests_DB_Charset::test_strip_invalid_text with data set #22', 'Tests_DB_Charset::test_strip_invalid_text with data set #23', @@ -77,23 +51,13 @@ const expectedFailures = [ 'Tests_DB_Charset::test_strip_invalid_text with data set #39', 'Tests_DB_Charset::test_strip_invalid_text with data set #40', 'Tests_DB_Charset::test_strip_invalid_text with data set #41', - 'Tests_DB_Charset::test_strip_invalid_text_for_column_bails_if_ascii_input_too_long', 'Tests_DB_dbDelta::test_spatial_indices', - 'Tests_DB::test_charset_switched_to_utf8mb4', 'Tests_DB::test_close', - 'Tests_DB::test_delete_value_too_long_for_field with data set "too long"', - 'Tests_DB::test_has_cap', - 'Tests_DB::test_insert_value_too_long_for_field with data set "too long"', 'Tests_DB::test_mysqli_flush_sync', - 'Tests_DB::test_non_unicode_collations', 'Tests_DB::test_pre_get_col_charset_filter', 'Tests_DB::test_process_fields_on_nonexistent_table', - 'Tests_DB::test_process_fields_value_too_long_for_field with data set "too long"', 'Tests_DB::test_query_value_contains_invalid_chars', - 'Tests_DB::test_replace_value_too_long_for_field with data set "too long"', 'Tests_DB::test_replace', - 'Tests_DB::test_supports_collation', - 'Tests_DB::test_update_value_too_long_for_field with data set "too long"', 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #1', 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #2', 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #3', diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 4a123acb4..d5427cdb0 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -348,6 +348,7 @@ public function query( string $query ): WP_DuckDB_Result_Statement { throw $this->new_unsupported_statement_exception( $tokens[0] ); } catch ( Throwable $e ) { $this->found_rows = 0; + $this->rollback_failed_active_transaction( $e ); throw $e; } } @@ -388,6 +389,54 @@ public function get_insert_id(): int { return $this->last_insert_id; } + /** + * Roll back active transactions after errors that can poison DuckDB state. + * + * Unsupported or preflight driver exceptions should leave caller + * transactions open. Native DuckDB execution failures and raw PHP type + * errors can leave the transaction unusable, matching the + * "Current transaction is aborted" dbDelta cascade. + * + * @param Throwable $error Query failure. + */ + private function rollback_failed_active_transaction( Throwable $error ): void { + if ( ! $this->connection->inTransaction() ) { + return; + } + + if ( ! $this->should_rollback_active_transaction_on_error( $error ) ) { + return; + } + + $this->connection->rollback(); + } + + /** + * Check whether a query error means the active DuckDB transaction is unsafe. + * + * @param Throwable $error Query failure. + * @return bool Whether to roll back the active transaction. + */ + private function should_rollback_active_transaction_on_error( Throwable $error ): bool { + if ( $error instanceof TypeError ) { + return true; + } + + for ( $current = $error; null !== $current; $current = $current->getPrevious() ) { + if ( + $current instanceof WP_DuckDB_Driver_Exception + && ( + 0 === strpos( $current->getMessage(), 'DuckDB query failed:' ) + || 0 === strpos( $current->getMessage(), 'Failed to prepare DuckDB query:' ) + ) + ) { + return true; + } + } + + return false; + } + /** * Get an emulated MySQL session system variable value. * @@ -17491,7 +17540,7 @@ private function like_escape_left_operand_sql( array $tokens, int $index ): ?arr */ private function is_empty_function_call( array $tokens, int $index, string $name ): bool { return isset( $tokens[ $index + 2 ] ) - && 0 === strcasecmp( $tokens[ $index ]->get_value(), $name ) + && 0 === strcasecmp( $this->token_value( $tokens[ $index ] ), $name ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index + 1 ]->id && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index + 2 ]->id; } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 12dea3a40..8b238bf0f 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1324,6 +1324,40 @@ public function test_sql_transaction_statements_update_connection_state_and_quer $this->assertSame( array(), $driver->get_last_duckdb_queries() ); } + public function test_native_error_rolls_back_active_transaction_before_dbdelta_insert(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE wptests_dbdelta_test ( + id int NOT NULL AUTO_INCREMENT, + column_1 varchar(255), + PRIMARY KEY (id) + )' + ); + + $driver->query( 'BEGIN' ); + try { + $driver->query( "SELECT '\xF0\x9F' AS invalid_bytes" ); + $this->fail( 'Expected invalid UTF-8 SELECT to fail.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'Invalid unicode', $e->getMessage() ); + } + + $this->assertFalse( $driver->get_connection()->inTransaction() ); + + $driver->query( "INSERT INTO wptests_dbdelta_test (column_1) VALUES ('wcphilly2015')" ); + $row = $driver->query( "select column_1 from wptests_dbdelta_test where column_1 = 'wcphilly2015'" )->fetch( PDO::FETCH_OBJ ); + + $this->assertIsObject( $row ); + $this->assertSame( 'wcphilly2015', $row->column_1 ); + } + public function test_savepoint_sql_is_rejected_without_mutating_active_transaction(): void { $this->requireDuckDBRuntime(); @@ -6446,6 +6480,43 @@ public function test_insert_id_tracks_wordpress_usermeta_shape(): void { $this->assertSame( 0, $driver->get_insert_id() ); } + public function test_insert_id_tracks_prefixed_wordpress_usermeta_shape(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + 'CREATE TABLE wp_e2e_usermeta ( + umeta_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + meta_key VARCHAR(255) DEFAULT NULL, + meta_value LONGTEXT, + PRIMARY KEY (umeta_id), + KEY user_id (user_id), + KEY meta_key (meta_key(191)) + )' + ); + + $insert = $driver->query( + "INSERT INTO `wp_e2e_usermeta` (`user_id`, `meta_key`, `meta_value`) + VALUES (1, 'wp_e2e_persisted_preferences', 'a:0:{}')" + ); + + $this->assertSame( 1, $insert->rowCount() ); + $this->assertSame( 1, $driver->get_insert_id() ); + $this->assertSame( + array( + array( + 'umeta_id' => 1, + 'user_id' => 1, + 'meta_key' => 'wp_e2e_persisted_preferences', + 'meta_value' => 'a:0:{}', + ), + ), + $driver->query( 'SELECT umeta_id, user_id, meta_key, meta_value FROM wp_e2e_usermeta' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + } + public function test_insert_ignore_explicit_auto_increment_insert_id_skips_ignored_rows(): void { $this->requireDuckDBRuntime(); diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index 57cab7db8..b064ae773 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -430,7 +430,10 @@ public function query( $query ) { } } if ( defined( 'WP_DUCKDB_E2E_DIAGNOSTICS' ) && WP_DUCKDB_E2E_DIAGNOSTICS && $this->is_persisted_preferences_usermeta_insert( $query ) ) { - $this->log_persisted_preferences_insert_diagnostic( $query ); + $this->log_persisted_preferences_insert_diagnostic( + $query, + $this->persisted_preferences_usermeta_insert_table( $query ) + ); } return $this->rows_affected; } @@ -447,18 +450,43 @@ public function query( $query ) { * @return bool */ private function is_persisted_preferences_usermeta_insert( $query ) { - return preg_match( '/^\s*insert\s+into\s+`?wp_usermeta`?\s/i', $query ) - && false !== strpos( $query, 'wp_persisted_preferences' ); + return false !== $this->persisted_preferences_usermeta_insert_table( $query ) + && false !== strpos( $query, 'persisted_preferences' ); + } + + /** + * Extract the usermeta table name from a persisted preferences insert. + * + * @param string $query Query to inspect. + * @return string|false Usermeta table name, or false when the query does not match. + */ + private function persisted_preferences_usermeta_insert_table( $query ) { + if ( ! preg_match( '/^\s*insert\s+into\s+`?([^`\s(]+)`?\s/i', $query, $matches ) ) { + return false; + } + + $table = $matches[1]; + if ( isset( $this->usermeta ) && 0 === strcasecmp( $table, $this->usermeta ) ) { + return $table; + } + + if ( 'usermeta' === substr( strtolower( $table ), -8 ) ) { + return $table; + } + + return false; } /** * Log immediate DuckDB state after the persisted preferences insert. * - * @param string $query Insert query. + * @param string $query Insert query. + * @param string|false $table_name Usermeta table name. */ - private function log_persisted_preferences_insert_diagnostic( $query ) { + private function log_persisted_preferences_insert_diagnostic( $query, $table_name ) { $diagnostics = array( 'query' => $query, + 'usermeta_table' => $table_name ? (string) $table_name : null, 'last_error' => (string) $this->last_error, 'statement_row_count' => $this->last_statement ? (int) $this->last_statement->rowCount() : null, 'rows_affected' => (int) $this->rows_affected, @@ -469,6 +497,8 @@ private function log_persisted_preferences_insert_diagnostic( $query ) { 'wp_usermeta_columns' => array(), 'wp_usermeta_row_counts' => array(), 'wp_usermeta_latest' => array(), + 'duckdb_column_metadata' => array(), + 'duckdb_metadata_error' => null, ); try { @@ -480,18 +510,33 @@ private function log_persisted_preferences_insert_diagnostic( $query ) { } if ( is_object( $this->dbh ) && method_exists( $this->dbh, 'get_connection' ) ) { $connection = $this->dbh->get_connection(); - $table = $connection->quote_identifier( 'wp_usermeta' ); + $table_name = $table_name ? (string) $table_name : 'wp_usermeta'; + $table = $connection->quote_identifier( $table_name ); $diagnostics['wp_usermeta_columns'] = $connection - ->query( 'SELECT cid, name, type, "notnull", dflt_value, pk FROM pragma_table_info(' . $connection->quote( 'wp_usermeta' ) . ') ORDER BY cid' ) + ->query( 'SELECT cid, name, type, "notnull", dflt_value, pk FROM pragma_table_info(' . $connection->quote( $table_name ) . ') ORDER BY cid' ) ->fetchAll( PDO::FETCH_ASSOC ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + try { + $diagnostics['duckdb_column_metadata'] = $connection + ->query( + 'SELECT column_name, ordinal_position, column_type, is_nullable, column_default, column_key, extra FROM ' + . $connection->quote_identifier( WP_DuckDB_Driver::COLUMN_METADATA_TABLE ) + . ' WHERE table_name = ' + . $connection->quote( $table_name ) + . ' ORDER BY ordinal_position' + ) + ->fetchAll( PDO::FETCH_ASSOC ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + } catch ( Throwable $e ) { + $diagnostics['duckdb_metadata_error'] = $e->getMessage(); + } + $diagnostics['wp_usermeta_row_counts'] = $connection ->query( 'SELECT COUNT(*) AS total_rows, MAX(umeta_id) AS max_umeta_id FROM ' . $table ) ->fetchAll( PDO::FETCH_ASSOC ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO $diagnostics['wp_usermeta_latest'] = $connection - ->query( 'SELECT umeta_id, user_id, meta_key, LENGTH(meta_value) AS meta_value_length FROM ' . $table . " WHERE meta_key = 'wp_persisted_preferences' ORDER BY umeta_id DESC LIMIT 3" ) + ->query( 'SELECT umeta_id, user_id, meta_key, LENGTH(meta_value) AS meta_value_length FROM ' . $table . " WHERE meta_key LIKE '%persisted_preferences' ORDER BY umeta_id DESC LIMIT 3" ) ->fetchAll( PDO::FETCH_ASSOC ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO } } catch ( Throwable $e ) { From 4ca1dea4d03f88693fb6a7205f815098306a6121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 15:22:46 +0000 Subject: [PATCH 135/317] Recover DuckDB insert IDs from physical metadata --- .github/workflows/wp-tests-phpunit-run.js | 3 - .../src/duckdb/class-wp-duckdb-driver.php | 34 ++++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 96 +++++++++++++++++++ .../wp-includes/duckdb/class-wp-duckdb-db.php | 30 +++++- 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 9f36962e7..4e1de9d03 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -52,10 +52,7 @@ const expectedFailures = [ 'Tests_DB_Charset::test_strip_invalid_text with data set #40', 'Tests_DB_Charset::test_strip_invalid_text with data set #41', 'Tests_DB_dbDelta::test_spatial_indices', - 'Tests_DB::test_close', 'Tests_DB::test_mysqli_flush_sync', - 'Tests_DB::test_pre_get_col_charset_filter', - 'Tests_DB::test_process_fields_on_nonexistent_table', 'Tests_DB::test_query_value_contains_invalid_chars', 'Tests_DB::test_replace', 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #1', diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index d5427cdb0..f578f9fe1 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -17911,6 +17911,7 @@ private function auto_increment_value_exists( string $table_name, string $column * Find metadata for a table's recorded AUTO_INCREMENT column. * * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. * @return array{column_name:string,sequence_name:string}|null Metadata, or null when no AUTO_INCREMENT column is known. */ private function auto_increment_metadata_for_table( string $table_name, bool $temporary = false ): ?array { @@ -17923,12 +17924,12 @@ private function auto_increment_metadata_for_table( string $table_name, bool $te . " AND extra = 'auto_increment' ORDER BY ordinal_position LIMIT 1" ); } catch ( WP_DuckDB_Driver_Exception $e ) { - return null; + return $this->physical_auto_increment_metadata_for_table( $table_name, $temporary ); } $column_name = $stmt->fetchColumn(); if ( false === $column_name || null === $column_name ) { - return null; + return $this->physical_auto_increment_metadata_for_table( $table_name, $temporary ); } $column_name = (string) $column_name; @@ -17938,6 +17939,35 @@ private function auto_increment_metadata_for_table( string $table_name, bool $te ); } + /** + * Find AUTO_INCREMENT metadata from a physical DuckDB nextval() default. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + * @return array{column_name:string,sequence_name:string}|null Metadata, or null when no nextval-backed column is known. + */ + private function physical_auto_increment_metadata_for_table( string $table_name, bool $temporary = false ): ?array { + try { + $metadata_rows = $this->pragma_column_metadata_rows( $table_name ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + return null; + } + + foreach ( $metadata_rows as $metadata ) { + if ( false === stripos( (string) ( $metadata['extra'] ?? '' ), 'auto_increment' ) ) { + continue; + } + + $column_name = (string) $metadata['column_name']; + return array( + 'column_name' => $column_name, + 'sequence_name' => $this->sequence_name( $table_name, $column_name, $temporary ), + ); + } + + return null; + } + /** * Find recorded AUTO_INCREMENT sequence names for a table. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 8b238bf0f..9586a1107 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -264,6 +264,102 @@ public function query( string $sql, array $params = array() ): WP_DuckDB_Result_ ); } + public function test_auto_increment_insert_id_recovers_from_physical_nextval_default_when_metadata_is_stale(): void { + $connection = new class() extends WP_DuckDB_Connection { + public $queries = array(); + + private $currval_reads = 0; + private $max_reads = 0; + + public function __construct() {} + + public function query( string $sql, array $params = array() ): WP_DuckDB_Result_Statement { + $this->queries[] = $sql; + + if ( 0 === strpos( $sql, 'CREATE OR REPLACE MACRO ' ) ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( false !== strpos( $sql, "table_type = 'LOCAL TEMPORARY'" ) ) { + return new WP_DuckDB_Result_Statement( array( 'table_name' ), array() ); + } + + if ( 0 === strpos( $sql, 'SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()' ) ) { + return new WP_DuckDB_Result_Statement( array( 'table_name' ), array( array( 'wp_usermeta' ) ) ); + } + + if ( 0 === strpos( $sql, 'SELECT column_name FROM "__wp_duckdb_column_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( array( 'column_name' ), array() ); + } + + if ( "SELECT cid, name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('wp_usermeta') ORDER BY cid" === $sql ) { + return new WP_DuckDB_Result_Statement( + array( 'cid', 'name', 'type', 'notnull', 'dflt_value', 'pk' ), + array( + array( 0, 'umeta_id', 'BIGINT', true, "nextval('__wp_duckdb_auto_increment_existing')", 1 ), + array( 1, 'user_id', 'BIGINT', false, null, 0 ), + array( 2, 'meta_key', 'VARCHAR', false, null, 0 ), + array( 3, 'meta_value', 'VARCHAR', false, null, 0 ), + ) + ); + } + + if ( 0 === strpos( $sql, 'SELECT currval(' ) ) { + ++$this->currval_reads; + if ( 1 === $this->currval_reads ) { + throw new WP_DuckDB_Driver_Exception( 'currval unavailable' ); + } + + return new WP_DuckDB_Result_Statement( array( 'currval' ), array( array( 18 ) ) ); + } + + if ( 'SELECT MAX("umeta_id") AS max_value FROM "wp_usermeta"' === $sql ) { + ++$this->max_reads; + return new WP_DuckDB_Result_Statement( array( 'max_value' ), array( array( 17 ) ) ); + } + + if ( 'INSERT INTO "wp_usermeta" ("user_id", "meta_key", "meta_value") VALUES (1, \'wp_persisted_preferences\', \'a:0:{}\')' === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } + }; + $driver = new WP_DuckDB_Driver( array( 'connection' => $connection ) ); + $mysql_sql = "INSERT INTO `wp_usermeta` (`user_id`, `meta_key`, `meta_value`) VALUES (1, 'wp_persisted_preferences', 'a:0:{}')"; + $duckdb_sql = 'INSERT INTO "wp_usermeta" ("user_id", "meta_key", "meta_value") VALUES (1, \'wp_persisted_preferences\', \'a:0:{}\')'; + + $tokenize = new ReflectionMethod( WP_DuckDB_Driver::class, 'tokenize_and_validate' ); + if ( PHP_VERSION_ID < 80100 ) { + $tokenize->setAccessible( true ); + } + $tokens = $tokenize->invoke( $driver, $mysql_sql ); + + $execute = new ReflectionMethod( WP_DuckDB_Driver::class, 'execute_auto_increment_write' ); + if ( PHP_VERSION_ID < 80100 ) { + $execute->setAccessible( true ); + } + $result = $execute->invoke( + $driver, + 'wp_usermeta', + $duckdb_sql, + 'Failed to execute DuckDB INSERT', + $tokens, + 2 + ); + + $this->assertSame( 1, $result->rowCount() ); + $this->assertSame( 18, $driver->get_insert_id() ); + $this->assertSame( + 1, + substr_count( implode( "\n", $connection->queries ), "SELECT cid, name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('wp_usermeta') ORDER BY cid" ) + ); + $this->assertSame( + 1, + substr_count( implode( "\n", $connection->queries ), 'SELECT MAX("umeta_id") AS max_value FROM "wp_usermeta"' ) + ); + } + public function test_record_found_rows_from_result_preserves_column_metadata(): void { $driver = ( new ReflectionClass( WP_DuckDB_Driver::class ) )->newInstanceWithoutConstructor(); $source = new WP_DuckDB_Result_Statement( diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index b064ae773..71d961e57 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -393,6 +393,9 @@ public function prepare( $query, ...$args ) { */ public function query( $query ) { if ( ! $this->ready ) { + if ( property_exists( $this, 'check_current_query' ) ) { + $this->check_current_query = true; + } return false; } @@ -403,7 +406,32 @@ public function query( $query ) { } $this->flush(); - $this->func_call = "\$db->query(\"$query\")"; + $this->func_call = "\$db->query(\"$query\")"; + + if ( + property_exists( $this, 'check_current_query' ) + && method_exists( $this, 'check_ascii' ) + && method_exists( $this, 'strip_invalid_text_from_query' ) + && $this->check_current_query + && ! $this->check_ascii( $query ) + ) { + $stripped_query = $this->strip_invalid_text_from_query( $query ); + $this->flush(); + if ( $stripped_query !== $query ) { + $this->insert_id = 0; + $this->last_query = $query; + + wp_load_translations_early(); + + $this->last_error = __( 'WordPress database error: Could not perform query because it contains invalid data.' ); + + return false; + } + } + + if ( property_exists( $this, 'check_current_query' ) ) { + $this->check_current_query = true; + } $this->last_query = $query; $this->_do_query( $query ); From ebbf26babee41273bddc30a998f04963cb9a13f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 15:31:21 +0000 Subject: [PATCH 136/317] Align DuckDB wpdb dbDelta metadata --- .../src/duckdb/class-wp-duckdb-driver.php | 7 +- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 47 ++++++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 141 ++++++++++++++++++ .../wp-includes/duckdb/class-wp-duckdb-db.php | 7 +- 4 files changed, 199 insertions(+), 3 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index f578f9fe1..89922ead4 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -423,11 +423,14 @@ private function should_rollback_active_transaction_on_error( Throwable $error ) } for ( $current = $error; null !== $current; $current = $current->getPrevious() ) { + $message = $current->getMessage(); if ( $current instanceof WP_DuckDB_Driver_Exception && ( - 0 === strpos( $current->getMessage(), 'DuckDB query failed:' ) - || 0 === strpos( $current->getMessage(), 'Failed to prepare DuckDB query:' ) + 0 === strpos( $message, 'DuckDB query failed:' ) + || 0 === strpos( $message, 'Failed to prepare DuckDB query:' ) + || false !== strpos( $message, ': DuckDB query failed:' ) + || false !== strpos( $message, ': Failed to prepare DuckDB query:' ) ) ) { return true; diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 9586a1107..a3b4faa99 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -484,6 +484,53 @@ public function rows( bool $assoc ): array { } } + public function test_wrapped_duckdb_query_failure_rolls_back_active_transaction(): void { + $connection = new class() extends WP_DuckDB_Connection { + public $rollback_count = 0; + + private $in_transaction = false; + + public function __construct() {} + + public function query( string $sql, array $params = array() ): WP_DuckDB_Result_Statement { + if ( 0 === strpos( $sql, 'SELECT ' ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DuckDB MySQL-emulation SELECT statement: DuckDB query failed: Invalid Input Error: invalid unicode' ); + } + + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + public function beginTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $this->in_transaction = true; + return true; + } + + public function rollback(): bool { + ++$this->rollback_count; + $this->in_transaction = false; + return true; + } + + public function inTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->in_transaction; + } + }; + $driver = new WP_DuckDB_Driver( array( 'connection' => $connection ) ); + + $driver->query( 'BEGIN' ); + $this->assertTrue( $connection->inTransaction() ); + + try { + $driver->query( 'SELECT 1 AS wrapped_failure' ); + $this->fail( 'Expected wrapped SELECT failure.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( ': DuckDB query failed: Invalid Input Error:', $e->getMessage() ); + } + + $this->assertSame( 1, $connection->rollback_count ); + $this->assertFalse( $connection->inTransaction() ); + } + public function test_select_mysql_functions_are_emulated(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index ce9ee605e..fec0e19fb 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -111,6 +111,16 @@ public function test_duckdb_copy_defaults_to_duckdb(): void { $this->assertSame( 'WP_DuckDB_DB', $result['wpdb_class'] ); } + public function test_duckdb_wpdb_reports_spatial_compatible_mysql_version(): void { + $result = $this->run_duckdb_version_state_script(); + + $this->assertSame( '8.0.11', $result['db_version'] ); + $this->assertSame( 'geomcollection', $result['dbdelta_spatial_type'] ); + $this->assertTrue( $result['is_mysql_8011_or_later'] ); + $this->assertTrue( $result['is_before_mysql_8017'] ); + $this->assertFalse( $result['is_mariadb'] ); + } + public function test_duckdb_wpdb_query_updates_raw_query_state(): void { $result = $this->run_raw_query_state_script(); @@ -298,6 +308,16 @@ public function test_duckdb_wpdb_col_info_defaults_for_name_only_metadata_provid } } + public function test_duckdb_wpdb_show_index_non_unique_uses_dbdelta_string_values(): void { + $result = $this->run_show_index_non_unique_state_script(); + + $this->assertTrue( $result['connected'] ); + $this->assertSame( 2, $result['show_index_return'] ); + $this->assertSame( array( '0', '1' ), $result['non_unique_values'] ); + $this->assertSame( array( 'string', 'string' ), $result['non_unique_types'] ); + $this->assertTrue( $result['dbdelta_primary_match'] ); + } + public function test_duckdb_wpdb_query_surface_provider(): void { $result = $this->run_query_surface_state_script(); $cases = $result['cases']; @@ -525,6 +545,36 @@ private function run_dropin_copy_script( string $copy_file, string $prepend_code } } + private function run_duckdb_version_state_script(): array { + $plugin_dir = $this->get_plugin_dir(); + $code = $this->get_wordpress_stub_code(); + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= <<<'PHP' + +$db = new WP_DuckDB_DB( 'wordpress_test' ); +$db_version = $db->db_version(); +$db_server_info = $db->db_server_info(); +$spatial_type = 'geometrycollection'; + +if ( version_compare( $db_version, '8.0.11', '>=' ) && false === strpos( $db_server_info, 'MariaDB' ) ) { + $spatial_type = 'geomcollection'; +} + +echo json_encode( + array( + 'db_version' => $db_version, + 'db_server_info' => $db_server_info, + 'dbdelta_spatial_type' => $spatial_type, + 'is_mysql_8011_or_later' => version_compare( $db_version, '8.0.11', '>=' ), + 'is_before_mysql_8017' => version_compare( $db_version, '8.0.17', '<' ), + 'is_mariadb' => false !== strpos( $db_server_info, 'MariaDB' ), + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + private function run_sql_mode_boot_state_script( bool $fail_read ): array { $plugin_dir = $this->get_plugin_dir(); $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; @@ -1207,6 +1257,97 @@ function wp_duckdb_plugin_col_info_fallback_case( WP_DuckDB_Plugin_Col_Info_Fall return $this->run_isolated_php( $code ); } + private function run_show_index_non_unique_state_script(): array { + $plugin_dir = $this->get_plugin_dir(); + $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; + $code = $this->get_wordpress_stub_code(); + $code .= "\nrequire_once " . var_export( $driver_load, true ) . ";\n"; + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= <<<'PHP' + +class WP_DuckDB_Plugin_Show_Index_Test_Driver extends WP_DuckDB_Driver { + public function __construct() {} + + public function query( string $sql ): WP_DuckDB_Result_Statement { + if ( 'SELECT @@SESSION.sql_mode' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( '@@SESSION.sql_mode' ), + array( + array( 'NO_ENGINE_SUBSTITUTION' ), + ), + 0 + ); + } + + if ( "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'" === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( 'SHOW INDEX FROM wp_posts' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( + 'Table', + 'Non_unique', + 'Key_name', + 'Seq_in_index', + 'Column_name', + 'Collation', + 'Cardinality', + 'Sub_part', + 'Packed', + 'Null', + 'Index_type', + 'Comment', + 'Index_comment', + 'Visible', + 'Expression', + ), + array( + array( 'wp_posts', 0, 'PRIMARY', 1, 'ID', 'A', 0, null, null, '', 'BTREE', '', '', 'YES', null ), + array( 'wp_posts', 1, 'post_name', 1, 'post_name', 'A', 0, 191, null, '', 'BTREE', '', '', 'YES', null ), + ), + 0 + ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } +} + +$GLOBALS['@duckdb_driver'] = new WP_DuckDB_Plugin_Show_Index_Test_Driver(); +$db = new WP_DuckDB_DB( 'wordpress_test' ); +$connected = $db->db_connect( false ); +$show_index_return = $db->query( 'SHOW INDEX FROM wp_posts' ); +$rows = array_map( + function ( $row ) { + return get_object_vars( $row ); + }, + $db->last_result +); +$non_unique_values = array_map( + function ( $row ) { + return $row['Non_unique']; + }, + $rows +); +$non_unique_types = array_map( 'gettype', $non_unique_values ); +$dbdelta_primary_match = isset( $db->last_result[0]->Non_unique ) && '0' === $db->last_result[0]->Non_unique; + +echo json_encode( + array( + 'connected' => $connected, + 'show_index_return' => $show_index_return, + 'rows' => $rows, + 'non_unique_values' => $non_unique_values, + 'non_unique_types' => $non_unique_types, + 'dbdelta_primary_match' => $dbdelta_primary_match, + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + private function run_query_surface_state_script(): array { $plugin_dir = $this->get_plugin_dir(); $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index 71d961e57..3c10f0019 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -625,6 +625,11 @@ private function _do_query( $query ) { private function normalize_result_rows( array $rows ) { foreach ( $rows as $row ) { foreach ( get_object_vars( $row ) as $name => $value ) { + if ( 'Non_unique' === $name && is_int( $value ) && ( 0 === $value || 1 === $value ) ) { + $row->$name = (string) $value; + continue; + } + if ( is_bool( $value ) ) { $row->$name = $value ? '1' : '0'; } @@ -699,7 +704,7 @@ public function has_cap( $db_cap ) { * @return string */ public function db_version() { - return '8.0'; + return '8.0.11'; } /** From e958711300178dfd9907c21622905b1d0a145ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 15:41:21 +0000 Subject: [PATCH 137/317] Recover DuckDB insert IDs after stale currval --- .../src/duckdb/class-wp-duckdb-driver.php | 2 +- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 105 ++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 89922ead4..787f21cef 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -17700,7 +17700,7 @@ private function execute_auto_increment_write( string $table_name, string $sql, ? $this->auto_increment_column_omitted_from_write( $tokens, $table_index, $metadata['column_name'] ) : false; $before_max = null; - if ( null === $before && $column_was_omitted && null !== $table_reference ) { + if ( $column_was_omitted && null !== $table_reference ) { $before_max = $this->max_auto_increment_column_value( $table_reference['table_name'], $metadata['column_name'], $table_reference['temporary'] ); } if ( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index a3b4faa99..f512fba25 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -187,6 +187,111 @@ public function query( string $sql, array $params = array() ): WP_DuckDB_Result_ ); } + public function test_auto_increment_insert_id_falls_back_to_max_when_currval_is_stale(): void { + $connection = new class() extends WP_DuckDB_Connection { + public $queries = array(); + + private $max_reads = 0; + + public function __construct() {} + + public function query( string $sql, array $params = array() ): WP_DuckDB_Result_Statement { + $this->queries[] = $sql; + + if ( 0 === strpos( $sql, 'CREATE OR REPLACE MACRO ' ) ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( 0 === strpos( $sql, 'CREATE TABLE IF NOT EXISTS "__wp_duckdb_column_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( 0 === strpos( $sql, 'CREATE TABLE IF NOT EXISTS "__wp_duckdb_table_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( false !== strpos( $sql, "table_type = 'LOCAL TEMPORARY'" ) ) { + return new WP_DuckDB_Result_Statement( array( 'table_name' ), array() ); + } + + if ( 0 === strpos( $sql, 'SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()' ) ) { + return new WP_DuckDB_Result_Statement( array( 'table_name' ), array( array( 'wp_usermeta' ) ) ); + } + + if ( 0 === strpos( $sql, 'SELECT table_name, engine, row_format, table_collation, table_comment, create_options, create_time FROM "__wp_duckdb_table_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( + array( 'table_name', 'engine', 'row_format', 'table_collation', 'table_comment', 'create_options', 'create_time' ), + array( + array( 'wp_usermeta', 'InnoDB', 'Dynamic', 'utf8mb4_bin', '', '', '2026-06-27 00:00:00' ), + ) + ); + } + + if ( 0 === strpos( $sql, 'SELECT ordinal_position, column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment FROM "__wp_duckdb_column_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( + array( 'ordinal_position', 'column_name', 'column_type', 'is_nullable', 'column_key', 'column_default', 'extra', 'collation_name', 'comment' ), + array( + array( 1, 'umeta_id', 'bigint(20) unsigned', 'NO', 'PRI', null, 'auto_increment', null, '' ), + array( 2, 'user_id', 'bigint(20) unsigned', 'NO', '', '0', '', null, '' ), + array( 3, 'meta_key', 'varchar(255)', 'YES', 'MUL', null, '', null, '' ), + array( 4, 'meta_value', 'longtext', 'YES', '', null, '', null, '' ), + ) + ); + } + + if ( 0 === strpos( $sql, 'SELECT column_name FROM "__wp_duckdb_column_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( array( 'column_name' ), array( array( 'umeta_id' ) ) ); + } + + if ( 0 === strpos( $sql, 'SELECT currval(' ) ) { + return new WP_DuckDB_Result_Statement( array( 'currval' ), array( array( 17 ) ) ); + } + + if ( 'SELECT MAX("umeta_id") AS max_value FROM "wp_usermeta"' === $sql ) { + ++$this->max_reads; + return new WP_DuckDB_Result_Statement( + array( 'max_value' ), + array( array( 1 === $this->max_reads ? 17 : 18 ) ) + ); + } + + if ( + 0 === strpos( $sql, 'INSERT INTO "wp_usermeta"' ) + && false !== strpos( $sql, '"user_id"' ) + && false !== strpos( $sql, '"meta_key"' ) + && false !== strpos( $sql, '"meta_value"' ) + && false === strpos( $sql, '"umeta_id"' ) + ) { + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } + }; + $driver = new WP_DuckDB_Driver( array( 'connection' => $connection ) ); + $insert = $driver->query( + "INSERT INTO `wp_usermeta` (`user_id`, `meta_key`, `meta_value`) VALUES (1, 'wp_persisted_preferences', 'a:0:{}')" + ); + $query_log = implode( "\n", $connection->queries ); + $insert_sql = array_values( + array_filter( + $connection->queries, + function ( string $sql ): bool { + return 0 === strpos( $sql, 'INSERT INTO "wp_usermeta"' ); + } + ) + ); + + $this->assertSame( 1, $insert->rowCount() ); + $this->assertSame( 18, $driver->get_insert_id() ); + $this->assertCount( 1, $insert_sql ); + $this->assertStringNotContainsString( '"umeta_id"', $insert_sql[0] ); + $this->assertSame( + 2, + substr_count( $query_log, 'SELECT MAX("umeta_id") AS max_value FROM "wp_usermeta"' ) + ); + } + public function test_auto_increment_insert_id_recovers_when_row_count_is_zero(): void { $connection = new class() extends WP_DuckDB_Connection { public $queries = array(); From 5125c0212c516ef3a1608cf1afb257289b558d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 16:05:47 +0000 Subject: [PATCH 138/317] Improve DuckDB WordPress parity behavior Translate MySQL YEAR() and MONTH() over WordPress datetime strings for DuckDB using TRY_CAST-backed timestamp extraction, with focused translation and runtime coverage for the media-month query shape. Roll back still-active DuckDB transactions at the wpdb drop-in boundary after native query/prepare failures so expected invalid-unicode failures do not poison later dbDelta statements. Prune stale wpdb expected-result entries confirmed by CI and add phase-level timeouts to the DuckDB workflow so slow setup/test phases fail with clearer, faster conclusions. --- .github/workflows/duckdb-phpunit-tests.yml | 21 +- .github/workflows/wp-tests-phpunit-run.js | 6 +- .../src/duckdb/class-wp-duckdb-driver.php | 22 +- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 88 +++++++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 217 ++++++++++++++++++ .../wp-includes/duckdb/class-wp-duckdb-db.php | 80 +++++++ 6 files changed, 426 insertions(+), 8 deletions(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index 5f08ea970..dad9f5074 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -74,6 +74,7 @@ jobs: composer-options: "--optimize-autoloader" - name: Install DuckDB PHP client + timeout-minutes: 8 run: | composer config --no-plugins allow-plugins.satur.io/duckdb-auto true composer require --dev --no-plugins --no-interaction --with-all-dependencies satur.io/duckdb-auto @@ -82,10 +83,12 @@ jobs: working-directory: packages/mysql-on-sqlite - name: Verify DuckDB runtime + timeout-minutes: 2 run: php -d ffi.enable=true -r "require 'vendor/autoload.php'; require 'src/load.php'; WP_DuckDB_Runtime::assert_available( true );" working-directory: packages/mysql-on-sqlite - name: Run DuckDB PHPUnit suite + timeout-minutes: 10 env: WP_DUCKDB_TESTS: '1' WP_DUCKDB_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php @@ -96,7 +99,7 @@ jobs: wp-duckdb-test: name: WordPress PHPUnit wpdb / DuckDB runs-on: ubuntu-latest - timeout-minutes: 40 + timeout-minutes: 30 permissions: contents: read # Required to clone the repo. @@ -128,6 +131,7 @@ jobs: composer-options: "--optimize-autoloader" - name: Install DuckDB PHP client + timeout-minutes: 8 run: | composer config --no-plugins allow-plugins.satur.io/duckdb-auto true composer require --dev --no-plugins --no-interaction --with-all-dependencies satur.io/duckdb-auto @@ -136,10 +140,12 @@ jobs: working-directory: packages/mysql-on-sqlite - name: Verify DuckDB runtime + timeout-minutes: 2 run: php -d ffi.enable=true -r "require 'vendor/autoload.php'; require 'src/load.php'; WP_DuckDB_Runtime::assert_available( true );" working-directory: packages/mysql-on-sqlite - name: Run WordPress wpdb PHPUnit tests with DuckDB + timeout-minutes: 15 env: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY: '1' @@ -150,6 +156,7 @@ jobs: - name: Dump WordPress DuckDB failure diagnostics if: failure() || cancelled() + timeout-minutes: 5 shell: bash run: | set +e @@ -230,12 +237,13 @@ jobs: - name: Stop Docker containers if: always() + timeout-minutes: 5 run: composer run wp-test-clean wp-duckdb-e2e-test: name: WordPress E2E / DuckDB runs-on: ubuntu-latest - timeout-minutes: 40 + timeout-minutes: 30 permissions: contents: read # Required to clone the repo. @@ -267,6 +275,7 @@ jobs: composer-options: "--optimize-autoloader" - name: Install DuckDB PHP client + timeout-minutes: 8 run: | composer config --no-plugins allow-plugins.satur.io/duckdb-auto true composer require --dev --no-plugins --no-interaction --with-all-dependencies satur.io/duckdb-auto @@ -275,18 +284,22 @@ jobs: working-directory: packages/mysql-on-sqlite - name: Verify DuckDB runtime + timeout-minutes: 2 run: php -d ffi.enable=true -r "require 'vendor/autoload.php'; require 'src/load.php'; WP_DuckDB_Runtime::assert_available( true );" working-directory: packages/mysql-on-sqlite - name: Install Playwright browsers + timeout-minutes: 10 run: npx playwright install --with-deps - name: Prepare WordPress DuckDB end-to-end environment + timeout-minutes: 15 env: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php run: composer run wp-test-ensure-env-duckdb - name: Serialize WordPress PHP-FPM for DuckDB end-to-end tests + timeout-minutes: 5 run: | set -euo pipefail cd wordpress @@ -316,6 +329,7 @@ jobs: docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.duckdb-e2e.yml exec -T php php -r 'exit( extension_loaded( "ffi" ) ? 0 : 1 );' - name: Install WordPress 500 response diagnostics + timeout-minutes: 5 run: | cat > wordpress/src/wp-content/duckdb-e2e-500-diagnostics.php <<'PHP' get_value() ); - if ( ! in_array( $name, array( 'DATE', 'DATEDIFF', 'DATE_ADD', 'DATE_SUB' ), true ) ) { + if ( ! in_array( $name, array( 'DATE', 'DATEDIFF', 'DATE_ADD', 'DATE_SUB', 'MONTH', 'YEAR' ), true ) ) { return null; } @@ -16702,6 +16702,26 @@ private function translate_date_time_function_call( return 'CAST(CAST((' . $start_sql . ') AS DATE) - CAST((' . $end_sql . ') AS DATE) AS BIGINT)'; } + if ( 'MONTH' === $name || 'YEAR' === $name ) { + if ( 1 !== count( $items ) || count( $items[0] ) === 0 ) { + return null; + } + + $index = $end_index - 1; + return strtolower( $name ) . '(TRY_CAST((' + . $this->translate_tokens_to_duckdb_sql( + $items[0], + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ) + . ') AS TIMESTAMP))'; + } + if ( 2 !== count( $items ) || count( $items[0] ) === 0 || count( $items[1] ) < 3 ) { return null; } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index f512fba25..9bc4bd18e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1026,6 +1026,94 @@ public function test_date_format_function_is_emulated(): void { $this->assertSame( '2026-06-26', $row['formatted_date'] ); } + public function test_year_and_month_functions_translate_wordpress_datetime_strings_with_try_cast(): void { + $connection = new class() extends WP_DuckDB_Connection { + public function __construct() {} + + public function query( string $sql, array $params = array() ): WP_DuckDB_Result_Statement { + if ( 0 === strpos( $sql, 'CREATE OR REPLACE MACRO ' ) ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } + }; + $driver = new WP_DuckDB_Driver( array( 'connection' => $connection ) ); + $tokenize = new ReflectionMethod( WP_DuckDB_Driver::class, 'tokenize_and_validate' ); + $translate = new ReflectionMethod( WP_DuckDB_Driver::class, 'translate_tokens_to_duckdb_sql' ); + if ( PHP_VERSION_ID < 80100 ) { + $tokenize->setAccessible( true ); + $translate->setAccessible( true ); + } + + $tokens = $tokenize->invoke( + $driver, + "SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month + FROM wp_posts + WHERE post_type = 'attachment' + ORDER BY post_date DESC" + ); + $sql = $translate->invoke( $driver, $tokens ); + + $this->assertStringContainsString( 'year(TRY_CAST((post_date) AS TIMESTAMP)) AS year', $sql ); + $this->assertStringContainsString( 'month(TRY_CAST((post_date) AS TIMESTAMP)) AS month', $sql ); + } + + public function test_year_and_month_functions_try_cast_wordpress_datetime_strings(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wp_posts ( + ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + post_date DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + PRIMARY KEY (ID), + KEY type_date (post_type, post_date) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wp_posts (post_date, post_type) VALUES + ('2026-06-26 14:15:16', 'attachment'), + ('2026-05-01 00:00:00', 'attachment'), + ('2026-04-01 00:00:00', 'post')" + ); + + $rows = $driver->query( + "SELECT DISTINCT YEAR(post_date) AS year, MONTH(post_date) AS month + FROM wp_posts + WHERE post_type = 'attachment' + ORDER BY post_date DESC" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + array( + 'year' => 2026, + 'month' => 6, + ), + array( + 'year' => 2026, + 'month' => 5, + ), + ), + $rows + ); + + $row = $driver->query( + "SELECT + YEAR('0000-00-00 00:00:00') AS zero_date_year, + MONTH('0000-00-00 00:00:00') AS zero_date_month, + YEAR(DATE '2026-07-01') AS date_year, + MONTH(TIMESTAMP '2026-08-02 03:04:05') AS timestamp_month" + )->fetch( PDO::FETCH_ASSOC ); + + $this->assertNull( $row['zero_date_year'] ); + $this->assertNull( $row['zero_date_month'] ); + $this->assertSame( 2026, $row['date_year'] ); + $this->assertSame( 8, $row['timestamp_month'] ); + } + public function test_create_table_insert_update_delete_show_and_describe(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index fec0e19fb..d12cee584 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -479,6 +479,55 @@ public function test_duckdb_wpdb_failure_diagnostics_clear_metadata_and_preserve $this->assertSame( 0, $cases['create_after_update_failure']['rows_affected'] ); } + public function test_duckdb_wpdb_rolls_back_only_native_active_transaction_failures(): void { + $result = $this->run_transaction_cleanup_state_script(); + $cases = $result['cases']; + + foreach ( $cases as $case_name => $case ) { + $this->assertTrue( $case['connected'], $case_name ); + $this->assertFalse( $case['return'], $case_name ); + } + + $this->assertSame( 'DuckDB query failed: invalid input.', $cases['native_query_marker']['last_error'] ); + $this->assertSame( + array( 'BEGIN TRANSACTION', 'ROLLBACK' ), + $cases['native_query_marker']['connection_queries'] + ); + $this->assertFalse( $cases['native_query_marker']['in_transaction_after'] ); + + $this->assertSame( + 'Failed to execute DuckDB write: Failed to prepare DuckDB query: syntax error.', + $cases['native_prepare_marker']['last_error'] + ); + $this->assertSame( + array( 'BEGIN TRANSACTION', 'ROLLBACK' ), + $cases['native_prepare_marker']['connection_queries'] + ); + $this->assertFalse( $cases['native_prepare_marker']['in_transaction_after'] ); + + $this->assertSame( + 'Unsupported INSERT statement in DuckDB driver. INSERT IGNORE is not supported.', + $cases['unsupported_error']['last_error'] + ); + $this->assertSame( array( 'BEGIN TRANSACTION' ), $cases['unsupported_error']['connection_queries'] ); + $this->assertTrue( $cases['unsupported_error']['in_transaction_after'] ); + + $this->assertSame( + 'DuckDB driver could not parse MySQL statement: syntax error.', + $cases['preflight_error']['last_error'] + ); + $this->assertSame( array( 'BEGIN TRANSACTION' ), $cases['preflight_error']['connection_queries'] ); + $this->assertTrue( $cases['preflight_error']['in_transaction_after'] ); + + $this->assertSame( 'Plain runtime failure.', $cases['plain_exception']['last_error'] ); + $this->assertSame( array( 'BEGIN TRANSACTION' ), $cases['plain_exception']['connection_queries'] ); + $this->assertTrue( $cases['plain_exception']['in_transaction_after'] ); + + $this->assertSame( 'DuckDB query failed: inactive transaction.', $cases['inactive_native_query_marker']['last_error'] ); + $this->assertSame( array(), $cases['inactive_native_query_marker']['connection_queries'] ); + $this->assertFalse( $cases['inactive_native_query_marker']['in_transaction_after'] ); + } + public function test_duckdb_wpdb_db_connect_sets_filtered_sql_mode(): void { $result = $this->run_sql_mode_boot_state_script( false ); @@ -696,6 +745,174 @@ public function query( string $sql ): WP_DuckDB_Result_Statement { return $this->run_isolated_php( $code ); } + private function run_transaction_cleanup_state_script(): array { + $plugin_dir = $this->get_plugin_dir(); + $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; + $code = $this->get_wordpress_stub_code(); + $code .= "\nrequire_once " . var_export( $driver_load, true ) . ";\n"; + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= <<<'PHP' + +class WP_DuckDB_Plugin_Transaction_Cleanup_Test_Result { + private $columns; + private $rows; + + public function __construct( array $columns = array(), array $rows = array() ) { + $this->columns = $columns; + $this->rows = $rows; + } + + public function columnNames() { + return new ArrayIterator( $this->columns ); + } + + public function rows( $assoc = false ) { + return new ArrayIterator( $this->rows ); + } +} + +class WP_DuckDB_Plugin_Transaction_Cleanup_Test_Client { + public $queries = array(); + + public function query( $sql ) { + $this->queries[] = $sql; + return new WP_DuckDB_Plugin_Transaction_Cleanup_Test_Result( + array( 'Success' ), + array( + array( 'Success' => true ), + ) + ); + } +} + +class WP_DuckDB_Plugin_Transaction_Cleanup_Test_Driver extends WP_DuckDB_Driver { + public $queries = array(); + private $connection; + private $failures; + + public function __construct( WP_DuckDB_Connection $connection, array $failures ) { + $this->connection = $connection; + $this->failures = $failures; + } + + public function query( string $sql ): WP_DuckDB_Result_Statement { + $this->queries[] = $sql; + + if ( 'SELECT @@SESSION.sql_mode' === $sql ) { + return new WP_DuckDB_Result_Statement( + array( '@@SESSION.sql_mode' ), + array( + array( 'NO_ENGINE_SUBSTITUTION' ), + ), + 0 + ); + } + + if ( "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'" === $sql ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( isset( $this->failures[ $sql ] ) ) { + throw $this->failures[ $sql ]; + } + + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + public function get_connection(): WP_DuckDB_Connection { + return $this->connection; + } +} + +function wp_duckdb_plugin_transaction_cleanup_failure( $case_name ) { + switch ( $case_name ) { + case 'native_query_marker': + return new WP_DuckDB_Driver_Exception( + 'DuckDB query failed: invalid input.', + 0, + new RuntimeException( 'Native query failure.' ) + ); + + case 'native_prepare_marker': + return new WP_DuckDB_Driver_Exception( + 'Failed to execute DuckDB write: Failed to prepare DuckDB query: syntax error.', + 0, + new RuntimeException( 'Native prepare failure.' ) + ); + + case 'unsupported_error': + return new WP_DuckDB_Driver_Exception( + 'Unsupported INSERT statement in DuckDB driver. INSERT IGNORE is not supported.' + ); + + case 'preflight_error': + return new WP_DuckDB_Driver_Exception( 'DuckDB driver could not parse MySQL statement: syntax error.' ); + + case 'plain_exception': + return new RuntimeException( 'Plain runtime failure.' ); + + case 'inactive_native_query_marker': + return new WP_DuckDB_Driver_Exception( + 'DuckDB query failed: inactive transaction.', + 0, + new RuntimeException( 'Inactive native query failure.' ) + ); + } + + throw new RuntimeException( 'Unknown cleanup test case: ' . $case_name ); +} + +function wp_duckdb_plugin_transaction_cleanup_case( $case_name, $active_transaction ) { + $client = new WP_DuckDB_Plugin_Transaction_Cleanup_Test_Client(); + $connection = new WP_DuckDB_Connection( array( 'duckdb' => $client ) ); + $sql = 'SELECT ' . $case_name; + $driver = new WP_DuckDB_Plugin_Transaction_Cleanup_Test_Driver( + $connection, + array( + $sql => wp_duckdb_plugin_transaction_cleanup_failure( $case_name ), + ) + ); + + $GLOBALS['@duckdb_driver'] = $driver; + $db = new WP_DuckDB_DB( 'wordpress_test' ); + $connected = $db->db_connect( false ); + $db->suppress_errors( true ); + + if ( $active_transaction ) { + $connection->beginTransaction(); + } + + $query_return = $db->query( $sql ); + + return array( + 'connected' => $connected, + 'return' => $query_return, + 'last_error' => $db->last_error, + 'connection_queries' => $client->queries, + 'driver_queries' => $driver->queries, + 'in_transaction_after' => $connection->inTransaction(), + ); +} + +$cases = array( + 'native_query_marker' => wp_duckdb_plugin_transaction_cleanup_case( 'native_query_marker', true ), + 'native_prepare_marker' => wp_duckdb_plugin_transaction_cleanup_case( 'native_prepare_marker', true ), + 'unsupported_error' => wp_duckdb_plugin_transaction_cleanup_case( 'unsupported_error', true ), + 'preflight_error' => wp_duckdb_plugin_transaction_cleanup_case( 'preflight_error', true ), + 'plain_exception' => wp_duckdb_plugin_transaction_cleanup_case( 'plain_exception', true ), + 'inactive_native_query_marker' => wp_duckdb_plugin_transaction_cleanup_case( 'inactive_native_query_marker', false ), +); + +echo json_encode( + array( + 'cases' => $cases, + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + private function run_raw_query_state_script(): array { $plugin_dir = $this->get_plugin_dir(); $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index 3c10f0019..20ce79952 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -601,6 +601,7 @@ private function _do_query( $query ) { } } catch ( Throwable $e ) { $this->last_error = $e->getMessage(); + $this->rollback_failed_active_duckdb_transaction( $e ); } ++$this->num_queries; @@ -616,6 +617,85 @@ private function _do_query( $query ) { } } + /** + * Roll back active DuckDB transactions after native engine failures. + * + * Unsupported or preflight driver errors should leave caller-managed + * transactions open. Native DuckDB query/prepare errors can leave the + * transaction aborted, so clean up only that narrow failure shape. + * + * @param Throwable $error Query failure. + */ + private function rollback_failed_active_duckdb_transaction( Throwable $error ) { + if ( ! $this->should_rollback_active_duckdb_transaction_on_error( $error ) ) { + return; + } + + $connection = $this->get_duckdb_connection(); + if ( ! $connection || ! $connection->inTransaction() ) { + return; + } + + try { + $connection->rollback(); + } catch ( Throwable $rollback_error ) { + if ( '' === $this->last_error ) { + $this->last_error = $rollback_error->getMessage(); + } + } + } + + /** + * Get the underlying DuckDB connection, when one is still available. + * + * @return WP_DuckDB_Connection|null Connection, or null when unavailable. + */ + private function get_duckdb_connection() { + if ( $this->dbh instanceof WP_DuckDB_Connection ) { + return $this->dbh; + } + + if ( ! $this->dbh instanceof WP_DuckDB_Driver || ! method_exists( $this->dbh, 'get_connection' ) ) { + return null; + } + + try { + $connection = $this->dbh->get_connection(); + } catch ( Throwable $e ) { + return null; + } + if ( ! $connection instanceof WP_DuckDB_Connection ) { + return null; + } + + return $connection; + } + + /** + * Check whether a query error means the active DuckDB transaction is unsafe. + * + * @param Throwable $error Query failure. + * @return bool Whether to roll back the active transaction. + */ + private function should_rollback_active_duckdb_transaction_on_error( Throwable $error ) { + for ( $current = $error; null !== $current; $current = $current->getPrevious() ) { + $message = $current->getMessage(); + if ( + $current instanceof WP_DuckDB_Driver_Exception + && ( + 0 === strpos( $message, 'DuckDB query failed:' ) + || 0 === strpos( $message, 'Failed to prepare DuckDB query:' ) + || false !== strpos( $message, ': DuckDB query failed:' ) + || false !== strpos( $message, ': Failed to prepare DuckDB query:' ) + ) + ) { + return true; + } + } + + return false; + } + /** * Normalize fetched DuckDB rows to MySQL/PDO-style scalar values. * From 18953c8bc5724c12b6ed320f62b7d7b43c2276d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 16:28:29 +0000 Subject: [PATCH 139/317] Avoid speculative DuckDB currval probes Skip the pre-write currval() probe for omitted AUTO_INCREMENT writes so a first generated insert inside a caller-owned transaction cannot abort the transaction before the physical INSERT. Trust post-write currval() only when it advances beyond the pre-write column MAX, otherwise keep the existing MAX fallback for insert_id recovery. Add focused driver coverage for omitted AUTO_INCREMENT inserts inside active transactions while preserving existing stale currval and physical nextval fallback behavior. --- .../src/duckdb/class-wp-duckdb-driver.php | 19 ++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 141 +++++++++++++++++- 2 files changed, 146 insertions(+), 14 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 1c3d00a5c..a2f3d7e1a 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -17694,7 +17694,10 @@ private function execute_auto_increment_write( string $table_name, string $sql, $explicit_insert_id = null === $metadata || null === $table_index ? null : $this->explicit_auto_increment_value_for_write( $tokens, $table_index, $metadata['column_name'] ); - $before = null === $sequence_name ? null : $this->sequence_currval( $sequence_name ); + $column_was_omitted = null !== $metadata && null !== $table_index + ? $this->auto_increment_column_omitted_from_write( $tokens, $table_index, $metadata['column_name'] ) + : false; + $before = null === $sequence_name || $column_was_omitted ? null : $this->sequence_currval( $sequence_name ); $insert_ignore_write = null !== $metadata && null !== $table_reference @@ -17716,10 +17719,7 @@ private function execute_auto_increment_write( string $table_name, string $sql, } } } - $column_was_omitted = null !== $metadata && null !== $table_index - ? $this->auto_increment_column_omitted_from_write( $tokens, $table_index, $metadata['column_name'] ) - : false; - $before_max = null; + $before_max = null; if ( $column_was_omitted && null !== $table_reference ) { $before_max = $this->max_auto_increment_column_value( $table_reference['table_name'], $metadata['column_name'], $table_reference['temporary'] ); } @@ -17734,8 +17734,13 @@ private function execute_auto_increment_write( string $table_name, string $sql, $result = $this->execute_duckdb_query( $sql, $context ); if ( null !== $sequence_name && ( $result->rowCount() > 0 || ! $insert_ignore_write ) ) { - $after = $this->sequence_currval( $sequence_name ); - if ( null !== $after && $after !== $before ) { + $after = $this->sequence_currval( $sequence_name ); + $sequence_advanced = null !== $after + && ( + ( null !== $before && $after !== $before ) + || ( $column_was_omitted && null !== $before_max && $after > $before_max ) + ); + if ( $sequence_advanced ) { $this->last_insert_id = $after; if ( 0 === $result->rowCount() ) { $result = new WP_DuckDB_Result_Statement( array(), array(), 1 ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 9bc4bd18e..5ae480c1d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -187,6 +187,139 @@ public function query( string $sql, array $params = array() ): WP_DuckDB_Result_ ); } + public function test_omitted_auto_increment_insert_in_active_transaction_skips_uninitialized_currval_probe(): void { + $connection = new class() extends WP_DuckDB_Connection { + public $queries = array(); + public $currval_reads = 0; + public $max_reads = 0; + + private $aborted = false; + private $inserted = false; + private $in_transaction = false; + + public function __construct() {} + + public function query( string $sql, array $params = array() ): WP_DuckDB_Result_Statement { + $this->queries[] = $sql; + + if ( 0 === strpos( $sql, 'CREATE OR REPLACE MACRO ' ) ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( 0 === strpos( $sql, 'CREATE TABLE IF NOT EXISTS "__wp_duckdb_column_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( 0 === strpos( $sql, 'CREATE TABLE IF NOT EXISTS "__wp_duckdb_table_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + if ( false !== strpos( $sql, "table_type = 'LOCAL TEMPORARY'" ) ) { + return new WP_DuckDB_Result_Statement( array( 'table_name' ), array() ); + } + + if ( 0 === strpos( $sql, 'SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()' ) ) { + return new WP_DuckDB_Result_Statement( array( 'table_name' ), array( array( 'wp_usermeta' ) ) ); + } + + if ( 0 === strpos( $sql, 'SELECT table_name, engine, row_format, table_collation, table_comment, create_options, create_time FROM "__wp_duckdb_table_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( + array( 'table_name', 'engine', 'row_format', 'table_collation', 'table_comment', 'create_options', 'create_time' ), + array( + array( 'wp_usermeta', 'InnoDB', 'Dynamic', 'utf8mb4_bin', '', '', '2026-06-27 00:00:00' ), + ) + ); + } + + if ( 0 === strpos( $sql, 'SELECT ordinal_position, column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment FROM "__wp_duckdb_column_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( + array( 'ordinal_position', 'column_name', 'column_type', 'is_nullable', 'column_key', 'column_default', 'extra', 'collation_name', 'comment' ), + array( + array( 1, 'umeta_id', 'bigint(20) unsigned', 'NO', 'PRI', null, 'auto_increment', null, '' ), + array( 2, 'user_id', 'bigint(20) unsigned', 'NO', '', '0', '', null, '' ), + array( 3, 'meta_key', 'varchar(255)', 'YES', 'MUL', null, '', null, '' ), + array( 4, 'meta_value', 'longtext', 'YES', '', null, '', null, '' ), + ) + ); + } + + if ( 0 === strpos( $sql, 'SELECT column_name FROM "__wp_duckdb_column_metadata"' ) ) { + return new WP_DuckDB_Result_Statement( array( 'column_name' ), array( array( 'umeta_id' ) ) ); + } + + if ( 'SELECT MAX("umeta_id") AS max_value FROM "wp_usermeta"' === $sql ) { + ++$this->max_reads; + return new WP_DuckDB_Result_Statement( + array( 'max_value' ), + array( array( 1 === $this->max_reads ? 17 : 18 ) ) + ); + } + + if ( 0 === strpos( $sql, 'SELECT currval(' ) ) { + ++$this->currval_reads; + if ( ! $this->inserted ) { + $this->aborted = true; + throw new WP_DuckDB_Driver_Exception( 'currval unavailable' ); + } + + return new WP_DuckDB_Result_Statement( array( 'currval' ), array( array( 18 ) ) ); + } + + if ( + 0 === strpos( $sql, 'INSERT INTO "wp_usermeta"' ) + && false !== strpos( $sql, '"user_id"' ) + && false !== strpos( $sql, '"meta_key"' ) + && false !== strpos( $sql, '"meta_value"' ) + && false === strpos( $sql, '"umeta_id"' ) + ) { + if ( $this->aborted ) { + throw new WP_DuckDB_Driver_Exception( 'DuckDB query failed: TransactionContext Error: Current transaction is aborted (please ROLLBACK)' ); + } + + $this->inserted = true; + return new WP_DuckDB_Result_Statement( array(), array(), 1 ); + } + + throw new RuntimeException( 'Unexpected query: ' . $sql ); + } + + public function beginTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $this->in_transaction = true; + return true; + } + + public function inTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->in_transaction; + } + }; + $driver = new WP_DuckDB_Driver( array( 'connection' => $connection ) ); + + $driver->query( 'BEGIN' ); + $insert = $driver->query( + "INSERT INTO `wp_usermeta` (`user_id`, `meta_key`, `meta_value`) VALUES (1, 'wp_persisted_preferences', 'a:0:{}')" + ); + + $currval_index = null; + $insert_index = null; + foreach ( $connection->queries as $index => $sql ) { + if ( 0 === strpos( $sql, 'SELECT currval(' ) ) { + $currval_index = $index; + } + if ( 0 === strpos( $sql, 'INSERT INTO "wp_usermeta"' ) ) { + $insert_index = $index; + } + } + + $this->assertSame( 1, $insert->rowCount() ); + $this->assertSame( 18, $driver->get_insert_id() ); + $this->assertSame( 1, $connection->max_reads ); + $this->assertSame( 1, $connection->currval_reads ); + $this->assertIsInt( $currval_index ); + $this->assertIsInt( $insert_index ); + $this->assertGreaterThan( $insert_index, $currval_index ); + $this->assertTrue( $connection->inTransaction() ); + } + public function test_auto_increment_insert_id_falls_back_to_max_when_currval_is_stale(): void { $connection = new class() extends WP_DuckDB_Connection { public $queries = array(); @@ -373,8 +506,7 @@ public function test_auto_increment_insert_id_recovers_from_physical_nextval_def $connection = new class() extends WP_DuckDB_Connection { public $queries = array(); - private $currval_reads = 0; - private $max_reads = 0; + private $max_reads = 0; public function __construct() {} @@ -410,11 +542,6 @@ public function query( string $sql, array $params = array() ): WP_DuckDB_Result_ } if ( 0 === strpos( $sql, 'SELECT currval(' ) ) { - ++$this->currval_reads; - if ( 1 === $this->currval_reads ) { - throw new WP_DuckDB_Driver_Exception( 'currval unavailable' ); - } - return new WP_DuckDB_Result_Statement( array( 'currval' ), array( array( 18 ) ) ); } From aac87d4d67a1ebbd3658773998bc34b78bcf18a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 16:28:35 +0000 Subject: [PATCH 140/317] Add faster DuckDB WordPress CI gates Add a bounded WordPress DuckDB smoke script that verifies the wpdb drop-in, FFI runtime, and a simple create/insert/select round trip before expensive WordPress suites. Split DuckDB WordPress setup into grouped timeout-bounded phases and add CI caps for setup, FFI image builds, environment start, and runtime probes. Move E2E Playwright installation after the DuckDB smoke gate and re-enable Composer install caching so slow failures produce useful signals sooner. --- .github/workflows/duckdb-phpunit-tests.yml | 35 ++++-- bin/wp-test-duckdb-smoke.sh | 124 ++++++++++++++++++++ bin/wp-test-ensure-env-duckdb.sh | 128 ++++++++++++++++++--- 3 files changed, 264 insertions(+), 23 deletions(-) create mode 100755 bin/wp-test-duckdb-smoke.sh diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index dad9f5074..17e907d12 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -7,6 +7,7 @@ on: paths: - '.github/workflows/duckdb-phpunit-tests.yml' - '.github/workflows/wp-tests-phpunit-run.js' + - 'bin/wp-test-duckdb-smoke.sh' - 'bin/wp-test-ensure-env-duckdb.sh' - 'composer.json' - 'wp-setup.sh' @@ -23,6 +24,7 @@ on: paths: - '.github/workflows/duckdb-phpunit-tests.yml' - '.github/workflows/wp-tests-phpunit-run.js' + - 'bin/wp-test-duckdb-smoke.sh' - 'bin/wp-test-ensure-env-duckdb.sh' - 'composer.json' - 'wp-setup.sh' @@ -70,7 +72,6 @@ jobs: uses: ramsey/composer-install@v3 with: working-directory: packages/mysql-on-sqlite - ignore-cache: "yes" composer-options: "--optimize-autoloader" - name: Install DuckDB PHP client @@ -127,7 +128,6 @@ jobs: uses: ramsey/composer-install@v3 with: working-directory: packages/mysql-on-sqlite - ignore-cache: "yes" composer-options: "--optimize-autoloader" - name: Install DuckDB PHP client @@ -144,6 +144,20 @@ jobs: run: php -d ffi.enable=true -r "require 'vendor/autoload.php'; require 'src/load.php'; WP_DuckDB_Runtime::assert_available( true );" working-directory: packages/mysql-on-sqlite + - name: Prepare WordPress DuckDB PHPUnit environment + timeout-minutes: 15 + env: + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_DUCKDB_SETUP_TIMEOUT_SECONDS: '300' + WP_DUCKDB_FFI_IMAGE_BUILD_TIMEOUT_SECONDS: '300' + WP_DUCKDB_TEST_START_TIMEOUT_SECONDS: '480' + WP_DUCKDB_FFI_RUNTIME_PROBE_TIMEOUT_SECONDS: '120' + run: composer run wp-test-ensure-env-duckdb + + - name: Smoke test WordPress DuckDB wpdb + timeout-minutes: 5 + run: ./bin/wp-test-duckdb-smoke.sh + - name: Run WordPress wpdb PHPUnit tests with DuckDB timeout-minutes: 15 env: @@ -271,7 +285,6 @@ jobs: uses: ramsey/composer-install@v3 with: working-directory: packages/mysql-on-sqlite - ignore-cache: "yes" composer-options: "--optimize-autoloader" - name: Install DuckDB PHP client @@ -288,16 +301,24 @@ jobs: run: php -d ffi.enable=true -r "require 'vendor/autoload.php'; require 'src/load.php'; WP_DuckDB_Runtime::assert_available( true );" working-directory: packages/mysql-on-sqlite - - name: Install Playwright browsers - timeout-minutes: 10 - run: npx playwright install --with-deps - - name: Prepare WordPress DuckDB end-to-end environment timeout-minutes: 15 env: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_DUCKDB_SETUP_TIMEOUT_SECONDS: '300' + WP_DUCKDB_FFI_IMAGE_BUILD_TIMEOUT_SECONDS: '300' + WP_DUCKDB_TEST_START_TIMEOUT_SECONDS: '480' + WP_DUCKDB_FFI_RUNTIME_PROBE_TIMEOUT_SECONDS: '120' run: composer run wp-test-ensure-env-duckdb + - name: Smoke test WordPress DuckDB wpdb + timeout-minutes: 5 + run: ./bin/wp-test-duckdb-smoke.sh + + - name: Install Playwright browsers + timeout-minutes: 10 + run: npx playwright install --with-deps + - name: Serialize WordPress PHP-FPM for DuckDB end-to-end tests timeout-minutes: 5 run: | diff --git a/bin/wp-test-duckdb-smoke.sh b/bin/wp-test-duckdb-smoke.sh new file mode 100755 index 000000000..390cd9a81 --- /dev/null +++ b/bin/wp-test-duckdb-smoke.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +## +# Run a fast Docker-backed smoke test against the WordPress DuckDB drop-in. +## + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd -P)" +WP_DIR="$ROOT_DIR/wordpress" +SMOKE_TIMEOUT="${WP_DUCKDB_SMOKE_TIMEOUT:-240}" +COMPOSE_ARGS=( -f docker-compose.yml -f docker-compose.override.yml ) + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +run_with_timeout() { + if command -v timeout > /dev/null 2>&1; then + timeout "$SMOKE_TIMEOUT" "$@" + return + fi + + "$@" +} + +if [ ! -d "$WP_DIR" ]; then + fail 'WordPress test directory is missing. Run composer run wp-test-ensure-env-duckdb first.' +fi + +if [ ! -f "$WP_DIR/docker-compose.override.yml" ]; then + fail 'WordPress Docker override is missing. Run composer run wp-test-ensure-env-duckdb first.' +fi + +if [ ! -f "$WP_DIR/src/wp-load.php" ]; then + fail 'WordPress checkout is incomplete. Run composer run wp-test-ensure-env-duckdb first.' +fi + +if [ ! -f "$WP_DIR/src/wp-content/db.php" ]; then + fail 'WordPress db.php drop-in is missing after DuckDB setup.' +fi + +if ! grep -q "define( 'DB_ENGINE', 'duckdb' );" "$WP_DIR/src/wp-content/db.php"; then + fail 'WordPress db.php drop-in is not configured for DB_ENGINE=duckdb.' +fi + +echo 'Running WordPress DuckDB smoke gate...' +echo "Using timeout: ${SMOKE_TIMEOUT}s" + +( + cd "$WP_DIR" + + echo 'Verifying Docker compose services...' + docker compose "${COMPOSE_ARGS[@]}" config --services | grep -qx 'cli' \ + || fail 'WordPress Docker compose does not define the cli service.' + + echo 'Verifying PHP FFI in the WordPress CLI container...' + run_with_timeout docker compose "${COMPOSE_ARGS[@]}" run --rm cli php -r ' +if ( ! extension_loaded( "ffi" ) ) { + fwrite( STDERR, "Error: PHP FFI extension is not loaded in the WordPress CLI container.\n" ); + exit( 1 ); +} + +$ffi_enabled = strtolower( (string) ini_get( "ffi.enable" ) ); +if ( ! in_array( $ffi_enabled, array( "1", "on", "true" ), true ) ) { + fwrite( STDERR, "Error: PHP FFI is not enabled in the WordPress CLI container. ffi.enable={$ffi_enabled}\n" ); + exit( 1 ); +} +' + + echo 'Loading WordPress and executing a DuckDB query round trip through $wpdb...' + run_with_timeout docker compose "${COMPOSE_ARGS[@]}" run --rm cli wp --path=/var/www/src eval ' +global $wpdb; + +$diagnostics = array( + "db_engine" => defined( "DB_ENGINE" ) ? DB_ENGINE : null, + "wpdb_class" => is_object( $wpdb ) ? get_class( $wpdb ) : gettype( $wpdb ), + "last_error" => is_object( $wpdb ) && isset( $wpdb->last_error ) ? (string) $wpdb->last_error : null, +); + +if ( ! defined( "DB_ENGINE" ) || "duckdb" !== DB_ENGINE ) { + fwrite( STDERR, "Error: WordPress did not load with DB_ENGINE=duckdb.\n" ); + fwrite( STDERR, wp_json_encode( $diagnostics, JSON_UNESCAPED_SLASHES ) . "\n" ); + exit( 1 ); +} + +if ( ! class_exists( "WP_DuckDB_DB" ) || ! $wpdb instanceof WP_DuckDB_DB ) { + fwrite( STDERR, "Error: WordPress did not load the DuckDB wpdb drop-in.\n" ); + fwrite( STDERR, wp_json_encode( $diagnostics, JSON_UNESCAPED_SLASHES ) . "\n" ); + exit( 1 ); +} + +$table = $wpdb->prefix . "duckdb_smoke_gate"; +$quoted_table = "`" . str_replace( "`", "``", $table ) . "`"; + +$wpdb->query( "DROP TABLE IF EXISTS {$quoted_table}" ); +$create = $wpdb->query( "CREATE TABLE {$quoted_table} (id INTEGER, note VARCHAR(20))" ); +$insert = $wpdb->query( "INSERT INTO {$quoted_table} VALUES (1, '\''ok'\'')" ); +$rows = $wpdb->get_results( "SELECT id, note FROM {$quoted_table} ORDER BY id", ARRAY_A ); +$wpdb->query( "DROP TABLE IF EXISTS {$quoted_table}" ); + +$diagnostics["create"] = $create; +$diagnostics["insert"] = $insert; +$diagnostics["rows"] = $rows; +$diagnostics["last_error"] = $wpdb->last_error; + +$has_expected_row = is_array( $rows ) + && 1 === count( $rows ) + && isset( $rows[0]["id"], $rows[0]["note"] ) + && "1" === (string) $rows[0]["id"] + && "ok" === (string) $rows[0]["note"]; + +if ( true !== $create || 1 !== $insert || ! $has_expected_row || "" !== $wpdb->last_error ) { + fwrite( STDERR, "Error: DuckDB smoke query failed.\n" ); + fwrite( STDERR, wp_json_encode( $diagnostics, JSON_UNESCAPED_SLASHES ) . "\n" ); + exit( 1 ); +} + +echo wp_json_encode( $diagnostics, JSON_UNESCAPED_SLASHES ) . "\n"; +' +) + +echo 'WordPress DuckDB smoke gate passed.' diff --git a/bin/wp-test-ensure-env-duckdb.sh b/bin/wp-test-ensure-env-duckdb.sh index 3cb17f6d4..9e531ba0b 100755 --- a/bin/wp-test-ensure-env-duckdb.sh +++ b/bin/wp-test-ensure-env-duckdb.sh @@ -15,6 +15,90 @@ CLI_BASE_IMAGE="wordpressdevelop/cli@sha256:85ad7d7a9c3bd9a8775fc83aea7f7dfc0aad PHP_FFI_IMAGE="sqlite-duckdb-wordpress-php:8.3.10-ffi" CLI_FFI_IMAGE="sqlite-duckdb-wordpress-cli:8.3.10-ffi" +WP_DUCKDB_SETUP_TIMEOUT_SECONDS="${WP_DUCKDB_SETUP_TIMEOUT_SECONDS:-900}" +WP_DUCKDB_FFI_IMAGE_BUILD_TIMEOUT_SECONDS="${WP_DUCKDB_FFI_IMAGE_BUILD_TIMEOUT_SECONDS:-900}" +WP_DUCKDB_TEST_START_TIMEOUT_SECONDS="${WP_DUCKDB_TEST_START_TIMEOUT_SECONDS:-900}" +WP_DUCKDB_FFI_RUNTIME_PROBE_TIMEOUT_SECONDS="${WP_DUCKDB_FFI_RUNTIME_PROBE_TIMEOUT_SECONDS:-120}" + +start_group() { + local label="$1" + + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + printf '::group::%s\n' "$label" + else + printf '%s\n' "$label" + fi +} + +end_group() { + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + printf '::endgroup::\n' + fi +} + +run_with_timeout() { + local seconds="$1" + shift + + if command -v timeout > /dev/null; then + timeout "${seconds}s" "$@" + return $? + fi + + local command_pid timer_pid timeout_file status + timeout_file="${TMPDIR:-/tmp}/wp-duckdb-timeout-$$-$RANDOM" + + "$@" & + command_pid=$! + + ( + sleep "$seconds" + if kill -0 "$command_pid" 2> /dev/null; then + : > "$timeout_file" + kill -TERM "$command_pid" 2> /dev/null || true + sleep 10 + kill -KILL "$command_pid" 2> /dev/null || true + fi + ) & + timer_pid=$! + + wait "$command_pid" + status=$? + + kill "$timer_pid" 2> /dev/null || true + wait "$timer_pid" 2> /dev/null || true + + if [ -f "$timeout_file" ]; then + rm -f "$timeout_file" + return 124 + fi + + rm -f "$timeout_file" + return "$status" +} + +run_phase() { + local label="$1" + local seconds="$2" + local status + shift 2 + + start_group "$label" + printf 'Timeout: %s seconds\n' "$seconds" + + set +e + run_with_timeout "$seconds" "$@" + status=$? + set -e + + if [ "$status" -eq 124 ]; then + echo "Error: $label timed out after $seconds seconds." >&2 + fi + + end_group + return "$status" +} + needs_duckdb_setup() { if [ ! -f "$WP_DIR/src/wp-load.php" ]; then return 0 @@ -33,10 +117,10 @@ needs_duckdb_setup() { ensure_duckdb_setup() { if needs_duckdb_setup; then - ( - cd "$ROOT_DIR" - composer run wp-setup-duckdb - ) + run_phase \ + 'WordPress DuckDB setup' \ + "$WP_DUCKDB_SETUP_TIMEOUT_SECONDS" \ + bash -c 'cd "$1" && composer run wp-setup-duckdb' bash "$ROOT_DIR" fi } @@ -72,10 +156,16 @@ build_ffi_images() { write_ffi_dockerfile if ! docker image inspect "$PHP_FFI_IMAGE" > /dev/null 2>&1; then - docker build --build-arg "BASE_IMAGE=$PHP_BASE_IMAGE" --tag "$PHP_FFI_IMAGE" "$image_dir" + run_phase \ + 'Build WordPress PHP FFI image' \ + "$WP_DUCKDB_FFI_IMAGE_BUILD_TIMEOUT_SECONDS" \ + docker build --build-arg "BASE_IMAGE=$PHP_BASE_IMAGE" --tag "$PHP_FFI_IMAGE" "$image_dir" fi if ! docker image inspect "$CLI_FFI_IMAGE" > /dev/null 2>&1; then - docker build --build-arg "BASE_IMAGE=$CLI_BASE_IMAGE" --tag "$CLI_FFI_IMAGE" "$image_dir" + run_phase \ + 'Build WordPress CLI FFI image' \ + "$WP_DUCKDB_FFI_IMAGE_BUILD_TIMEOUT_SECONDS" \ + docker build --build-arg "BASE_IMAGE=$CLI_BASE_IMAGE" --tag "$CLI_FFI_IMAGE" "$image_dir" fi } @@ -101,28 +191,34 @@ start_wordpress_environment() { export COMPOSE_IGNORE_ORPHANS=true if [ -n "$(cd "$WP_DIR" && node tools/local-env/scripts/docker.js ps -q)" ]; then - ( - cd "$WP_DIR" - docker compose -f docker-compose.yml -f docker-compose.override.yml up -d --no-deps --force-recreate php - ) + run_phase \ + 'Restart WordPress PHP container with DuckDB FFI image' \ + "$WP_DUCKDB_TEST_START_TIMEOUT_SECONDS" \ + bash -c 'cd "$1" && docker compose -f docker-compose.yml -f docker-compose.override.yml up -d --no-deps --force-recreate php' bash "$WP_DIR" return fi - ( - cd "$ROOT_DIR" - composer run wp-test-start - ) + run_phase \ + 'Start WordPress test environment' \ + "$WP_DUCKDB_TEST_START_TIMEOUT_SECONDS" \ + bash -c 'cd "$1" && composer run wp-test-start' bash "$ROOT_DIR" } verify_service_ffi() { local service="$1" - if ! ( cd "$WP_DIR" && docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm "$service" php -r 'exit( extension_loaded( "ffi" ) ? 0 : 1 );' ); then + if ! run_phase \ + "Probe WordPress $service FFI extension" \ + "$WP_DUCKDB_FFI_RUNTIME_PROBE_TIMEOUT_SECONDS" \ + bash -c 'cd "$1" && docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm "$2" php -r '\''exit( extension_loaded( "ffi" ) ? 0 : 1 );'\''' bash "$WP_DIR" "$service"; then echo "Error: PHP FFI extension is not loaded in the WordPress $service container." >&2 exit 1 fi - if ! ( cd "$WP_DIR" && docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm "$service" php -r '$value = strtolower( (string) ini_get( "ffi.enable" ) ); exit( in_array( $value, array( "1", "on", "true" ), true ) ? 0 : 1 );' ); then + if ! run_phase \ + "Probe WordPress $service FFI enablement" \ + "$WP_DUCKDB_FFI_RUNTIME_PROBE_TIMEOUT_SECONDS" \ + bash -c 'cd "$1" && docker compose -f docker-compose.yml -f docker-compose.override.yml run --rm "$2" php -r '\''$value = strtolower( (string) ini_get( "ffi.enable" ) ); exit( in_array( $value, array( "1", "on", "true" ), true ) ? 0 : 1 );'\''' bash "$WP_DIR" "$service"; then echo "Error: PHP FFI is not enabled in the WordPress $service container." >&2 exit 1 fi From 4996df0f01b42311fe0cc5484113cbc8251c9a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 16:37:33 +0000 Subject: [PATCH 141/317] Cap PR workflow timeouts at thirty minutes Reduce the PR-triggered Rust extension PHPUnit and WASM spike job timeouts to 30 minutes so pull request CI paths do not exceed the fast-failure policy. Leave the publish-only WASM artifact timeout unchanged because it runs on trunk/manual dispatch rather than pull requests. --- .github/workflows/wasm-spike.yml | 2 +- .github/workflows/wp-tests-phpunit.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wasm-spike.yml b/.github/workflows/wasm-spike.yml index 1fee946c2..ed78fe7ce 100644 --- a/.github/workflows/wasm-spike.yml +++ b/.github/workflows/wasm-spike.yml @@ -62,7 +62,7 @@ jobs: name: Build wp_mysql_parser.so and load it in Playground (PHP ${{ matrix.php }}) needs: base-image runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 30 permissions: contents: read # Required to clone the repo. strategy: diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index 810b77b8a..a12dae1fb 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -40,7 +40,7 @@ jobs: native-parser-test: name: WordPress PHPUnit Tests / Rust extension runs-on: ubuntu-latest - timeout-minutes: 40 + timeout-minutes: 30 permissions: contents: read # Required to clone the repo. From eef6681597f8e4382fe7b6141d75173b41999b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 16:44:06 +0000 Subject: [PATCH 142/317] Keep DuckDB driver tests parseable on PHP 7.2 Move SHOW CREATE TABLE nowdoc terminators to column one so the test file remains compatible with PHP 7.2, which does not support indented heredoc/nowdoc closing markers. --- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 5ae480c1d..d1b14f8e4 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -9852,7 +9852,8 @@ public function test_show_create_table_reconstructs_mysql_shaped_ddl(): void { KEY `autoload` (`autoload`), KEY `option_value_prefix` (`option_value`(12)) ) ENGINE=MyISAM AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Options table' -SQL, +SQL + , $metadata_rows[0]['Create Table'] ); @@ -9869,7 +9870,8 @@ public function test_show_create_table_reconstructs_mysql_shaped_ddl(): void { PRIMARY KEY (`site_id`, `option_id`), UNIQUE KEY `unique_site_option` (`site_id`, `option_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci -SQL, +SQL + , $composite_rows[0]['Create Table'] ); @@ -9880,7 +9882,8 @@ public function test_show_create_table_reconstructs_mysql_shaped_ddl(): void { `id` int DEFAULT NULL, `name` text DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci -SQL, +SQL + , $plain_rows[0]['Create Table'] ); From 80ca38ce8d6b1a5bca17a565f2c0cc034fcaa941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 16:54:02 +0000 Subject: [PATCH 143/317] Fix DuckDB PHPUnit matrix compatibility Keep invalid fetchColumn index exceptions aligned with PHP runtime behavior instead of ValueError polyfill load order. Normalize DuckDB driver tokenization through lexer remaining_tokens() so native parser token streams are not passed to iterator_to_array(). --- .../src/duckdb/class-wp-duckdb-driver.php | 33 +++++++++++++------ .../class-wp-duckdb-result-statement.php | 4 +-- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index a2f3d7e1a..16c0a77e9 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -475,11 +475,8 @@ private function get_session_system_variable( string $name ) { * @throws WP_DuckDB_Driver_Exception When parsing fails or multiple statements are supplied. */ private function tokenize_and_validate( string $query ): array { - $lexer = new WP_MySQL_Lexer( $query, $this->mysql_version ); - $raw_tokens = class_exists( 'WP_MySQL_Native_Lexer', false ) && $lexer instanceof WP_MySQL_Native_Lexer - ? $lexer->native_token_stream() - : $lexer->remaining_tokens(); - $tokens = is_array( $raw_tokens ) ? array_values( $raw_tokens ) : array_values( iterator_to_array( $raw_tokens ) ); + $lexer = new WP_MySQL_Lexer( $query, $this->mysql_version ); + $tokens = $this->lexer_tokens_to_array( $lexer ); $this->assert_single_statement( $tokens ); $this->parse_tokens( $tokens ); @@ -499,15 +496,31 @@ private function tokenize_and_validate( string $query ): array { * @return WP_Parser_Token[] */ private function tokenize_fragment( string $fragment ): array { - $lexer = new WP_MySQL_Lexer( $fragment, $this->mysql_version ); - $raw_tokens = class_exists( 'WP_MySQL_Native_Lexer', false ) && $lexer instanceof WP_MySQL_Native_Lexer - ? $lexer->native_token_stream() - : $lexer->remaining_tokens(); - $tokens = is_array( $raw_tokens ) ? array_values( $raw_tokens ) : array_values( iterator_to_array( $raw_tokens ) ); + $lexer = new WP_MySQL_Lexer( $fragment, $this->mysql_version ); + $tokens = $this->lexer_tokens_to_array( $lexer ); return array_values( $this->without_eof( $tokens ) ); } + /** + * Get lexer tokens as a PHP array. + * + * @param WP_MySQL_Lexer $lexer MySQL lexer. + * @return WP_Parser_Token[] + */ + private function lexer_tokens_to_array( WP_MySQL_Lexer $lexer ): array { + $tokens = $lexer->remaining_tokens(); + if ( is_array( $tokens ) ) { + return array_values( $tokens ); + } + + if ( $tokens instanceof Traversable ) { + return array_values( iterator_to_array( $tokens ) ); + } + + throw new WP_DuckDB_Driver_Exception( 'DuckDB driver could not tokenize MySQL statement.' ); + } + /** * Ensure the token stream contains at most one trailing semicolon. * diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php index 0cef41dfd..61f61ec56 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php @@ -540,14 +540,14 @@ private function class_row( array $row, string $class, array $constructor_args, */ private function assert_valid_column_index( int $column ): void { if ( $column < 0 ) { - if ( class_exists( 'ValueError' ) ) { + if ( PHP_VERSION_ID >= 80000 ) { throw new ValueError( 'Column index must be greater than or equal to 0' ); } throw new PDOException( 'Invalid column index' ); } if ( $column >= count( $this->columns ) ) { - if ( class_exists( 'ValueError' ) ) { + if ( PHP_VERSION_ID >= 80000 ) { throw new ValueError( 'Invalid column index' ); } throw new PDOException( 'Invalid column index' ); From f2e6a00f9f937683cb3a7070f4628d97544cfe38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 17:17:20 +0000 Subject: [PATCH 144/317] Split WordPress PHPUnit expected failure policy --- .github/workflows/wp-tests-phpunit-run.js | 101 +++++++++++++++++++++- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index acfa177b5..b0898ad67 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -12,6 +12,7 @@ const path = require( 'path' ); const repoRoot = path.join( __dirname, '..', '..' ); const requiresNativeParserExtension = process.env.WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION === '1'; const phpunitCommand = process.env.WP_SQLITE_PHPUNIT_COMMAND || 'composer run wp-test-php -- --log-junit=phpunit-results.xml --verbose'; +const isDuckDBPhpunitRun = phpunitCommand.includes( 'wp-test-php-duckdb' ); const phpunitEnsureEnvironmentCommand = process.env.WP_SQLITE_PHPUNIT_ENSURE_ENV_COMMAND || getDefaultEnsureEnvironmentCommand(); const ensurePhpunitCompatibility = process.env.WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY === '1'; const phpunitCompatibilityConstraint = process.env.WP_SQLITE_PHPUNIT_COMPATIBILITY_CONSTRAINT || '^9.6'; @@ -26,13 +27,41 @@ const duckdbAutoloadCompatibilityWrapperPath = path.join( repoRoot, 'wordpress', const phpunitCompatibilityPrependContainerPath = '/var/www/phpunit-runner-compat-prepend.php'; const duckdbAutoloadCompatibilityWrapperContainerPath = '/var/www/phpunit-duckdb-autoload-wrapper.php'; -const expectedErrors = []; +const sqliteExpectedErrors = [ + 'Tests_DB_Charset::test_invalid_characters_in_query', + 'Tests_DB_Charset::test_set_charset_changes_the_connection_collation', +]; -const expectedFailures = [ +const sqliteExpectedFailures = [ 'Tests_Admin_wpSiteHealth::test_object_cache_thresholds with data set #2', 'Tests_Admin_wpSiteHealth::test_object_cache_thresholds with data set #3', 'Tests_Comment::test_wp_new_comment_respects_comment_field_lengths', 'Tests_Comment::test_wp_update_comment', + 'Tests_DB_Charset::test_get_column_charset with data set #0', + 'Tests_DB_Charset::test_get_column_charset with data set #1', + 'Tests_DB_Charset::test_get_column_charset with data set #2', + 'Tests_DB_Charset::test_get_column_charset with data set #3', + 'Tests_DB_Charset::test_get_column_charset with data set #4', + 'Tests_DB_Charset::test_get_column_charset with data set #5', + 'Tests_DB_Charset::test_get_column_charset with data set #6', + 'Tests_DB_Charset::test_get_column_charset with data set #7', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #0', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #1', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #2', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #3', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #4', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #5', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #6', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #7', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #0', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #1', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #2', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #3', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #4', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #5', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #6', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #7', + 'Tests_DB_Charset::test_process_field_charsets_on_nonexistent_table', 'Tests_DB_Charset::test_strip_invalid_text with data set #21', 'Tests_DB_Charset::test_strip_invalid_text with data set #22', 'Tests_DB_Charset::test_strip_invalid_text with data set #23', @@ -49,8 +78,23 @@ const expectedFailures = [ 'Tests_DB_Charset::test_strip_invalid_text with data set #39', 'Tests_DB_Charset::test_strip_invalid_text with data set #40', 'Tests_DB_Charset::test_strip_invalid_text with data set #41', + 'Tests_DB_Charset::test_strip_invalid_text_for_column_bails_if_ascii_input_too_long', + 'Tests_DB_dbDelta::test_spatial_indices', + 'Tests_DB::test_charset_switched_to_utf8mb4', + 'Tests_DB::test_close', + 'Tests_DB::test_delete_value_too_long_for_field with data set "too long"', + 'Tests_DB::test_has_cap', + 'Tests_DB::test_insert_value_too_long_for_field with data set "too long"', 'Tests_DB::test_mysqli_flush_sync', + 'Tests_DB::test_non_unicode_collations', + 'Tests_DB::test_pre_get_col_charset_filter', + 'Tests_DB::test_process_fields_on_nonexistent_table', + 'Tests_DB::test_process_fields_value_too_long_for_field with data set "too long"', + 'Tests_DB::test_query_value_contains_invalid_chars', + 'Tests_DB::test_replace_value_too_long_for_field with data set "too long"', 'Tests_DB::test_replace', + 'Tests_DB::test_supports_collation', + 'Tests_DB::test_update_value_too_long_for_field with data set "too long"', 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #1', 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #2', 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #3', @@ -62,11 +106,60 @@ const expectedFailures = [ 'WP_Test_REST_Posts_Controller::test_get_items_orderby_modified_query', ]; +const duckdbExpectedFailuresToPrune = new Set( [ + 'Tests_DB_Charset::test_get_column_charset with data set #0', + 'Tests_DB_Charset::test_get_column_charset with data set #1', + 'Tests_DB_Charset::test_get_column_charset with data set #2', + 'Tests_DB_Charset::test_get_column_charset with data set #3', + 'Tests_DB_Charset::test_get_column_charset with data set #4', + 'Tests_DB_Charset::test_get_column_charset with data set #5', + 'Tests_DB_Charset::test_get_column_charset with data set #6', + 'Tests_DB_Charset::test_get_column_charset with data set #7', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #0', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #1', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #2', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #3', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #4', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #5', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #6', + 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #7', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #0', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #1', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #2', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #3', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #4', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #5', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #6', + 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #7', + 'Tests_DB_Charset::test_process_field_charsets_on_nonexistent_table', + 'Tests_DB_Charset::test_strip_invalid_text_for_column_bails_if_ascii_input_too_long', + 'Tests_DB_dbDelta::test_spatial_indices', + 'Tests_DB::test_charset_switched_to_utf8mb4', + 'Tests_DB::test_close', + 'Tests_DB::test_delete_value_too_long_for_field with data set "too long"', + 'Tests_DB::test_has_cap', + 'Tests_DB::test_insert_value_too_long_for_field with data set "too long"', + 'Tests_DB::test_non_unicode_collations', + 'Tests_DB::test_pre_get_col_charset_filter', + 'Tests_DB::test_process_fields_on_nonexistent_table', + 'Tests_DB::test_process_fields_value_too_long_for_field with data set "too long"', + 'Tests_DB::test_query_value_contains_invalid_chars', + 'Tests_DB::test_replace_value_too_long_for_field with data set "too long"', + 'Tests_DB::test_supports_collation', + 'Tests_DB::test_update_value_too_long_for_field with data set "too long"', +] ); + +const expectedErrors = isDuckDBPhpunitRun ? [] : sqliteExpectedErrors; +const expectedFailures = isDuckDBPhpunitRun + ? sqliteExpectedFailures.filter( test => ! duckdbExpectedFailuresToPrune.has( test ) ) + : sqliteExpectedFailures; + console.log( 'Running WordPress PHPUnit tests with expected failures tracking...' ); if ( requiresNativeParserExtension ) { console.log( 'Native parser extension is required for this PHPUnit run.' ); } console.log( 'PHPUnit command:', phpunitCommand ); +console.log( 'Expected-result mode:', isDuckDBPhpunitRun ? 'duckdb' : 'sqlite' ); console.log( 'JUnit output:', junitOutputFile ); console.log( 'Expected errors:', expectedErrors ); console.log( 'Expected failures:', expectedFailures ); @@ -75,7 +168,7 @@ if ( ignoreMissingExpectedResults ) { } function getDefaultEnsureEnvironmentCommand() { - return phpunitCommand.includes( 'wp-test-php-duckdb' ) + return isDuckDBPhpunitRun ? 'composer run wp-test-ensure-env-duckdb' : 'composer run wp-test-ensure-env'; } @@ -105,7 +198,7 @@ function shouldPreloadCompatiblePhpunitRunner() { return ( ensurePhpunitCompatibility && ! skipPhpunitCompatibilityCheck && - phpunitCommand.includes( 'wp-test-php-duckdb' ) + isDuckDBPhpunitRun ); } From 80c1b27f0464d4e2f0bbd4b6a9037a61eb6f68e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 22:27:14 +0000 Subject: [PATCH 145/317] Run full WordPress PHPUnit suite for DuckDB --- .github/workflows/duckdb-phpunit-tests.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index 17e907d12..6460b836d 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -98,7 +98,7 @@ jobs: working-directory: packages/mysql-on-sqlite wp-duckdb-test: - name: WordPress PHPUnit wpdb / DuckDB + name: WordPress PHPUnit / DuckDB runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -158,13 +158,12 @@ jobs: timeout-minutes: 5 run: ./bin/wp-test-duckdb-smoke.sh - - name: Run WordPress wpdb PHPUnit tests with DuckDB + - name: Run full WordPress PHPUnit tests with DuckDB timeout-minutes: 15 env: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY: '1' - WP_SQLITE_IGNORE_MISSING_EXPECTED_RESULTS: '1' - WP_SQLITE_PHPUNIT_COMMAND: composer run wp-test-php-duckdb -- --group=wpdb --log-junit=phpunit-duckdb-results.xml --verbose + WP_SQLITE_PHPUNIT_COMMAND: composer run wp-test-php-duckdb -- --log-junit=phpunit-duckdb-results.xml --verbose WP_SQLITE_PHPUNIT_JUNIT_PATH: wordpress/phpunit-duckdb-results.xml run: node .github/workflows/wp-tests-phpunit-run.js From b6c0358202041468bca10695572a021a2449003e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 22:28:11 +0000 Subject: [PATCH 146/317] Require strict full DuckDB WordPress PHPUnit --- .github/workflows/duckdb-phpunit-tests.yml | 1 + .github/workflows/wp-tests-phpunit-run.js | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index 6460b836d..aa5814623 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -163,6 +163,7 @@ jobs: env: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY: '1' + WP_SQLITE_DISABLE_EXPECTED_RESULTS: '1' WP_SQLITE_PHPUNIT_COMMAND: composer run wp-test-php-duckdb -- --log-junit=phpunit-duckdb-results.xml --verbose WP_SQLITE_PHPUNIT_JUNIT_PATH: wordpress/phpunit-duckdb-results.xml run: node .github/workflows/wp-tests-phpunit-run.js diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index b0898ad67..4497674f0 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -18,6 +18,7 @@ const ensurePhpunitCompatibility = process.env.WP_SQLITE_ENSURE_PHPUNIT_COMPATIB const phpunitCompatibilityConstraint = process.env.WP_SQLITE_PHPUNIT_COMPATIBILITY_CONSTRAINT || '^9.6'; const skipPhpunitCompatibilityCheck = process.env.WP_SQLITE_SKIP_PHPUNIT_COMPATIBILITY_CHECK === '1'; const ignoreMissingExpectedResults = process.env.WP_SQLITE_IGNORE_MISSING_EXPECTED_RESULTS === '1'; +const disableExpectedResults = process.env.WP_SQLITE_DISABLE_EXPECTED_RESULTS === '1'; const junitOutputPath = process.env.WP_SQLITE_PHPUNIT_JUNIT_PATH || 'wordpress/phpunit-results.xml'; const junitOutputFile = path.isAbsolute( junitOutputPath ) ? junitOutputPath @@ -149,10 +150,16 @@ const duckdbExpectedFailuresToPrune = new Set( [ 'Tests_DB::test_update_value_too_long_for_field with data set "too long"', ] ); -const expectedErrors = isDuckDBPhpunitRun ? [] : sqliteExpectedErrors; -const expectedFailures = isDuckDBPhpunitRun - ? sqliteExpectedFailures.filter( test => ! duckdbExpectedFailuresToPrune.has( test ) ) - : sqliteExpectedFailures; +const expectedErrors = disableExpectedResults + ? [] + : ( isDuckDBPhpunitRun ? [] : sqliteExpectedErrors ); +const expectedFailures = disableExpectedResults + ? [] + : ( + isDuckDBPhpunitRun + ? sqliteExpectedFailures.filter( test => ! duckdbExpectedFailuresToPrune.has( test ) ) + : sqliteExpectedFailures + ); console.log( 'Running WordPress PHPUnit tests with expected failures tracking...' ); if ( requiresNativeParserExtension ) { @@ -160,6 +167,9 @@ if ( requiresNativeParserExtension ) { } console.log( 'PHPUnit command:', phpunitCommand ); console.log( 'Expected-result mode:', isDuckDBPhpunitRun ? 'duckdb' : 'sqlite' ); +if ( disableExpectedResults ) { + console.log( 'Expected-result allowlist disabled.' ); +} console.log( 'JUnit output:', junitOutputFile ); console.log( 'Expected errors:', expectedErrors ); console.log( 'Expected failures:', expectedFailures ); From 793346d132bc119a59894c289c12194ce782b96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 22:58:48 +0000 Subject: [PATCH 147/317] Fast path DuckDB PHPUnit transaction boilerplate --- .../src/duckdb/class-wp-duckdb-driver.php | 84 +++++++++++++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 43 ++++++++++ 2 files changed, 127 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 16c0a77e9..20d00ac25 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -276,6 +276,11 @@ public function query( string $query ): WP_DuckDB_Result_Statement { $this->last_insert_id = 0; try { + $fast_path_result = $this->execute_fast_path_statement( $query ); + if ( null !== $fast_path_result ) { + return $fast_path_result; + } + $tokens = $this->tokenize_and_validate( $query ); if ( count( $tokens ) === 0 ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported DuckDB MySQL-emulation statement: empty query.' ); @@ -353,6 +358,85 @@ public function query( string $query ): WP_DuckDB_Result_Statement { } } + /** + * Execute high-frequency WordPress PHPUnit boilerplate without full parsing. + * + * The full MySQL parser is still used for every non-exact form so richer + * transaction, SET, and savepoint statements keep their existing validation. + * + * @param string $query MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_fast_path_statement( string $query ): ?WP_DuckDB_Result_Statement { + $normalized = $this->normalize_fast_path_statement( $query ); + if ( null === $normalized ) { + return null; + } + + if ( preg_match( '/^SET\s+autocommit\s*=\s*([01])$/i', $normalized, $matches ) ) { + $this->found_rows = 0; + $this->session_system_variables['autocommit'] = (int) $matches[1]; + return $this->empty_ddl_result(); + } + + if ( 0 === strcasecmp( $normalized, 'BEGIN' ) ) { + $this->found_rows = 0; + $this->begin_user_transaction(); + return $this->empty_ddl_result(); + } + + if ( 0 === strcasecmp( $normalized, 'START TRANSACTION' ) ) { + $this->found_rows = 0; + $this->begin_user_transaction(); + return $this->empty_ddl_result(); + } + + if ( 0 === strcasecmp( $normalized, 'COMMIT' ) || 0 === strcasecmp( $normalized, 'COMMIT WORK' ) ) { + $this->found_rows = 0; + if ( $this->connection->inTransaction() ) { + $this->last_duckdb_queries[] = 'COMMIT'; + $this->connection->commit(); + } + $this->table_lock_active = false; + return $this->empty_ddl_result(); + } + + if ( 0 === strcasecmp( $normalized, 'ROLLBACK' ) || 0 === strcasecmp( $normalized, 'ROLLBACK WORK' ) ) { + $this->found_rows = 0; + if ( $this->connection->inTransaction() ) { + $this->last_duckdb_queries[] = 'ROLLBACK'; + $this->connection->rollback(); + } + $this->table_lock_active = false; + return $this->empty_ddl_result(); + } + + return null; + } + + /** + * Normalize a statement only enough for exact fast-path comparisons. + * + * @param string $query MySQL query. + * @return string|null Normalized query, or null when empty. + */ + private function normalize_fast_path_statement( string $query ): ?string { + $normalized = trim( $query ); + if ( '' === $normalized ) { + return null; + } + + if ( ';' === substr( $normalized, -1 ) ) { + $normalized = trim( substr( $normalized, 0, -1 ) ); + } + + if ( '' === $normalized || false !== strpos( $normalized, ';' ) ) { + return null; + } + + return preg_replace( '/\s+/', ' ', $normalized ); + } + /** * Get the underlying DuckDB connection. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index d1b14f8e4..02e0e4cb3 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1787,6 +1787,49 @@ public function test_sql_transaction_statements_update_connection_state_and_quer $this->assertSame( array(), $driver->get_last_duckdb_queries() ); } + public function test_wordpress_phpunit_boilerplate_statements_accept_fast_path_forms(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE tx_fast_path (id INT)' ); + + $set = $driver->query( " \n SET autocommit=0; \t" ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + $this->assertSame( + array( '@@autocommit' => 0 ), + $driver->query( 'SELECT @@autocommit' )->fetch( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'start transaction;' ); + $this->assertTrue( $driver->get_connection()->inTransaction() ); + $this->assertSame( array( 'BEGIN TRANSACTION' ), $driver->get_last_duckdb_queries() ); + + $driver->query( 'INSERT INTO tx_fast_path (id) VALUES (1)' ); + $driver->query( 'commit work;' ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + $this->assertSame( array( 'COMMIT' ), $driver->get_last_duckdb_queries() ); + + $driver->query( 'BEGIN;' ); + $driver->query( 'INSERT INTO tx_fast_path (id) VALUES (2)' ); + $driver->query( 'rollback WORK;' ); + $this->assertFalse( $driver->get_connection()->inTransaction() ); + $this->assertSame( array( 'ROLLBACK' ), $driver->get_last_duckdb_queries() ); + $this->assertSame( + array( array( 'id' => 1 ) ), + $driver->query( 'SELECT id FROM tx_fast_path ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'BEGIN' ); + $this->assertDriverQueryRejected( + $driver, + 'ROLLBACK TO sp1;', + 'Unsupported ROLLBACK statement in DuckDB driver. Only ROLLBACK [WORK] is supported.' + ); + $driver->query( 'ROLLBACK' ); + } + public function test_native_error_rolls_back_active_transaction_before_dbdelta_insert(): void { $this->requireDuckDBRuntime(); From 9bbdb5843ab1cf101e37e383040c47ccd1d3f5a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 23:00:49 +0000 Subject: [PATCH 148/317] Fix DuckDB driver coding standards --- packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 20d00ac25..c528ad0c7 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -374,7 +374,7 @@ private function execute_fast_path_statement( string $query ): ?WP_DuckDB_Result } if ( preg_match( '/^SET\s+autocommit\s*=\s*([01])$/i', $normalized, $matches ) ) { - $this->found_rows = 0; + $this->found_rows = 0; $this->session_system_variables['autocommit'] = (int) $matches[1]; return $this->empty_ddl_result(); } From 4a949421801a133b91f9a6e383cb7af0ba0138c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 27 Jun 2026 23:05:53 +0000 Subject: [PATCH 149/317] Short-circuit slow DuckDB WordPress PHPUnit runs --- .github/workflows/duckdb-phpunit-tests.yml | 6 +- .github/workflows/wp-tests-phpunit-run.js | 112 ++++++++++++++++++++- composer.json | 4 + 3 files changed, 120 insertions(+), 2 deletions(-) diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml index aa5814623..c93aab0fe 100644 --- a/.github/workflows/duckdb-phpunit-tests.yml +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -164,7 +164,11 @@ jobs: DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY: '1' WP_SQLITE_DISABLE_EXPECTED_RESULTS: '1' - WP_SQLITE_PHPUNIT_COMMAND: composer run wp-test-php-duckdb -- --log-junit=phpunit-duckdb-results.xml --verbose + WP_SQLITE_PHPUNIT_ENSURE_ENV_COMMAND: 'true' + WP_SQLITE_PHPUNIT_TIMING_LABEL: duckdb-full + WP_SQLITE_PHPUNIT_BASELINE_SECONDS: '380' + WP_SQLITE_PHPUNIT_MAX_SECONDS: '420' + WP_SQLITE_PHPUNIT_COMMAND: composer run wp-test-php-duckdb-prepared -- --log-junit=phpunit-duckdb-results.xml --verbose WP_SQLITE_PHPUNIT_JUNIT_PATH: wordpress/phpunit-duckdb-results.xml run: node .github/workflows/wp-tests-phpunit-run.js diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 4497674f0..c4cfb0f15 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -10,10 +10,14 @@ const fs = require( 'fs' ); const path = require( 'path' ); const repoRoot = path.join( __dirname, '..', '..' ); +const wrapperStartedAt = Date.now(); const requiresNativeParserExtension = process.env.WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION === '1'; const phpunitCommand = process.env.WP_SQLITE_PHPUNIT_COMMAND || 'composer run wp-test-php -- --log-junit=phpunit-results.xml --verbose'; const isDuckDBPhpunitRun = phpunitCommand.includes( 'wp-test-php-duckdb' ); const phpunitEnsureEnvironmentCommand = process.env.WP_SQLITE_PHPUNIT_ENSURE_ENV_COMMAND || getDefaultEnsureEnvironmentCommand(); +const phpunitMaxSeconds = getPositiveNumberEnv( 'WP_SQLITE_PHPUNIT_MAX_SECONDS' ); +const phpunitBaselineSeconds = getPositiveNumberEnv( 'WP_SQLITE_PHPUNIT_BASELINE_SECONDS' ); +const phpunitTimingLabel = process.env.WP_SQLITE_PHPUNIT_TIMING_LABEL || ( isDuckDBPhpunitRun ? 'duckdb' : 'sqlite' ); const ensurePhpunitCompatibility = process.env.WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY === '1'; const phpunitCompatibilityConstraint = process.env.WP_SQLITE_PHPUNIT_COMPATIBILITY_CONSTRAINT || '^9.6'; const skipPhpunitCompatibilityCheck = process.env.WP_SQLITE_SKIP_PHPUNIT_COMPATIBILITY_CHECK === '1'; @@ -167,6 +171,9 @@ if ( requiresNativeParserExtension ) { } console.log( 'PHPUnit command:', phpunitCommand ); console.log( 'Expected-result mode:', isDuckDBPhpunitRun ? 'duckdb' : 'sqlite' ); +console.log( 'PHPUnit timing label:', phpunitTimingLabel ); +console.log( 'PHPUnit baseline seconds:', phpunitBaselineSeconds || 'none' ); +console.log( 'PHPUnit max seconds:', phpunitMaxSeconds || 'none' ); if ( disableExpectedResults ) { console.log( 'Expected-result allowlist disabled.' ); } @@ -183,6 +190,42 @@ function getDefaultEnsureEnvironmentCommand() { : 'composer run wp-test-ensure-env'; } +function getPositiveNumberEnv( name ) { + const value = Number( process.env[ name ] || 0 ); + return Number.isFinite( value ) && value > 0 ? value : 0; +} + +function markProgress( phase, extra = {} ) { + const elapsedSeconds = ( Date.now() - wrapperStartedAt ) / 1000; + const fields = { + phase, + label: phpunitTimingLabel, + elapsed_seconds: elapsedSeconds.toFixed( 3 ), + ...extra, + }; + const message = Object.entries( fields ).map( ( [ key, value ] ) => `${ key }=${ value }` ).join( ' ' ); + console.log( `WP_SQLITE_PHPUNIT_PROGRESS ${ message }` ); + if ( process.env.GITHUB_ACTIONS === 'true' ) { + console.log( `::notice title=WordPress PHPUnit progress::${ message }` ); + } +} + +function appendTimingSummary( timingSummary ) { + if ( ! process.env.GITHUB_STEP_SUMMARY ) { + return; + } + + const rows = [ + '| Field | Value |', + '| --- | --- |', + ...Object.entries( timingSummary ).map( ( [ key, value ] ) => `| ${ key } | ${ value } |` ), + ]; + fs.appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `\n### WordPress PHPUnit timing\n\n${ rows.join( '\n' ) }\n` + ); +} + function preparePhpunitCommand() { if ( ! shouldPreloadCompatiblePhpunitRunner() ) { return phpunitCommand; @@ -431,13 +474,48 @@ try { verifyNativeParserExtension(); } + markProgress( 'compatibility_start' ); ensureCompatiblePhpunitRunner(); + markProgress( 'compatibility_done' ); const effectivePhpunitCommand = preparePhpunitCommand(); + markProgress( 'command_prepared' ); + + let phpunitTimedOut = false; + let phpunitCommandSeconds = 0; + let phpunitStartedAt = 0; try { - execSync( effectivePhpunitCommand, { stdio: 'inherit' } ); + markProgress( 'phpunit_start', { max_seconds: phpunitMaxSeconds || 'none' } ); + phpunitStartedAt = Date.now(); + execSync( + effectivePhpunitCommand, + { + stdio: 'inherit', + timeout: phpunitMaxSeconds ? phpunitMaxSeconds * 1000 : undefined, + killSignal: 'SIGTERM', + } + ); + phpunitCommandSeconds = ( Date.now() - phpunitStartedAt ) / 1000; + markProgress( 'phpunit_exit_zero', { command_seconds: phpunitCommandSeconds.toFixed( 3 ) } ); console.log( '\n⚠️ All tests passed, checking if expected errors/failures occurred...' ); } catch ( error ) { + phpunitCommandSeconds = phpunitStartedAt ? ( Date.now() - phpunitStartedAt ) / 1000 : 0; + phpunitTimedOut = Boolean( + phpunitMaxSeconds && + ( + phpunitCommandSeconds >= phpunitMaxSeconds || + error.signal === 'SIGTERM' || + String( error.message || '' ).includes( 'ETIMEDOUT' ) + ) + ); + markProgress( + phpunitTimedOut ? 'phpunit_timeout' : 'phpunit_exit_nonzero', + { command_seconds: phpunitCommandSeconds.toFixed( 3 ) } + ); + if ( phpunitTimedOut ) { + console.error( `\n❌ PHPUnit command exceeded ${ phpunitMaxSeconds }s for ${ phpunitTimingLabel }.` ); + process.exit( 1 ); + } console.log( '\n⚠️ Some tests errored/failed (expected). Analyzing results...' ); } @@ -447,11 +525,13 @@ try { process.exit( 1 ); } const junitXml = fs.readFileSync( junitOutputFile, 'utf8' ); + markProgress( 'junit_read', { command_seconds: phpunitCommandSeconds.toFixed( 3 ) } ); // Extract test info from the XML: const actualTests = []; const actualErrors = []; const actualFailures = []; + let junitTestcaseSeconds = 0; for ( const testcase of junitXml.matchAll( /]*)\/>|]*)>([\s\S]*?)<\/testcase>/g ) ) { const attributes = {}; const attributesString = testcase[2] ?? testcase[1]; @@ -462,6 +542,12 @@ try { const content = testcase[3] ?? ''; const fqn = attributes.class ? `${attributes.class}::${attributes.name}` : attributes.name; actualTests.push( fqn ); + if ( attributes.time ) { + const testcaseSeconds = Number( attributes.time ); + if ( Number.isFinite( testcaseSeconds ) ) { + junitTestcaseSeconds += testcaseSeconds; + } + } const hasError = content.includes( ' `${ key }=${ value }` ).join( ' ' ) + ); + appendTimingSummary( timingSummary ); + markProgress( 'junit_parsed', { tests: actualTests.length } ); + let isSuccess = true; + if ( phpunitBaselineSeconds && phpunitCommandSeconds > phpunitBaselineSeconds ) { + console.error( + `\n❌ PHPUnit command took ${ phpunitCommandSeconds.toFixed( 3 ) }s, above ${ phpunitBaselineSeconds }s baseline.` + ); + isSuccess = false; + } + // Check if all expected errors actually errored const expectedErrorsInScope = filterExpectedResultsInScope( expectedErrors, actualTests ); const unexpectedNonErrors = expectedErrorsInScope.filter( test => ! actualErrors.includes( test ) ); diff --git a/composer.json b/composer.json index 262e91e00..b86693567 100644 --- a/composer.json +++ b/composer.json @@ -80,6 +80,10 @@ "rm -rf wordpress/src/wp-content/database/.ht.duckdb wordpress/src/wp-content/database/.ht.duckdb.wal @no_additional_args", "npm --prefix wordpress run test:php -- @additional_args" ], + "wp-test-php-duckdb-prepared": [ + "rm -rf wordpress/src/wp-content/database/.ht.duckdb wordpress/src/wp-content/database/.ht.duckdb.wal @no_additional_args", + "npm --prefix wordpress run test:php -- @additional_args" + ], "wp-smoke-duckdb-local": [ "./bin/wp-duckdb-local-smoke.sh" ], From cfde2777bc74e4f13cea1952df2135532e6e8f5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 15:08:44 +0000 Subject: [PATCH 150/317] Improve DuckDB WordPress parity and runtime --- .../src/duckdb/class-wp-duckdb-driver.php | 3038 ++++++++++++++--- .../class-wp-duckdb-result-statement.php | 42 +- .../duckdb/WP_DuckDB_Connection_Tests.php | 56 + .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 870 +++-- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 1315 ++++++- 5 files changed, 4451 insertions(+), 870 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index c528ad0c7..5d8a3cfe1 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -38,13 +38,54 @@ class WP_DuckDB_Driver { const INFO_SCHEMA_CHECK_CONSTRAINTS_TABLE = '__wp_duckdb_information_schema_check_constraints'; const SUPPORTED_SESSION_SYSTEM_VARIABLES = array( - 'autocommit' => true, - 'big_tables' => true, - 'default_storage_engine' => true, - 'foreign_key_checks' => true, - 'sql_mode' => true, - 'sql_warnings' => true, - 'unique_checks' => true, + 'autocommit' => true, + 'big_tables' => true, + 'character_set_client' => true, + 'character_set_results' => true, + 'collation_connection' => true, + 'default_collation_for_utf8mb4' => true, + 'default_storage_engine' => true, + 'end_markers_in_json' => true, + 'explicit_defaults_for_timestamp' => true, + 'foreign_key_checks' => true, + 'keep_files_on_create' => true, + 'old_alter_table' => true, + 'print_identified_with_as_hex' => true, + 'require_row_format' => true, + 'resultset_metadata' => true, + 'select_into_disk_sync' => true, + 'session_track_gtids' => true, + 'session_track_schema' => true, + 'session_track_state_change' => true, + 'session_track_transaction_info' => true, + 'show_create_table_skip_secondary_engine' => true, + 'show_create_table_verbosity' => true, + 'sql_auto_is_null' => true, + 'sql_big_selects' => true, + 'sql_buffer_result' => true, + 'sql_mode' => true, + 'sql_notes' => true, + 'sql_safe_updates' => true, + 'sql_warnings' => true, + 'time_zone' => true, + 'transaction_isolation' => true, + 'transaction_read_only' => true, + 'unique_checks' => true, + 'use_secondary_engine' => true, + ); + + const STRING_SESSION_SYSTEM_VARIABLES = array( + 'character_set_client' => true, + 'character_set_results' => true, + 'collation_connection' => true, + 'default_collation_for_utf8mb4' => true, + 'default_storage_engine' => true, + 'resultset_metadata' => true, + 'session_track_gtids' => true, + 'session_track_transaction_info' => true, + 'time_zone' => true, + 'transaction_isolation' => true, + 'use_secondary_engine' => true, ); const READ_ONLY_SYSTEM_VARIABLES = array( @@ -52,6 +93,17 @@ class WP_DuckDB_Driver { 'version_comment' => true, ); + const READ_ONLY_GLOBAL_SYSTEM_VARIABLES = array( + 'gtid_purged' => true, + 'log_bin' => true, + 'log_bin_trust_function_creators' => true, + 'sql_mode' => true, + ); + + const READ_ONLY_SESSION_SYSTEM_VARIABLES = array( + 'max_allowed_packet' => true, + ); + const BIT_SIGNED_BIGINT_MAX_DECIMAL = '9223372036854775807'; const BIT_SIGNED_BIGINT_MAX_HEX = '7fffffffffffffff'; const BIT_SIGNED_BIGINT_MAX_BITS = 63; @@ -215,6 +267,13 @@ class WP_DuckDB_Driver { */ private $user_variables = array(); + /** + * Internal metadata tables already initialized for this connection. + * + * @var array + */ + private $ensured_metadata_tables = array(); + /** * Whether a MySQL LOCK TABLES statement opened the current transaction. * @@ -550,6 +609,33 @@ private function get_session_system_variable( string $name ) { return $this->session_system_variables[ $normalized_name ] ?? null; } + /** + * Get an emulated MySQL system variable value for a supported scope. + * + * @param string $name Variable name. + * @param string $scope Variable scope: session or global. + * @return int|string|null Stored value, or null when supported but unset. + */ + private function get_system_variable( string $name, string $scope ) { + $normalized_name = strtolower( $name ); + + if ( 'global' === $scope ) { + if ( 'sql_mode' === $normalized_name ) { + return implode( ',', $this->active_sql_modes ); + } + + if ( isset( self::READ_ONLY_GLOBAL_SYSTEM_VARIABLES[ $normalized_name ] ) ) { + return null; + } + } + + if ( isset( self::READ_ONLY_SESSION_SYSTEM_VARIABLES[ $normalized_name ] ) ) { + return $this->session_system_variables[ $normalized_name ] ?? null; + } + + return $this->get_session_system_variable( $name ); + } + /** * Tokenize and parse a single MySQL statement. * @@ -684,20 +770,51 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { return $this->record_found_rows_from_result( $variable_select ); } + $variable_expression_select = $this->execute_variable_expression_select( $tokens ); + if ( null !== $variable_expression_select ) { + return $this->record_found_rows_from_result( $variable_expression_select ); + } + $has_sql_calc_found_rows = $this->has_top_level_sql_calc_found_rows( $tokens ); $tokens = $this->normalize_select_helper_tokens( $tokens ); $column_meta = $this->simple_select_column_metadata( $tokens ); $group_by_expansion = $this->primary_key_group_by_expansion( $tokens ); $seeded_rand_expressions = $this->parse_seeded_rand_select_expressions( $tokens ); - $seeded_rand_rewrites = $this->seeded_rand_rewrite_map( $seeded_rand_expressions ); - - $rewrite_information_schema_tables = $this->uses_information_schema_tables( $tokens ); - $rewrite_information_schema_columns = $this->uses_information_schema_columns( $tokens ); - $rewrite_information_schema_statistics = $this->uses_information_schema_statistics( $tokens ); - $rewrite_information_schema_table_constraints = $this->uses_information_schema_table_constraints( $tokens ); - $rewrite_information_schema_key_column_usage = $this->uses_information_schema_key_column_usage( $tokens ); - $rewrite_information_schema_referential_constraints = $this->uses_information_schema_referential_constraints( $tokens ); - $rewrite_information_schema_check_constraints = $this->uses_information_schema_check_constraints( $tokens ); + $seeded_rand_where = $this->parse_seeded_rand_where_clause( $tokens ); + if ( null !== $seeded_rand_where && $has_sql_calc_found_rows ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB WHERE is not supported with SQL_CALC_FOUND_ROWS.' ); + } + if ( null !== $seeded_rand_where && count( $seeded_rand_expressions ) > 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB driver cannot be used as both a top-level SELECT expression and a WHERE predicate.' ); + } + + $sql_tokens = null === $seeded_rand_where ? $tokens : $seeded_rand_where['tokens']; + $seeded_rand_ordering = $this->parse_seeded_rand_order_by_clause( $sql_tokens ); + $seeded_rand_where_order = null; + if ( null !== $seeded_rand_ordering && count( $seeded_rand_expressions ) > 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB driver cannot be used as both a top-level SELECT expression and an ORDER BY expression.' ); + } + if ( null !== $seeded_rand_where && null !== $seeded_rand_ordering ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB driver cannot be used as both a WHERE predicate and an ORDER BY expression.' ); + } + if ( null !== $seeded_rand_where ) { + $seeded_rand_where_order = $this->parse_seeded_rand_where_order_by_clause( $sql_tokens ); + if ( null !== $seeded_rand_where_order ) { + $sql_tokens = $seeded_rand_where_order['tokens']; + } + } elseif ( null !== $seeded_rand_ordering ) { + $sql_tokens = $seeded_rand_ordering['tokens']; + } + + $seeded_rand_rewrites = $this->seeded_rand_rewrite_map( $seeded_rand_expressions ); + + $rewrite_information_schema_tables = $this->uses_information_schema_tables( $sql_tokens ); + $rewrite_information_schema_columns = $this->uses_information_schema_columns( $sql_tokens ); + $rewrite_information_schema_statistics = $this->uses_information_schema_statistics( $sql_tokens ); + $rewrite_information_schema_table_constraints = $this->uses_information_schema_table_constraints( $sql_tokens ); + $rewrite_information_schema_key_column_usage = $this->uses_information_schema_key_column_usage( $sql_tokens ); + $rewrite_information_schema_referential_constraints = $this->uses_information_schema_referential_constraints( $sql_tokens ); + $rewrite_information_schema_check_constraints = $this->uses_information_schema_check_constraints( $sql_tokens ); if ( $rewrite_information_schema_tables ) { $this->refresh_information_schema_tables_table(); } @@ -721,7 +838,7 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { } $sql = $this->translate_tokens_to_duckdb_sql( - $tokens, + $sql_tokens, $rewrite_information_schema_tables, $rewrite_information_schema_columns, $rewrite_information_schema_statistics, @@ -737,7 +854,7 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { if ( $has_sql_calc_found_rows ) { try { $this->found_rows = $this->count_select_rows( - $this->strip_top_level_limit_clause( $tokens ), + $this->strip_top_level_limit_clause( $sql_tokens ), $rewrite_information_schema_tables, $rewrite_information_schema_columns, $rewrite_information_schema_statistics, @@ -750,7 +867,10 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { ); $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); $result = $this->apply_result_column_metadata( $result, $column_meta ); - return $this->apply_seeded_rand_select_expressions( $result, $seeded_rand_expressions ); + $result = $this->apply_seeded_rand_where_filter( $result, $seeded_rand_where ); + $result = $this->apply_seeded_rand_select_expressions( $result, $seeded_rand_expressions ); + $result = $this->apply_seeded_rand_ordering( $result, $seeded_rand_ordering ); + return $this->apply_seeded_rand_where_ordering( $result, $seeded_rand_where_order ); } catch ( Throwable $e ) { $this->found_rows = 0; throw $e; @@ -760,7 +880,10 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); $this->found_rows = $sql; $result = $this->apply_result_column_metadata( $result, $column_meta ); - return $this->apply_seeded_rand_select_expressions( $result, $seeded_rand_expressions ); + $result = $this->apply_seeded_rand_where_filter( $result, $seeded_rand_where ); + $result = $this->apply_seeded_rand_select_expressions( $result, $seeded_rand_expressions ); + $result = $this->apply_seeded_rand_ordering( $result, $seeded_rand_ordering ); + return $this->apply_seeded_rand_where_ordering( $result, $seeded_rand_where_order ); } /** @@ -782,7 +905,7 @@ private function apply_result_column_metadata( WP_DuckDB_Result_Statement $resul * Parse top-level SELECT-list RAND(seed) expressions supported by the DuckDB driver. * * @param WP_Parser_Token[] $tokens SELECT tokens. - * @return array + * @return array */ private function parse_seeded_rand_select_expressions( array $tokens ): array { if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { @@ -795,22 +918,36 @@ private function parse_seeded_rand_select_expressions( array $tokens ): array { $this->top_level_select_list_end( $tokens ) ); - $expressions = array(); - $has_wildcard = false; + $expressions = array(); + $has_wildcard = false; + $last_wildcard_index = null; + $seeded_columns = array(); foreach ( $ranges as $column_index => $range ) { if ( $this->select_item_is_wildcard( $range['tokens'] ) ) { - $has_wildcard = true; + $has_wildcard = true; + $last_wildcard_index = $column_index; continue; } $expression = $this->parse_seeded_rand_select_item( $range['tokens'], $range['start'], $column_index ); if ( null !== $expression ) { - $expressions[] = $expression; + $expressions[] = $expression; + $seeded_columns[] = $column_index; } } if ( $has_wildcard && count( $expressions ) > 0 ) { - throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() with SELECT-list wildcards is not supported by the DuckDB driver.' ); + foreach ( $seeded_columns as $seeded_column ) { + if ( null === $last_wildcard_index || $seeded_column <= $last_wildcard_index ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() with SELECT-list wildcards is supported only after wildcard columns in the DuckDB driver.' ); + } + } + + $select_item_count = count( $ranges ); + foreach ( $expressions as &$expression ) { + $expression['column'] -= $select_item_count; + } + unset( $expression ); } return $expressions; @@ -819,8 +956,8 @@ private function parse_seeded_rand_select_expressions( array $tokens ): array { /** * Index seeded RAND rewrites by absolute token offset. * - * @param array $expressions Seeded RAND expressions. - * @return array + * @param array $expressions Seeded RAND expressions. + * @return array */ private function seeded_rand_rewrite_map( array $expressions ): array { $rewrites = array(); @@ -937,7 +1074,7 @@ private function split_top_level_select_item_ranges( array $tokens, int $start, * @param WP_Parser_Token[] $tokens SELECT item tokens. * @param int $absolute_start Absolute start offset in the full SELECT token stream. * @param int $column_index Result column index. - * @return array{column:int,start:int,end:int,seed:int,replacement:string}|null Parsed expression, or null for non-RAND items. + * @return array{column:int,start:int,end:int,seed:int|null,replacement:string}|null Parsed expression, or null for non-RAND items. */ private function parse_seeded_rand_select_item( array $tokens, int $absolute_start, int $column_index ): ?array { if ( @@ -959,16 +1096,14 @@ private function parse_seeded_rand_select_item( array $tokens, int $absolute_sta throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB SELECT supports exactly one seed argument.' ); } - $seed = $this->parse_seeded_rand_literal_seed( $seed_tokens ); - if ( null === $seed ) { - throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB SELECT supports only literal numeric, string, or NULL seeds.' ); - } - + $seed = $this->parse_seeded_rand_literal_seed( $seed_tokens ); $has_alias = $this->validate_seeded_rand_select_item_tail( $tokens, $close_index + 1 ); - $replacement = '0.0'; + $replacement = null === $seed + ? $this->translate_tokens_to_duckdb_sql( $seed_tokens ) + : '0.0'; if ( ! $has_alias ) { $replacement .= ' AS ' . $this->connection->quote_identifier( - $this->concatenate_token_bytes( array_slice( $tokens, 0, $close_index + 1 ) ) + $this->seeded_rand_select_expression_label( array_slice( $tokens, 0, $close_index + 1 ) ) ); } @@ -1099,11 +1234,512 @@ private function select_item_is_wildcard( array $tokens ): bool { && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[2]->id; } + /** + * Parse a bounded SELECT ORDER BY RAND(seed) clause. + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @return array{seed:int,descending:bool,tokens:array}|null Parsed ordering, or null when unsupported/not present. + */ + private function parse_seeded_rand_order_by_clause( array $tokens ): ?array { + $order_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::ORDER_SYMBOL ); + if ( + null === $order_index + || ! isset( $tokens[ $order_index + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_index + 1 ]->id + || null !== $this->find_top_level_token_index( $tokens, $order_index + 2, WP_MySQL_Lexer::LIMIT_SYMBOL ) + ) { + return null; + } + + $order_tokens = array_slice( $tokens, $order_index + 2 ); + $order_items = $this->split_top_level_comma_items( $order_tokens ); + if ( 1 !== count( $order_items ) ) { + return null; + } + + $item = array_values( $order_items[0] ); + $descending = false; + $last = end( $item ); + if ( $last instanceof WP_Parser_Token && ( WP_MySQL_Lexer::ASC_SYMBOL === $last->id || WP_MySQL_Lexer::DESC_SYMBOL === $last->id ) ) { + $descending = WP_MySQL_Lexer::DESC_SYMBOL === $last->id; + array_pop( $item ); + } + + $seed = $this->parse_seeded_rand_order_item_seed( $item ); + if ( null === $seed ) { + return null; + } + + return array( + 'seed' => $seed, + 'descending' => $descending, + 'tokens' => array_slice( $tokens, 0, $order_index ), + ); + } + + /** + * Parse a RAND(seed) ORDER BY item. + * + * @param WP_Parser_Token[] $tokens ORDER BY item tokens. + * @return int|null Normalized seed, or null when unsupported. + */ + private function parse_seeded_rand_order_item_seed( array $tokens ): ?int { + if ( + ! isset( $tokens[0], $tokens[1] ) + || $this->is_non_identifier_token( $tokens[0] ) + || 0 !== strcasecmp( $tokens[0]->get_value(), 'RAND' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $close_index = $this->matching_parenthesis_index( $tokens, 1 ); + if ( null === $close_index || 2 === $close_index || count( $tokens ) !== $close_index + 1 ) { + return null; + } + + $seed_tokens = array_slice( $tokens, 2, $close_index - 2 ); + if ( 1 !== count( $this->split_top_level_comma_items( $seed_tokens ) ) ) { + return null; + } + + return $this->parse_seeded_rand_literal_seed( $seed_tokens ); + } + + /** + * Parse a bounded SELECT WHERE RAND(seed) comparison. + * + * Supported shape is a full top-level WHERE predicate comparing RAND(seed) with + * a literal numeric/string threshold. Later GROUP/HAVING/LIMIT clauses are not + * supported because they would require broader relational rewriting. + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @return array{seed:int,operator:int,threshold:float,rand_left:bool,tokens:array}|null Parsed filter, or null when unsupported/not present. + */ + private function parse_seeded_rand_where_clause( array $tokens ): ?array { + $where_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::WHERE_SYMBOL ); + if ( null === $where_index ) { + return null; + } + + $clause_end = count( $tokens ); + foreach ( + array( + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + ) as $boundary + ) { + $boundary_index = $this->find_top_level_token_index( $tokens, $where_index + 1, $boundary ); + if ( null !== $boundary_index && $boundary_index < $clause_end ) { + $clause_end = $boundary_index; + } + } + + if ( + isset( $tokens[ $clause_end ] ) + && in_array( + $tokens[ $clause_end ]->id, + array( + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + ), + true + ) + ) { + return null; + } + + $where_tokens = array_slice( $tokens, $where_index + 1, $clause_end - $where_index - 1 ); + $predicate = $this->parse_seeded_rand_where_predicate( $where_tokens ); + if ( null === $predicate ) { + return null; + } + + $predicate['tokens'] = array_merge( + array_slice( $tokens, 0, $where_index ), + array_slice( $tokens, $clause_end ) + ); + + return $predicate; + } + + /** + * Parse the supported seeded RAND() WHERE predicate body. + * + * @param WP_Parser_Token[] $tokens WHERE predicate tokens. + * @return array{seed:int,operator:int,threshold:float,rand_left:bool}|null Parsed predicate, or null when unsupported. + */ + private function parse_seeded_rand_where_predicate( array $tokens ): ?array { + $operator_index = null; + $depth = 0; + foreach ( $tokens as $index => $token ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + ++$depth; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id ) { + --$depth; + continue; + } + if ( 0 === $depth && $this->is_numeric_comparison_operator_token( $token ) ) { + if ( null !== $operator_index ) { + return null; + } + $operator_index = $index; + } + } + + if ( null === $operator_index ) { + return null; + } + + $left = array_slice( $tokens, 0, $operator_index ); + $operator = $tokens[ $operator_index ]->id; + $right = array_slice( $tokens, $operator_index + 1 ); + $left_seed = $this->parse_seeded_rand_order_item_seed( $left ); + if ( null !== $left_seed ) { + $threshold = $this->parse_seeded_rand_where_threshold( $right ); + if ( null === $threshold ) { + return null; + } + + return array( + 'seed' => $left_seed, + 'operator' => $operator, + 'threshold' => $threshold, + 'rand_left' => true, + ); + } + + $right_seed = $this->parse_seeded_rand_order_item_seed( $right ); + if ( null === $right_seed ) { + return null; + } + + $threshold = $this->parse_seeded_rand_where_threshold( $left ); + if ( null === $threshold ) { + return null; + } + + return array( + 'seed' => $right_seed, + 'operator' => $operator, + 'threshold' => $threshold, + 'rand_left' => false, + ); + } + + /** + * Parse a literal threshold for a seeded RAND() WHERE comparison. + * + * @param WP_Parser_Token[] $tokens Threshold tokens. + * @return float|null Literal threshold, or null when unsupported. + */ + private function parse_seeded_rand_where_threshold( array $tokens ): ?float { + if ( 1 === count( $tokens ) ) { + if ( $this->is_number_token( $tokens[0] ) ) { + return (float) $this->number_token_value( $tokens[0] ); + } + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $tokens[0]->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[0]->id ) { + return (float) $tokens[0]->get_value(); + } + } + + if ( + 2 === count( $tokens ) + && $this->is_sign_token( $tokens[0] ) + && $this->is_number_token( $tokens[1] ) + ) { + return (float) $this->signed_number_token_value( $tokens[0], $tokens[1] ); + } + + return null; + } + + /** + * Parse a simple ORDER BY clause used after a seeded RAND() WHERE filter. + * + * @param WP_Parser_Token[] $tokens SELECT tokens with the RAND() WHERE removed. + * @return array{items:array,tokens:array}|null Parsed ordering, no ordering, or unsupported ordering. + */ + private function parse_seeded_rand_where_order_by_clause( array $tokens ): ?array { + $order_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::ORDER_SYMBOL ); + if ( null === $order_index ) { + return null; + } + if ( + ! isset( $tokens[ $order_index + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_index + 1 ]->id + || null !== $this->find_top_level_token_index( $tokens, $order_index + 2, WP_MySQL_Lexer::LIMIT_SYMBOL ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB WHERE supports only simple ORDER BY columns without LIMIT.' ); + } + + $items = array(); + foreach ( $this->split_top_level_comma_items( array_slice( $tokens, $order_index + 2 ) ) as $item ) { + $item = array_values( $item ); + $descending = false; + $last = end( $item ); + if ( $last instanceof WP_Parser_Token && ( WP_MySQL_Lexer::ASC_SYMBOL === $last->id || WP_MySQL_Lexer::DESC_SYMBOL === $last->id ) ) { + $descending = WP_MySQL_Lexer::DESC_SYMBOL === $last->id; + array_pop( $item ); + } + + if ( 1 === count( $item ) && $this->is_number_token( $item[0] ) ) { + $ordinal = (int) $this->number_token_value( $item[0] ); + if ( $ordinal < 1 ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB WHERE supports only positive ORDER BY ordinals.' ); + } + $items[] = array( + 'column' => null, + 'ordinal' => $ordinal - 1, + 'descending' => $descending, + ); + continue; + } + + if ( 1 === count( $item ) && ! $this->is_non_identifier_token( $item[0] ) ) { + $items[] = array( + 'column' => $item[0]->get_value(), + 'ordinal' => null, + 'descending' => $descending, + ); + continue; + } + + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB WHERE supports only simple ORDER BY result columns.' ); + } + + return array( + 'items' => $items, + 'tokens' => array_slice( $tokens, 0, $order_index ), + ); + } + + /** + * Build the no-alias result column label for a seeded RAND() expression. + * + * @param WP_Parser_Token[] $tokens RAND expression tokens. + * @return string Display label. + */ + private function seeded_rand_select_expression_label( array $tokens ): string { + $pieces = array(); + foreach ( $tokens as $token ) { + $pieces[] = $token->get_bytes(); + } + + return $this->join_sql_pieces( $pieces ); + } + + /** + * Apply a bounded seeded RAND() ORDER BY to materialized SELECT rows. + * + * @param WP_DuckDB_Result_Statement $result Result statement. + * @param array{seed:int,descending:bool,tokens:array}|null $ordering Parsed ordering. + * @return WP_DuckDB_Result_Statement Result with rows sorted. + */ + private function apply_seeded_rand_ordering( WP_DuckDB_Result_Statement $result, ?array $ordering ): WP_DuckDB_Result_Statement { + if ( null === $ordering ) { + return $result; + } + + $columns = array(); + $column_meta = array(); + for ( $index = 0; $index < $result->columnCount(); ++$index ) { + $meta = $result->getColumnMeta( $index ); + $column_meta[] = is_array( $meta ) ? $meta : array(); + $columns[] = is_array( $meta ) && isset( $meta['name'] ) ? (string) $meta['name'] : (string) $index; + } + + $state = array(); + $decorated = array(); + foreach ( $result->fetchAll( PDO::FETCH_NUM ) as $position => $row ) { + $decorated[] = array( + 'key' => $this->next_seeded_rand_value( $ordering['seed'], $state ), + 'position' => $position, + 'row' => $row, + ); + } + + usort( + $decorated, + function ( array $left, array $right ) use ( $ordering ): int { + if ( $left['key'] === $right['key'] ) { + return $left['position'] <=> $right['position']; + } + + $comparison = $left['key'] <=> $right['key']; + return $ordering['descending'] ? -$comparison : $comparison; + } + ); + + $rows = array(); + foreach ( $decorated as $item ) { + $rows[] = $item['row']; + } + + return new WP_DuckDB_Result_Statement( $columns, $rows, $result->rowCount(), $column_meta ); + } + + /** + * Apply a bounded seeded RAND() WHERE predicate to materialized SELECT rows. + * + * @param WP_DuckDB_Result_Statement $result Result statement. + * @param array{seed:int,operator:int,threshold:float,rand_left:bool,tokens:array}|null $filter Parsed filter. + * @return WP_DuckDB_Result_Statement Filtered result. + */ + private function apply_seeded_rand_where_filter( WP_DuckDB_Result_Statement $result, ?array $filter ): WP_DuckDB_Result_Statement { + if ( null === $filter ) { + return $result; + } + + $columns = array(); + $column_meta = array(); + for ( $index = 0; $index < $result->columnCount(); ++$index ) { + $meta = $result->getColumnMeta( $index ); + $column_meta[] = is_array( $meta ) ? $meta : array(); + $columns[] = is_array( $meta ) && isset( $meta['name'] ) ? (string) $meta['name'] : (string) $index; + } + + $rows = array(); + $state = array(); + foreach ( $result->fetchAll( PDO::FETCH_NUM ) as $row ) { + $value = $this->next_seeded_rand_value( $filter['seed'], $state ); + $left = $filter['rand_left'] ? $value : $filter['threshold']; + $right = $filter['rand_left'] ? $filter['threshold'] : $value; + if ( $this->compare_seeded_rand_where_values( $left, $filter['operator'], $right ) ) { + $rows[] = $row; + } + } + + return new WP_DuckDB_Result_Statement( $columns, $rows, $result->rowCount(), $column_meta ); + } + + /** + * Apply simple ORDER BY result-column ordering after a seeded RAND() WHERE filter. + * + * @param WP_DuckDB_Result_Statement $result Result statement. + * @param array{items:array,tokens:array}|null $ordering Parsed ordering. + * @return WP_DuckDB_Result_Statement Ordered result. + */ + private function apply_seeded_rand_where_ordering( WP_DuckDB_Result_Statement $result, ?array $ordering ): WP_DuckDB_Result_Statement { + if ( null === $ordering ) { + return $result; + } + + $columns = array(); + $column_meta = array(); + for ( $index = 0; $index < $result->columnCount(); ++$index ) { + $meta = $result->getColumnMeta( $index ); + $column_meta[] = is_array( $meta ) ? $meta : array(); + $columns[] = is_array( $meta ) && isset( $meta['name'] ) ? (string) $meta['name'] : (string) $index; + } + + $column_indexes = array(); + foreach ( $ordering['items'] as $item ) { + if ( null !== $item['ordinal'] ) { + if ( ! array_key_exists( $item['ordinal'], $columns ) ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() WHERE ORDER BY ordinal is outside the result column range.' ); + } + $column_indexes[] = $item['ordinal']; + continue; + } + + $column_index = null; + foreach ( $columns as $index => $name ) { + if ( 0 === strcasecmp( $name, (string) $item['column'] ) ) { + $column_index = $index; + break; + } + } + if ( null === $column_index ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() WHERE ORDER BY column is not present in the result set.' ); + } + $column_indexes[] = $column_index; + } + + $rows = $result->fetchAll( PDO::FETCH_NUM ); + usort( + $rows, + function ( array $left, array $right ) use ( $ordering, $column_indexes ): int { + foreach ( $ordering['items'] as $item_index => $item ) { + $comparison = $this->compare_result_order_values( + $left[ $column_indexes[ $item_index ] ] ?? null, + $right[ $column_indexes[ $item_index ] ] ?? null + ); + if ( 0 !== $comparison ) { + return $item['descending'] ? -$comparison : $comparison; + } + } + + return 0; + } + ); + + return new WP_DuckDB_Result_Statement( $columns, $rows, $result->rowCount(), $column_meta ); + } + + /** + * Compare two values with a seeded RAND() WHERE comparison operator. + * + * @param float $left Left value. + * @param int $operator Comparison operator token ID. + * @param float $right Right value. + * @return bool Comparison result. + */ + private function compare_seeded_rand_where_values( float $left, int $operator, float $right ): bool { + switch ( $operator ) { + case WP_MySQL_Lexer::LESS_THAN_OPERATOR: + return $left < $right; + case WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR: + return $left <= $right; + case WP_MySQL_Lexer::GREATER_THAN_OPERATOR: + return $left > $right; + case WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR: + return $left >= $right; + case WP_MySQL_Lexer::EQUAL_OPERATOR: + return $left === $right; + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported seeded RAND() WHERE comparison operator.' ); + } + + /** + * Compare materialized result values for simple ORDER BY. + * + * @param mixed $left Left value. + * @param mixed $right Right value. + * @return int Comparison result. + */ + private function compare_result_order_values( $left, $right ): int { + if ( null === $left && null === $right ) { + return 0; + } + if ( null === $left ) { + return -1; + } + if ( null === $right ) { + return 1; + } + if ( is_numeric( $left ) && is_numeric( $right ) ) { + return (float) $left <=> (float) $right; + } + + return strcmp( (string) $left, (string) $right ); + } + /** * Apply MySQL's seeded RAND(N) sequence to materialized SELECT rows. * * @param WP_DuckDB_Result_Statement $result Result statement. - * @param array $expressions Seeded RAND expressions. + * @param array $expressions Seeded RAND expressions. * @return WP_DuckDB_Result_Statement Result with seeded RAND columns replaced. */ private function apply_seeded_rand_select_expressions( WP_DuckDB_Result_Statement $result, array $expressions ): WP_DuckDB_Result_Statement { @@ -1123,10 +1759,16 @@ private function apply_seeded_rand_select_expressions( WP_DuckDB_Result_Statemen $state = array(); foreach ( $rows as &$row ) { foreach ( $expressions as $expression ) { - if ( ! array_key_exists( $expression['column'], $row ) ) { + $column = $expression['column'] < 0 + ? count( $row ) + $expression['column'] + : $expression['column']; + if ( ! array_key_exists( $column, $row ) ) { throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() result column was not found in DuckDB SELECT result.' ); } - $row[ $expression['column'] ] = $this->next_seeded_rand_value( $expression['seed'], $state ); + $seed = null === $expression['seed'] + ? $this->normalize_seeded_rand_seed( $row[ $column ] ) + : $expression['seed']; + $row[ $column ] = $this->next_seeded_rand_value( $seed, $state ); } } unset( $row ); @@ -2334,12 +2976,263 @@ private function execute_variable_select( array $tokens ): ?WP_DuckDB_Result_Sta $columns[] = $variable['alias']; $row[] = 'user' === $variable['type'] ? $this->get_user_variable( $variable['name'] ) - : $this->get_session_system_variable( $variable['name'] ); + : $this->get_system_variable( $variable['name'], $variable['scope'] ); } return new WP_DuckDB_Result_Statement( $columns, array( $row ), 0 ); } + /** + * Execute a simple SELECT list containing supported MySQL variable expressions. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement|null Statement when emulated, null for generic SELECT handling. + */ + private function execute_variable_expression_select( array $tokens ): ?WP_DuckDB_Result_Statement { + $select_items = $this->parse_variable_expression_select_items( $tokens ); + if ( null === $select_items ) { + return null; + } + + $has_variable = false; + $sql_items = array(); + foreach ( $select_items as $item ) { + $translated = $this->translate_variable_expression_tokens_to_duckdb_sql( $item['tokens'] ); + if ( null === $translated ) { + return null; + } + $has_variable = $has_variable || $translated['has_variable']; + $sql_items[] = $translated['sql'] . ' AS ' . $this->connection->quote_identifier( $item['alias'] ); + } + + if ( ! $has_variable ) { + return null; + } + + return $this->execute_duckdb_query( + 'SELECT ' . implode( ', ', $sql_items ), + 'Failed to execute DuckDB MySQL variable expression SELECT' + ); + } + + /** + * Parse a simple variable-expression SELECT list. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return array,alias:string}>|null Parsed items. + */ + private function parse_variable_expression_select_items( array $tokens ): ?array { + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $select_list_end = $this->top_level_select_list_end( $tokens ); + if ( $select_list_end <= 1 ) { + return null; + } + + if ( $select_list_end < count( $tokens ) ) { + if ( + ! isset( $tokens[ $select_list_end + 1 ] ) + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $select_list_end ]->id + || WP_MySQL_Lexer::DUAL_SYMBOL !== $tokens[ $select_list_end + 1 ]->id + || count( $tokens ) !== $select_list_end + 2 + ) { + return null; + } + } + + $items = array(); + foreach ( $this->split_top_level_select_item_ranges( $tokens, 1, $select_list_end ) as $range ) { + if ( $this->select_item_is_wildcard( $range['tokens'] ) ) { + return null; + } + + $item = $this->parse_variable_expression_select_item( $range['tokens'] ); + if ( null === $item || count( $item['tokens'] ) === 0 ) { + return null; + } + + $items[] = $item; + } + + return count( $items ) > 0 ? $items : null; + } + + /** + * Parse a SELECT-list item and its SQLite-compatible result label. + * + * @param WP_Parser_Token[] $tokens SELECT item tokens. + * @return array{tokens:array,alias:string}|null Parsed item. + */ + private function parse_variable_expression_select_item( array $tokens ): ?array { + $as_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::AS_SYMBOL ); + if ( null !== $as_index ) { + if ( 0 === $as_index || count( $tokens ) !== $as_index + 2 ) { + return null; + } + + return array( + 'tokens' => array_slice( $tokens, 0, $as_index ), + 'alias' => $this->identifier_value( $tokens[ $as_index + 1 ] ), + ); + } + + if ( $this->select_item_has_implicit_alias( $tokens ) ) { + $alias = $this->identifier_value( $tokens[ count( $tokens ) - 1 ] ); + return array( + 'tokens' => array_slice( $tokens, 0, -1 ), + 'alias' => $alias, + ); + } + + return array( + 'tokens' => $tokens, + 'alias' => $this->variable_expression_select_label( $tokens ), + ); + } + + /** + * Translate expression tokens after replacing supported variable references with literals. + * + * @param WP_Parser_Token[] $tokens Expression tokens. + * @return array{sql:string,has_variable:bool}|null Translated expression, or null when variables are unsupported. + */ + private function translate_variable_expression_tokens_to_duckdb_sql( array $tokens ): ?array { + $pieces = array(); + $has_variable = false; + + for ( $index = 0; $index < count( $tokens ); ++$index ) { + $token = $tokens[ $index ]; + if ( $this->is_user_variable_token( $token ) ) { + $pieces[] = $this->duckdb_literal_sql( $this->get_user_variable( $this->user_variable_name( $token ) ) ); + $has_variable = true; + continue; + } + + $system_variable = $this->parse_system_variable_reference_at( $tokens, $index ); + if ( null !== $system_variable ) { + $pieces[] = $this->duckdb_literal_sql( + $this->get_system_variable( $system_variable['name'], $system_variable['scope'] ) + ); + $has_variable = true; + $index += $system_variable['length'] - 1; + continue; + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $token->id ) { + return null; + } + + $pieces[] = $this->translate_token_to_duckdb_sql( $token ); + } + + return array( + 'sql' => $this->join_sql_pieces( $pieces ), + 'has_variable' => $has_variable, + ); + } + + /** + * Build a DuckDB SQL literal for an emulated variable value. + * + * @param mixed $value Variable value. + * @return string SQL literal. + */ + private function duckdb_literal_sql( $value ): string { + if ( null === $value ) { + return 'NULL'; + } + if ( is_int( $value ) || is_float( $value ) ) { + return (string) $value; + } + + return $this->connection->quote( (string) $value ); + } + + /** + * Build a SQLite-style default result label for a variable expression. + * + * @param WP_Parser_Token[] $tokens Expression tokens. + * @return string Label. + */ + private function variable_expression_select_label( array $tokens ): string { + $label = ''; + for ( $index = 0; $index < count( $tokens ); ++$index ) { + $system_variable = $this->parse_system_variable_reference_at( $tokens, $index ); + if ( null !== $system_variable ) { + $label .= $this->concatenate_token_bytes( array_slice( $tokens, $index, $system_variable['length'] ) ); + $index += $system_variable['length'] - 1; + continue; + } + + $token = $tokens[ $index ]; + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + $label = rtrim( $label ) . '('; + continue; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id ) { + $label = rtrim( $label ) . ')'; + continue; + } + if ( WP_MySQL_Lexer::COMMA_SYMBOL === $token->id ) { + $label = rtrim( $label ) . ', '; + continue; + } + if ( $this->is_label_spaced_operator_token( $token ) ) { + $label = rtrim( $label ) . ' ' . $token->get_bytes() . ' '; + continue; + } + + if ( '' !== $label && ! $this->label_ends_with_separator( $label ) ) { + $label .= ' '; + } + $label .= $token->get_bytes(); + } + + return trim( $label ); + } + + /** + * Check whether a token is rendered with surrounding spaces in a result label. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether to add surrounding spaces. + */ + private function is_label_spaced_operator_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::PLUS_OPERATOR, + WP_MySQL_Lexer::MINUS_OPERATOR, + WP_MySQL_Lexer::MULT_OPERATOR, + WP_MySQL_Lexer::DIV_OPERATOR, + WP_MySQL_Lexer::MOD_OPERATOR, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::NULL_SAFE_EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_THAN_OPERATOR, + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + ), + true + ); + } + + /** + * Check whether a generated label already has a separator before the next token. + * + * @param string $label Label built so far. + * @return bool Whether no extra separator is needed. + */ + private function label_ends_with_separator( string $label ): bool { + return '' === $label + || ' ' === substr( $label, -1 ) + || '(' === substr( $label, -1 ) + || '.' === substr( $label, -1 ); + } + /** * Parse a simple SELECT list of supported MySQL variables. * @@ -2389,31 +3282,17 @@ private function parse_variable_select( array $tokens ): ?array { * Parse one supported @@session_variable reference. * * @param WP_Parser_Token[] $tokens Reference tokens. - * @return array{name:string,alias:string}|null Variable, or null when the item is not supported by this slice. + * @return array{name:string,alias:string,scope:string}|null Variable, or null when the item is not supported by this slice. */ private function parse_session_system_variable_reference( array $tokens ): ?array { - if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[0]->id ) { - return null; - } - - $session_scoped = false; - if ( - isset( $tokens[3] ) - && WP_MySQL_Lexer::SESSION_SYMBOL === $tokens[1]->id - && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[2]->id - ) { - $name = $this->session_system_variable_name( $tokens[3] ); - $session_scoped = true; - $index = 4; - } else { - $name = $this->session_system_variable_name( $tokens[1] ); - $index = 2; - } - - if ( null === $name || ! $this->is_supported_session_system_variable_reference( $name, $session_scoped ) ) { + $reference = $this->parse_system_variable_reference_at( $tokens, 0 ); + if ( null === $reference ) { return null; } + $name = $reference['name']; + $scope = $reference['scope']; + $index = $reference['length']; $alias = $this->concatenate_token_bytes( array_slice( $tokens, 0, $index ) ); if ( isset( $tokens[ $index ] ) ) { if ( 'sql_mode' !== $name ) { @@ -2437,6 +3316,49 @@ private function parse_session_system_variable_reference( array $tokens ): ?arra return array( 'name' => $name, 'alias' => $alias, + 'scope' => $scope, + ); + } + + /** + * Parse a supported @@system-variable reference at a token offset. + * + * @param WP_Parser_Token[] $tokens Tokens. + * @param int $index Current token offset. + * @return array{name:string,scope:string,length:int}|null Parsed reference. + */ + private function parse_system_variable_reference_at( array $tokens, int $index ): ?array { + if ( + ! isset( $tokens[ $index ], $tokens[ $index + 1 ] ) + || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[ $index ]->id + ) { + return null; + } + + $scope = 'session'; + $explicit_scope = false; + if ( + isset( $tokens[ $index + 3 ] ) + && ( WP_MySQL_Lexer::SESSION_SYMBOL === $tokens[ $index + 1 ]->id || WP_MySQL_Lexer::GLOBAL_SYMBOL === $tokens[ $index + 1 ]->id ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 2 ]->id + ) { + $name = $this->session_system_variable_name( $tokens[ $index + 3 ] ); + $scope = WP_MySQL_Lexer::GLOBAL_SYMBOL === $tokens[ $index + 1 ]->id ? 'global' : 'session'; + $explicit_scope = true; + $length = 4; + } else { + $name = $this->session_system_variable_name( $tokens[ $index + 1 ] ); + $length = 2; + } + + if ( null === $name || ! $this->is_supported_system_variable_reference( $name, $scope, $explicit_scope ) ) { + return null; + } + + return array( + 'name' => $name, + 'scope' => $scope, + 'length' => $length, ); } @@ -2524,15 +3446,24 @@ private function is_user_variable_token( WP_Parser_Token $token ): bool { * Check whether this bounded slice supports a system-variable reference. * * @param string $name Lowercase variable name. - * @param bool $session_scoped Whether the reference uses @@SESSION. + * @param string $scope Variable scope: session or global. + * @param bool $explicit_scope Whether the reference uses @@scope.name. * @return bool Whether the variable is supported. */ - private function is_supported_session_system_variable_reference( string $name, bool $session_scoped ): bool { + private function is_supported_system_variable_reference( string $name, string $scope, bool $explicit_scope ): bool { + if ( 'global' === $scope ) { + return isset( self::READ_ONLY_GLOBAL_SYSTEM_VARIABLES[ $name ] ); + } + if ( isset( self::SUPPORTED_SESSION_SYSTEM_VARIABLES[ $name ] ) ) { return true; } - return ! $session_scoped && isset( self::READ_ONLY_SYSTEM_VARIABLES[ $name ] ); + if ( isset( self::READ_ONLY_SESSION_SYSTEM_VARIABLES[ $name ] ) ) { + return true; + } + + return ! $explicit_scope && isset( self::READ_ONLY_SYSTEM_VARIABLES[ $name ] ); } /** @@ -2732,7 +3663,7 @@ private function execute_create_table( array $tokens ): WP_DuckDB_Result_Stateme } $table_sql .= $this->connection->quote_identifier( $table_name ); $table_sql .= ' ('; - $table_sql .= implode( ', ', array_merge( $columns, $constraints ) ); + $table_sql .= implode( ', ', array_merge( $columns, $this->native_create_table_constraints( $constraints, $foreign_keys, $temporary ) ) ); $table_sql .= ')'; $result = $this->execute_duckdb_query( $table_sql, 'Failed to create DuckDB table' ); @@ -2825,7 +3756,7 @@ private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { if ( null !== $this->find_on_duplicate_key_update_index( $tokens ) ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT statement in DuckDB driver. INSERT ... SELECT ... ON DUPLICATE KEY UPDATE is not supported.' ); } - return $this->execute_insert_select_with_temporal_coercion( $tokens, $index, $select_index, $ignore ); + return $this->execute_insert_select_with_write_coercion( $tokens, $index, $select_index, $ignore ); } $set_index = $this->find_insert_set_index( $tokens, $index ); @@ -2906,7 +3837,7 @@ private function execute_replace( array $tokens ): WP_DuckDB_Result_Statement { $select_index = $this->find_insert_select_index( $tokens, $index ); if ( null !== $select_index ) { - return $this->execute_replace_select_with_temporal_coercion( $tokens, $index, $select_index ); + return $this->execute_replace_select_with_write_coercion( $tokens, $index, $select_index ); } $this->assert_values_write_statement( $tokens, $index, 'REPLACE' ); @@ -2943,32 +3874,182 @@ private function execute_update( array $tokens ): WP_DuckDB_Result_Statement { if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $index ]->id ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only simple single-table UPDATE is supported.' ); } - ++$index; + ++$index; + + $clauses = $this->dml_clause_indexes( $tokens, $index ); + $update_end = $clauses['where'] ?? $clauses['order'] ?? $clauses['limit'] ?? count( $tokens ); + $update_tokens = array_slice( $tokens, $index, $update_end - $index ); + if ( count( $update_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE list is required.' ); + } + + $seeded_rand_where = $this->parse_seeded_rand_dml_where_clause( $tokens, $clauses ); + if ( null !== $seeded_rand_where ) { + if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB UPDATE WHERE is not supported with ORDER BY or LIMIT.' ); + } + if ( $this->update_assignment_tokens_contain_literal_seeded_rand( $update_tokens ) ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB driver cannot be used as both an UPDATE assignment and a WHERE predicate.' ); + } + return $this->execute_update_with_seeded_rand_where( $reference, $update_tokens, $seeded_rand_where ); + } + + if ( $this->update_assignment_tokens_contain_literal_seeded_rand( $update_tokens ) ) { + return $this->execute_update_with_seeded_rand_assignments( $tokens, $reference, $clauses, $update_tokens ); + } + + $sql = 'UPDATE ' . $this->dml_table_reference_sql( $reference ) + . ' SET ' + . $this->translate_update_assignment_tokens_to_duckdb_sql( $update_tokens, $reference ); + + if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { + $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'UPDATE', $reference['temporary'] ); + $sql .= ' WHERE rowid IN ( ' + . $this->dml_rowid_subquery_sql( $tokens, $clauses, $reference ) + . ' )'; + } elseif ( null !== $clauses['where'] ) { + $where_end = $clauses['order'] ?? $clauses['limit'] ?? count( $tokens ); + $sql .= ' WHERE ' . $this->translate_tokens_to_duckdb_sql( + array_slice( $tokens, $clauses['where'] + 1, $where_end - $clauses['where'] - 1 ) + ); + } + + return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB UPDATE' ); + } + + /** + * Parse a supported seeded RAND() UPDATE WHERE predicate. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param array{where:int|null,order:int|null,limit:int|null} $clauses DML clause indexes. + * @return array{seed:int,operator:int,threshold:float,rand_left:bool}|null Parsed predicate, or null when unsupported/not present. + */ + private function parse_seeded_rand_dml_where_clause( array $tokens, array $clauses ): ?array { + if ( null === $clauses['where'] ) { + return null; + } + + $where_end = $clauses['order'] ?? $clauses['limit'] ?? count( $tokens ); + $where_tokens = array_slice( $tokens, $clauses['where'] + 1, $where_end - $clauses['where'] - 1 ); + return $this->parse_seeded_rand_where_predicate( $where_tokens ); + } + + /** + * Execute an UPDATE with a supported seeded RAND() WHERE predicate. + * + * @param array{table_name:string,requested_table_name:string,alias:string|null,next_index:int,temporary:bool} $reference Parsed table reference. + * @param WP_Parser_Token[] $update_tokens Update-list tokens. + * @param array{seed:int,operator:int,threshold:float,rand_left:bool} $filter Parsed seeded RAND() predicate. + * @return WP_DuckDB_Result_Statement + */ + private function execute_update_with_seeded_rand_where( array $reference, array $update_tokens, array $filter ): WP_DuckDB_Result_Statement { + $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'UPDATE', $reference['temporary'] ); + + $stmt = $this->execute_duckdb_query( + 'SELECT rowid FROM ' . $this->dml_table_reference_sql( $reference ) . ' ORDER BY rowid', + 'Failed to inspect DuckDB UPDATE target rows' + ); + + $rowids = array(); + $state = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN ) as $rowid ) { + $value = $this->next_seeded_rand_value( $filter['seed'], $state ); + $left = $filter['rand_left'] ? $value : $filter['threshold']; + $right = $filter['rand_left'] ? $filter['threshold'] : $value; + if ( $this->compare_seeded_rand_where_values( $left, $filter['operator'], $right ) ) { + $rowids[] = (int) $rowid; + } + } + + if ( count( $rowids ) === 0 ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + $assignment_sql = $this->translate_update_assignment_tokens_to_duckdb_sql( $update_tokens, $reference ); + $sql = 'UPDATE ' . $this->dml_table_reference_sql( $reference ) + . ' SET ' . $assignment_sql + . ' WHERE rowid IN (' . implode( ', ', array_map( 'strval', $rowids ) ) . ')'; + + return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB UPDATE' ); + } + + /** + * Execute an UPDATE whose assignment list contains literal seeded RAND() calls. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param array{table_name:string,requested_table_name:string,alias:string|null,next_index:int,temporary:bool} $reference Parsed table reference. + * @param array{where:int|null,order:int|null,limit:int|null} $clauses DML clause indexes. + * @param WP_Parser_Token[] $update_tokens Update-list tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_update_with_seeded_rand_assignments( array $tokens, array $reference, array $clauses, array $update_tokens ): WP_DuckDB_Result_Statement { + $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'UPDATE', $reference['temporary'] ); + + if ( null === $clauses['order'] && null !== $clauses['limit'] ) { + $rowid_sql = $this->dml_rowid_subquery_sql( + $tokens, + array( + 'where' => $clauses['where'], + 'order' => null, + 'limit' => null, + ), + $reference + ); + $rowid_sql .= ' ORDER BY rowid ' + . $this->translate_tokens_to_duckdb_sql( array_slice( $tokens, $clauses['limit'] ) ); + } else { + $rowid_sql = $this->dml_rowid_subquery_sql( $tokens, $clauses, $reference ); + } + if ( null === $clauses['order'] && null === $clauses['limit'] ) { + $rowid_sql .= ' ORDER BY rowid'; + } + + $stmt = $this->execute_duckdb_query( $rowid_sql, 'Failed to inspect DuckDB UPDATE target rows' ); + $rowids = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN ) as $rowid ) { + $rowids[] = (int) $rowid; + } + + if ( count( $rowids ) === 0 ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } - $clauses = $this->dml_clause_indexes( $tokens, $index ); - $update_end = $clauses['where'] ?? $clauses['order'] ?? $clauses['limit'] ?? count( $tokens ); - $update_tokens = array_slice( $tokens, $index, $update_end - $index ); - if ( count( $update_tokens ) === 0 ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE list is required.' ); + $started_transaction = ! $this->connection->inTransaction(); + if ( $started_transaction ) { + $this->connection->beginTransaction(); } - $sql = 'UPDATE ' . $this->dml_table_reference_sql( $reference ) - . ' SET ' - . $this->translate_update_assignment_tokens_to_duckdb_sql( $update_tokens, $reference ); + $affected_rows = 0; + $seeded_rand_state = array(); + $target_sql = $this->dml_table_reference_sql( $reference ); + try { + foreach ( $rowids as $rowid ) { + $assignment_sql = $this->translate_update_assignment_tokens_to_duckdb_sql_with_seeded_rand( + $update_tokens, + $reference, + true, + $seeded_rand_state + ); + $result = $this->execute_duckdb_query( + 'UPDATE ' . $target_sql + . ' SET ' . $assignment_sql + . ' WHERE rowid = ' . (string) $rowid, + 'Failed to execute DuckDB UPDATE' + ); + $affected_rows += $result->rowCount(); + } - if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { - $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'UPDATE', $reference['temporary'] ); - $sql .= ' WHERE rowid IN ( ' - . $this->dml_rowid_subquery_sql( $tokens, $clauses, $reference ) - . ' )'; - } elseif ( null !== $clauses['where'] ) { - $where_end = $clauses['order'] ?? $clauses['limit'] ?? count( $tokens ); - $sql .= ' WHERE ' . $this->translate_tokens_to_duckdb_sql( - array_slice( $tokens, $clauses['where'] + 1, $where_end - $clauses['where'] - 1 ) - ); + if ( $started_transaction ) { + $this->connection->commit(); + } + } catch ( Throwable $e ) { + if ( $started_transaction && $this->connection->inTransaction() ) { + $this->connection->rollback(); + } + throw $e; } - return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB UPDATE' ); + return new WP_DuckDB_Result_Statement( array(), array(), $affected_rows ); } /** @@ -3044,32 +4125,48 @@ private function execute_joined_update( array $shape ): WP_DuckDB_Result_Stateme $target = $references[ $update['target_index'] ]; $sources = array(); foreach ( $references as $index => $reference ) { - if ( $update['target_index'] !== $index ) { + if ( $update['include_target_reference_in_from'] || $update['target_index'] !== $index ) { $sources[] = $reference; } } - $sql = 'UPDATE ' - . $this->connection->quote_identifier( $target['table_name'] ) - . ' AS ' - . $this->connection->quote_identifier( $target['alias'] ) - . ' SET ' - . $update['sql'] - . ' FROM ' - . implode( ', ', array_column( $sources, 'sql' ) ); - - $where_clauses = array(); + $target_alias_sql = $update['include_target_reference_in_from'] + ? '' + : ' AS ' . $this->connection->quote_identifier( $target['alias'] ); + $source_sql = implode( ', ', array_column( $sources, 'sql' ) ); + $where_clauses = array(); if ( count( $shape['where_tokens'] ) > 0 ) { $where_clauses[] = $this->translate_tokens_to_duckdb_sql( $shape['where_tokens'] ); } foreach ( $shape['join_predicates'] as $predicate ) { $where_clauses[] = $this->joined_dml_predicate_sql( $predicate ); } + + $count_sql = 'SELECT COUNT(*) AS affected FROM ' + . $this->connection->quote_identifier( $target['table_name'] ) + . $target_alias_sql + . ' WHERE EXISTS ( SELECT 1 FROM ' + . $source_sql; + if ( count( $where_clauses ) > 0 ) { + $count_sql .= ' WHERE (' . implode( ') AND (', $where_clauses ) . ')'; + } + $count_sql .= ' )'; + $count_row = $this->execute_duckdb_query( $count_sql, 'Failed to count DuckDB joined UPDATE targets' )->fetch( PDO::FETCH_ASSOC ); + $row_count = false === $count_row ? 0 : (int) $count_row['affected']; + + $sql = 'UPDATE ' + . $this->connection->quote_identifier( $target['table_name'] ) + . $target_alias_sql + . ' SET ' + . $update['sql'] + . ' FROM ' + . $source_sql; if ( count( $where_clauses ) > 0 ) { $sql .= ' WHERE (' . implode( ') AND (', $where_clauses ) . ')'; } - return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB joined UPDATE' ); + $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB joined UPDATE' ); + return new WP_DuckDB_Result_Statement( array(), array(), $row_count ); } /** @@ -3091,6 +4188,14 @@ private function execute_delete( array $tokens ): WP_DuckDB_Result_Statement { $reference = $this->parse_single_table_dml_reference( $tokens, 2, 'DELETE' ); $clauses = $this->dml_clause_indexes( $tokens, $reference['next_index'] ); + $seeded_rand_where = $this->parse_seeded_rand_dml_where_clause( $tokens, $clauses ); + if ( null !== $seeded_rand_where ) { + if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB DELETE WHERE is not supported with ORDER BY or LIMIT.' ); + } + return $this->execute_delete_with_seeded_rand_where( $reference, $seeded_rand_where ); + } + if ( null !== $clauses['order'] || null !== $clauses['limit'] ) { $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'DELETE', $reference['temporary'] ); $sql = 'DELETE FROM ' . $this->connection->quote_identifier( $reference['table_name'] ) @@ -3109,6 +4214,43 @@ private function execute_delete( array $tokens ): WP_DuckDB_Result_Statement { return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB DELETE' ); } + /** + * Execute a DELETE with a supported seeded RAND() WHERE predicate. + * + * @param array{table_name:string,requested_table_name:string,alias:string|null,next_index:int,temporary:bool} $reference Parsed table reference. + * @param array{seed:int,operator:int,threshold:float,rand_left:bool} $filter Parsed seeded RAND() predicate. + * @return WP_DuckDB_Result_Statement + */ + private function execute_delete_with_seeded_rand_where( array $reference, array $filter ): WP_DuckDB_Result_Statement { + $this->assert_dml_rowid_rewrite_supported( $reference['table_name'], 'DELETE', $reference['temporary'] ); + + $stmt = $this->execute_duckdb_query( + 'SELECT rowid FROM ' . $this->dml_table_reference_sql( $reference ) . ' ORDER BY rowid', + 'Failed to inspect DuckDB DELETE target rows' + ); + + $rowids = array(); + $state = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN ) as $rowid ) { + $value = $this->next_seeded_rand_value( $filter['seed'], $state ); + $left = $filter['rand_left'] ? $value : $filter['threshold']; + $right = $filter['rand_left'] ? $filter['threshold'] : $value; + if ( $this->compare_seeded_rand_where_values( $left, $filter['operator'], $right ) ) { + $rowids[] = (int) $rowid; + } + } + + if ( count( $rowids ) === 0 ) { + return new WP_DuckDB_Result_Statement( array(), array(), 0 ); + } + + return $this->execute_duckdb_query( + 'DELETE FROM ' . $this->connection->quote_identifier( $reference['table_name'] ) + . ' WHERE rowid IN (' . implode( ', ', array_map( 'strval', $rowids ) ) . ')', + 'Failed to execute DuckDB DELETE' + ); + } + /** * Parse supported multi-table DELETE shapes. * @@ -3345,6 +4487,7 @@ private function parse_multi_delete_table_references( array $tokens ): array { if ( $this->contains_top_level_join_token( $tokens ) || $this->contains_top_level_derived_table_factor( $tokens ) + || $this->contains_information_schema_reference( $tokens ) ) { return $this->parse_joined_multi_delete_table_references( $tokens ); } @@ -3576,6 +4719,10 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo ++$index; if ( 0 === strcasecmp( $database, 'information_schema' ) ) { + if ( 'DELETE' === $statement && ! $is_target && 0 === strcasecmp( $table_name, 'tables' ) ) { + return $this->parse_joined_delete_information_schema_tables_source( $tokens, $index, $table_name ); + } + throw new WP_DuckDB_Driver_Exception( "Access denied for user 'duckdb'@'%' to database 'information_schema'" ); } @@ -3623,6 +4770,45 @@ private function parse_joined_update_table_factor( array $tokens, int $index, bo ); } + /** + * Parse information_schema.tables as a read-only joined DELETE source. + * + * @param WP_Parser_Token[] $tokens Table reference tokens. + * @param int $index Current index after information_schema.tables. + * @param string $table_name Referenced information_schema table name. + * @return array{reference:array{alias:string,explicit_alias:bool,sql:string,table_name:null,temporary:bool,requested_table_name:string},next_index:int} + */ + private function parse_joined_delete_information_schema_tables_source( array $tokens, int $index, string $table_name ): array { + $this->refresh_information_schema_tables_table(); + + $alias = $table_name; + $explicit_alias = false; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $alias = $this->identifier_value( $tokens[ $index ] ?? null ); + $explicit_alias = true; + ++$index; + } elseif ( isset( $tokens[ $index ] ) && ! $this->is_joined_update_table_reference_boundary( $tokens[ $index ] ) ) { + $alias = $this->identifier_value( $tokens[ $index ] ); + $explicit_alias = true; + ++$index; + } + + return array( + 'reference' => array( + 'alias' => $alias, + 'explicit_alias' => $explicit_alias, + 'sql' => $this->connection->quote_identifier( self::INFO_SCHEMA_TABLES_TABLE ) + . ' AS ' + . $this->connection->quote_identifier( $alias ), + 'table_name' => null, + 'temporary' => false, + 'requested_table_name' => 'information_schema.' . $table_name, + ), + 'next_index' => $index, + ); + } + /** * Parse a joined UPDATE join chain. * @@ -3643,6 +4829,38 @@ private function parse_joined_update_join_chain( array $tokens, int $index, arra $join_type = 'CROSS'; ++$index; $this->expect_token( $tokens, $index, WP_MySQL_Lexer::JOIN_SYMBOL, 'Expected JOIN after CROSS.' ); + } elseif ( WP_MySQL_Lexer::NATURAL_SYMBOL === $tokens[ $index ]->id ) { + if ( 'UPDATE' !== $statement ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported.' ); + } + $join_type = 'NATURAL_AS_CROSS'; + ++$index; + if ( + isset( $tokens[ $index ] ) + && ( + WP_MySQL_Lexer::LEFT_SYMBOL === $tokens[ $index ]->id + || WP_MySQL_Lexer::RIGHT_SYMBOL === $tokens[ $index ]->id + ) + ) { + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OUTER_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + } + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::JOIN_SYMBOL, 'Expected JOIN after NATURAL.' ); + } elseif ( + WP_MySQL_Lexer::LEFT_SYMBOL === $tokens[ $index ]->id + || WP_MySQL_Lexer::RIGHT_SYMBOL === $tokens[ $index ]->id + ) { + if ( 'UPDATE' !== $statement ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported.' ); + } + $join_type = 'OUTER_AS_INNER'; + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OUTER_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::JOIN_SYMBOL, 'Expected JOIN after LEFT or RIGHT.' ); } elseif ( WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === $tokens[ $index ]->id ) { if ( 'UPDATE' !== $statement ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported.' ); @@ -3678,9 +4896,12 @@ private function parse_joined_update_join_chain( array $tokens, int $index, arra } if ( 'CROSS' === $join_type ) { - if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $index ]->id ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver. CROSS JOIN ... ON is not supported.' ); + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $index ]->id ) { + $left_reference = $source['reference']; + continue; } + } + if ( 'NATURAL_AS_CROSS' === $join_type ) { $left_reference = $source['reference']; continue; } @@ -4586,11 +5807,7 @@ private function normalize_set_session_system_variable_value_tokens( string $nam } if ( $this->is_user_variable_token( $tokens[0] ) ) { - if ( ! in_array( $name, array( 'foreign_key_checks', 'unique_checks' ), true ) ) { - throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); - } - - return $this->normalize_set_dump_check_variable_value( + return $this->normalize_set_session_system_variable_stored_value( $name, $this->get_user_variable( $this->user_variable_name( $tokens[0] ) ) ); @@ -4613,6 +5830,9 @@ private function normalize_set_session_system_variable_value( string $name, WP_P if ( 'default_storage_engine' === $name ) { return $this->normalize_set_default_storage_engine_value( $token ); } + if ( isset( self::STRING_SESSION_SYSTEM_VARIABLES[ $name ] ) ) { + return $this->normalize_set_string_session_system_variable_value( $name, $token ); + } $value = $token->get_value(); $lower = strtolower( $value ); @@ -4648,6 +5868,29 @@ private function normalize_set_session_system_variable_value( string $name, WP_P throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); } + /** + * Normalize a SET string-valued session variable for driver-local readback. + * + * @param string $name Normalized variable name. + * @param WP_Parser_Token $token Value token. + * @return string Normalized variable value. + */ + private function normalize_set_string_session_system_variable_value( string $name, WP_Parser_Token $token ): string { + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $token->get_value(); + } + + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id || $this->is_non_identifier_token( $token ) ) { + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + if ( 'default' === strtolower( $token->get_value() ) ) { + return 'DEFAULT'; + } + + return $token->get_value(); + } + /** * Normalize a SET default_storage_engine value for driver-local readback. * @@ -4655,67 +5898,223 @@ private function normalize_set_session_system_variable_value( string $name, WP_P * @return string Normalized storage engine value. */ private function normalize_set_default_storage_engine_value( WP_Parser_Token $token ): string { - if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { - return $token->get_value(); + return $this->normalize_set_string_session_system_variable_value( 'default_storage_engine', $token ); + } + + /** + * Normalize a restored dump check variable value. + * + * @param string $name Normalized variable name. + * @param mixed $value Restored user-variable value. + * @return int|string|null Normalized stored value. + */ + private function normalize_set_dump_check_variable_value( string $name, $value ) { + if ( null === $value || 'DEFAULT' === $value ) { + if ( ! in_array( $name, array( 'foreign_key_checks', 'unique_checks' ), true ) ) { + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + return $value; + } + + if ( is_int( $value ) && ( 0 === $value || 1 === $value ) ) { + return $value; + } + + if ( is_string( $value ) ) { + $lower = strtolower( $value ); + if ( 'on' === $lower || 'true' === $lower || '1' === $value ) { + return 1; + } + if ( 'off' === $lower || 'false' === $lower || '0' === $value ) { + return 0; + } + } + + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + /** + * Normalize a session system variable value restored from an emulated user variable. + * + * @param string $name Normalized variable name. + * @param mixed $value Stored user-variable value. + * @return int|string|null Normalized stored value. + */ + private function normalize_set_session_system_variable_stored_value( string $name, $value ) { + if ( 'sql_mode' === $name ) { + if ( ! is_scalar( $value ) ) { + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + return implode( ',', $this->normalize_sql_modes( (string) $value ) ); + } + + if ( isset( self::STRING_SESSION_SYSTEM_VARIABLES[ $name ] ) ) { + if ( null === $value || 'DEFAULT' === $value ) { + return $value; + } + if ( is_scalar( $value ) ) { + return (string) $value; + } + + throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + } + + return $this->normalize_set_dump_check_variable_value( $name, $value ); + } + + /** + * Normalize an emulated user variable assignment value. + * + * @param WP_Parser_Token[] $tokens Value tokens. + * @return int|float|string|null Normalized stored value. + */ + private function normalize_set_user_variable_value( array $tokens ) { + $system_variable = $this->parse_session_system_variable_reference( $tokens ); + if ( null !== $system_variable ) { + return $this->get_system_variable( $system_variable['name'], $system_variable['scope'] ); + } + + if ( 1 === count( $tokens ) && $this->is_user_variable_token( $tokens[0] ) ) { + return $this->get_user_variable( $this->user_variable_name( $tokens[0] ) ); + } + + $function_value = $this->normalize_set_user_variable_function_value( $tokens ); + if ( null !== $function_value['matched'] ) { + return $function_value['value']; + } + + $arithmetic_value = $this->normalize_set_user_variable_arithmetic_value( $tokens ); + if ( null !== $arithmetic_value['matched'] ) { + return $arithmetic_value['value']; + } + + return $this->normalize_set_user_variable_literal_value( $tokens ); + } + + /** + * Normalize a bounded empty function call for a user-variable assignment. + * + * @param WP_Parser_Token[] $tokens Value tokens. + * @return array{matched:bool|null,value:string|null} Match state and computed value. + */ + private function normalize_set_user_variable_function_value( array $tokens ): array { + if ( 3 !== count( $tokens ) ) { + return array( + 'matched' => null, + 'value' => null, + ); + } + + if ( $this->is_empty_function_call( $tokens, 0, 'DATABASE' ) ) { + return array( + 'matched' => true, + 'value' => $this->current_database, + ); + } + + if ( $this->is_empty_function_call( $tokens, 0, 'VERSION' ) ) { + return array( + 'matched' => true, + 'value' => $this->format_mysql_system_variable_version(), + ); + } + + return array( + 'matched' => null, + 'value' => null, + ); + } + + /** + * Normalize a bounded arithmetic expression for a user-variable assignment. + * + * @param WP_Parser_Token[] $tokens Value tokens. + * @return array{matched:bool|null,value:int|float|null} Match state and computed value. + */ + private function normalize_set_user_variable_arithmetic_value( array $tokens ): array { + if ( + 3 !== count( $tokens ) + || ! $this->is_user_variable_token( $tokens[0] ) + || ( WP_MySQL_Lexer::PLUS_OPERATOR !== $tokens[1]->id && WP_MySQL_Lexer::MINUS_OPERATOR !== $tokens[1]->id ) + ) { + return array( + 'matched' => null, + 'value' => null, + ); } - if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id || $this->is_non_identifier_token( $token ) ) { - throw $this->new_unsupported_set_session_system_variable_value_exception( 'default_storage_engine' ); + $right = $this->user_variable_arithmetic_operand_value( $tokens[2] ); + if ( null === $right['matched'] ) { + return array( + 'matched' => null, + 'value' => null, + ); } - if ( 'default' === strtolower( $token->get_value() ) ) { - return 'DEFAULT'; + $left = $this->user_variable_numeric_value( + $this->get_user_variable( $this->user_variable_name( $tokens[0] ) ) + ); + + if ( null === $left || null === $right['value'] ) { + return array( + 'matched' => true, + 'value' => null, + ); } - return $token->get_value(); + return array( + 'matched' => true, + 'value' => WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[1]->id ? $left + $right['value'] : $left - $right['value'], + ); } /** - * Normalize a restored dump check variable value. + * Normalize a bounded arithmetic operand for user-variable assignments. * - * @param string $name Normalized variable name. - * @param mixed $value Restored user-variable value. - * @return int|string|null Normalized stored value. + * @param WP_Parser_Token $token Operand token. + * @return array{matched:bool|null,value:int|float|null} Match state and numeric value. */ - private function normalize_set_dump_check_variable_value( string $name, $value ) { - if ( null === $value || 'DEFAULT' === $value ) { - return $value; - } - - if ( is_int( $value ) && ( 0 === $value || 1 === $value ) ) { - return $value; + private function user_variable_arithmetic_operand_value( WP_Parser_Token $token ): array { + if ( $this->is_number_token( $token ) ) { + return array( + 'matched' => true, + 'value' => $this->number_token_value( $token ), + ); } - if ( is_string( $value ) ) { - $lower = strtolower( $value ); - if ( 'on' === $lower || 'true' === $lower || '1' === $value ) { - return 1; - } - if ( 'off' === $lower || 'false' === $lower || '0' === $value ) { - return 0; - } + if ( $this->is_user_variable_token( $token ) ) { + return array( + 'matched' => true, + 'value' => $this->user_variable_numeric_value( $this->get_user_variable( $this->user_variable_name( $token ) ) ), + ); } - throw $this->new_unsupported_set_session_system_variable_value_exception( $name ); + return array( + 'matched' => null, + 'value' => null, + ); } /** - * Normalize an emulated user variable assignment value. + * Convert a stored user variable to a bounded numeric operand. * - * @param WP_Parser_Token[] $tokens Value tokens. - * @return int|float|string|null Normalized stored value. + * @param mixed $value Stored user-variable value. + * @return int|float|null Numeric value, or null when the variable is NULL. */ - private function normalize_set_user_variable_value( array $tokens ) { - $system_variable = $this->parse_session_system_variable_reference( $tokens ); - if ( null !== $system_variable ) { - return $this->get_session_system_variable( $system_variable['name'] ); + private function user_variable_numeric_value( $value ) { + if ( null === $value ) { + return null; } - - if ( 1 === count( $tokens ) && $this->is_user_variable_token( $tokens[0] ) ) { - return $this->get_user_variable( $this->user_variable_name( $tokens[0] ) ); + if ( is_int( $value ) || is_float( $value ) ) { + return $value; + } + if ( is_string( $value ) && is_numeric( $value ) ) { + return false !== strpos( $value, '.' ) || false !== stripos( $value, 'e' ) ? (float) $value : (int) $value; } - return $this->normalize_set_user_variable_literal_value( $tokens ); + throw $this->new_unsupported_set_user_variable_value_exception(); } /** @@ -4820,6 +6219,13 @@ private function new_unsupported_set_session_system_variable_value_exception( st 'Unsupported SET value for default_storage_engine in DuckDB driver. Only string literals, bare engine names, and DEFAULT are supported.' ); } + if ( isset( self::STRING_SESSION_SYSTEM_VARIABLES[ $name ] ) ) { + return new WP_DuckDB_Driver_Exception( + 'Unsupported SET value for ' + . $name + . ' in DuckDB driver. Only string literals, bare identifiers, DEFAULT, and supported dump restores are supported.' + ); + } return new WP_DuckDB_Driver_Exception( 'Unsupported SET value for ' @@ -4851,7 +6257,9 @@ private function normalize_supported_session_system_variable_name( string $name throw new WP_DuckDB_Driver_Exception( 'Unsupported SET session variable in DuckDB driver: ' . $name - . '. Only autocommit, big_tables, default_storage_engine, foreign_key_checks, sql_mode, sql_warnings, and unique_checks are supported.' + . '. Supported variables: ' + . implode( ', ', array_keys( self::SUPPORTED_SESSION_SYSTEM_VARIABLES ) ) + . '.' ); } @@ -5727,6 +7135,20 @@ private function is_dml_clause_start_token( WP_Parser_Token $token ): bool { * @return string DuckDB update list SQL. */ private function translate_update_assignment_tokens_to_duckdb_sql( array $tokens, array $reference ): string { + $seeded_rand_state = array(); + return $this->translate_update_assignment_tokens_to_duckdb_sql_with_seeded_rand( $tokens, $reference, false, $seeded_rand_state ); + } + + /** + * Translate UPDATE assignments, optionally rewriting seeded RAND() calls. + * + * @param WP_Parser_Token[] $tokens Update-list tokens. + * @param array{table_name:string,requested_table_name:string,alias:string|null,next_index:int} $reference Parsed table reference. + * @param bool $rewrite_seeded_rand_literals Whether literal seeded RAND() calls are supported. + * @param array $seeded_rand_state Per-statement seeded RAND() state. + * @return string DuckDB update list SQL. + */ + private function translate_update_assignment_tokens_to_duckdb_sql_with_seeded_rand( array $tokens, array $reference, bool $rewrite_seeded_rand_literals, array &$seeded_rand_state ): string { $qualifiers = array_filter( array( $reference['alias'], @@ -5761,7 +7183,11 @@ private function translate_update_assignment_tokens_to_duckdb_sql( array $tokens } $column_name = $this->identifier_value( $left_tokens[0] ); - $value_sql = $this->translate_tokens_to_duckdb_sql( $right_tokens ); + $value_sql = $this->translate_update_assignment_value_tokens_to_duckdb_sql( + $right_tokens, + $rewrite_seeded_rand_literals, + $seeded_rand_state + ); if ( isset( $metadata_map[ strtolower( $column_name ) ] ) ) { $value_sql = $this->coerce_write_value_for_column_sql( $metadata_map[ strtolower( $column_name ) ], @@ -5777,12 +7203,105 @@ private function translate_update_assignment_tokens_to_duckdb_sql( array $tokens return implode( ', ', $items ); } + /** + * Translate one UPDATE assignment value, optionally rewriting literal seeded RAND(). + * + * @param WP_Parser_Token[] $tokens Value expression tokens. + * @param bool $rewrite_seeded_rand_literals Whether literal seeded RAND() calls are supported. + * @param array $seeded_rand_state Per-statement seeded RAND() state. + * @return string DuckDB SQL. + */ + private function translate_update_assignment_value_tokens_to_duckdb_sql( array $tokens, bool $rewrite_seeded_rand_literals, array &$seeded_rand_state ): string { + if ( ! $rewrite_seeded_rand_literals ) { + return $this->translate_tokens_to_duckdb_sql( $tokens ); + } + + $seeded_rand_rewrites = $this->seeded_rand_literal_rewrite_map( $tokens, $seeded_rand_state, 'UPDATE SET' ); + if ( count( $seeded_rand_rewrites ) === 0 ) { + return $this->translate_tokens_to_duckdb_sql( $tokens ); + } + + return $this->translate_tokens_to_duckdb_sql( + $tokens, + false, + false, + false, + false, + false, + false, + false, + $seeded_rand_rewrites + ); + } + + /** + * Check whether UPDATE assignment tokens contain a literal seeded RAND() call. + * + * @param WP_Parser_Token[] $tokens Update-list tokens. + * @return bool Whether literal seeded RAND() appears in an assignment value. + */ + private function update_assignment_tokens_contain_literal_seeded_rand( array $tokens ): bool { + foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { + $equals_index = $this->find_top_level_token_index( $item, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); + if ( null === $equals_index ) { + continue; + } + + if ( $this->tokens_contain_literal_seeded_rand( array_slice( $item, $equals_index + 1 ) ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether tokens contain at least one literal seeded RAND() call. + * + * @param WP_Parser_Token[] $tokens Tokens. + * @return bool Whether a literal seeded RAND() call appears. + */ + private function tokens_contain_literal_seeded_rand( array $tokens ): bool { + for ( $index = 0; $index < count( $tokens ); ++$index ) { + if ( + ! isset( $tokens[ $index + 2 ] ) + || $this->is_non_identifier_token( $tokens[ $index ] ) + || 0 !== strcasecmp( $tokens[ $index ]->get_value(), 'RAND' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index + 2 ]->id ) { + $index += 2; + continue; + } + + $close_index = $this->matching_parenthesis_index( $tokens, $index + 1 ); + if ( null === $close_index ) { + return false; + } + + $seed_tokens = array_slice( $tokens, $index + 2, $close_index - $index - 2 ); + if ( + count( $this->split_top_level_comma_items( $seed_tokens ) ) === 1 + && null !== $this->parse_seeded_rand_literal_seed( $seed_tokens ) + ) { + return true; + } + + $index = $close_index; + } + + return false; + } + /** * Translate joined UPDATE assignments and enforce a single writable target. * * @param WP_Parser_Token[] $tokens Update-list tokens. * @param array> $references Joined references. - * @return array{target_index:int,sql:string} Resolved writable target index and DuckDB update-list SQL. + * @return array{target_index:int,include_target_reference_in_from:bool,sql:string} Resolved writable target index and DuckDB update-list SQL. */ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array $tokens, array $references ): array { $qualifier_references = array(); @@ -5805,9 +7324,11 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array } } - $items = array(); - $metadata_maps = array(); - $target_index = null; + $items = array(); + $metadata_maps = array(); + $target_index = null; + $target_key = null; + $include_target_reference_in_from = false; foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { $equals_index = $this->find_top_level_token_index( $item, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); if ( null === $equals_index ) { @@ -5823,6 +7344,7 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array if ( 1 === count( $left_tokens ) ) { $column = $this->identifier_value( $left_tokens[0] ); $assignment_target_index = null; + $assignment_target_key = null; foreach ( $references as $index => $reference ) { $has_column = null !== $reference['table_name'] && $this->table_has_column( $reference['table_name'], $column, $reference['temporary'] ); @@ -5837,7 +7359,10 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target column '{$column}' in DuckDB driver." ); } if ( $this->is_unsafe_unqualified_joined_update_target( $references, $assignment_target_index ) ) { - throw new WP_DuckDB_Driver_Exception( "Unqualified UPDATE target column '{$column}' is not supported for aliased joined UPDATE targets in DuckDB driver. Qualify the target column." ); + $include_target_reference_in_from = true; + $assignment_target_key = 'table:' . strtolower( (string) $references[ $assignment_target_index ]['table_name'] ); + } else { + $assignment_target_key = 'reference:' . $assignment_target_index; } } elseif ( 3 === count( $left_tokens ) @@ -5861,14 +7386,18 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array if ( ! $this->table_has_column( $assignment_target['table_name'], $column, $assignment_target['temporary'] ) ) { throw new WP_DuckDB_Driver_Exception( "Unknown UPDATE target column '{$column}' in DuckDB driver." ); } + $assignment_target_key = 'reference:' . $assignment_target_index; } else { throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. Only simple column assignments are supported.' ); } if ( null === $target_index ) { $target_index = $assignment_target_index; + $target_key = $assignment_target_key; } elseif ( $target_index !== $assignment_target_index ) { throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE statement modifying multiple tables is not supported.' ); + } elseif ( $target_key !== $assignment_target_key ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE statement modifying multiple tables is not supported.' ); } if ( ! isset( $metadata_maps[ $assignment_target_index ] ) ) { @@ -5898,8 +7427,9 @@ private function translate_joined_update_assignment_tokens_to_duckdb_sql( array } return array( - 'target_index' => $target_index, - 'sql' => implode( ', ', $items ), + 'target_index' => $target_index, + 'include_target_reference_in_from' => $include_target_reference_in_from, + 'sql' => implode( ', ', $items ), ); } @@ -7914,8 +9444,10 @@ private function assert_alter_table_foreign_key_parent_supported( string $table_ } $primary_key_columns = $this->primary_key_columns_for_table( $resolved_parent_name ); - if ( 1 !== count( $primary_key_columns ) || 0 !== strcasecmp( $primary_key_columns[0], $referenced_column ) ) { - throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD FOREIGN KEY currently requires a single-column referenced PRIMARY KEY.' ); + $references_primary = 1 === count( $primary_key_columns ) && 0 === strcasecmp( $primary_key_columns[0], $referenced_column ); + $references_unique = null !== $this->single_column_unique_secondary_index_name( $resolved_parent_name, $referenced_column ); + if ( ! $references_primary && ! $references_unique ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ALTER TABLE statement in DuckDB driver. ADD FOREIGN KEY currently requires a single-column referenced PRIMARY KEY or UNIQUE KEY.' ); } $foreign_key['referenced_table_name'] = $resolved_parent_name; @@ -8035,6 +9567,86 @@ function ( string $column_name ): string { $this->refresh_column_key_metadata( $table_name, $temporary ); } + /** + * Filter native CREATE TABLE constraints for MySQL metadata-only FOREIGN KEYs. + * + * DuckDB only accepts referenced columns backed by a native PRIMARY KEY or + * UNIQUE table constraint. MySQL UNIQUE KEY definitions are represented by + * this driver as secondary indexes so they can be dropped/rebuilt with + * MySQL-facing metadata. Preserve those FOREIGN KEYs in metadata while + * omitting only their native DuckDB constraint clauses. + * + * @param string[] $constraints Native DuckDB constraint SQL fragments. + * @param array> $foreign_keys FOREIGN KEY metadata groups. + * @param bool $temporary Whether the child table is temporary. + * @return string[] Native constraint SQL fragments to include in CREATE TABLE. + */ + private function native_create_table_constraints( array $constraints, array $foreign_keys, bool $temporary = false ): array { + $metadata_only_constraints = array(); + foreach ( $foreign_keys as $foreign_key ) { + if ( $this->foreign_key_references_driver_managed_unique_index( $foreign_key, $temporary ) ) { + $metadata_only_constraints[ $this->foreign_key_native_constraint_sql( $foreign_key ) ] = true; + } + } + + if ( count( $metadata_only_constraints ) === 0 ) { + return $constraints; + } + + return array_values( + array_filter( + $constraints, + function ( string $constraint ) use ( $metadata_only_constraints ): bool { + return ! isset( $metadata_only_constraints[ $constraint ] ); + } + ) + ); + } + + /** + * Check whether a FOREIGN KEY references a driver-managed UNIQUE secondary index. + * + * @param array $foreign_key FOREIGN KEY metadata group. + * @param bool $temporary Whether the child table is temporary. + * @return bool Whether the FK should be metadata-only in native DuckDB DDL. + */ + private function foreign_key_references_driver_managed_unique_index( array $foreign_key, bool $temporary = false ): bool { + if ( $temporary || 1 !== count( $foreign_key['columns'] ) || 1 !== count( $foreign_key['referenced_columns'] ) ) { + return false; + } + + $referenced_table_name = $this->resolve_user_table_name( (string) $foreign_key['referenced_table_name'] ); + if ( null === $referenced_table_name ) { + return false; + } + + $referenced_column = (string) $foreign_key['referenced_columns'][0]; + $primary_key = $this->primary_key_columns_for_table( $referenced_table_name ); + if ( 1 === count( $primary_key ) && 0 === strcasecmp( $primary_key[0], $referenced_column ) ) { + return false; + } + + return null !== $this->single_column_unique_secondary_index_name( $referenced_table_name, $referenced_column ); + } + + /** + * Rebuild the native DuckDB FOREIGN KEY clause generated for a metadata group. + * + * @param array $foreign_key FOREIGN KEY metadata group. + * @return string Native DuckDB constraint SQL fragment. + */ + private function foreign_key_native_constraint_sql( array $foreign_key ): string { + return 'CONSTRAINT ' + . $this->connection->quote_identifier( (string) $foreign_key['constraint_name'] ) + . ' FOREIGN KEY (' + . $this->connection->quote_identifier( (string) $foreign_key['columns'][0] ) + . ') REFERENCES ' + . $this->connection->quote_identifier( (string) $foreign_key['referenced_table_name'] ) + . ' (' + . $this->connection->quote_identifier( (string) $foreign_key['referenced_columns'][0] ) + . ')'; + } + /** * Build a MySQL CREATE TABLE statement from explicit planned metadata. * @@ -13802,7 +15414,7 @@ private function translate_insert_select_tokens_to_duckdb_sql( array $tokens, in } /** - * Execute MySQL INSERT ... SELECT with staged temporal write coercion. + * Execute MySQL INSERT ... SELECT with staged write coercion/validation. * * @param WP_Parser_Token[] $tokens MySQL tokens. * @param int $table_index Index of the table token. @@ -13810,10 +15422,13 @@ private function translate_insert_select_tokens_to_duckdb_sql( array $tokens, in * @param bool $ignore Whether INSERT IGNORE was used. * @return WP_DuckDB_Result_Statement */ - private function execute_insert_select_with_temporal_coercion( array $tokens, int $table_index, int $select_index, bool $ignore ): WP_DuckDB_Result_Statement { + private function execute_insert_select_with_write_coercion( array $tokens, int $table_index, int $select_index, bool $ignore ): WP_DuckDB_Result_Statement { $shape = $this->parse_insert_select_target_shape( $tokens, $table_index, $select_index ); $text_blob_write_plan = $this->insert_select_text_blob_coercion_plan( $shape ); - $requires_write_stage = $this->insert_select_shape_requires_temporal_coercion( $shape ) || null !== $text_blob_write_plan; + $requires_write_stage = $this->insert_select_shape_requires_temporal_coercion( $shape ) + || $this->insert_select_shape_requires_strict_integer_validation( $shape ) + || $this->insert_select_shape_requires_non_strict_numeric_coercion( $shape ) + || null !== $text_blob_write_plan; if ( ! $requires_write_stage ) { return $this->execute_auto_increment_write( $shape['requested_table_name'], @@ -13837,7 +15452,7 @@ private function execute_insert_select_with_temporal_coercion( array $tokens, in 'INSERT', null === $text_blob_write_plan ? array() : $text_blob_write_plan['precoerced_offsets'] ); - $this->validate_staged_temporal_write( $stage['table_name'], $projection['validations'], 'Failed to validate DuckDB INSERT SELECT values' ); + $this->validate_staged_write( $stage['table_name'], $projection['validations'], 'Failed to validate DuckDB INSERT SELECT values' ); return $this->execute_auto_increment_write( $shape['requested_table_name'], @@ -13863,14 +15478,14 @@ private function execute_insert_select_with_temporal_coercion( array $tokens, in } /** - * Execute MySQL REPLACE ... SELECT with staged temporal write coercion. + * Execute MySQL REPLACE ... SELECT with staged write coercion/validation. * * @param WP_Parser_Token[] $tokens MySQL tokens. * @param int $table_index Index of the table token. * @param int $select_index Index of the SELECT token. * @return WP_DuckDB_Result_Statement */ - private function execute_replace_select_with_temporal_coercion( array $tokens, int $table_index, int $select_index ): WP_DuckDB_Result_Statement { + private function execute_replace_select_with_write_coercion( array $tokens, int $table_index, int $select_index ): WP_DuckDB_Result_Statement { $shape = $this->parse_insert_select_target_shape( $tokens, $table_index, $select_index ); $unique_sets = $this->unique_key_column_sets( $shape['table_name'], $shape['temporary'] ); $case_insensitive_columns = $this->case_insensitive_column_names( $shape['table_name'], $shape['temporary'] ); @@ -13878,6 +15493,8 @@ private function execute_replace_select_with_temporal_coercion( array $tokens, i $text_blob_write_plan = $this->insert_select_text_blob_coercion_plan( $shape ); $requires_write_stage = $manual_conflicts || $this->insert_select_shape_requires_temporal_coercion( $shape ) + || $this->insert_select_shape_requires_strict_integer_validation( $shape ) + || $this->insert_select_shape_requires_non_strict_numeric_coercion( $shape ) || null !== $text_blob_write_plan; if ( ! $requires_write_stage ) { @@ -13904,7 +15521,7 @@ private function execute_replace_select_with_temporal_coercion( array $tokens, i 'REPLACE', null === $text_blob_write_plan ? array() : $text_blob_write_plan['precoerced_offsets'] ); - $this->validate_staged_temporal_write( $stage['table_name'], $projection['validations'], 'Failed to validate DuckDB REPLACE SELECT values' ); + $this->validate_staged_write( $stage['table_name'], $projection['validations'], 'Failed to validate DuckDB REPLACE SELECT values' ); if ( $manual_conflicts ) { $evaluable_unique_sets = $this->replace_select_evaluable_unique_sets( $unique_sets, $projection['columns'] ); @@ -14515,36 +16132,201 @@ private function replace_select_manual_unique_key_predicate( . '.' . $this->connection->quote_identifier( $column_name ); - $where[] = $incoming_sql . ' IS NOT NULL'; - if ( isset( $case_insensitive_columns[ $column_key ] ) ) { - $where[] = 'lower(CAST(' . $target_sql . ' AS VARCHAR)) = lower(CAST(' . $incoming_sql . ' AS VARCHAR))'; + $where[] = $incoming_sql . ' IS NOT NULL'; + if ( isset( $case_insensitive_columns[ $column_key ] ) ) { + $where[] = 'lower(CAST(' . $target_sql . ' AS VARCHAR)) = lower(CAST(' . $incoming_sql . ' AS VARCHAR))'; + continue; + } + + $where[] = $target_sql . ' = ' . $incoming_sql; + } + + return implode( ' AND ', $where ); + } + + /** + * Check whether a SELECT-input write target needs temporal rewrite work. + * + * @param array{target_metadata:array>,omitted_defaults:array} $shape Target shape. + * @return bool Whether staging/coercion is required. + */ + private function insert_select_shape_requires_temporal_coercion( array $shape ): bool { + if ( count( $shape['omitted_defaults'] ) > 0 ) { + return true; + } + + foreach ( $shape['target_metadata'] as $metadata ) { + if ( $this->is_temporal_write_data_type( $this->mysql_column_data_type( $metadata ) ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a SELECT-input write target needs non-strict numeric coercion. + * + * @param array{target_metadata:array>} $shape Target shape. + * @return bool Whether staging/coercion is required. + */ + private function insert_select_shape_requires_non_strict_numeric_coercion( array $shape ): bool { + if ( $this->is_strict_sql_mode_active() ) { + return false; + } + + foreach ( $shape['target_metadata'] as $metadata ) { + $data_type = $this->mysql_column_data_type( $metadata ); + if ( 'bit' === $data_type || $this->is_numeric_write_data_type( $data_type ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a SELECT-input write target needs strict integer validation. + * + * @param array{target_metadata:array>} $shape Target shape. + * @return bool Whether staging/validation is required. + */ + private function insert_select_shape_requires_strict_integer_validation( array $shape ): bool { + if ( ! $this->is_strict_sql_mode_active() ) { + return false; + } + + $select_items = $this->simple_insert_select_items( $shape['source_tokens'] ); + foreach ( $shape['target_metadata'] as $offset => $metadata ) { + if ( ! $this->is_integer_write_data_type( $this->mysql_column_data_type( $metadata ) ) ) { + continue; + } + + if ( + null !== $select_items + && isset( $select_items[ $offset ] ) + && ( + $this->is_static_integral_write_value_tokens( $select_items[ $offset ] ) + || $this->insert_select_item_is_integer_source_column( $shape['source_tokens'], $select_items[ $offset ] ) + ) + ) { + continue; + } + + if ( null !== $select_items && ! isset( $select_items[ $offset ] ) ) { continue; } - $where[] = $target_sql . ' = ' . $incoming_sql; + if ( null === $select_items ) { + return true; + } + + return true; } - return implode( ' AND ', $where ); + return false; } /** - * Check whether a SELECT-input write target needs temporal rewrite work. + * Check whether a simple SELECT item reads an integer-backed source column. * - * @param array{target_metadata:array>,omitted_defaults:array} $shape Target shape. - * @return bool Whether staging/coercion is required. + * @param WP_Parser_Token[] $source_tokens SELECT tokens. + * @param WP_Parser_Token[] $item_tokens SELECT item tokens. + * @return bool Whether the item is an integer source column reference. */ - private function insert_select_shape_requires_temporal_coercion( array $shape ): bool { - if ( count( $shape['omitted_defaults'] ) > 0 ) { - return true; + private function insert_select_item_is_integer_source_column( array $source_tokens, array $item_tokens ): bool { + $source = $this->simple_insert_select_source_table_reference( $source_tokens ); + if ( null === $source ) { + return false; } - foreach ( $shape['target_metadata'] as $metadata ) { - if ( $this->is_temporal_write_data_type( $this->mysql_column_data_type( $metadata ) ) ) { - return true; + if ( 1 === count( $item_tokens ) ) { + $column_name = $this->identifier_value( $item_tokens[0] ); + } elseif ( + 3 === count( $item_tokens ) + && WP_MySQL_Lexer::DOT_SYMBOL === $item_tokens[1]->id + && in_array( strtolower( $this->identifier_value( $item_tokens[0] ) ), $source['qualifiers'], true ) + ) { + $column_name = $this->identifier_value( $item_tokens[2] ); + } else { + return false; + } + + $metadata_map = $this->write_column_metadata_map( $source['table_name'], $source['temporary'] ); + $metadata = $metadata_map[ strtolower( $column_name ) ] ?? null; + + return is_array( $metadata ) && $this->is_integer_write_data_type( $this->mysql_column_data_type( $metadata ) ); + } + + /** + * Resolve the single source table for a simple INSERT/REPLACE SELECT. + * + * @param WP_Parser_Token[] $source_tokens SELECT tokens. + * @return array{table_name:string,temporary:bool,qualifiers:string[]}|null Source table reference. + */ + private function simple_insert_select_source_table_reference( array $source_tokens ): ?array { + $list_end = $this->top_level_select_list_end( $source_tokens ); + if ( ! isset( $source_tokens[ $list_end ], $source_tokens[ $list_end + 1 ] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $source_tokens[ $list_end ]->id ) { + return null; + } + + $table_name = $this->identifier_value( $source_tokens[ $list_end + 1 ] ); + $reference = $this->resolve_visible_user_table_reference( $table_name ); + if ( null === $reference ) { + return null; + } + + $alias = null; + $index = $list_end + 2; + if ( isset( $source_tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $source_tokens[ $index ]->id ) { + ++$index; + if ( ! isset( $source_tokens[ $index ] ) || $this->is_non_identifier_token( $source_tokens[ $index ] ) ) { + return null; } + $alias = $this->identifier_value( $source_tokens[ $index ] ); + ++$index; + } elseif ( isset( $source_tokens[ $index ] ) && ! $this->is_select_tail_clause_start_token( $source_tokens[ $index ] ) ) { + if ( $this->is_non_identifier_token( $source_tokens[ $index ] ) ) { + return null; + } + $alias = $this->identifier_value( $source_tokens[ $index ] ); + ++$index; } - return false; + if ( isset( $source_tokens[ $index ] ) && ! $this->is_select_tail_clause_start_token( $source_tokens[ $index ] ) ) { + return null; + } + + $qualifiers = array( strtolower( $table_name ), strtolower( $reference['table_name'] ) ); + if ( null !== $alias ) { + $qualifiers[] = strtolower( $alias ); + } + + return array( + 'table_name' => $reference['table_name'], + 'temporary' => $reference['temporary'], + 'qualifiers' => array_values( array_unique( $qualifiers ) ), + ); + } + + /** + * Check whether a token starts a simple SELECT source-tail clause. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token begins a tail clause. + */ + private function is_select_tail_clause_start_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::WHERE_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + ), + true + ); } /** @@ -14615,7 +16397,7 @@ private function drop_select_write_stage( string $stage_table ): void { * @param bool $coalesce_select_nulls Whether non-strict temporal NOT NULL NULLs should become implicit defaults. * @param string $statement Statement name for errors. * @param array $precoerced_offsets SELECT offsets already coerced for storage in the stage. - * @return array{columns:string[],expressions:string[],validations:array} + * @return array{columns:string[],expressions:string[],validations:array} */ private function build_insert_select_projection( array $shape, array $stage_columns, bool $coalesce_select_nulls, string $statement, array $precoerced_offsets = array() ): array { if ( count( $stage_columns ) !== count( $shape['target_columns'] ) ) { @@ -14631,7 +16413,7 @@ private function build_insert_select_projection( array $shape, array $stage_colu . $this->connection->quote_identifier( $stage_columns[ $offset ] ); $metadata = $shape['target_metadata'][ $offset ]; - $validation = $this->temporal_write_validation_for_column( $metadata, array(), $source_sql ); + $validation = $this->write_validation_for_column( $metadata, array(), $source_sql ); if ( null !== $validation ) { $validations[] = $validation; } @@ -14660,13 +16442,13 @@ private function build_insert_select_projection( array $shape, array $stage_colu } /** - * Validate strict temporal staged values before mutating the target table. + * Validate staged values before mutating the target table. * * @param string $stage_table Stage table name. - * @param array $validations Validation expressions. + * @param array $validations Validation expressions. * @param string $context Failure context. */ - private function validate_staged_temporal_write( string $stage_table, array $validations, string $context ): void { + private function validate_staged_write( string $stage_table, array $validations, string $context ): void { foreach ( $validations as $validation ) { $stmt = $this->execute_duckdb_query( 'SELECT (' @@ -14683,8 +16465,9 @@ private function validate_staged_temporal_write( string $stage_table, array $val $result = $stmt->fetch( PDO::FETCH_ASSOC ); if ( false !== $result && null !== $result['invalid_display'] ) { + $error_message = $validation['error_message'] ?? ( 'Incorrect ' . $validation['data_type'] . ' value' ); throw new WP_DuckDB_Driver_Exception( - 'Incorrect ' . $validation['data_type'] . " value: '" . (string) $result['invalid_display'] . "'" + $error_message . ": '" . (string) $result['invalid_display'] . "'" ); } } @@ -14781,13 +16564,14 @@ function ( string $column_name ): string { * @return string DuckDB SQL. */ private function translate_insert_set_tokens_to_duckdb_sql( array $tokens, int $table_index, int $set_index, bool $ignore ): string { - $assignments = $this->parse_insert_set_assignments( array_slice( $tokens, $set_index + 1 ) ); - $table_name = $this->identifier_value( $tokens[ $table_index ] ?? null ); - $reference = $this->resolve_write_table_reference( $table_name ); - $metadata_map = $this->write_column_metadata_map( $reference['table_name'], $reference['temporary'] ); - $columns = array(); - $values = array(); - $supplied = array(); + $seeded_rand_state = array(); + $assignments = $this->parse_insert_set_assignments( array_slice( $tokens, $set_index + 1 ), $seeded_rand_state ); + $table_name = $this->identifier_value( $tokens[ $table_index ] ?? null ); + $reference = $this->resolve_write_table_reference( $table_name ); + $metadata_map = $this->write_column_metadata_map( $reference['table_name'], $reference['temporary'] ); + $columns = array(); + $values = array(); + $supplied = array(); foreach ( $assignments as $assignment ) { $columns[] = $assignment['column_sql']; @@ -14823,10 +16607,11 @@ private function translate_insert_set_tokens_to_duckdb_sql( array $tokens, int $ /** * Parse INSERT ... SET assignments. * - * @param WP_Parser_Token[] $tokens Assignment-list tokens after SET. + * @param WP_Parser_Token[] $tokens Assignment-list tokens after SET. + * @param array $seeded_rand_state Per-statement seeded RAND() state. * @return array,value_sql:string}> */ - private function parse_insert_set_assignments( array $tokens ): array { + private function parse_insert_set_assignments( array $tokens, array &$seeded_rand_state ): array { $assignments = array(); $index = 0; @@ -14866,7 +16651,7 @@ private function parse_insert_set_assignments( array $tokens ): array { 'column_name' => $column_name, 'column_sql' => $column_sql, 'value_tokens' => $value_tokens, - 'value_sql' => $this->translate_tokens_to_duckdb_sql( $value_tokens ), + 'value_sql' => $this->translate_insert_set_value_tokens_to_duckdb_sql( $value_tokens, $seeded_rand_state ), ); if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { ++$index; @@ -14880,6 +16665,32 @@ private function parse_insert_set_assignments( array $tokens ): array { return $assignments; } + /** + * Translate one INSERT ... SET value, optionally rewriting literal seeded RAND(). + * + * @param WP_Parser_Token[] $tokens Value expression tokens. + * @param array $seeded_rand_state Per-statement seeded RAND() state. + * @return string DuckDB SQL. + */ + private function translate_insert_set_value_tokens_to_duckdb_sql( array $tokens, array &$seeded_rand_state ): string { + $seeded_rand_rewrites = $this->seeded_rand_literal_rewrite_map( $tokens, $seeded_rand_state, 'INSERT ... SET' ); + if ( count( $seeded_rand_rewrites ) === 0 ) { + return $this->translate_tokens_to_duckdb_sql( $tokens ); + } + + return $this->translate_tokens_to_duckdb_sql( + $tokens, + false, + false, + false, + false, + false, + false, + false, + $seeded_rand_rewrites + ); + } + /** * Translate a bounded MySQL INSERT ... ON DUPLICATE KEY UPDATE statement. * @@ -15068,7 +16879,7 @@ private function parse_insert_values_shape( array $tokens, int $table_index, boo * @param int|null $end_index Optional token index where VALUES input ends. * @param bool $coerce_for_storage Whether values should be coerced for storage. * @param bool $rewrite_seeded_rand_literals Whether literal seeded RAND() calls are supported in VALUES. - * @return array{table_name:string,temporary:bool,columns:string[],rows:array>,ordered_rows:array,coerced_value_rows:array,temporal_validation_rows:array>>,requires_coercion:bool,requires_seeded_rand_rewrite:bool} + * @return array{table_name:string,temporary:bool,columns:string[],rows:array>,ordered_rows:array,coerced_value_rows:array,write_validation_rows:array>>,requires_coercion:bool,requires_seeded_rand_rewrite:bool} */ private function parse_insert_values_write_shape( array $tokens, int $table_index, ?int $end_index = null, bool $coerce_for_storage = false, bool $rewrite_seeded_rand_literals = false ): array { if ( null !== $end_index ) { @@ -15116,7 +16927,7 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde $rows = array(); $ordered_rows = array(); $coerced_value_rows = array(); - $temporal_validation_rows = array(); + $write_validation_rows = array(); $requires_coercion = false; $requires_seeded_rand_rewrite = false; $seeded_rand_state = array(); @@ -15138,7 +16949,7 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde $requires_seeded_rand_rewrite ); if ( $coerce_for_storage && isset( $metadata_map[ strtolower( $column_name ) ] ) ) { - $validation = $this->temporal_write_validation_for_column( + $validation = $this->write_validation_for_column( $metadata_map[ strtolower( $column_name ) ], $value_items[ $offset ], $value_sql @@ -15166,10 +16977,10 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde $values_by_column[ strtolower( $default_write['column_name'] ) ] = $default_write['value_sql']; $ordered_row[] = $default_write['value_sql']; } - $rows[] = $values_by_column; - $ordered_rows[] = $ordered_row; - $coerced_value_rows[] = $coerced_values; - $temporal_validation_rows[] = $validation_values; + $rows[] = $values_by_column; + $ordered_rows[] = $ordered_row; + $coerced_value_rows[] = $coerced_values; + $write_validation_rows[] = $validation_values; if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { ++$index; @@ -15185,7 +16996,7 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde 'rows' => $rows, 'ordered_rows' => $ordered_rows, 'coerced_value_rows' => $coerced_value_rows, - 'temporal_validation_rows' => $temporal_validation_rows, + 'write_validation_rows' => $write_validation_rows, 'requires_coercion' => $requires_coercion || count( $omitted_defaults ) > 0, 'requires_seeded_rand_rewrite' => $requires_seeded_rand_rewrite, ); @@ -15196,7 +17007,7 @@ private function parse_insert_values_write_shape( array $tokens, int $table_inde * * @param string $table_name Table name. * @param bool $temporary Whether the target is temporary. - * @return array{table_name:string,temporary:bool,columns:string[],rows:array>,ordered_rows:array,coerced_value_rows:array,temporal_validation_rows:array>>,requires_coercion:bool,requires_seeded_rand_rewrite:bool} + * @return array{table_name:string,temporary:bool,columns:string[],rows:array>,ordered_rows:array,coerced_value_rows:array,write_validation_rows:array>>,requires_coercion:bool,requires_seeded_rand_rewrite:bool} */ private function empty_insert_values_write_shape( string $table_name, bool $temporary ): array { return array( @@ -15206,7 +17017,7 @@ private function empty_insert_values_write_shape( string $table_name, bool $temp 'rows' => array(), 'ordered_rows' => array(), 'coerced_value_rows' => array(), - 'temporal_validation_rows' => array(), + 'write_validation_rows' => array(), 'requires_coercion' => false, 'requires_seeded_rand_rewrite' => false, ); @@ -15253,6 +17064,18 @@ private function translate_insert_values_value_tokens_to_duckdb_sql( array $toke * @return array */ private function seeded_rand_insert_values_rewrite_map( array $tokens, array &$seeded_rand_state ): array { + return $this->seeded_rand_literal_rewrite_map( $tokens, $seeded_rand_state, 'INSERT ... VALUES' ); + } + + /** + * Build deterministic literal rewrites for seeded RAND() calls. + * + * @param WP_Parser_Token[] $tokens Value expression tokens. + * @param array $seeded_rand_state Per-statement seeded RAND() state. + * @param string $context SQL context label for errors. + * @return array + */ + private function seeded_rand_literal_rewrite_map( array $tokens, array &$seeded_rand_state, string $context ): array { $rewrites = array(); for ( $index = 0; $index < count( $tokens ); ++$index ) { if ( @@ -15276,12 +17099,12 @@ private function seeded_rand_insert_values_rewrite_map( array $tokens, array &$s $seed_tokens = array_slice( $tokens, $index + 2, $close_index - $index - 2 ); if ( count( $this->split_top_level_comma_items( $seed_tokens ) ) !== 1 ) { - throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB INSERT ... VALUES supports exactly one seed argument.' ); + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB ' . $context . ' supports exactly one seed argument.' ); } $seed = $this->parse_seeded_rand_literal_seed( $seed_tokens ); if ( null === $seed ) { - throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB INSERT ... VALUES supports only literal numeric, string, or NULL seeds.' ); + throw new WP_DuckDB_Driver_Exception( 'Seeded RAND() in DuckDB ' . $context . ' supports only literal numeric, string, or NULL seeds.' ); } $rewrites[ $index ] = array( @@ -15428,7 +17251,25 @@ private function coerce_write_value_for_column_sql( array $metadata, array $valu return $value_sql; } - if ( $this->is_numeric_write_data_type( $data_type ) && ! $this->is_strict_sql_mode_active() ) { + if ( $this->is_numeric_write_data_type( $data_type ) ) { + if ( $this->is_strict_sql_mode_active() ) { + if ( $this->is_integer_write_data_type( $data_type ) ) { + if ( + $this->is_static_integral_write_value_tokens( $value_tokens ) + || $this->is_on_duplicate_values_reference_tokens( $value_tokens ) + ) { + return $value_sql; + } + + return $this->coerce_strict_integer_write_value_sql( + $value_sql, + $this->write_value_display_sql( $value_tokens, $value_sql ) + ); + } + + return $value_sql; + } + $value_sql = $this->coerce_numeric_write_value_sql( $data_type, $value_sql ); if ( $coalesce_non_strict_not_null @@ -15513,6 +17354,85 @@ private function is_numeric_write_data_type( string $data_type ): bool { ); } + /** + * Check whether a data type is stored as an integer in the SQLite driver. + * + * @param string $data_type MySQL data type. + * @return bool Whether the type is integer-backed. + */ + private function is_integer_write_data_type( string $data_type ): bool { + return in_array( $data_type, array( 'tinyint', 'smallint', 'mediumint', 'int', 'bigint' ), true ); + } + + /** + * Check whether value tokens are a literal SQLite already stores as INTEGER. + * + * @param WP_Parser_Token[] $value_tokens RHS value tokens. + * @return bool Whether the value is statically integral. + */ + private function is_static_integral_write_value_tokens( array $value_tokens ): bool { + if ( 1 === count( $value_tokens ) ) { + $token = $value_tokens[0]; + if ( + WP_MySQL_Lexer::NULL_SYMBOL === $token->id + || WP_MySQL_Lexer::NULL2_SYMBOL === $token->id + || WP_MySQL_Lexer::TRUE_SYMBOL === $token->id + || WP_MySQL_Lexer::FALSE_SYMBOL === $token->id + ) { + return true; + } + if ( $this->is_number_token( $token ) ) { + return $this->numeric_literal_string_is_integral( $token->get_bytes() ); + } + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $this->numeric_literal_string_is_integral( $this->token_value( $token ) ); + } + } + + if ( + 2 === count( $value_tokens ) + && $this->is_sign_token( $value_tokens[0] ) + && $this->is_number_token( $value_tokens[1] ) + ) { + return $this->numeric_literal_string_is_integral( $value_tokens[0]->get_bytes() . $value_tokens[1]->get_bytes() ); + } + + return false; + } + + /** + * Check whether a numeric literal string has no fractional component. + * + * @param string $literal Numeric literal/display string. + * @return bool Whether the literal is integral. + */ + private function numeric_literal_string_is_integral( string $literal ): bool { + $literal = trim( $literal ); + if ( 1 === preg_match( '/^[+-]?[0-9]+$/', $literal ) ) { + return true; + } + if ( '' === $literal || strlen( $literal ) > 15 || ! is_numeric( $literal ) ) { + return false; + } + + $number = (float) $literal; + return floor( abs( $number ) ) === abs( $number ); + } + + /** + * Check whether ODKU tokens are exactly VALUES(column). + * + * @param WP_Parser_Token[] $value_tokens RHS value tokens. + * @return bool Whether the RHS is an incoming-values reference. + */ + private function is_on_duplicate_values_reference_tokens( array $value_tokens ): bool { + return 4 === count( $value_tokens ) + && WP_MySQL_Lexer::VALUES_SYMBOL === $value_tokens[0]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $value_tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $value_tokens[3]->id + && ! $this->is_non_identifier_token( $value_tokens[2] ); + } + /** * Coerce a write value to MySQL/SQLite-like text storage. * @@ -15559,6 +17479,26 @@ private function coerce_numeric_write_value_sql( string $data_type, string $valu . ' END'; } + /** + * Reject strict integer writes that SQLite would classify as REAL. + * + * DuckDB rounds fractional numeric strings/literals when casting to an integer. + * SQLite strict tables reject them instead, which is the behavior the existing + * SQLite-backed driver exposes. + * + * @param string $value_sql Translated RHS SQL. + * @param string $display_sql String SQL expression for checks and messages. + * @return string Coerced value SQL. + */ + private function coerce_strict_integer_write_value_sql( string $value_sql, string $display_sql ): string { + $invalid_display_sql = $this->strict_integer_invalid_write_display_sql( $value_sql, $display_sql ); + + return 'CASE' + . ' WHEN (' . $invalid_display_sql . ") IS NOT NULL THEN error('cannot store REAL value in INTEGER column')" + . ' ELSE ' . $value_sql + . ' END'; + } + /** * Return the DuckDB cast target for a MySQL numeric data type. * @@ -15988,6 +17928,23 @@ private function binary_literal_write_hex_sql( array $value_tokens ): ?string { return $this->connection->quote( $hex ); } + /** + * Build a non-throwing validation expression for a target column. + * + * @param array $metadata Column metadata. + * @param WP_Parser_Token[] $value_tokens RHS value tokens. + * @param string $value_sql Translated RHS SQL. + * @return array{data_type:string,invalid_display_sql:string,error_message?:string}|null Validation metadata, or null when not needed. + */ + private function write_validation_for_column( array $metadata, array $value_tokens, string $value_sql ): ?array { + $validation = $this->temporal_write_validation_for_column( $metadata, $value_tokens, $value_sql ); + if ( null !== $validation ) { + return $validation; + } + + return $this->strict_integer_write_validation_for_column( $metadata, $value_tokens, $value_sql ); + } + /** * Build a non-throwing strict temporal validation expression for a target column. * @@ -16014,6 +17971,36 @@ private function temporal_write_validation_for_column( array $metadata, array $v ); } + /** + * Build a non-throwing strict integer validation expression for a target column. + * + * @param array $metadata Column metadata. + * @param WP_Parser_Token[] $value_tokens RHS value tokens. + * @param string $value_sql Translated RHS SQL. + * @return array{data_type:string,invalid_display_sql:string,error_message:string}|null Validation metadata, or null when not needed. + */ + private function strict_integer_write_validation_for_column( array $metadata, array $value_tokens, string $value_sql ): ?array { + $data_type = $this->mysql_column_data_type( $metadata ); + if ( + ! $this->is_strict_sql_mode_active() + || ! $this->is_integer_write_data_type( $data_type ) + || $this->is_default_value_tokens( $value_tokens ) + || $this->is_static_integral_write_value_tokens( $value_tokens ) + || $this->is_on_duplicate_values_reference_tokens( $value_tokens ) + ) { + return null; + } + + return array( + 'data_type' => $data_type, + 'invalid_display_sql' => $this->strict_integer_invalid_write_display_sql( + $value_sql, + $this->write_value_display_sql( $value_tokens, $value_sql ) + ), + 'error_message' => 'cannot store REAL value in INTEGER column', + ); + } + /** * Read a MySQL-facing column data type from metadata. * @@ -16158,6 +18145,24 @@ private function temporal_invalid_write_display_sql( string $data_type, string $ . ' END'; } + /** + * Build a CASE expression that returns a strict integer REAL display or NULL. + * + * @param string $value_sql Translated RHS SQL. + * @param string $display_sql String SQL expression for checks and messages. + * @return string Invalid display SQL. + */ + private function strict_integer_invalid_write_display_sql( string $value_sql, string $display_sql ): string { + $numeric_sql = 'TRY_CAST(' . $display_sql . ' AS DOUBLE)'; + + return 'CASE' + . ' WHEN (' . $value_sql . ') IS NULL THEN NULL' + . ' WHEN ' . $numeric_sql . ' IS NULL THEN NULL' + . ' WHEN abs(' . $numeric_sql . ') <> floor(abs(' . $numeric_sql . ')) THEN ' . $display_sql + . ' ELSE NULL' + . ' END'; + } + /** * Build formatted TRY_CAST SQL for a temporal value. * @@ -16288,17 +18293,17 @@ private function execute_replace_values_with_manual_conflict_handling( array $to } /** - * Evaluate coerced INSERT/REPLACE VALUES expressions before side-effecting emulation. + * Evaluate INSERT/REPLACE VALUES validations before side-effecting emulation. * - * @param array{temporal_validation_rows:array>>} $shape Parsed VALUES shape. - * @param string $context Failure context. + * @param array{write_validation_rows:array>>} $shape Parsed VALUES shape. + * @param string $context Failure context. */ private function validate_insert_values_write_shape( array $shape, string $context ): void { - if ( empty( $shape['temporal_validation_rows'] ) ) { + if ( empty( $shape['write_validation_rows'] ) ) { return; } - foreach ( $shape['temporal_validation_rows'] as $row ) { + foreach ( $shape['write_validation_rows'] as $row ) { if ( count( $row ) === 0 ) { continue; } @@ -16310,8 +18315,9 @@ private function validate_insert_values_write_shape( array $shape, string $conte ); $result = $stmt->fetch( PDO::FETCH_ASSOC ); if ( false !== $result && null !== $result['invalid_display'] ) { + $error_message = $validation['error_message'] ?? ( 'Incorrect ' . $validation['data_type'] . ' value' ); throw new WP_DuckDB_Driver_Exception( - 'Incorrect ' . $validation['data_type'] . " value: '" . (string) $result['invalid_display'] . "'" + $error_message . ": '" . (string) $result['invalid_display'] . "'" ); } } @@ -16564,12 +18570,14 @@ private function translate_on_duplicate_update_tokens_to_duckdb_sql( throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. UPDATE assignment value is required.' ); } - $target = $this->on_duplicate_assignment_target( $left_tokens, $target_table_name, $requested_table_name ); - $value_sql = $this->translate_on_duplicate_value_tokens_to_duckdb_sql( $right_tokens ); - $column_name = $target['column_name']; - if ( isset( $metadata_map[ strtolower( $column_name ) ] ) ) { + $target = $this->on_duplicate_assignment_target( $left_tokens, $target_table_name, $requested_table_name ); + $value_sql = $this->translate_on_duplicate_value_tokens_to_duckdb_sql( $right_tokens ); + $column_name = $target['column_name']; + $metadata = $metadata_map[ strtolower( $column_name ) ] ?? null; + $requires_wrap = ! is_array( $metadata ) || ! $this->on_duplicate_update_tokens_are_integer_preserving( $right_tokens, $metadata, $metadata_map ); + if ( is_array( $metadata ) && $requires_wrap ) { $value_sql = $this->coerce_write_value_for_column_sql( - $metadata_map[ strtolower( $column_name ) ], + $metadata, $right_tokens, $value_sql, false @@ -16624,7 +18632,98 @@ private function on_duplicate_assignment_target( ); } - throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Only simple column assignments are supported.' ); + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Only simple column assignments are supported.' ); + } + + /** + * Check whether an ODKU RHS expression is known to stay integer-valued. + * + * @param WP_Parser_Token[] $tokens RHS tokens. + * @param array $metadata Target column metadata. + * @param array> $metadata_map Target metadata keyed by lowercase column name. + * @return bool Whether the strict integer guard can be skipped. + */ + private function on_duplicate_update_tokens_are_integer_preserving( array $tokens, array $metadata, array $metadata_map ): bool { + if ( + ! $this->is_strict_sql_mode_active() + || ! $this->is_integer_write_data_type( $this->mysql_column_data_type( $metadata ) ) + || count( $tokens ) === 0 + ) { + return false; + } + + for ( $index = 0; $index < count( $tokens ); ++$index ) { + $values_reference = $this->on_duplicate_values_reference_column_name_at( $tokens, $index ); + if ( null !== $values_reference ) { + if ( ! $this->metadata_column_is_integer( $values_reference['column_name'], $metadata_map ) ) { + return false; + } + $index = $values_reference['next_index'] - 1; + continue; + } + + $token = $tokens[ $index ]; + if ( $this->is_number_token( $token ) ) { + if ( ! $this->is_static_integral_write_value_tokens( array( $token ) ) ) { + return false; + } + continue; + } + + if ( + $this->is_sign_token( $token ) + || WP_MySQL_Lexer::MULT_OPERATOR === $token->id + || WP_MySQL_Lexer::MOD_OPERATOR === $token->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id + ) { + continue; + } + + if ( ! $this->is_non_identifier_token( $token ) && $this->metadata_column_is_integer( $this->identifier_value( $token ), $metadata_map ) ) { + continue; + } + + return false; + } + + return true; + } + + /** + * Return VALUES(column) metadata when the token stream has one at the index. + * + * @param WP_Parser_Token[] $tokens RHS tokens. + * @param int $index Current token index. + * @return array{column_name:string,next_index:int}|null VALUES() reference metadata. + */ + private function on_duplicate_values_reference_column_name_at( array $tokens, int $index ): ?array { + if ( + ! isset( $tokens[ $index + 3 ] ) + || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $index ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $index + 3 ]->id + || $this->is_non_identifier_token( $tokens[ $index + 2 ] ) + ) { + return null; + } + + return array( + 'column_name' => $this->identifier_value( $tokens[ $index + 2 ] ), + 'next_index' => $index + 4, + ); + } + + /** + * Check whether target metadata identifies an integer-backed column. + * + * @param string $column_name Column name. + * @param array> $metadata_map Target metadata keyed by lowercase column name. + * @return bool Whether the column is integer-backed. + */ + private function metadata_column_is_integer( string $column_name, array $metadata_map ): bool { + $metadata = $metadata_map[ strtolower( $column_name ) ] ?? null; + return is_array( $metadata ) && $this->is_integer_write_data_type( $this->mysql_column_data_type( $metadata ) ); } /** @@ -18359,6 +20458,11 @@ private function execute_duckdb_query( string $sql, string $context ): WP_DuckDB * Ensure the internal index metadata table exists. */ private function ensure_index_metadata_table( bool $temporary = false ): void { + $ensure_key = $this->metadata_ensure_key( 'index', $temporary ); + if ( isset( $this->ensured_metadata_tables[ $ensure_key ] ) ) { + return; + } + $table_name = $this->index_metadata_table_name( $temporary ); $this->execute_duckdb_query( 'CREATE ' @@ -18382,12 +20486,19 @@ private function ensure_index_metadata_table( bool $temporary = false ): void { 'Failed to upgrade DuckDB index metadata' ); } + + $this->ensured_metadata_tables[ $ensure_key ] = true; } /** * Ensure the internal column metadata table exists. */ private function ensure_column_metadata_table( bool $temporary = false ): void { + $ensure_key = $this->metadata_ensure_key( 'column', $temporary ); + if ( isset( $this->ensured_metadata_tables[ $ensure_key ] ) ) { + return; + } + $this->execute_duckdb_query( 'CREATE ' . ( $temporary ? 'TEMP ' : '' ) @@ -18396,12 +20507,19 @@ private function ensure_column_metadata_table( bool $temporary = false ): void { . ' (table_name VARCHAR, ordinal_position INTEGER, column_name VARCHAR, column_type VARCHAR, is_nullable VARCHAR, column_key VARCHAR, column_default VARCHAR, extra VARCHAR, collation_name VARCHAR, comment VARCHAR)', 'Failed to initialize DuckDB column metadata' ); + + $this->ensured_metadata_tables[ $ensure_key ] = true; } /** * Ensure the internal table metadata table exists. */ private function ensure_table_metadata_table( bool $temporary = false ): void { + $ensure_key = $this->metadata_ensure_key( 'table', $temporary ); + if ( isset( $this->ensured_metadata_tables[ $ensure_key ] ) ) { + return; + } + $this->execute_duckdb_query( 'CREATE ' . ( $temporary ? 'TEMP ' : '' ) @@ -18410,12 +20528,19 @@ private function ensure_table_metadata_table( bool $temporary = false ): void { . ' (table_name VARCHAR, engine VARCHAR, row_format VARCHAR, table_collation VARCHAR, table_comment VARCHAR, create_options VARCHAR, create_time VARCHAR)', 'Failed to initialize DuckDB table metadata' ); + + $this->ensured_metadata_tables[ $ensure_key ] = true; } /** * Ensure the internal CHECK constraint metadata table exists. */ private function ensure_check_metadata_table( bool $temporary = false ): void { + $ensure_key = $this->metadata_ensure_key( 'check', $temporary ); + if ( isset( $this->ensured_metadata_tables[ $ensure_key ] ) ) { + return; + } + $this->execute_duckdb_query( 'CREATE ' . ( $temporary ? 'TEMP ' : '' ) @@ -18424,12 +20549,19 @@ private function ensure_check_metadata_table( bool $temporary = false ): void { . ' (table_name VARCHAR, constraint_name VARCHAR, check_clause VARCHAR, enforced VARCHAR)', 'Failed to initialize DuckDB CHECK constraint metadata' ); + + $this->ensured_metadata_tables[ $ensure_key ] = true; } /** * Ensure the internal FOREIGN KEY metadata table exists. */ private function ensure_foreign_key_metadata_table( bool $temporary = false ): void { + $ensure_key = $this->metadata_ensure_key( 'foreign_key', $temporary ); + if ( isset( $this->ensured_metadata_tables[ $ensure_key ] ) ) { + return; + } + $this->execute_duckdb_query( 'CREATE ' . ( $temporary ? 'TEMP ' : '' ) @@ -18438,6 +20570,19 @@ private function ensure_foreign_key_metadata_table( bool $temporary = false ): v . ' (table_name VARCHAR, constraint_name VARCHAR, ordinal_position INTEGER, column_name VARCHAR, referenced_table_name VARCHAR, referenced_column_name VARCHAR, update_rule VARCHAR, delete_rule VARCHAR)', 'Failed to initialize DuckDB FOREIGN KEY metadata' ); + + $this->ensured_metadata_tables[ $ensure_key ] = true; + } + + /** + * Return a cache key for a metadata table ensure operation. + * + * @param string $kind Metadata table kind. + * @param bool $temporary Whether to use session-local temporary metadata. + * @return string Cache key. + */ + private function metadata_ensure_key( string $kind, bool $temporary ): string { + return ( $temporary ? 'temporary:' : 'persistent:' ) . $kind; } /** @@ -18490,6 +20635,40 @@ private function foreign_key_metadata_table_name( bool $temporary ): string { return $temporary ? self::TEMP_FOREIGN_KEY_METADATA_TABLE : self::FOREIGN_KEY_METADATA_TABLE; } + /** + * Insert rows whose values have already been quoted or otherwise rendered as SQL. + * + * @param string $table_name Table name. + * @param array $columns Column names. + * @param array> $value_rows Pre-rendered SQL value rows. + * @param string $error_message Error message for failed inserts. + */ + private function insert_rendered_value_rows( string $table_name, array $columns, array $value_rows, string $error_message ): void { + if ( count( $value_rows ) === 0 ) { + return; + } + + $quoted_columns = array(); + foreach ( $columns as $column_name ) { + $quoted_columns[] = $this->connection->quote_identifier( $column_name ); + } + + $tuples = array(); + foreach ( $value_rows as $values ) { + $tuples[] = '(' . implode( ', ', $values ) . ')'; + } + + $this->execute_duckdb_query( + 'INSERT INTO ' + . $this->connection->quote_identifier( $table_name ) + . ' (' + . implode( ', ', $quoted_columns ) + . ') VALUES ' + . implode( ', ', $tuples ), + $error_message + ); + } + /** * Record MySQL index metadata for SHOW INDEX. * @@ -18513,28 +20692,25 @@ private function record_index_metadata( array $index_definition ): void { 'Failed to reset DuckDB index metadata' ); + $value_rows = array(); foreach ( $index_definition['columns'] as $offset => $column ) { - $this->execute_duckdb_query( - 'INSERT INTO ' - . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) - . ' (table_name, index_name, non_unique, seq_in_index, column_name, sub_part, index_type) VALUES (' - . $this->connection->quote( $table_name ) - . ', ' - . $this->connection->quote( $index_name ) - . ', ' - . ( $index_definition['unique'] ? '0' : '1' ) - . ', ' - . ( $offset + 1 ) - . ', ' - . $this->connection->quote( $column['name'] ) - . ', ' - . $this->connection->quote( $column['sub_part'] ) - . ', ' - . $this->connection->quote( $index_type ) - . ')', - 'Failed to store DuckDB index metadata' + $value_rows[] = array( + $this->connection->quote( $table_name ), + $this->connection->quote( $index_name ), + $index_definition['unique'] ? '0' : '1', + (string) ( $offset + 1 ), + $this->connection->quote( $column['name'] ), + $this->connection->quote( $column['sub_part'] ), + $this->connection->quote( $index_type ), ); } + + $this->insert_rendered_value_rows( + $this->index_metadata_table_name( $temporary ), + array( 'table_name', 'index_name', 'non_unique', 'seq_in_index', 'column_name', 'sub_part', 'index_type' ), + $value_rows, + 'Failed to store DuckDB index metadata' + ); } /** @@ -18641,34 +20817,39 @@ private function record_column_metadata( string $table_name, array $metadata, bo 'Failed to reset DuckDB column metadata' ); + $value_rows = array(); foreach ( $metadata as $offset => $column ) { - $this->execute_duckdb_query( - 'INSERT INTO ' - . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) - . ' (table_name, ordinal_position, column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment) VALUES (' - . $this->connection->quote( $table_name ) - . ', ' - . ( $offset + 1 ) - . ', ' - . $this->connection->quote( $column['column_name'] ) - . ', ' - . $this->connection->quote( $column['column_type'] ) - . ', ' - . $this->connection->quote( $column['is_nullable'] ) - . ', ' - . $this->connection->quote( $column['column_key'] ) - . ', ' - . $this->connection->quote( $column['column_default'] ) - . ', ' - . $this->connection->quote( $column['extra'] ) - . ', ' - . $this->connection->quote( $column['collation_name'] ) - . ', ' - . $this->connection->quote( $column['comment'] ) - . ')', - 'Failed to store DuckDB column metadata' + $value_rows[] = array( + $this->connection->quote( $table_name ), + (string) ( $offset + 1 ), + $this->connection->quote( $column['column_name'] ), + $this->connection->quote( $column['column_type'] ), + $this->connection->quote( $column['is_nullable'] ), + $this->connection->quote( $column['column_key'] ), + $this->connection->quote( $column['column_default'] ), + $this->connection->quote( $column['extra'] ), + $this->connection->quote( $column['collation_name'] ), + $this->connection->quote( $column['comment'] ), ); } + + $this->insert_rendered_value_rows( + $this->column_metadata_table_name( $temporary ), + array( + 'table_name', + 'ordinal_position', + 'column_name', + 'column_type', + 'is_nullable', + 'column_key', + 'column_default', + 'extra', + 'collation_name', + 'comment', + ), + $value_rows, + 'Failed to store DuckDB column metadata' + ); } /** @@ -18728,22 +20909,22 @@ private function record_check_metadata( string $table_name, array $metadata, boo 'Failed to reset DuckDB CHECK constraint metadata' ); + $value_rows = array(); foreach ( $metadata as $constraint ) { - $this->execute_duckdb_query( - 'INSERT INTO ' - . $this->connection->quote_identifier( $this->check_metadata_table_name( $temporary ) ) - . ' (table_name, constraint_name, check_clause, enforced) VALUES (' - . $this->connection->quote( $table_name ) - . ', ' - . $this->connection->quote( $constraint['constraint_name'] ) - . ', ' - . $this->connection->quote( $constraint['check_clause'] ) - . ', ' - . $this->connection->quote( $constraint['enforced'] ) - . ')', - 'Failed to store DuckDB CHECK constraint metadata' + $value_rows[] = array( + $this->connection->quote( $table_name ), + $this->connection->quote( $constraint['constraint_name'] ), + $this->connection->quote( $constraint['check_clause'] ), + $this->connection->quote( $constraint['enforced'] ), ); } + + $this->insert_rendered_value_rows( + $this->check_metadata_table_name( $temporary ), + array( 'table_name', 'constraint_name', 'check_clause', 'enforced' ), + $value_rows, + 'Failed to store DuckDB CHECK constraint metadata' + ); } /** @@ -18764,32 +20945,37 @@ private function record_foreign_key_metadata( string $table_name, array $metadat 'Failed to reset DuckDB FOREIGN KEY metadata' ); + $value_rows = array(); foreach ( $metadata as $constraint ) { foreach ( $constraint['columns'] as $offset => $column_name ) { - $this->execute_duckdb_query( - 'INSERT INTO ' - . $this->connection->quote_identifier( $this->foreign_key_metadata_table_name( $temporary ) ) - . ' (table_name, constraint_name, ordinal_position, column_name, referenced_table_name, referenced_column_name, update_rule, delete_rule) VALUES (' - . $this->connection->quote( $table_name ) - . ', ' - . $this->connection->quote( $constraint['constraint_name'] ) - . ', ' - . ( $offset + 1 ) - . ', ' - . $this->connection->quote( $column_name ) - . ', ' - . $this->connection->quote( $constraint['referenced_table_name'] ) - . ', ' - . $this->connection->quote( $constraint['referenced_columns'][ $offset ] ) - . ', ' - . $this->connection->quote( $constraint['update_rule'] ) - . ', ' - . $this->connection->quote( $constraint['delete_rule'] ) - . ')', - 'Failed to store DuckDB FOREIGN KEY metadata' + $value_rows[] = array( + $this->connection->quote( $table_name ), + $this->connection->quote( $constraint['constraint_name'] ), + (string) ( $offset + 1 ), + $this->connection->quote( $column_name ), + $this->connection->quote( $constraint['referenced_table_name'] ), + $this->connection->quote( $constraint['referenced_columns'][ $offset ] ), + $this->connection->quote( $constraint['update_rule'] ), + $this->connection->quote( $constraint['delete_rule'] ), ); } } + + $this->insert_rendered_value_rows( + $this->foreign_key_metadata_table_name( $temporary ), + array( + 'table_name', + 'constraint_name', + 'ordinal_position', + 'column_name', + 'referenced_table_name', + 'referenced_column_name', + 'update_rule', + 'delete_rule', + ), + $value_rows, + 'Failed to store DuckDB FOREIGN KEY metadata' + ); } /** @@ -18923,6 +21109,33 @@ private function resolve_secondary_index_name( string $table_name, string $mysql return null; } + /** + * Resolve a single-column UNIQUE secondary index by column name. + * + * @param string $table_name Table name. + * @param string $column_name Column name. + * @param bool $temporary Whether the target table is temporary. + * @return string|null MySQL-facing index name when a full-column unique index matches. + */ + private function single_column_unique_secondary_index_name( string $table_name, string $column_name, bool $temporary = false ): ?string { + foreach ( $this->secondary_index_definitions_for_table( $table_name, $temporary ) as $index_definition ) { + if ( ! $index_definition['unique'] || 1 !== count( $index_definition['columns'] ) ) { + continue; + } + + $column = $index_definition['columns'][0]; + if ( null !== $column['sub_part'] ) { + continue; + } + + if ( 0 === strcasecmp( (string) $column['name'], $column_name ) ) { + return (string) $index_definition['index_name']; + } + } + + return null; + } + /** * Reject duplicate secondary index names before CREATE INDEX IF NOT EXISTS can drift metadata. * @@ -19590,33 +21803,22 @@ private function refresh_information_schema_tables_table(): void { return; } - $quoted_columns = implode( - ', ', - array_map( - function ( string $column_name ): string { - return $this->connection->quote_identifier( $column_name ); - }, - $columns - ) - ); - + $value_rows = array(); foreach ( $rows as $row ) { $values = array(); foreach ( $columns as $column_name ) { $values[] = $this->connection->quote( $row[ $column_name ] ); } - $this->execute_duckdb_query( - 'INSERT INTO ' - . $this->connection->quote_identifier( self::INFO_SCHEMA_TABLES_TABLE ) - . ' (' - . $quoted_columns - . ') VALUES (' - . implode( ', ', $values ) - . ')', - 'Failed to populate DuckDB information_schema.tables compatibility table' - ); + $value_rows[] = $values; } + + $this->insert_rendered_value_rows( + self::INFO_SCHEMA_TABLES_TABLE, + $columns, + $value_rows, + 'Failed to populate DuckDB information_schema.tables compatibility table' + ); } /** @@ -19874,33 +22076,22 @@ private function refresh_information_schema_columns_table(): void { return; } - $quoted_columns = implode( - ', ', - array_map( - function ( string $column_name ): string { - return $this->connection->quote_identifier( $column_name ); - }, - $columns - ) - ); - + $value_rows = array(); foreach ( $rows as $row ) { $values = array(); foreach ( $columns as $column_name ) { $values[] = $this->connection->quote( $row[ $column_name ] ); } - $this->execute_duckdb_query( - 'INSERT INTO ' - . $this->connection->quote_identifier( self::INFO_SCHEMA_COLUMNS_TABLE ) - . ' (' - . $quoted_columns - . ') VALUES (' - . implode( ', ', $values ) - . ')', - 'Failed to populate DuckDB information_schema.columns compatibility table' - ); + $value_rows[] = $values; } + + $this->insert_rendered_value_rows( + self::INFO_SCHEMA_COLUMNS_TABLE, + $columns, + $value_rows, + 'Failed to populate DuckDB information_schema.columns compatibility table' + ); } /** @@ -19961,33 +22152,22 @@ private function refresh_information_schema_statistics_table(): void { return; } - $quoted_columns = implode( - ', ', - array_map( - function ( string $column_name ): string { - return $this->connection->quote_identifier( $column_name ); - }, - $columns - ) - ); - + $value_rows = array(); foreach ( $rows as $row ) { $values = array(); foreach ( $columns as $column_name ) { $values[] = $this->connection->quote( $row[ $column_name ] ); } - $this->execute_duckdb_query( - 'INSERT INTO ' - . $this->connection->quote_identifier( self::INFO_SCHEMA_STATISTICS_TABLE ) - . ' (' - . $quoted_columns - . ') VALUES (' - . implode( ', ', $values ) - . ')', - 'Failed to populate DuckDB information_schema.statistics compatibility table' - ); + $value_rows[] = $values; } + + $this->insert_rendered_value_rows( + self::INFO_SCHEMA_STATISTICS_TABLE, + $columns, + $value_rows, + 'Failed to populate DuckDB information_schema.statistics compatibility table' + ); } /** @@ -20645,10 +22825,10 @@ private function information_schema_check_constraint_rows(): array { /** * Refresh a temporary MySQL-shaped information_schema compatibility table. * - * @param string $table_name Temporary table name. - * @param array $definitions Column definitions. + * @param string $table_name Temporary table name. + * @param array $definitions Column definitions. * @param array> $rows Rows to insert. - * @param string $label User-facing information_schema table label. + * @param string $label User-facing information_schema table label. */ private function refresh_information_schema_compatibility_table( string $table_name, array $definitions, array $rows, string $label ): void { $columns = array_keys( $definitions ); @@ -20670,33 +22850,22 @@ private function refresh_information_schema_compatibility_table( string $table_n return; } - $quoted_columns = implode( - ', ', - array_map( - function ( string $column_name ): string { - return $this->connection->quote_identifier( $column_name ); - }, - $columns - ) - ); - + $value_rows = array(); foreach ( $rows as $row ) { $values = array(); foreach ( $columns as $column_name ) { $values[] = $this->connection->quote( $row[ $column_name ] ); } - $this->execute_duckdb_query( - 'INSERT INTO ' - . $this->connection->quote_identifier( $table_name ) - . ' (' - . $quoted_columns - . ') VALUES (' - . implode( ', ', $values ) - . ')', - 'Failed to populate DuckDB ' . $label . ' compatibility table' - ); + $value_rows[] = $values; } + + $this->insert_rendered_value_rows( + $table_name, + $columns, + $value_rows, + 'Failed to populate DuckDB ' . $label . ' compatibility table' + ); } /** @@ -20784,6 +22953,72 @@ private function temporary_user_table_names(): array { ); } + /** + * Resolve a requested table name to a visible persistent DuckDB user table. + * + * @param string $table_name Requested table name. + * @return string|null Actual table name, or null when no persistent user table matches. + */ + private function resolve_persistent_user_table_name( string $table_name ): ?string { + $stmt = $this->execute_duckdb_query( + 'SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()' + . " AND table_type = 'BASE TABLE'" + . ' AND lower(table_name) = ' + . $this->connection->quote( strtolower( $table_name ) ) + . ' AND table_name <> ' + . $this->connection->quote( self::INDEX_METADATA_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::COLUMN_METADATA_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::TABLE_METADATA_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::CHECK_METADATA_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::FOREIGN_KEY_METADATA_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_TABLES_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_STATISTICS_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_REFERENTIAL_CONSTRAINTS_TABLE ) + . ' AND table_name <> ' + . $this->connection->quote( self::INFO_SCHEMA_CHECK_CONSTRAINTS_TABLE ) + . ' LIMIT 1', + 'Failed to inspect DuckDB table' + ); + + $resolved = $stmt->fetchColumn(); + return false === $resolved || null === $resolved ? null : (string) $resolved; + } + + /** + * Resolve a requested table name to a visible temporary DuckDB user table. + * + * @param string $table_name Requested table name. + * @return string|null Actual table name, or null when no temporary user table matches. + */ + private function resolve_temporary_user_table_name( string $table_name ): ?string { + $stmt = $this->execute_duckdb_query( + "SELECT table_name FROM information_schema.tables WHERE table_type = 'LOCAL TEMPORARY'" + . ' AND lower(table_name) = ' + . $this->connection->quote( strtolower( $table_name ) ) + . ' AND table_name NOT LIKE ' + . $this->connection->quote( '\_\_wp\_duckdb\_%' ) + . " ESCAPE '\\'" + . ' LIMIT 1', + 'Failed to inspect DuckDB temporary table' + ); + + $resolved = $stmt->fetchColumn(); + return false === $resolved || null === $resolved ? null : (string) $resolved; + } + /** * Resolve a requested table name to the visible DuckDB table, preferring temporary tables. * @@ -20791,22 +23026,20 @@ private function temporary_user_table_names(): array { * @return array{table_name:string,temporary:bool}|null Resolved table reference, or null when no table matches. */ private function resolve_visible_user_table_reference( string $table_name ): ?array { - foreach ( $this->temporary_user_table_names() as $candidate ) { - if ( 0 === strcasecmp( $candidate, $table_name ) ) { - return array( - 'table_name' => $candidate, - 'temporary' => true, - ); - } + $temporary_table_name = $this->resolve_temporary_user_table_name( $table_name ); + if ( null !== $temporary_table_name ) { + return array( + 'table_name' => $temporary_table_name, + 'temporary' => true, + ); } - foreach ( $this->user_table_names() as $candidate ) { - if ( 0 === strcasecmp( $candidate, $table_name ) ) { - return array( - 'table_name' => $candidate, - 'temporary' => false, - ); - } + $persistent_table_name = $this->resolve_persistent_user_table_name( $table_name ); + if ( null !== $persistent_table_name ) { + return array( + 'table_name' => $persistent_table_name, + 'temporary' => false, + ); } return null; @@ -20819,13 +23052,12 @@ private function resolve_visible_user_table_reference( string $table_name ): ?ar * @return array{table_name:string,temporary:bool}|null Resolved table reference, or null when no temp table matches. */ private function resolve_temporary_user_table_reference( string $table_name ): ?array { - foreach ( $this->temporary_user_table_names() as $candidate ) { - if ( 0 === strcasecmp( $candidate, $table_name ) ) { - return array( - 'table_name' => $candidate, - 'temporary' => true, - ); - } + $temporary_table_name = $this->resolve_temporary_user_table_name( $table_name ); + if ( null !== $temporary_table_name ) { + return array( + 'table_name' => $temporary_table_name, + 'temporary' => true, + ); } return null; @@ -20838,13 +23070,7 @@ private function resolve_temporary_user_table_reference( string $table_name ): ? * @return string|null Actual table name, or null when no user table matches. */ private function resolve_user_table_name( string $table_name ): ?string { - foreach ( $this->user_table_names() as $candidate ) { - if ( 0 === strcasecmp( $candidate, $table_name ) ) { - return $candidate; - } - } - - return null; + return $this->resolve_persistent_user_table_name( $table_name ); } /** diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php index 61f61ec56..a1eafb940 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php @@ -59,14 +59,7 @@ class WP_DuckDB_Result_Statement implements IteratorAggregate { */ public function __construct( array $columns, array $rows, int $affected_rows = 0, array $column_meta = array() ) { $this->columns = array_values( $columns ); - $this->rows = array_values( - array_map( - function ( array $row ): array { - return array_values( $row ); - }, - $rows - ) - ); + $this->rows = $this->normalize_rows( $rows ); $this->affected_rows = $affected_rows; $this->setColumnMeta( $column_meta ); } @@ -289,6 +282,39 @@ public function getIterator(): Traversable { } } + /** + * Normalize input rows to zero-based numeric lists. + * + * @param array> $rows Rows to normalize. + * @return array> Normalized rows. + */ + private function normalize_rows( array $rows ): array { + $normalized_rows = array(); + foreach ( $rows as $row ) { + $normalized_rows[] = $this->is_list_array( $row ) ? $row : array_values( $row ); + } + + return $normalized_rows; + } + + /** + * Check whether an array is already a zero-based numeric list. + * + * @param array $row Row array. + * @return bool Whether the row has consecutive integer keys from zero. + */ + private function is_list_array( array $row ): bool { + $expected_key = 0; + foreach ( $row as $key => $_value ) { + if ( $key !== $expected_key ) { + return false; + } + ++$expected_key; + } + + return true; + } + /** * Format a row for a fetch mode. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index 607324522..82f92bb43 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -24,6 +24,62 @@ public function test_result_statement_fetch_named_preserves_duplicate_columns(): $this->assertFalse( $stmt->fetch() ); } + public function test_result_statement_constructor_normalizes_row_shapes(): void { + $row_shapes = array( + 'list' => array( + array( 10, 'Ada' ), + ), + 'associative' => array( + array( + 'id' => 10, + 'name' => 'Ada', + ), + ), + 'sparse' => array( + array( + 2 => 10, + 4 => 'Ada', + ), + ), + ); + + foreach ( $row_shapes as $label => $rows ) { + $stmt = new WP_DuckDB_Result_Statement( array( 'id', 'name' ), $rows ); + $this->assertSame( array( array( 10, 'Ada' ) ), $stmt->fetchAll( PDO::FETCH_NUM ), $label ); + + $stmt = new WP_DuckDB_Result_Statement( array( 'id', 'name' ), $rows ); + $this->assertSame( + array( + array( + 'id' => 10, + 'name' => 'Ada', + ), + ), + $stmt->fetchAll( PDO::FETCH_ASSOC ), + $label + ); + + $stmt = new WP_DuckDB_Result_Statement( array( 'id', 'name' ), $rows ); + $this->assertSame( + array( + array( + 'id' => 10, + 'name' => 'Ada', + 0 => 10, + 1 => 'Ada', + ), + ), + $stmt->fetchAll( PDO::FETCH_BOTH ), + $label + ); + + $stmt = new WP_DuckDB_Result_Statement( array( 'id', 'name' ), $rows ); + $row = $stmt->fetch( PDO::FETCH_OBJ ); + $this->assertSame( 10, $row->id, $label ); + $this->assertSame( 'Ada', $row->name, $label ); + } + } + public function test_result_statement_fetch_column_preserves_nulls_and_validates_indexes(): void { $stmt = new WP_DuckDB_Result_Statement( array( 'id', 'label' ), diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 24e6e04c7..9adbcc4b5 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -72,6 +72,32 @@ public function test_select_seeded_rand_multi_row_sequence_matches_sqlite(): voi $this->assertParityRows( 'SELECT id, RAND(3) AS r FROM seeded_rand_rows ORDER BY id' ); } + public function test_select_seeded_rand_expression_seeds_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE seeded_rand_expr (id INT, seed_text VARCHAR(20))', + "INSERT INTO seeded_rand_expr (id, seed_text) VALUES (1, '1'), (2, '2'), (3, '3')", + ) + ); + + $this->assertParityRows( 'SELECT id, RAND(CAST(seed_text AS SIGNED)) AS r FROM seeded_rand_expr ORDER BY id' ); + $this->assertParityRows( 'SELECT RAND(NULLIF(1, 1)) AS r' ); + $this->assertParityRows( 'SELECT RAND(CAST(1 AS SIGNED))' ); + } + + public function test_select_wildcard_seeded_rand_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE seeded_rand_wildcard (id INT, name VARCHAR(20))', + "INSERT INTO seeded_rand_wildcard (id, name) VALUES (1, 'a'), (2, 'b')", + ) + ); + + $this->assertParityRows( 'SELECT *, RAND(1) AS r FROM seeded_rand_wildcard ORDER BY id' ); + $this->assertParityRows( 'SELECT *, RAND(1) AS r, id AS explicit_id FROM seeded_rand_wildcard ORDER BY id' ); + $this->assertParityRows( 'SELECT *, id AS explicit_id, RAND(CAST(id AS SIGNED)) AS r FROM seeded_rand_wildcard ORDER BY id' ); + } + public function test_insert_values_seeded_rand_literals_match_sqlite(): void { $this->runParitySetup( array( @@ -89,27 +115,62 @@ public function test_insert_values_seeded_rand_literals_match_sqlite(): void { $this->assertParityRows( 'SELECT id, value, other FROM seeded_rand_out ORDER BY id' ); } - public function test_seeded_rand_where_and_order_by_contexts_have_explicit_rejection_contract(): void { + public function test_insert_set_seeded_rand_literals_match_sqlite(): void { $this->runParitySetup( array( - 'CREATE TABLE seeded_rand_ctx (id INT, value DOUBLE)', - 'INSERT INTO seeded_rand_ctx (id, value) VALUES (1, 0.0), (2, 0.0), (3, 0.0)', + 'CREATE TABLE seeded_rand_set (id INT, value DOUBLE, other DOUBLE)', ) ); - $this->assertDuckDBRejectsWhileSqliteAccepts( - 'SELECT id FROM seeded_rand_ctx WHERE RAND(1) < 0.5 ORDER BY id', - 'top-level SELECT expression' + $this->assertParityRowCount( 'INSERT INTO seeded_rand_set SET id = 1, value = RAND(1), other = RAND(1)' ); + $this->assertParityRows( 'SELECT id, value, other FROM seeded_rand_set ORDER BY id' ); + + $this->assertParityRowCount( 'INSERT seeded_rand_set SET id = 2, value = RAND(NULL), other = RAND(1) + 0' ); + $this->assertParityRows( 'SELECT id, value, other FROM seeded_rand_set ORDER BY id' ); + } + + public function test_update_seeded_rand_literals_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE seeded_rand_update (id INT, value DOUBLE, other DOUBLE)', + 'INSERT INTO seeded_rand_update (id, value, other) VALUES (1, 0.0, 0.0), (2, 0.0, 0.0), (3, 0.0, 0.0)', + ) ); - $this->assertDuckDBRejectsWhileSqliteAccepts( - 'SELECT id FROM seeded_rand_ctx ORDER BY RAND(1)', - 'top-level SELECT expression' + + $this->assertParityRowCount( 'UPDATE seeded_rand_update SET value = RAND(1) WHERE id = 1' ); + $this->assertParityRows( 'SELECT id, value, other FROM seeded_rand_update ORDER BY id' ); + + $this->assertParityRowCount( 'UPDATE seeded_rand_update SET value = RAND(1), other = RAND(1) ORDER BY id LIMIT 2' ); + $this->assertParityRows( 'SELECT id, value, other FROM seeded_rand_update ORDER BY id' ); + } + + public function test_select_order_by_seeded_rand_literal_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE seeded_rand_order (id INT)', + 'INSERT INTO seeded_rand_order (id) VALUES (1), (2), (3), (4), (5)', + ) ); - $this->assertDuckDBRejectsWithoutMutatingRows( - 'UPDATE seeded_rand_ctx SET value = 9 WHERE RAND(1) < 0.5', - 'top-level SELECT expression', - 'SELECT id, value FROM seeded_rand_ctx ORDER BY id' + + $this->assertParityRows( 'SELECT id FROM seeded_rand_order ORDER BY RAND(1)' ); + $this->assertParityRows( 'SELECT id FROM seeded_rand_order ORDER BY RAND(1) DESC' ); + } + + public function test_seeded_rand_select_and_update_where_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE seeded_rand_ctx (id INT, value DOUBLE)', + 'INSERT INTO seeded_rand_ctx (id, value) VALUES (3, 0.0), (1, 0.0), (2, 0.0)', + 'CREATE TABLE seeded_rand_delete_ctx (id INT)', + 'INSERT INTO seeded_rand_delete_ctx (id) VALUES (3), (1), (2)', + ) ); + + $this->assertParityRows( 'SELECT id FROM seeded_rand_ctx WHERE RAND(1) < 0.5 ORDER BY id' ); + $this->assertParityRowCount( 'UPDATE seeded_rand_ctx SET value = 9 WHERE RAND(1) < 0.5' ); + $this->assertParityRows( 'SELECT id, value FROM seeded_rand_ctx ORDER BY id' ); + $this->assertParityRowCount( 'DELETE FROM seeded_rand_delete_ctx WHERE RAND(1) < 0.5' ); + $this->assertParityRows( 'SELECT id FROM seeded_rand_delete_ctx ORDER BY id' ); } public function test_select_cast_convert_binary_expressions_match_sqlite(): void { @@ -295,6 +356,77 @@ public function test_non_strict_numeric_update_null_not_null_matches_sqlite(): v $this->assertParityRows( 'SELECT id, int_value, decimal_value, float_value FROM numeric_not_null_coercions ORDER BY id' ); } + public function test_non_strict_numeric_insert_select_write_coercions_match_sqlite(): void { + $this->assertParityRowCount( "SET SESSION sql_mode = ''" ); + $this->runParitySetup( + array( + 'CREATE TABLE numeric_insert_select_coercions ( + id INT PRIMARY KEY, + int_value INT NOT NULL, + decimal_value DECIMAL(10,2) NOT NULL, + float_value FLOAT NOT NULL, + nullable_int INT + )', + 'CREATE TABLE numeric_insert_select_source ( + id INT, + int_text VARCHAR(20), + decimal_text VARCHAR(20), + float_text VARCHAR(20), + nullable_text VARCHAR(20) + )', + "INSERT INTO numeric_insert_select_source VALUES + (1, 'bad-int', 'bad-decimal', 'bad-float', NULL), + (2, '7', '8.25', '9.5', '11')", + ) + ); + + $this->assertParityRowCount( + 'INSERT INTO numeric_insert_select_coercions + (id, int_value, decimal_value, float_value, nullable_int) + SELECT id, int_text, decimal_text, float_text, nullable_text + FROM numeric_insert_select_source + ORDER BY id' + ); + $this->assertParityRows( + 'SELECT id, int_value, decimal_value, float_value, nullable_int + FROM numeric_insert_select_coercions + ORDER BY id' + ); + + $this->assertParityRowCount( + 'INSERT INTO numeric_insert_select_coercions + (id, int_value, decimal_value, float_value, nullable_int) + SELECT 3, NULL, NULL, NULL, NULL' + ); + $this->assertParityRows( + 'SELECT id, int_value, decimal_value, float_value, nullable_int + FROM numeric_insert_select_coercions + ORDER BY id' + ); + + $this->assertParityRowCount( + "INSERT IGNORE INTO numeric_insert_select_coercions + (id, int_value, decimal_value, float_value, nullable_int) + SELECT 1, 'ignored-int', 'ignored-decimal', 'ignored-float', 'ignored-nullable'" + ); + $this->assertParityRows( + 'SELECT id, int_value, decimal_value, float_value, nullable_int + FROM numeric_insert_select_coercions + ORDER BY id' + ); + + $this->assertParityRowCount( + "REPLACE INTO numeric_insert_select_coercions + (id, int_value, decimal_value, float_value, nullable_int) + SELECT 2, 'replace-int', 'replace-decimal', 'replace-float', 'replace-nullable'" + ); + $this->assertParityRows( + 'SELECT id, int_value, decimal_value, float_value, nullable_int + FROM numeric_insert_select_coercions + ORDER BY id' + ); + } + public function test_strict_numeric_write_errors_preserve_duckdb_rows(): void { $driver = new WP_DuckDB_Driver( array( @@ -396,6 +528,90 @@ public function test_strict_numeric_write_errors_preserve_duckdb_rows(): void { ); } + public function test_strict_integer_fractional_writes_reject_like_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE strict_integer_write_rejections ( + id INT PRIMARY KEY, + int_value INT, + big_value BIGINT, + decimal_value DECIMAL(10,2), + float_value FLOAT + )', + 'CREATE TABLE strict_integer_write_source ( + id INT, + int_text VARCHAR(20), + big_text VARCHAR(20), + decimal_text VARCHAR(20), + float_text VARCHAR(20) + )', + 'INSERT INTO strict_integer_write_rejections (id, int_value, big_value, decimal_value, float_value) + VALUES (1, 7, 8, 9.25, 10.5)', + "INSERT INTO strict_integer_write_source + VALUES (2, '4.5', '8', '9.25', '10.5')", + ) + ); + + $state_sql = 'SELECT id, int_value, big_value, decimal_value, float_value + FROM strict_integer_write_rejections + ORDER BY id'; + $assert_rejects_without_mutation = function ( string $sql ) use ( $state_sql ): void { + $this->assertParityErrorContains( $sql, 'REAL value in INTEGER column' ); + $this->assertParityRows( $state_sql ); + }; + + $assert_rejects_without_mutation( + "INSERT INTO strict_integer_write_rejections (id, int_value, big_value, decimal_value, float_value) + VALUES (2, '4.5', 8, 9.25, 10.5)" + ); + $assert_rejects_without_mutation( + "INSERT INTO strict_integer_write_rejections (id, int_value, big_value, decimal_value, float_value) + VALUES (2, 4, '4e-1', 9.25, 10.5)" + ); + $assert_rejects_without_mutation( + "INSERT INTO strict_integer_write_rejections SET + id = 2, + int_value = '4.5', + big_value = 8, + decimal_value = 9.25, + float_value = 10.5" + ); + $assert_rejects_without_mutation( + "REPLACE INTO strict_integer_write_rejections (id, int_value, big_value, decimal_value, float_value) + VALUES (1, '4.5', 8, 9.25, 10.5)" + ); + $assert_rejects_without_mutation( + "UPDATE strict_integer_write_rejections + SET int_value = '4.5' + WHERE id = 1" + ); + $assert_rejects_without_mutation( + "INSERT INTO strict_integer_write_rejections (id, int_value, big_value, decimal_value, float_value) + VALUES (1, 7, 8, 9.25, 10.5) + ON DUPLICATE KEY UPDATE int_value = '4.5'" + ); + $assert_rejects_without_mutation( + "INSERT INTO strict_integer_write_rejections (id, int_value, big_value, decimal_value, float_value) + VALUES (1, '4.5', 8, 9.25, 10.5) + ON DUPLICATE KEY UPDATE int_value = int_value + VALUES(int_value)" + ); + $assert_rejects_without_mutation( + 'INSERT INTO strict_integer_write_rejections (id, int_value, big_value, decimal_value, float_value) + SELECT id, int_text, big_text, decimal_text, float_text + FROM strict_integer_write_source' + ); + $assert_rejects_without_mutation( + "REPLACE INTO strict_integer_write_rejections (id, int_value, big_value, decimal_value, float_value) + SELECT 1, '4.5', 8, 9.25, 10.5" + ); + + $this->assertParityRowCount( + "INSERT INTO strict_integer_write_rejections (id, int_value, big_value, decimal_value, float_value) + VALUES (2, '4.0', '8.00', 9.25, 10.5)" + ); + $this->assertParityRows( $state_sql ); + } + public function test_show_full_tables_sql_matches_sqlite(): void { $this->runParitySetup( array( @@ -584,6 +800,15 @@ public function test_transaction_sql_matches_sqlite(): void { public function test_session_variable_sql_matches_sqlite(): void { $this->assertParityRows( 'SELECT @@autocommit, @@session.autocommit, @@big_tables, @@SESSION.big_tables' ); + $this->assertParityRows( + 'SELECT @@GLOBAL.gtid_purged, + @@GLOBAL.log_bin, + @@GLOBAL.log_bin_trust_function_creators, + @@GLOBAL.sql_mode, + @@SESSION.max_allowed_packet, + @@SESSION.sql_mode' + ); + $this->assertParityRows( 'SELECT @@gLoBAL.gTiD_purGed, @@sEssIOn.sqL_moDe' ); $this->assertParityRowCount( 'SET SESSION autocommit = 1, big_tables = 0' ); $this->assertParityRows( 'SELECT @@autocommit, @@session.autocommit, @@big_tables, @@session.big_tables' ); @@ -608,11 +833,74 @@ public function test_session_variable_sql_matches_sqlite(): void { $this->assertParityRowCount( "SET @@session.default_storage_engine = 'MyISAM'" ); $this->assertParityRows( 'SELECT @@default_storage_engine' ); + $this->assertParityRowCount( + 'SET default_collation_for_utf8mb4 = utf8mb4_0900_ai_ci, + resultset_metadata = FULL, + session_track_gtids = OWN_GTID, + session_track_transaction_info = STATE, + transaction_isolation = SERIALIZABLE, + use_secondary_engine = FORCED' + ); + $this->assertParityRows( + 'SELECT @@default_collation_for_utf8mb4, + @@resultset_metadata, + @@session_track_gtids, + @@session_track_transaction_info, + @@transaction_isolation, + @@use_secondary_engine' + ); + $this->assertParityRowCount( "SET @@session.session_track_transaction_info = 'CHARACTERISTICS'" ); + $this->assertParityRows( 'SELECT @@session.session_track_transaction_info' ); + $this->assertParityRowCount( 'SET autocommit = OFF' ); $this->assertParityRows( 'SELECT @@autocommit' ); + $this->assertParityRows( 'SELECT @@autocommit AS ac' ); + $this->assertParityRows( 'SELECT @@autocommit + 0' ); + $this->assertParityRows( 'SELECT COALESCE(@@autocommit, 1)' ); $this->assertParityRowCount( 'SET big_tables = ON' ); $this->assertParityRows( 'SELECT @@big_tables' ); + $this->assertParityRowCount( + 'SET end_markers_in_json = ON, + explicit_defaults_for_timestamp = OFF, + keep_files_on_create = ON, + old_alter_table = OFF, + print_identified_with_as_hex = ON, + require_row_format = OFF, + select_into_disk_sync = ON, + session_track_schema = ON, + session_track_state_change = OFF, + show_create_table_skip_secondary_engine = ON, + show_create_table_verbosity = OFF, + sql_auto_is_null = ON, + sql_big_selects = OFF, + sql_buffer_result = ON, + sql_safe_updates = OFF, + transaction_read_only = OFF' + ); + $this->assertParityRows( + 'SELECT @@end_markers_in_json, + @@explicit_defaults_for_timestamp, + @@keep_files_on_create, + @@old_alter_table, + @@print_identified_with_as_hex, + @@require_row_format, + @@select_into_disk_sync, + @@session_track_schema, + @@session_track_state_change, + @@show_create_table_skip_secondary_engine, + @@show_create_table_verbosity, + @@sql_auto_is_null, + @@sql_big_selects, + @@sql_buffer_result, + @@sql_safe_updates, + @@transaction_read_only' + ); + $this->assertParityRowCount( 'SET @old_safe_updates = @@sql_safe_updates' ); + $this->assertParityRowCount( 'SET @@sql_safe_updates = ON' ); + $this->assertParityRowCount( 'SET @@sql_safe_updates = @old_safe_updates' ); + $this->assertParityRows( 'SELECT @@sql_safe_updates' ); + $this->assertParityRowCount( 'SET sql_warnings = ON' ); $this->assertParityRows( 'SELECT @@sql_warnings' ); $this->assertParityRowCount( 'SET @@session.sql_warnings = OFF' ); @@ -640,9 +928,41 @@ public function test_user_variable_sql_matches_sqlite(): void { $this->assertParityRowCount( 'SET @signed = -2, @decimal = +1.25, @flag = TRUE' ); $this->assertParityRows( 'SELECT @signed, @decimal, @flag' ); + + $this->assertParityRowCount( 'SET @my_var = @my_var + 1' ); + $this->assertParityRows( 'SELECT @my_var' ); + + $this->assertParityRowCount( 'SET @my_var = @my_var + 1' ); + $this->assertParityRows( 'SELECT @my_var' ); + $this->assertParityRows( 'SELECT @my_var AS alias, 1' ); + $this->assertParityRows( 'SELECT @my_var + 1' ); + $this->assertParityRows( 'SELECT @my_var + 1 AS expr_var' ); + $this->assertParityRows( 'SELECT COALESCE(@my_var, 1)' ); + $this->assertParityRows( 'SELECT COALESCE(@missing, 1)' ); + $this->assertParityRows( 'SELECT @my_var AS alias, 1 FROM DUAL' ); + + $this->assertParityRowCount( 'SET @other = 4, @sum = @my_var + @other' ); + $this->assertParityRows( 'SELECT @sum' ); + + $this->assertParityRowCount( 'SET @db = DATABASE(), @version = VERSION()' ); + $this->assertParityRows( 'SELECT @db, @version' ); } public function test_dump_check_variable_backup_and_restore_sql_matches_sqlite(): void { + $this->assertParityRowCount( + "SET character_set_client = 'latin1', + character_set_results = 'latin1', + collation_connection = latin1_swedish_ci, + time_zone = '+02:00', + sql_notes = 1" + ); + $this->assertParityRowCount( '/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;' ); + $this->assertParityRowCount( '/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;' ); + $this->assertParityRowCount( '/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;' ); + $this->assertParityRowCount( '/*!50503 SET NAMES utf8mb4 */;' ); + $this->assertParityRowCount( '/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;' ); + $this->assertParityRowCount( "/*!40103 SET TIME_ZONE='+00:00' */;" ); + $this->assertParityRowCount( '/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;' ); @@ -653,9 +973,43 @@ public function test_dump_check_variable_backup_and_restore_sql_matches_sqlite() ); $this->assertParityRows( 'SELECT @OLD_FOREIGN_KEY_CHECKS, @@FOREIGN_KEY_CHECKS' ); + $this->assertParityRowCount( "/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;" ); + $this->assertParityRowCount( '/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;' ); + $this->assertParityRowCount( '/*!40101 SET @saved_cs_client = @@character_set_client */; ' ); + $this->assertParityRowCount( '/*!50503 SET character_set_client = utf8mb4 */;' ); + $this->assertParityRows( + 'SELECT @OLD_CHARACTER_SET_CLIENT, + @OLD_CHARACTER_SET_RESULTS, + @OLD_COLLATION_CONNECTION, + @OLD_TIME_ZONE, + @OLD_SQL_MODE, + @OLD_SQL_NOTES, + @saved_cs_client, + @@CHARACTER_SET_CLIENT, + @@TIME_ZONE, + @@SQL_MODE, + @@SQL_NOTES' + ); + + $this->assertParityRowCount( '/*!40101 SET character_set_client = @saved_cs_client */;' ); + $this->assertParityRowCount( '/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;' ); + $this->assertParityRowCount( '/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;' ); $this->assertParityRowCount( '/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;' ); $this->assertParityRowCount( '/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;' ); - $this->assertParityRows( 'SELECT @@UNIQUE_CHECKS, @@FOREIGN_KEY_CHECKS' ); + $this->assertParityRowCount( '/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;' ); + $this->assertParityRowCount( '/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;' ); + $this->assertParityRowCount( '/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;' ); + $this->assertParityRowCount( '/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;' ); + $this->assertParityRows( + 'SELECT @@CHARACTER_SET_CLIENT, + @@CHARACTER_SET_RESULTS, + @@COLLATION_CONNECTION, + @@TIME_ZONE, + @@SQL_MODE, + @@UNIQUE_CHECKS, + @@FOREIGN_KEY_CHECKS, + @@SQL_NOTES' + ); $this->assertParityRowCount( 'SET @RESTORED_UNIQUE_CHECKS = 1, @RESTORED_FOREIGN_KEY_CHECKS = "0"' @@ -1011,6 +1365,14 @@ public function test_cross_joined_update_matches_sqlite(): void { ); $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + 'UPDATE t1 a CROSS JOIN t2 b ON a.id = b.id + SET a.note = b.note + WHERE b.flag = 1' + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); } public function test_straight_joined_update_matches_sqlite(): void { @@ -1032,6 +1394,49 @@ public function test_straight_joined_update_matches_sqlite(): void { $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); } + public function test_left_and_right_joined_update_match_sqlite_current_rewrite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)', + 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)', + "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30), (4, 'a4', 40)", + "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (5, 'b5', 1)", + ) + ); + + $this->assertParityRowCount( + "UPDATE t1 a LEFT JOIN t2 b ON a.id = b.id + SET a.note = 'left'" + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + "UPDATE t1 a RIGHT JOIN t2 b ON a.id = b.id + SET a.note = 'right'" + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + } + + public function test_natural_joined_update_matches_sqlite_current_rewrite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)', + 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)', + "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30), (4, 'a4', 40)", + "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (5, 'b5', 1)", + ) + ); + + $this->assertParityRowCount( + "UPDATE t1 a NATURAL JOIN t2 b + SET a.note = 'natural'" + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + } + public function test_joined_update_using_columns_matches_sqlite(): void { $this->runParitySetup( array( @@ -1139,194 +1544,23 @@ public function test_joined_update_unqualified_unique_unaliased_target_matches_s $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); } - public function test_joined_update_unsupported_join_forms_document_current_duckdb_gap(): void { - foreach ( + public function test_joined_update_unqualified_unique_aliased_target_matches_sqlite_current_rewrite(): void { + $this->runParitySetup( array( - array( - 'sql' => "UPDATE t1 a LEFT JOIN t2 b ON a.id = b.id SET a.note = 'left'", - 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', - 'sqlite_row_count' => 2, - 'sqlite_rows' => array( - array( - 'id' => '1', - 'note' => 'left', - 'only_t1' => '10', - ), - array( - 'id' => '2', - 'note' => 'a2', - 'only_t1' => '20', - ), - array( - 'id' => '3', - 'note' => 'left', - 'only_t1' => '30', - ), - array( - 'id' => '4', - 'note' => 'a4', - 'only_t1' => '40', - ), - ), - ), - array( - 'sql' => "UPDATE t1 a RIGHT JOIN t2 b ON a.id = b.id SET a.note = 'right'", - 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', - 'sqlite_row_count' => 2, - 'sqlite_rows' => array( - array( - 'id' => '1', - 'note' => 'right', - 'only_t1' => '10', - ), - array( - 'id' => '2', - 'note' => 'a2', - 'only_t1' => '20', - ), - array( - 'id' => '3', - 'note' => 'right', - 'only_t1' => '30', - ), - array( - 'id' => '4', - 'note' => 'a4', - 'only_t1' => '40', - ), - ), - ), - array( - 'sql' => 'UPDATE t1 a CROSS JOIN t2 b ON a.id = b.id SET a.note = b.note WHERE b.flag = 1', - 'duckdb_message' => 'CROSS JOIN ... ON is not supported', - 'sqlite_row_count' => 2, - 'sqlite_rows' => array( - array( - 'id' => '1', - 'note' => 'b1', - 'only_t1' => '10', - ), - array( - 'id' => '2', - 'note' => 'a2', - 'only_t1' => '20', - ), - array( - 'id' => '3', - 'note' => 'b3', - 'only_t1' => '30', - ), - array( - 'id' => '4', - 'note' => 'a4', - 'only_t1' => '40', - ), - ), - ), - array( - 'sql' => "UPDATE t1 a NATURAL JOIN t2 b SET a.note = 'natural'", - 'duckdb_message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', - 'sqlite_row_count' => 4, - 'sqlite_rows' => array( - array( - 'id' => '1', - 'note' => 'natural', - 'only_t1' => '10', - ), - array( - 'id' => '2', - 'note' => 'natural', - 'only_t1' => '20', - ), - array( - 'id' => '3', - 'note' => 'natural', - 'only_t1' => '30', - ), - array( - 'id' => '4', - 'note' => 'natural', - 'only_t1' => '40', - ), - ), - ), - ) as $case - ) { - $drivers = $this->createJoinedUpdateGapDrivers(); - - $this->assertSame( - $case['sqlite_row_count'], - (int) $drivers['sqlite']->query( $case['sql'], PDO::FETCH_ASSOC ), - 'SQLite row count changed for SQL: ' . $case['sql'] - ); - $this->assertDuckDBGapQueryRejected( $drivers['duckdb'], $case['sql'], $case['duckdb_message'] ); - - $this->assertSame( - $case['sqlite_rows'], - $drivers['sqlite']->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id', PDO::FETCH_ASSOC ), - 'SQLite rows changed for SQL: ' . $case['sql'] - ); - $this->assertSame( - $this->joinedUpdateGapInitialDuckDBRows(), - $drivers['duckdb']->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), - 'DuckDB rejected joined UPDATE mutated t1 for SQL: ' . $case['sql'] - ); - $this->assertSame( - $this->joinedUpdateGapInitialDuckDBSourceRows(), - $drivers['duckdb']->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), - 'DuckDB rejected joined UPDATE mutated t2 for SQL: ' . $case['sql'] - ); - } - } - - public function test_joined_update_unqualified_unique_aliased_target_rejects_duckdb_gap(): void { - $drivers = $this->createJoinedUpdateGapDrivers(); - $sql = 'UPDATE t1 a JOIN t2 b ON a.id = b.id - SET only_t1 = 99 - WHERE b.flag = 1'; - - $this->assertSame( 4, (int) $drivers['sqlite']->query( $sql, PDO::FETCH_ASSOC ) ); - $this->assertDuckDBGapQueryRejected( - $drivers['duckdb'], - $sql, - "Unqualified UPDATE target column 'only_t1' is not supported for aliased joined UPDATE targets" + 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)', + 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)', + "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30), (4, 'a4', 40)", + "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (5, 'b5', 1)", + ) ); - $this->assertSame( - array( - array( - 'id' => '1', - 'note' => 'a1', - 'only_t1' => '99', - ), - array( - 'id' => '2', - 'note' => 'a2', - 'only_t1' => '99', - ), - array( - 'id' => '3', - 'note' => 'a3', - 'only_t1' => '99', - ), - array( - 'id' => '4', - 'note' => 'a4', - 'only_t1' => '99', - ), - ), - $drivers['sqlite']->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id', PDO::FETCH_ASSOC ) - ); - $this->assertSame( - $this->joinedUpdateGapInitialDuckDBRows(), - $drivers['duckdb']->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), - 'DuckDB rejected joined UPDATE mutated t1.' - ); - $this->assertSame( - $this->joinedUpdateGapInitialDuckDBSourceRows(), - $drivers['duckdb']->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ), - 'DuckDB rejected joined UPDATE mutated t2.' + $this->assertParityRowCount( + 'UPDATE t1 a JOIN t2 b ON a.id = b.id + SET only_t1 = 99 + WHERE b.flag = 1' ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); } public function test_joined_update_derived_table_claim_query_matches_sqlite(): void { @@ -1479,6 +1713,66 @@ public function test_cross_joined_delete_matches_sqlite(): void { $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); } + public function test_cross_joined_delete_on_predicates_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)', + 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)', + "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30)", + "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (4, 'b4', 1), (5, 'b5', 0)", + ) + ); + + $this->assertParityRowCount( + 'DELETE a FROM t1 a CROSS JOIN t2 b ON a.id = b.id + WHERE b.flag = 1 AND a.id = 1' + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( + 'DELETE a, b FROM t1 a CROSS JOIN t2 b ON a.id = b.id + WHERE a.id = 3' + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + + $this->assertParityRowCount( "INSERT INTO t1 VALUES (6, 'a6', 60)" ); + $this->assertParityRowCount( "INSERT INTO t2 VALUES (6, 'b6', 1)" ); + $this->assertParityRowCount( + 'DELETE FROM a USING t1 a CROSS JOIN t2 b ON a.id = b.id + WHERE b.id = 6' + ); + $this->assertParityRows( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' ); + $this->assertParityRows( 'SELECT id, note, flag FROM t2 ORDER BY id' ); + } + + public function test_joined_delete_information_schema_source_matches_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE info_delete_items (id INT, value VARCHAR(64))', + "INSERT INTO info_delete_items VALUES + (1, 'info_delete_items'), + (2, 'other'), + (3, 'info_delete_items'), + (4, 'info_delete_items')", + ) + ); + + $this->assertParityRowCount( + "DELETE d FROM info_delete_items d + JOIN information_schema.tables it ON d.value = it.table_name + WHERE it.table_schema = 'wp' AND d.id < 4" + ); + $this->assertParityRows( 'SELECT id, value FROM info_delete_items ORDER BY id' ); + + $this->assertParityRowCount( + "DELETE d FROM info_delete_items d, information_schema.tables it + WHERE d.value = it.table_name AND it.table_schema = 'wp'" + ); + $this->assertParityRows( 'SELECT id, value FROM info_delete_items ORDER BY id' ); + } + public function test_joined_delete_target_wildcards_match_sqlite(): void { $this->runParitySetup( array( @@ -4251,80 +4545,43 @@ public function test_alter_table_drop_unique_constraint_matches_sqlite(): void { $this->assertParityRows( 'SELECT id, name FROM alter_unique_drop_gap ORDER BY id' ); } - public function test_unique_key_foreign_key_metadata_documents_current_duckdb_gap(): void { - $sqlite_driver = new WP_SQLite_Driver( - new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), - 'wp' - ); - $duckdb_driver = new WP_DuckDB_Driver( + public function test_unique_key_foreign_key_metadata_matches_sqlite(): void { + $this->runParitySetup( array( - 'path' => ':memory:', - 'database' => 'wp', + 'CREATE TABLE unique_parent ( + id INT PRIMARY KEY, + code INT, + UNIQUE KEY code_u (code) + )', + 'CREATE TABLE unique_child ( + id INT, + parent_code INT, + CONSTRAINT fk_parent_code FOREIGN KEY (parent_code) REFERENCES unique_parent (code) + )', ) ); - $parent_sql = 'CREATE TABLE unique_parent ( - id INT PRIMARY KEY, - code INT, - UNIQUE KEY code_u (code) - )'; - $child_sql = 'CREATE TABLE unique_child ( - id INT, - parent_code INT, - CONSTRAINT fk_parent_code FOREIGN KEY (parent_code) REFERENCES unique_parent (code) - )'; - - $sqlite_driver->query( $parent_sql, PDO::FETCH_ASSOC ); - $duckdb_driver->query( $parent_sql ); - $sqlite_driver->query( $child_sql, PDO::FETCH_ASSOC ); - - try { - $duckdb_driver->query( $child_sql ); - $this->fail( 'Expected DuckDB to reject a foreign key referencing a driver-managed unique index.' ); - } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( 'primary key or unique constraint', strtolower( $e->getMessage() ) ); - } - - $this->assertSame( - array( - array( - 'CONSTRAINT_NAME' => 'fk_parent_code', - 'UNIQUE_CONSTRAINT_NAME' => 'code_u', - 'TABLE_NAME' => 'unique_child', - 'REFERENCED_TABLE_NAME' => 'unique_parent', - ), - ), - $sqlite_driver->query( - "SELECT CONSTRAINT_NAME, UNIQUE_CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME - FROM information_schema.referential_constraints - WHERE constraint_schema = 'wp' AND table_name = 'unique_child' - ORDER BY constraint_name", - PDO::FETCH_ASSOC - ) + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, ENFORCED + FROM information_schema.table_constraints + WHERE table_schema = 'wp' AND table_name = 'unique_child' + ORDER BY constraint_name" ); - - $sqlite_usage = $sqlite_driver->query( + $this->assertParityRows( + "SELECT CONSTRAINT_NAME, UNIQUE_CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' AND table_name = 'unique_child' + ORDER BY constraint_name" + ); + $this->assertParityRows( "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME FROM information_schema.key_column_usage WHERE table_schema = 'wp' AND table_name = 'unique_child' - ORDER BY constraint_name, ordinal_position", - PDO::FETCH_ASSOC - ); - $this->assertSame( - array( - array( - 'CONSTRAINT_NAME' => 'fk_parent_code', - 'TABLE_NAME' => 'unique_child', - 'COLUMN_NAME' => 'parent_code', - 'POSITION_IN_UNIQUE_CONSTRAINT' => '1', - 'REFERENCED_TABLE_NAME' => 'unique_parent', - 'REFERENCED_COLUMN_NAME' => 'code', - ), - ), - $sqlite_usage + ORDER BY constraint_name, ordinal_position" ); + $this->assertParityRows( 'SHOW CREATE TABLE unique_child' ); } public function test_information_schema_tables_metadata_matches_sqlite(): void { @@ -5050,97 +5307,6 @@ private function assert_no_select_write_stage_tables( WP_DuckDB_Driver $driver ) ); } - private function createJoinedUpdateGapDrivers(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid - $sqlite_driver = new WP_SQLite_Driver( - new WP_SQLite_Connection( array( 'path' => ':memory:' ) ), - 'wp' - ); - $duckdb_driver = new WP_DuckDB_Driver( - array( - 'path' => ':memory:', - 'database' => 'wp', - ) - ); - - $setup_queries = array( - 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)', - 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)', - "INSERT INTO t1 VALUES - (1, 'a1', 10), - (2, 'a2', 20), - (3, 'a3', 30), - (4, 'a4', 40)", - "INSERT INTO t2 VALUES - (1, 'b1', 1), - (3, 'b3', 1), - (5, 'b5', 1)", - ); - - foreach ( $setup_queries as $query ) { - $sqlite_driver->query( $query, PDO::FETCH_ASSOC ); - $duckdb_driver->query( $query ); - } - - return array( - 'sqlite' => $sqlite_driver, - 'duckdb' => $duckdb_driver, - ); - } - - private function assertDuckDBGapQueryRejected( WP_DuckDB_Driver $driver, string $sql, string $message ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid - try { - $driver->query( $sql ); - $this->fail( 'Expected DuckDB joined UPDATE rejection for SQL: ' . $sql ); - } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( $message, $e->getMessage() ); - } - } - - private function joinedUpdateGapInitialDuckDBRows(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid - return array( - array( - 'id' => 1, - 'note' => 'a1', - 'only_t1' => 10, - ), - array( - 'id' => 2, - 'note' => 'a2', - 'only_t1' => 20, - ), - array( - 'id' => 3, - 'note' => 'a3', - 'only_t1' => 30, - ), - array( - 'id' => 4, - 'note' => 'a4', - 'only_t1' => 40, - ), - ); - } - - private function joinedUpdateGapInitialDuckDBSourceRows(): array { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid - return array( - array( - 'id' => 1, - 'note' => 'b1', - 'flag' => 1, - ), - array( - 'id' => 3, - 'note' => 'b3', - 'flag' => 1, - ), - array( - 'id' => 5, - 'note' => 'b5', - 'flag' => 1, - ), - ); - } - private function lifecycleTableSql( string $table_name ): string { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid return "CREATE TABLE {$table_name} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 02e0e4cb3..37b937380 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1007,6 +1007,93 @@ public function test_select_seeded_rand_sequence_advances_per_statement(): void $this->assertSame( $first, $second ); } + public function test_select_order_by_seeded_rand_literal_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE seeded_rand_order (id INT)' ); + $driver->query( 'INSERT INTO seeded_rand_order (id) VALUES (1), (2), (3), (4), (5)' ); + + $first = $driver->query( 'SELECT id FROM seeded_rand_order ORDER BY RAND(1)' )->fetchAll( PDO::FETCH_COLUMN ); + $second = $driver->query( 'SELECT id FROM seeded_rand_order ORDER BY RAND(1)' )->fetchAll( PDO::FETCH_COLUMN ); + $this->assertSame( array( 5, 4, 3, 1, 2 ), array_map( 'intval', $first ) ); + $this->assertSame( $first, $second ); + + $descending = $driver->query( 'SELECT id FROM seeded_rand_order ORDER BY RAND(1) DESC' )->fetchAll( PDO::FETCH_COLUMN ); + $this->assertSame( array( 2, 1, 3, 4, 5 ), array_map( 'intval', $descending ) ); + } + + public function test_select_seeded_rand_where_literal_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE seeded_rand_where (id INT)' ); + $driver->query( 'INSERT INTO seeded_rand_where (id) VALUES (3), (1), (2)' ); + + $filtered = $driver->query( 'SELECT id FROM seeded_rand_where WHERE RAND(1) < 0.5 ORDER BY id' )->fetchAll( PDO::FETCH_COLUMN ); + $this->assertSame( array( 2, 3 ), array_map( 'intval', $filtered ) ); + + $reversed = $driver->query( 'SELECT id FROM seeded_rand_where WHERE 0.5 > RAND(1) ORDER BY 1 DESC' )->fetchAll( PDO::FETCH_COLUMN ); + $this->assertSame( array( 3, 2 ), array_map( 'intval', $reversed ) ); + } + + public function test_select_wildcard_seeded_rand_literal_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE seeded_rand_wildcard (id INT, name VARCHAR(20))' ); + $driver->query( "INSERT INTO seeded_rand_wildcard (id, name) VALUES (1, 'a'), (2, 'b')" ); + + $rows = $driver->query( 'SELECT *, RAND(1) AS r FROM seeded_rand_wildcard ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertCount( 2, $rows ); + $this->assertSame( 1, (int) $rows[0]['id'] ); + $this->assertSame( 'a', $rows[0]['name'] ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $rows[0]['r'], 1e-12 ); + $this->assertSame( 2, (int) $rows[1]['id'] ); + $this->assertSame( 'b', $rows[1]['name'] ); + $this->assertEqualsWithDelta( 0.87161418038571, (float) $rows[1]['r'], 1e-12 ); + + $rows = $driver->query( 'SELECT *, RAND(1) AS r, id AS explicit_id FROM seeded_rand_wildcard ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertCount( 2, $rows ); + $this->assertSame( 1, (int) $rows[0]['id'] ); + $this->assertSame( 'a', $rows[0]['name'] ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $rows[0]['r'], 1e-12 ); + $this->assertSame( 1, (int) $rows[0]['explicit_id'] ); + $this->assertSame( 2, (int) $rows[1]['id'] ); + $this->assertSame( 'b', $rows[1]['name'] ); + $this->assertEqualsWithDelta( 0.87161418038571, (float) $rows[1]['r'], 1e-12 ); + $this->assertSame( 2, (int) $rows[1]['explicit_id'] ); + } + + public function test_select_seeded_rand_expression_seeds_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE seeded_rand_expr (id INT, seed_text VARCHAR(20))' ); + $driver->query( "INSERT INTO seeded_rand_expr (id, seed_text) VALUES (1, '1'), (2, '2'), (3, '3')" ); + + $rows = $driver->query( 'SELECT id, RAND(CAST(seed_text AS SIGNED)) AS r FROM seeded_rand_expr ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertCount( 3, $rows ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $rows[0]['r'], 1e-12 ); + $this->assertEqualsWithDelta( 0.65558664654902, (float) $rows[1]['r'], 1e-12 ); + $this->assertEqualsWithDelta( 0.90576975597606, (float) $rows[2]['r'], 1e-12 ); + + $row = $driver->query( 'SELECT RAND(NULLIF(1, 1)) AS r' )->fetch( PDO::FETCH_ASSOC ); + $this->assertEqualsWithDelta( 0.15522042769494, (float) $row['r'], 1e-12 ); + } + + public function test_select_seeded_rand_expression_without_alias_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $row = $driver->query( 'SELECT RAND(CAST(1 AS SIGNED))' )->fetch( PDO::FETCH_ASSOC ); + + $this->assertArrayHasKey( 'RAND(CAST(1 AS SIGNED))', $row ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $row['RAND(CAST(1 AS SIGNED))'], 1e-12 ); + } + public function test_select_seeded_rand_call_sites_share_statement_state(): void { $this->requireDuckDBRuntime(); @@ -1060,6 +1147,55 @@ public function test_insert_values_seeded_rand_literals_are_emulated(): void { $this->assertEqualsWithDelta( 0.87161418038571, (float) $row['other'], 1e-12 ); } + public function test_insert_set_seeded_rand_literals_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE seeded_rand_set (id INT, value DOUBLE, other DOUBLE)' ); + + $driver->query( 'INSERT INTO seeded_rand_set SET id = 1, value = RAND(1), other = RAND(1)' ); + $driver->query( 'INSERT seeded_rand_set SET id = 2, value = RAND(NULL), other = RAND(1) + 0' ); + + $rows = $driver->query( 'SELECT id, value, other FROM seeded_rand_set ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertCount( 2, $rows ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $rows[0]['value'], 1e-12 ); + $this->assertEqualsWithDelta( 0.87161418038571, (float) $rows[0]['other'], 1e-12 ); + $this->assertEqualsWithDelta( 0.15522042769494, (float) $rows[1]['value'], 1e-12 ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $rows[1]['other'], 1e-12 ); + } + + public function test_update_seeded_rand_literals_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE seeded_rand_update (id INT, value DOUBLE, other DOUBLE)' ); + $driver->query( 'INSERT INTO seeded_rand_update (id, value, other) VALUES (1, 0.0, 0.0), (2, 0.0, 0.0), (3, 0.0, 0.0)' ); + + $single = $driver->query( 'UPDATE seeded_rand_update SET value = RAND(1) WHERE id = 1' ); + $this->assertSame( 1, $single->rowCount() ); + $row = $driver->query( 'SELECT value FROM seeded_rand_update WHERE id = 1' )->fetch( PDO::FETCH_ASSOC ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $row['value'], 1e-12 ); + + $ordered = $driver->query( 'UPDATE seeded_rand_update SET value = RAND(1), other = RAND(1) ORDER BY id LIMIT 2' ); + $this->assertSame( 2, $ordered->rowCount() ); + $rows = $driver->query( 'SELECT id, value, other FROM seeded_rand_update ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertCount( 3, $rows ); + $this->assertEqualsWithDelta( 0.40540353712198, (float) $rows[0]['value'], 1e-12 ); + $this->assertEqualsWithDelta( 0.87161418038571, (float) $rows[0]['other'], 1e-12 ); + $this->assertEqualsWithDelta( 0.14186032129625, (float) $rows[1]['value'], 1e-12 ); + $this->assertEqualsWithDelta( 0.09445909605777, (float) $rows[1]['other'], 1e-12 ); + $this->assertEqualsWithDelta( 0.0, (float) $rows[2]['value'], 1e-12 ); + $this->assertEqualsWithDelta( 0.0, (float) $rows[2]['other'], 1e-12 ); + + $driver->query( 'UPDATE seeded_rand_update SET value = 0.0, other = 0.0' ); + $limited = $driver->query( 'UPDATE seeded_rand_update SET value = RAND(1) LIMIT 1' ); + $this->assertSame( 1, $limited->rowCount() ); + $row = $driver->query( 'SELECT COUNT(*) AS changed FROM seeded_rand_update WHERE abs(value - 0.40540353712198) < 0.000000000001' )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( 1, (int) $row['changed'] ); + } + public function test_select_seeded_rand_unsupported_shapes_are_rejected(): void { $this->requireDuckDBRuntime(); @@ -1067,13 +1203,6 @@ public function test_select_seeded_rand_unsupported_shapes_are_rejected(): void $driver->query( 'CREATE TABLE t (id INT, value DOUBLE)' ); $driver->query( 'INSERT INTO t (id, value) VALUES (1, 0.0)' ); - try { - $driver->query( 'SELECT RAND(CAST(1 AS SIGNED)) AS r' ); - $this->fail( 'Expected unsupported non-literal seeded RAND() shape to fail.' ); - } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( 'literal numeric, string, or NULL seeds', $e->getMessage() ); - } - try { $driver->query( 'SELECT CAST(RAND(1) AS DOUBLE) AS r' ); $this->fail( 'Expected nested seeded RAND() shape to fail.' ); @@ -1082,26 +1211,10 @@ public function test_select_seeded_rand_unsupported_shapes_are_rejected(): void } $unsupported_contexts = array( - array( - 'sql' => 'SELECT *, RAND(1) AS r FROM t', - 'message' => 'SELECT-list wildcards', - ), array( 'sql' => 'SELECT RAND(1) AS r FROM t ORDER BY RAND(1)', 'message' => 'top-level SELECT expression', ), - array( - 'sql' => 'SELECT id FROM t ORDER BY RAND(1)', - 'message' => 'top-level SELECT expression', - ), - array( - 'sql' => 'SELECT id FROM t WHERE RAND(1) < 1', - 'message' => 'top-level SELECT expression', - ), - array( - 'sql' => 'UPDATE t SET value = RAND(1)', - 'message' => 'top-level SELECT expression', - ), ); foreach ( $unsupported_contexts as $case ) { @@ -1119,31 +1232,77 @@ public function test_select_seeded_rand_unsupported_shapes_are_rejected(): void } catch ( WP_DuckDB_Driver_Exception $e ) { $this->assertStringContainsString( 'literal numeric, string, or NULL seeds', $e->getMessage() ); } + + try { + $driver->query( 'INSERT INTO t SET value = RAND(CAST(1 AS SIGNED))' ); + $this->fail( 'Expected unsupported non-literal INSERT SET seeded RAND() shape to fail.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'literal numeric, string, or NULL seeds', $e->getMessage() ); + } } - public function test_seeded_rand_where_rejection_does_not_mutate_rows_or_state(): void { + public function test_update_seeded_rand_where_literal_is_emulated(): void { $this->requireDuckDBRuntime(); $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); - $driver->query( 'CREATE TABLE seeded_rand_where_reject (id INT, value DOUBLE)' ); - $driver->query( 'INSERT INTO seeded_rand_where_reject (id, value) VALUES (1, 0.0), (2, 0.0), (3, 0.0)' ); + $driver->query( 'CREATE TABLE seeded_rand_where_update (id INT, value DOUBLE)' ); + $driver->query( 'INSERT INTO seeded_rand_where_update (id, value) VALUES (3, 0.0), (1, 0.0), (2, 0.0)' ); + + $updated = $driver->query( 'UPDATE seeded_rand_where_update SET value = 9 WHERE RAND(1) < 0.5' ); + $this->assertSame( 2, $updated->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'value' => 0.0, + ), + array( + 'id' => 2, + 'value' => 9.0, + ), + array( + 'id' => 3, + 'value' => 9.0, + ), + ), + $driver->query( 'SELECT id, value FROM seeded_rand_where_update ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); - $before = $driver->query( 'SELECT id, value FROM seeded_rand_where_reject ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + $before = $driver->query( 'SELECT id, value FROM seeded_rand_where_update ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); try { - $driver->query( 'UPDATE seeded_rand_where_reject SET value = 9 WHERE RAND(1) < 0.5' ); - $this->fail( 'Expected unsupported seeded RAND() WHERE context to fail.' ); + $driver->query( 'UPDATE seeded_rand_where_update SET value = RAND(1) WHERE RAND(1) < 0.5' ); + $this->fail( 'Expected mixed seeded RAND() UPDATE context to fail.' ); } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( 'top-level SELECT expression', $e->getMessage() ); + $this->assertStringContainsString( 'cannot be used as both an UPDATE assignment and a WHERE predicate', $e->getMessage() ); } - $after = $driver->query( 'SELECT id, value FROM seeded_rand_where_reject ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + $after = $driver->query( 'SELECT id, value FROM seeded_rand_where_update ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); $this->assertSame( $before, $after ); $row = $driver->query( 'SELECT RAND(1) AS r' )->fetch( PDO::FETCH_ASSOC ); $this->assertEqualsWithDelta( 0.40540353712198, (float) $row['r'], 1e-12 ); } + public function test_delete_seeded_rand_where_literal_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE seeded_rand_where_delete (id INT)' ); + $driver->query( 'INSERT INTO seeded_rand_where_delete (id) VALUES (3), (1), (2)' ); + + $deleted = $driver->query( 'DELETE FROM seeded_rand_where_delete WHERE RAND(1) < 0.5' ); + + $this->assertSame( 2, $deleted->rowCount() ); + $this->assertSame( + array( 1 ), + array_map( + 'intval', + $driver->query( 'SELECT id FROM seeded_rand_where_delete ORDER BY id' )->fetchAll( PDO::FETCH_COLUMN ) + ) + ); + } + public function test_date_format_function_is_emulated(): void { $this->requireDuckDBRuntime(); @@ -2100,6 +2259,17 @@ public function test_session_variable_scoped_forms_are_emulated(): void { )->fetch( PDO::FETCH_ASSOC ) ); $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $this->assertSame( + array( + 'ac' => 0, + '@@autocommit + 0' => 0, + 'COALESCE(@@autocommit, 1)' => 0, + ), + $driver->query( + 'SELECT @@autocommit AS ac, @@autocommit + 0, COALESCE(@@autocommit, 1)' + )->fetch( PDO::FETCH_ASSOC ) + ); } public function test_session_variable_scoped_comma_list_matches_sqlite_current_behavior(): void { @@ -2189,6 +2359,152 @@ public function test_default_storage_engine_session_variable_is_emulated(): void $this->assertSame( array(), $driver->get_last_duckdb_queries() ); } + public function test_keyword_session_variables_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + + $set = $driver->query( + 'SET default_collation_for_utf8mb4 = utf8mb4_0900_ai_ci, + resultset_metadata = FULL, + session_track_gtids = OWN_GTID, + session_track_transaction_info = STATE, + transaction_isolation = SERIALIZABLE, + use_secondary_engine = FORCED' + ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $read = $driver->query( + 'SELECT @@default_collation_for_utf8mb4, + @@resultset_metadata, + @@session_track_gtids, + @@session_track_transaction_info, + @@transaction_isolation, + @@use_secondary_engine' + ); + $this->assertSame( array( 'name' => '@@default_collation_for_utf8mb4' ), $read->getColumnMeta( 0 ) ); + $this->assertSame( + array( + '@@default_collation_for_utf8mb4' => 'utf8mb4_0900_ai_ci', + '@@resultset_metadata' => 'FULL', + '@@session_track_gtids' => 'OWN_GTID', + '@@session_track_transaction_info' => 'STATE', + '@@transaction_isolation' => 'SERIALIZABLE', + '@@use_secondary_engine' => 'FORCED', + ), + $read->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $driver->query( "SET @@session.session_track_transaction_info = 'CHARACTERISTICS'" ); + $this->assertSame( + array( + '@@session.session_track_transaction_info' => 'CHARACTERISTICS', + ), + $driver->query( 'SELECT @@session.session_track_transaction_info' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + + public function test_boolean_like_session_variables_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $assignments = array( + 'end_markers_in_json' => array( + 'value' => 'ON', + 'expected' => 1, + ), + 'explicit_defaults_for_timestamp' => array( + 'value' => 'OFF', + 'expected' => 0, + ), + 'keep_files_on_create' => array( + 'value' => 'ON', + 'expected' => 1, + ), + 'old_alter_table' => array( + 'value' => 'OFF', + 'expected' => 0, + ), + 'print_identified_with_as_hex' => array( + 'value' => 'ON', + 'expected' => 1, + ), + 'require_row_format' => array( + 'value' => 'OFF', + 'expected' => 0, + ), + 'select_into_disk_sync' => array( + 'value' => 'ON', + 'expected' => 1, + ), + 'session_track_schema' => array( + 'value' => 'ON', + 'expected' => 1, + ), + 'session_track_state_change' => array( + 'value' => 'OFF', + 'expected' => 0, + ), + 'show_create_table_skip_secondary_engine' => array( + 'value' => 'ON', + 'expected' => 1, + ), + 'show_create_table_verbosity' => array( + 'value' => 'OFF', + 'expected' => 0, + ), + 'sql_auto_is_null' => array( + 'value' => 'ON', + 'expected' => 1, + ), + 'sql_big_selects' => array( + 'value' => 'OFF', + 'expected' => 0, + ), + 'sql_buffer_result' => array( + 'value' => 'ON', + 'expected' => 1, + ), + 'sql_safe_updates' => array( + 'value' => 'OFF', + 'expected' => 0, + ), + 'transaction_read_only' => array( + 'value' => 'OFF', + 'expected' => 0, + ), + ); + + foreach ( $assignments as $name => $assignment ) { + $set = $driver->query( 'SET ' . $name . ' = ' . $assignment['value'] ); + $this->assertSame( 0, $set->rowCount() ); + $this->assertSame( 0, $set->columnCount() ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + + $expected = array(); + foreach ( $assignments as $name => $assignment ) { + $expected[ '@@' . $name ] = $assignment['expected']; + } + + $read = $driver->query( 'SELECT @@' . implode( ', @@', array_keys( $assignments ) ) ); + $this->assertSame( $expected, $read->fetch( PDO::FETCH_ASSOC ) ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $driver->query( 'SET @old_safe_updates = @@sql_safe_updates' ); + $driver->query( 'SET @@sql_safe_updates = ON' ); + $driver->query( 'SET @@sql_safe_updates = @old_safe_updates' ); + $this->assertSame( + array( '@@sql_safe_updates' => 0 ), + $driver->query( 'SELECT @@sql_safe_updates' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + public function test_sql_mode_bootstrap_statements_are_emulated(): void { $this->requireDuckDBRuntime(); @@ -2332,6 +2648,48 @@ public function test_builtin_system_variables_are_emulated(): void { $this->assertSame( array(), $driver->get_last_duckdb_queries() ); } + public function test_scoped_system_variable_read_probes_are_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $default_sql_mode = 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,' + . 'NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES'; + + $read = $driver->query( + 'SELECT @@GLOBAL.gtid_purged, + @@GLOBAL.log_bin, + @@GLOBAL.log_bin_trust_function_creators, + @@GLOBAL.sql_mode, + @@SESSION.max_allowed_packet, + @@SESSION.sql_mode' + ); + $this->assertSame( + array( + '@@GLOBAL.gtid_purged' => null, + '@@GLOBAL.log_bin' => null, + '@@GLOBAL.log_bin_trust_function_creators' => null, + '@@GLOBAL.sql_mode' => $default_sql_mode, + '@@SESSION.max_allowed_packet' => null, + '@@SESSION.sql_mode' => $default_sql_mode, + ), + $read->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $driver->query( "SET SESSION sql_mode = 'NO_ZERO_DATE'" ); + $read = $driver->query( 'SELECT @@gLoBAL.gTiD_purGed, @@sEssIOn.sqL_moDe' ); + $this->assertSame( array( 'name' => '@@gLoBAL.gTiD_purGed' ), $read->getColumnMeta( 0 ) ); + $this->assertSame( array( 'name' => '@@sEssIOn.sqL_moDe' ), $read->getColumnMeta( 1 ) ); + $this->assertSame( + array( + '@@gLoBAL.gTiD_purGed' => null, + '@@sEssIOn.sqL_moDe' => 'NO_ZERO_DATE', + ), + $read->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + } + public function test_user_variables_are_emulated_for_bounded_values(): void { $this->requireDuckDBRuntime(); @@ -2384,13 +2742,86 @@ public function test_user_variables_are_emulated_for_bounded_values(): void { $driver->query( 'SELECT @signed, @decimal, @flag' )->fetch( PDO::FETCH_ASSOC ) ); $this->assertSame( array(), $driver->get_last_duckdb_queries() ); + + $driver->query( 'SET @my_var = @my_var + 1' ); + $this->assertSame( + array( '@my_var' => 2 ), + $driver->query( 'SELECT @my_var' )->fetch( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'SET @my_var = @my_var + 1' ); + $this->assertSame( + array( '@my_var' => 3 ), + $driver->query( 'SELECT @my_var' )->fetch( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'SET @other = 4, @sum = @my_var + @other' ); + $this->assertSame( + array( '@sum' => 7 ), + $driver->query( 'SELECT @sum' )->fetch( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'SET @db = DATABASE(), @version = VERSION()' ); + $this->assertSame( + array( + '@db' => 'wp', + '@version' => '8.0.38', + ), + $driver->query( 'SELECT @db, @version' )->fetch( PDO::FETCH_ASSOC ) + ); + + $this->assertSame( + array( + 'alias' => 3, + 1 => 1, + ), + $driver->query( 'SELECT @my_var AS alias, 1' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( '@my_var + 1' => 4 ), + $driver->query( 'SELECT @my_var + 1' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( 'expr_var' => 4 ), + $driver->query( 'SELECT @my_var + 1 AS expr_var' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( 'COALESCE(@my_var, 1)' => 3 ), + $driver->query( 'SELECT COALESCE(@my_var, 1)' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( 'COALESCE(@missing, 1)' => 1 ), + $driver->query( 'SELECT COALESCE(@missing, 1)' )->fetch( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + 'alias' => 3, + 1 => 1, + ), + $driver->query( 'SELECT @my_var AS alias, 1 FROM DUAL' )->fetch( PDO::FETCH_ASSOC ) + ); } public function test_dump_check_variable_backup_and_restore_are_emulated(): void { $this->requireDuckDBRuntime(); $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "SET character_set_client = 'latin1', + character_set_results = 'latin1', + collation_connection = latin1_swedish_ci, + time_zone = '+02:00', + sql_notes = 1" + ); + $default_sql_mode = 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,' + . 'NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES'; + $driver->query( '/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;' ); + $driver->query( '/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;' ); + $driver->query( '/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;' ); + $driver->query( '/*!50503 SET NAMES utf8mb4 */;' ); + $driver->query( '/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;' ); + $driver->query( "/*!40103 SET TIME_ZONE='+00:00' */;" ); $set = $driver->query( '/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;' ); $this->assertSame( 0, $set->rowCount() ); $this->assertSame( 0, $set->columnCount() ); @@ -2417,14 +2848,69 @@ public function test_dump_check_variable_backup_and_restore_are_emulated(): void $driver->query( 'SELECT @OLD_FOREIGN_KEY_CHECKS, @@FOREIGN_KEY_CHECKS' )->fetch( PDO::FETCH_ASSOC ) ); + $driver->query( "/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;" ); + $driver->query( '/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;' ); + $driver->query( '/*!40101 SET @saved_cs_client = @@character_set_client */; ' ); + $driver->query( '/*!50503 SET character_set_client = utf8mb4 */;' ); + $this->assertSame( + array( + '@OLD_CHARACTER_SET_CLIENT' => 'latin1', + '@OLD_CHARACTER_SET_RESULTS' => 'latin1', + '@OLD_COLLATION_CONNECTION' => 'latin1_swedish_ci', + '@OLD_TIME_ZONE' => '+02:00', + '@OLD_SQL_MODE' => $default_sql_mode, + '@OLD_SQL_NOTES' => 1, + '@saved_cs_client' => 'latin1', + '@@CHARACTER_SET_CLIENT' => 'utf8mb4', + '@@TIME_ZONE' => '+00:00', + '@@SQL_MODE' => 'NO_AUTO_VALUE_ON_ZERO', + '@@SQL_NOTES' => 0, + ), + $driver->query( + 'SELECT @OLD_CHARACTER_SET_CLIENT, + @OLD_CHARACTER_SET_RESULTS, + @OLD_COLLATION_CONNECTION, + @OLD_TIME_ZONE, + @OLD_SQL_MODE, + @OLD_SQL_NOTES, + @saved_cs_client, + @@CHARACTER_SET_CLIENT, + @@TIME_ZONE, + @@SQL_MODE, + @@SQL_NOTES' + )->fetch( PDO::FETCH_ASSOC ) + ); + + $driver->query( '/*!40101 SET character_set_client = @saved_cs_client */;' ); + $driver->query( '/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;' ); + $driver->query( '/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;' ); $driver->query( '/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;' ); $driver->query( '/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;' ); + $driver->query( '/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;' ); + $driver->query( '/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;' ); + $driver->query( '/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;' ); + $driver->query( '/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;' ); $this->assertSame( array( - '@@UNIQUE_CHECKS' => null, - '@@FOREIGN_KEY_CHECKS' => null, + '@@CHARACTER_SET_CLIENT' => 'latin1', + '@@CHARACTER_SET_RESULTS' => 'latin1', + '@@COLLATION_CONNECTION' => 'latin1_swedish_ci', + '@@TIME_ZONE' => '+02:00', + '@@SQL_MODE' => $default_sql_mode, + '@@UNIQUE_CHECKS' => null, + '@@FOREIGN_KEY_CHECKS' => null, + '@@SQL_NOTES' => 1, ), - $driver->query( 'SELECT @@UNIQUE_CHECKS, @@FOREIGN_KEY_CHECKS' )->fetch( PDO::FETCH_ASSOC ) + $driver->query( + 'SELECT @@CHARACTER_SET_CLIENT, + @@CHARACTER_SET_RESULTS, + @@COLLATION_CONNECTION, + @@TIME_ZONE, + @@SQL_MODE, + @@UNIQUE_CHECKS, + @@FOREIGN_KEY_CHECKS, + @@SQL_NOTES' + )->fetch( PDO::FETCH_ASSOC ) ); $driver->query( 'SET @RESTORED_UNIQUE_CHECKS = 1, @RESTORED_FOREIGN_KEY_CHECKS = "0"' ); @@ -2473,12 +2959,9 @@ public function test_user_variable_unsupported_expression_forms_are_rejected(): foreach ( array( - 'SET @my_var = @my_var + 1', - 'SET @my_var = DATABASE()', 'SET @my_var', - 'SELECT @my_var AS alias, 1', - 'SELECT @my_var + 1', - 'SELECT COALESCE(@my_var, 1)', + 'SET @my_var = DATABASE(1)', + 'SET @my_var = @my_var * 2', 'SELECT @my_var FROM real_table', ) as $sql ) { @@ -2496,9 +2979,6 @@ public function test_session_variable_unsupported_select_shapes_are_rejected(): array( 'SELECT @@GLOBAL.autocommit', 'SELECT @@LOCAL.autocommit', - 'SELECT @@autocommit AS ac', - 'SELECT @@autocommit + 0', - 'SELECT COALESCE(@@autocommit, 1)', 'SELECT @@autocommit FROM real_table', ) as $sql ) { @@ -2920,6 +3400,33 @@ public function test_joined_update_rewrites_cross_join(): void { ), $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) ); + + $joined_update = $driver->query( + 'UPDATE t1 a CROSS JOIN t2 b ON a.id = b.id + SET a.note = b.note + WHERE b.flag = 1' + ); + $this->assertSame( 2, $joined_update->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'b1', + 'only_t1' => 10, + ), + array( + 'id' => 2, + 'note' => 'cross', + 'only_t1' => 20, + ), + array( + 'id' => 3, + 'note' => 'b3', + 'only_t1' => 30, + ), + ), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); } public function test_joined_update_rewrites_straight_join_on(): void { @@ -2985,6 +3492,159 @@ public function test_joined_update_rewrites_straight_join_on(): void { ); } + public function test_joined_update_rewrites_left_and_right_join_like_sqlite(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)' ); + $driver->query( 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30), (4, 'a4', 40)" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (5, 'b5', 1)" ); + + $left_update = $driver->query( + "UPDATE t1 a LEFT JOIN t2 b ON a.id = b.id + SET a.note = 'left'" + ); + $this->assertSame( 2, $left_update->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'left', + 'only_t1' => 10, + ), + array( + 'id' => 2, + 'note' => 'a2', + 'only_t1' => 20, + ), + array( + 'id' => 3, + 'note' => 'left', + 'only_t1' => 30, + ), + array( + 'id' => 4, + 'note' => 'a4', + 'only_t1' => 40, + ), + ), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $right_update = $driver->query( + "UPDATE t1 a RIGHT OUTER JOIN t2 b ON a.id = b.id + SET a.note = 'right'" + ); + $this->assertSame( 2, $right_update->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'right', + 'only_t1' => 10, + ), + array( + 'id' => 2, + 'note' => 'a2', + 'only_t1' => 20, + ), + array( + 'id' => 3, + 'note' => 'right', + 'only_t1' => 30, + ), + array( + 'id' => 4, + 'note' => 'a4', + 'only_t1' => 40, + ), + ), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'b1', + 'flag' => 1, + ), + array( + 'id' => 3, + 'note' => 'b3', + 'flag' => 1, + ), + array( + 'id' => 5, + 'note' => 'b5', + 'flag' => 1, + ), + ), + $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_joined_update_rewrites_natural_join_like_sqlite(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)' ); + $driver->query( 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30), (4, 'a4', 40)" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (5, 'b5', 1)" ); + + $updated = $driver->query( + "UPDATE t1 a NATURAL JOIN t2 b + SET a.note = 'natural'" + ); + $this->assertSame( 4, $updated->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'natural', + 'only_t1' => 10, + ), + array( + 'id' => 2, + 'note' => 'natural', + 'only_t1' => 20, + ), + array( + 'id' => 3, + 'note' => 'natural', + 'only_t1' => 30, + ), + array( + 'id' => 4, + 'note' => 'natural', + 'only_t1' => 40, + ), + ), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'b1', + 'flag' => 1, + ), + array( + 'id' => 3, + 'note' => 'b3', + 'flag' => 1, + ), + array( + 'id' => 5, + 'note' => 'b5', + 'flag' => 1, + ), + ), + $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_joined_update_rewrites_join_using_columns(): void { $this->requireDuckDBRuntime(); @@ -3333,7 +3993,7 @@ public function test_joined_update_infers_unqualified_unique_target_column(): vo ); } - public function test_joined_update_rejects_unqualified_unique_aliased_target_column(): void { + public function test_joined_update_rewrites_unqualified_unique_aliased_target_column_like_sqlite(): void { $this->requireDuckDBRuntime(); $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); @@ -3342,36 +4002,29 @@ public function test_joined_update_rejects_unqualified_unique_aliased_target_col $driver->query( "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30)" ); $driver->query( "INSERT INTO t2 VALUES (1, 'b1', 1), (2, 'b2', 0), (3, 'b3', 1)" ); - try { - $driver->query( - 'UPDATE t1 a JOIN t2 b ON a.id = b.id - SET only_t1 = 99 - WHERE b.flag = 1' - ); - $this->fail( 'Expected joined UPDATE rejection for unqualified aliased target column.' ); - } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( - "Unqualified UPDATE target column 'only_t1' is not supported for aliased joined UPDATE targets", - $e->getMessage() - ); - } + $updated = $driver->query( + 'UPDATE t1 a JOIN t2 b ON a.id = b.id + SET only_t1 = 99 + WHERE b.flag = 1' + ); + $this->assertSame( 3, $updated->rowCount() ); $this->assertSame( array( array( 'id' => 1, 'note' => 'a1', - 'only_t1' => 10, + 'only_t1' => 99, ), array( 'id' => 2, 'note' => 'a2', - 'only_t1' => 20, + 'only_t1' => 99, ), array( 'id' => 3, 'note' => 'a3', - 'only_t1' => 30, + 'only_t1' => 99, ), ), $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) @@ -3450,22 +4103,6 @@ public function test_joined_update_rejects_unsupported_shapes(): void { 'sql' => "UPDATE t1 a JOIN t2 b USING (id) SET a.note = 'target', b.note = 'source'", 'message' => 'UPDATE statement modifying multiple tables is not supported', ), - array( - 'sql' => "UPDATE t1 a LEFT JOIN t2 b ON a.id = b.id SET a.note = 'target'", - 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', - ), - array( - 'sql' => "UPDATE t1 a RIGHT JOIN t2 b ON a.id = b.id SET a.note = 'target'", - 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', - ), - array( - 'sql' => "UPDATE t1 a NATURAL JOIN t2 b SET a.note = 'target'", - 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', - ), - array( - 'sql' => "UPDATE t1 a CROSS JOIN t2 b ON a.id = b.id SET a.note = 'target'", - 'message' => 'CROSS JOIN ... ON is not supported', - ), array( 'sql' => "UPDATE t1 a CROSS JOIN t2 b USING (id) SET a.note = 'target'", 'message' => 'CROSS JOIN ... USING is not supported', @@ -4250,8 +4887,105 @@ public function test_joined_delete_rewrites_cross_join_forms(): void { 'flag' => 1, ), array( - 'id' => 3, - 'note' => 'b3', + 'id' => 3, + 'note' => 'b3', + 'flag' => 1, + ), + array( + 'id' => 5, + 'note' => 'b5', + 'flag' => 0, + ), + ), + $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $using_form_delete = $driver->query( + 'DELETE FROM a USING t1 a CROSS JOIN t2 b + WHERE a.id = 3 AND b.id = 5' + ); + $this->assertSame( 1, $using_form_delete->rowCount() ); + $this->assertSame( + array(), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'b1', + 'flag' => 1, + ), + array( + 'id' => 3, + 'note' => 'b3', + 'flag' => 1, + ), + array( + 'id' => 5, + 'note' => 'b5', + 'flag' => 0, + ), + ), + $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_joined_delete_rewrites_cross_join_on_forms(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE t1 (id INT, note VARCHAR(20), only_t1 INT)' ); + $driver->query( 'CREATE TABLE t2 (id INT, note VARCHAR(20), flag INT)' ); + $driver->query( "INSERT INTO t1 VALUES (1, 'a1', 10), (2, 'a2', 20), (3, 'a3', 30)" ); + $driver->query( "INSERT INTO t2 VALUES (1, 'b1', 1), (3, 'b3', 1), (4, 'b4', 1), (5, 'b5', 0)" ); + + $single_target_delete = $driver->query( + 'DELETE a FROM t1 a CROSS JOIN t2 b ON a.id = b.id + WHERE b.flag = 1 AND a.id = 1' + ); + $this->assertSame( 1, $single_target_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'a2', + 'only_t1' => 20, + ), + array( + 'id' => 3, + 'note' => 'a3', + 'only_t1' => 30, + ), + ), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $multi_target_delete = $driver->query( + 'DELETE a, b FROM t1 a CROSS JOIN t2 b ON a.id = b.id + WHERE a.id = 3' + ); + $this->assertSame( 2, $multi_target_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'note' => 'a2', + 'only_t1' => 20, + ), + ), + $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( + array( + 'id' => 1, + 'note' => 'b1', + 'flag' => 1, + ), + array( + 'id' => 4, + 'note' => 'b4', 'flag' => 1, ), array( @@ -4263,13 +4997,21 @@ public function test_joined_delete_rewrites_cross_join_forms(): void { $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) ); + $driver->query( "INSERT INTO t1 VALUES (6, 'a6', 60)" ); + $driver->query( "INSERT INTO t2 VALUES (6, 'b6', 1)" ); $using_form_delete = $driver->query( - 'DELETE FROM a USING t1 a CROSS JOIN t2 b - WHERE a.id = 3 AND b.id = 5' + 'DELETE FROM a USING t1 a CROSS JOIN t2 b ON a.id = b.id + WHERE b.id = 6' ); $this->assertSame( 1, $using_form_delete->rowCount() ); $this->assertSame( - array(), + array( + array( + 'id' => 2, + 'note' => 'a2', + 'only_t1' => 20, + ), + ), $driver->query( 'SELECT id, note, only_t1 FROM t1 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) ); $this->assertSame( @@ -4280,8 +5022,8 @@ public function test_joined_delete_rewrites_cross_join_forms(): void { 'flag' => 1, ), array( - 'id' => 3, - 'note' => 'b3', + 'id' => 4, + 'note' => 'b4', 'flag' => 1, ), array( @@ -4289,6 +5031,11 @@ public function test_joined_delete_rewrites_cross_join_forms(): void { 'note' => 'b5', 'flag' => 0, ), + array( + 'id' => 6, + 'note' => 'b6', + 'flag' => 1, + ), ), $driver->query( 'SELECT id, note, flag FROM t2 ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) ); @@ -4743,6 +5490,55 @@ public function test_multi_target_joined_delete_rewrites_join_using_and_alias_fo ); } + public function test_joined_delete_accepts_information_schema_tables_read_only_source(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE info_delete_items (id INT, value VARCHAR(64))' ); + $driver->query( + "INSERT INTO info_delete_items VALUES + (1, 'info_delete_items'), + (2, 'other'), + (3, 'info_delete_items'), + (4, 'info_delete_items')" + ); + + $joined_delete = $driver->query( + "DELETE d FROM info_delete_items d + JOIN information_schema.tables it ON d.value = it.table_name + WHERE it.table_schema = 'wp' AND d.id < 4" + ); + $this->assertSame( 2, $joined_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'value' => 'other', + ), + array( + 'id' => 4, + 'value' => 'info_delete_items', + ), + ), + $driver->query( 'SELECT id, value FROM info_delete_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $comma_delete = $driver->query( + "DELETE d FROM info_delete_items d, information_schema.tables it + WHERE d.value = it.table_name AND it.table_schema = 'wp'" + ); + $this->assertSame( 1, $comma_delete->rowCount() ); + $this->assertSame( + array( + array( + 'id' => 2, + 'value' => 'other', + ), + ), + $driver->query( 'SELECT id, value FROM info_delete_items ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_multi_table_delete_rejects_unsupported_shapes(): void { $this->requireDuckDBRuntime(); @@ -4787,10 +5583,6 @@ public function test_multi_table_delete_rejects_unsupported_shapes(): void { 'sql' => 'DELETE a, b FROM t1 a NATURAL JOIN t2 b', 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', ), - array( - 'sql' => 'DELETE a, b FROM t1 a CROSS JOIN t2 b ON a.id = b.id', - 'message' => 'CROSS JOIN ... ON is not supported', - ), array( 'sql' => 'DELETE a, b FROM t1 a STRAIGHT_JOIN t2 b ON a.id = b.id', 'message' => 'Only comma joins, CROSS JOIN, and INNER JOIN ... ON or USING are supported', @@ -4973,6 +5765,35 @@ public function test_simple_select_result_metadata_uses_recorded_table_columns() $this->assertFalse( $update->getColumnMeta( 0 ) ); } + public function test_write_and_control_statements_expose_empty_result_metadata(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + foreach ( + array( + 'CREATE TABLE empty_meta (id INT, note VARCHAR(20))', + 'CREATE INDEX empty_meta_note ON empty_meta (note)', + 'ALTER TABLE empty_meta ADD COLUMN extra INT', + 'TRUNCATE TABLE empty_meta', + 'DROP INDEX empty_meta_note ON empty_meta', + 'SET autocommit=1', + 'START TRANSACTION', + 'COMMIT', + 'LOCK TABLES empty_meta READ', + 'UNLOCK TABLES', + 'DROP TABLE empty_meta', + ) as $sql + ) { + $this->assert_empty_statement_result( $driver->query( $sql ), $sql ); + } + } + public function test_direct_column_metadata_type_matrix_for_supported_mysql_types(): void { $this->requireDuckDBRuntime(); @@ -8183,6 +9004,70 @@ public function test_information_schema_statistics_exposes_mysql_shaped_index_me $this->assertSame( array(), $internal ); } + public function test_information_schema_refresh_inserts_are_batched(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE batched_info_metadata ( + id INT NOT NULL, + slug VARCHAR(20) NOT NULL, + name VARCHAR(20), + KEY slug_name_key (slug, name) + )' + ); + + $column_rows = $driver->query( + "SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'wp' AND table_name = 'batched_info_metadata' + ORDER BY ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'id', 'slug', 'name' ), array_column( $column_rows, 'COLUMN_NAME' ) ); + + $column_inserts = array_values( + array_filter( + $driver->get_last_duckdb_queries(), + function ( string $sql ): bool { + return 0 === strpos( $sql, 'INSERT INTO "__wp_duckdb_information_schema_columns"' ); + } + ) + ); + $this->assertCount( 1, $column_inserts ); + $this->assertStringContainsString( + "), ('def', 'wp', 'batched_info_metadata', 'slug'", + $column_inserts[0] + ); + + $statistics_rows = $driver->query( + "SELECT index_name, seq_in_index, column_name + FROM information_schema.statistics + WHERE table_schema = 'wp' AND table_name = 'batched_info_metadata' + ORDER BY index_name, seq_in_index" + )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'slug', 'name' ), array_column( $statistics_rows, 'COLUMN_NAME' ) ); + $this->assertSame( array( 1, 2 ), array_map( 'intval', array_column( $statistics_rows, 'SEQ_IN_INDEX' ) ) ); + + $statistics_inserts = array_values( + array_filter( + $driver->get_last_duckdb_queries(), + function ( string $sql ): bool { + return 0 === strpos( $sql, 'INSERT INTO "__wp_duckdb_information_schema_statistics"' ); + } + ) + ); + $this->assertCount( 1, $statistics_inserts ); + $this->assertStringContainsString( + "), ('def', 'wp', 'batched_info_metadata', 1, 'wp', 'slug_name_key', 2, 'name'", + $statistics_inserts[0] + ); + } + public function test_information_schema_constraints_expose_mysql_shaped_metadata(): void { $this->requireDuckDBRuntime(); @@ -9126,7 +10011,7 @@ public function test_create_table_inline_foreign_keys_use_native_enforcement_and ); } - public function test_foreign_keys_referencing_driver_managed_unique_keys_are_not_supported(): void { + public function test_foreign_keys_referencing_driver_managed_unique_keys_expose_metadata(): void { $this->requireDuckDBRuntime(); $driver = new WP_DuckDB_Driver( @@ -9194,26 +10079,58 @@ public function test_foreign_keys_referencing_driver_managed_unique_keys_are_not $parent_usage ); - try { - $driver->query( - 'CREATE TABLE unique_child ( - id INT, - parent_code INT, - CONSTRAINT fk_parent_code FOREIGN KEY (parent_code) REFERENCES unique_parent (code) - )' - ); - $this->fail( 'Expected DuckDB to reject a foreign key referencing a driver-managed unique index.' ); - } catch ( WP_DuckDB_Driver_Exception $e ) { - $this->assertStringContainsString( 'primary key or unique constraint', strtolower( $e->getMessage() ) ); - } + $driver->query( + 'CREATE TABLE unique_child ( + id INT, + parent_code INT, + CONSTRAINT fk_parent_code FOREIGN KEY (parent_code) REFERENCES unique_parent (code) + )' + ); $referential_constraints = $driver->query( - "SELECT CONSTRAINT_NAME + "SELECT CONSTRAINT_NAME, UNIQUE_CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME FROM information_schema.referential_constraints - WHERE table_name = 'unique_child' - OR referenced_table_name = 'unique_parent'" + WHERE constraint_schema = 'wp' AND table_name = 'unique_child' + ORDER BY constraint_name" )->fetchAll( PDO::FETCH_ASSOC ); - $this->assertSame( array(), $referential_constraints ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'fk_parent_code', + 'UNIQUE_CONSTRAINT_NAME' => 'code_u', + 'TABLE_NAME' => 'unique_child', + 'REFERENCED_TABLE_NAME' => 'unique_parent', + ), + ), + $referential_constraints + ); + + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'fk_parent_code', + 'TABLE_NAME' => 'unique_child', + 'COLUMN_NAME' => 'parent_code', + 'POSITION_IN_UNIQUE_CONSTRAINT' => 1, + 'REFERENCED_TABLE_NAME' => 'unique_parent', + 'REFERENCED_COLUMN_NAME' => 'code', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, + POSITION_IN_UNIQUE_CONSTRAINT, REFERENCED_TABLE_NAME, + REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_schema = 'wp' AND table_name = 'unique_child' + ORDER BY constraint_name, ordinal_position" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $create = $driver->query( 'SHOW CREATE TABLE unique_child' )->fetch( PDO::FETCH_ASSOC ); + $this->assertStringContainsString( + 'CONSTRAINT `fk_parent_code` FOREIGN KEY (`parent_code`) REFERENCES `unique_parent` (`code`)', + $create['Create Table'] + ); } public function test_information_schema_tables_exposes_mysql_shaped_table_metadata(): void { @@ -9988,6 +10905,97 @@ public function test_temporary_table_lifecycle_uses_session_metadata(): void { $this->assertSame( array(), $driver->query( 'SHOW CREATE TABLE temp_items' )->fetchAll( PDO::FETCH_ASSOC ) ); } + public function test_repeated_metadata_reads_do_not_reensure_metadata_tables(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE metadata_cache_items (id INT NOT NULL, name VARCHAR(20), KEY name_key (name))' ); + + $first = $driver->query( 'SHOW INDEX FROM metadata_cache_items' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'name_key' ), array_column( $first, 'Key_name' ) ); + + $second = $driver->query( 'SHOW INDEX FROM metadata_cache_items' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( $first, $second ); + + foreach ( $driver->get_last_duckdb_queries() as $duckdb_sql ) { + $this->assertStringNotContainsString( 'CREATE TABLE IF NOT EXISTS "__wp_duckdb_index_metadata"', $duckdb_sql ); + $this->assertStringNotContainsString( "pragma_table_info('__wp_duckdb_index_metadata')", $duckdb_sql ); + } + + $driver->query( 'CREATE TEMPORARY TABLE temp_metadata_cache_items (id INT NOT NULL, name VARCHAR(20), KEY temp_name_key (name))' ); + $temp_first = $driver->query( 'SHOW INDEX FROM temp_metadata_cache_items' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'temp_name_key' ), array_column( $temp_first, 'Key_name' ) ); + + $temp_second = $driver->query( 'SHOW INDEX FROM temp_metadata_cache_items' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( $temp_first, $temp_second ); + + foreach ( $driver->get_last_duckdb_queries() as $duckdb_sql ) { + $this->assertStringNotContainsString( 'CREATE TEMP TABLE IF NOT EXISTS "__wp_duckdb_temp_index_metadata"', $duckdb_sql ); + $this->assertStringNotContainsString( "pragma_table_info('__wp_duckdb_temp_index_metadata')", $duckdb_sql ); + } + } + + public function test_create_table_metadata_inserts_are_batched(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE batched_metadata ( + id INT NOT NULL, + slug VARCHAR(20) NOT NULL, + name VARCHAR(20), + KEY slug_name_key (slug, name) + )' + ); + + $queries = $driver->get_last_duckdb_queries(); + $column_inserts = array_values( + array_filter( + $queries, + function ( string $sql ): bool { + return 0 === strpos( $sql, 'INSERT INTO "__wp_duckdb_column_metadata"' ); + } + ) + ); + $index_inserts = array_values( + array_filter( + $queries, + function ( string $sql ): bool { + return 0 === strpos( $sql, 'INSERT INTO "__wp_duckdb_index_metadata"' ); + } + ) + ); + + $this->assertCount( 1, $column_inserts ); + $this->assertCount( 1, $index_inserts ); + $this->assertStringContainsString( + "), ('batched_metadata', 2, 'slug'", + $column_inserts[0] + ); + $this->assertStringContainsString( + "), ('batched_metadata', 'slug_name_key', 1, 2, 'name'", + $index_inserts[0] + ); + + $this->assertSame( + array( 'id', 'slug', 'name' ), + array_column( $driver->query( 'SHOW COLUMNS FROM batched_metadata' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + $index_rows = $driver->query( 'SHOW INDEX FROM batched_metadata' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'slug', 'name' ), array_column( $index_rows, 'Column_name' ) ); + $this->assertSame( array( 1, 2 ), array_map( 'intval', array_column( $index_rows, 'Seq_in_index' ) ) ); + } + public function test_temporary_table_inline_checks_use_temp_metadata_only(): void { $this->requireDuckDBRuntime(); @@ -10132,6 +11140,73 @@ public function test_temporary_table_takes_precedence_over_persistent_table(): v ); } + public function test_targeted_table_resolution_preserves_temp_shadow_without_broad_table_scan(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TABLE runtime_lookup_target (persistent_id INT, persistent_label VARCHAR(20))' ); + $driver->query( "INSERT INTO runtime_lookup_target VALUES (1, 'persistent')" ); + + for ( $i = 0; $i < 5; ++$i ) { + $driver->query( 'CREATE TEMPORARY TABLE runtime_lookup_noise_' . $i . ' (noise_id INT)' ); + } + + $driver->query( 'CREATE TEMPORARY TABLE runtime_lookup_target (temp_id INT, temp_label VARCHAR(20))' ); + $driver->query( "INSERT INTO runtime_lookup_target VALUES (2, 'temporary')" ); + + $this->assertSame( + array( + array( + 'temp_id' => 2, + 'temp_label' => 'temporary', + ), + ), + $driver->query( 'SELECT * FROM runtime_lookup_target' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $driver->query( 'ALTER TABLE runtime_lookup_target ADD COLUMN temp_marker INT' ); + foreach ( $driver->get_last_duckdb_queries() as $duckdb_sql ) { + $this->assertFalse( + 0 === strpos( $duckdb_sql, 'SELECT table_name FROM information_schema.tables' ) + && false !== strpos( $duckdb_sql, 'ORDER BY table_name' ), + $duckdb_sql + ); + } + + $this->assertSame( + array( 'temp_id', 'temp_label', 'temp_marker' ), + array_column( $driver->query( 'SHOW COLUMNS FROM runtime_lookup_target' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + + $driver->query( 'DROP TEMPORARY TABLE runtime_lookup_target' ); + foreach ( $driver->get_last_duckdb_queries() as $duckdb_sql ) { + $this->assertFalse( + 0 === strpos( $duckdb_sql, 'SELECT table_name FROM information_schema.tables' ) + && false !== strpos( $duckdb_sql, 'ORDER BY table_name' ), + $duckdb_sql + ); + } + + $this->assertSame( + array( + array( + 'persistent_id' => 1, + 'persistent_label' => 'persistent', + ), + ), + $driver->query( 'SELECT * FROM runtime_lookup_target' )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( 'persistent_id', 'persistent_label' ), + array_column( $driver->query( 'SHOW COLUMNS FROM runtime_lookup_target' )->fetchAll( PDO::FETCH_ASSOC ), 'Field' ) + ); + } + public function test_qualified_describe_does_not_expose_information_schema_tables(): void { $this->requireDuckDBRuntime(); @@ -13632,7 +14707,6 @@ public function test_alter_table_foreign_key_limitations_reject_before_mutation( 'ALTER TABLE alter_fk_limit_child ADD CONSTRAINT fk_multi FOREIGN KEY (parent_id, other_id) REFERENCES alter_fk_limit_parent (id, other_id)' => 'Only single-column foreign keys are supported', 'ALTER TABLE alter_fk_limit_child ADD CONSTRAINT fk_schema FOREIGN KEY (parent_id) REFERENCES wp.alter_fk_limit_parent (id)' => 'Schema-qualified references are not supported', 'ALTER TABLE alter_fk_limit_child ADD CONSTRAINT fk_cascade FOREIGN KEY (parent_id) REFERENCES alter_fk_limit_parent (id) ON DELETE CASCADE' => 'ON DELETE CASCADE is not supported', - 'ALTER TABLE alter_fk_limit_child ADD CONSTRAINT fk_unique_gap FOREIGN KEY (code) REFERENCES alter_fk_limit_parent (code)' => 'single-column referenced PRIMARY KEY', 'ALTER TABLE alter_fk_limit_temp ADD CONSTRAINT fk_temp FOREIGN KEY (parent_id) REFERENCES alter_fk_limit_parent (id)' => 'temporary tables is not supported', 'ALTER TABLE alter_fk_limit_child ADD COLUMN parent_ref INT REFERENCES alter_fk_limit_parent (id)' => 'Inline REFERENCES constraints are only supported in CREATE TABLE', 'ALTER TABLE alter_fk_limit_child ADD COLUMN should_not_exist INT DEFAULT 2, ADD CONSTRAINT fk_multi_action FOREIGN KEY (parent_id) REFERENCES alter_fk_limit_parent (id)' => 'ADD/DROP FOREIGN KEY cannot be combined with other ALTER TABLE actions', @@ -13652,6 +14726,31 @@ public function test_alter_table_foreign_key_limitations_reject_before_mutation( $this->assertSame( $before_temp, $driver->query( 'SELECT id, parent_id FROM alter_fk_limit_temp ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) ); } + $this->assertSame( + 0, + $driver->query( 'ALTER TABLE alter_fk_limit_child ADD CONSTRAINT fk_unique_gap FOREIGN KEY (code) REFERENCES alter_fk_limit_parent (code)' )->rowCount() + ); + $this->assertSame( + array( + array( + 'CONSTRAINT_NAME' => 'fk_unique_gap', + 'UNIQUE_CONSTRAINT_NAME' => 'code_u', + 'TABLE_NAME' => 'alter_fk_limit_child', + 'REFERENCED_TABLE_NAME' => 'alter_fk_limit_parent', + ), + ), + $driver->query( + "SELECT CONSTRAINT_NAME, UNIQUE_CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME + FROM information_schema.referential_constraints + WHERE constraint_schema = 'wp' AND table_name = 'alter_fk_limit_child' + ORDER BY constraint_name" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertStringContainsString( + 'CONSTRAINT `fk_unique_gap` FOREIGN KEY (`code`) REFERENCES `alter_fk_limit_parent` (`code`)', + $driver->query( 'SHOW CREATE TABLE alter_fk_limit_child' )->fetch( PDO::FETCH_ASSOC )['Create Table'] + ); + $driver->query( 'BEGIN' ); try { $driver->query( 'ALTER TABLE alter_fk_limit_child ADD CONSTRAINT fk_tx FOREIGN KEY (parent_id) REFERENCES alter_fk_limit_parent (id)' ); @@ -14774,6 +15873,14 @@ private function alter_table_referenced_parent_snapshot( WP_DuckDB_Driver $drive ); } + private function assert_empty_statement_result( WP_DuckDB_Result_Statement $result, string $sql ): void { + $this->assertSame( 0, $result->rowCount(), 'Row count mismatch for SQL: ' . $sql ); + $this->assertSame( 0, $result->columnCount(), 'Column count mismatch for SQL: ' . $sql ); + $this->assertFalse( $result->getColumnMeta( 0 ), 'Column metadata mismatch for SQL: ' . $sql ); + $this->assertFalse( $result->fetch( PDO::FETCH_ASSOC ), 'Fetch mismatch for SQL: ' . $sql ); + $this->assertSame( array(), $result->fetchAll( PDO::FETCH_ASSOC ), 'Fetch-all mismatch for SQL: ' . $sql ); + } + private function assertDriverQueryRejected( WP_DuckDB_Driver $driver, string $sql, string $message_substring = '' ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid try { $driver->query( $sql ); From 35e23e65f4ee1f30964fdcbb9de91b376a390a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 15:59:25 +0000 Subject: [PATCH 151/317] Fix DuckDB explicit auto-increment sequencing --- .../src/duckdb/class-wp-duckdb-driver.php | 137 ++++++++++++++---- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 45 ++++++ 2 files changed, 152 insertions(+), 30 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 5d8a3cfe1..7991897bb 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -19884,16 +19884,19 @@ private function should_omit_space_before_open_parenthesis( ?string $previous ): * @return WP_DuckDB_Result_Statement */ private function execute_auto_increment_write( string $table_name, string $sql, string $context, array $tokens = array(), ?int $table_index = null ): WP_DuckDB_Result_Statement { - $table_reference = $this->resolve_visible_user_table_reference( $table_name ); - $metadata = null === $table_reference ? null : $this->auto_increment_metadata_for_table( $table_reference['table_name'], $table_reference['temporary'] ); - $sequence_name = null === $metadata ? null : $metadata['sequence_name']; - $explicit_insert_id = null === $metadata || null === $table_index - ? null - : $this->explicit_auto_increment_value_for_write( $tokens, $table_index, $metadata['column_name'] ); - $column_was_omitted = null !== $metadata && null !== $table_index + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + $metadata = null === $table_reference ? null : $this->auto_increment_metadata_for_table( $table_reference['table_name'], $table_reference['temporary'] ); + $sequence_name = null === $metadata ? null : $metadata['sequence_name']; + $explicit_insert_ids = null === $metadata || null === $table_index + ? array() + : $this->explicit_auto_increment_values_for_write( $tokens, $table_index, $metadata['column_name'] ); + $explicit_insert_id = count( $explicit_insert_ids ) > 0 + ? $explicit_insert_ids[ count( $explicit_insert_ids ) - 1 ] + : null; + $column_was_omitted = null !== $metadata && null !== $table_index ? $this->auto_increment_column_omitted_from_write( $tokens, $table_index, $metadata['column_name'] ) : false; - $before = null === $sequence_name || $column_was_omitted ? null : $this->sequence_currval( $sequence_name ); + $before = null === $sequence_name || $column_was_omitted ? null : $this->sequence_currval( $sequence_name ); $insert_ignore_write = null !== $metadata && null !== $table_reference @@ -19903,7 +19906,7 @@ private function execute_auto_increment_write( string $table_name, string $sql, $insert_ignore_explicit_ids_existed = array(); $tracks_insert_ignore_explicit_values = $insert_ignore_write; if ( $tracks_insert_ignore_explicit_values ) { - $insert_ignore_explicit_ids = $this->explicit_auto_increment_values_for_write( $tokens, $table_index, $metadata['column_name'] ); + $insert_ignore_explicit_ids = $explicit_insert_ids; foreach ( $insert_ignore_explicit_ids as $insert_ignore_explicit_id ) { $key = (string) $insert_ignore_explicit_id; if ( ! array_key_exists( $key, $insert_ignore_explicit_ids_existed ) ) { @@ -19967,6 +19970,25 @@ private function execute_auto_increment_write( string $table_name, string $sql, } } + if ( + null !== $metadata + && null !== $sequence_name + && null !== $table_reference + && $result->rowCount() > 0 + && count( $explicit_insert_ids ) > 0 + ) { + $inserted_explicit_ids = $tracks_insert_ignore_explicit_values + ? $this->inserted_explicit_auto_increment_values_for_insert_ignore( + $table_reference['table_name'], + $metadata['column_name'], + $insert_ignore_explicit_ids, + $insert_ignore_explicit_ids_existed, + $table_reference['temporary'] + ) + : $explicit_insert_ids; + $this->advance_auto_increment_sequence_after_explicit_write( $sequence_name, $inserted_explicit_ids ); + } + return $result; } @@ -20002,17 +20024,36 @@ private function is_insert_ignore_write( array $tokens, int $table_index ): bool * @return int|null Last inserted explicit AUTO_INCREMENT value. */ private function inserted_explicit_auto_increment_value_for_insert_ignore( string $table_name, string $column_name, array $explicit_ids, array $ids_existed, bool $temporary = false ): ?int { - for ( $index = count( $explicit_ids ) - 1; $index >= 0; --$index ) { - $explicit_id = $explicit_ids[ $index ]; + $inserted_ids = $this->inserted_explicit_auto_increment_values_for_insert_ignore( $table_name, $column_name, $explicit_ids, $ids_existed, $temporary ); + if ( count( $inserted_ids ) > 0 ) { + return $inserted_ids[ count( $inserted_ids ) - 1 ]; + } + + return null; + } + + /** + * Find explicit AUTO_INCREMENT values inserted by INSERT IGNORE. + * + * @param string $table_name Table name. + * @param string $column_name AUTO_INCREMENT column name. + * @param int[] $explicit_ids Explicit AUTO_INCREMENT IDs in statement order. + * @param array $ids_existed Whether each explicit ID existed before the write. + * @param bool $temporary Whether the target is a temporary table. + * @return int[] Inserted explicit AUTO_INCREMENT values in statement order. + */ + private function inserted_explicit_auto_increment_values_for_insert_ignore( string $table_name, string $column_name, array $explicit_ids, array $ids_existed, bool $temporary = false ): array { + $inserted_ids = array(); + foreach ( $explicit_ids as $explicit_id ) { if ( ! empty( $ids_existed[ (string) $explicit_id ] ) ) { continue; } if ( $this->auto_increment_value_exists( $table_name, $column_name, $explicit_id ) ) { - return $explicit_id; + $inserted_ids[] = $explicit_id; } } - return null; + return $inserted_ids; } /** @@ -20232,23 +20273,6 @@ private function drop_auto_increment_sequences( array $sequence_names ): void { } } - /** - * Parse the last explicit literal assigned to an AUTO_INCREMENT column. - * - * @param WP_Parser_Token[] $tokens MySQL token stream. - * @param int $table_index Index of the table token. - * @param string $column_name AUTO_INCREMENT column name. - * @return int|null Explicit value, or null when the statement uses generated/default values. - */ - private function explicit_auto_increment_value_for_write( array $tokens, int $table_index, string $column_name ): ?int { - $explicit_values = $this->explicit_auto_increment_values_for_write( $tokens, $table_index, $column_name ); - if ( count( $explicit_values ) > 0 ) { - return $explicit_values[ count( $explicit_values ) - 1 ]; - } - - return null; - } - /** * Parse explicit literal values assigned to an AUTO_INCREMENT column. * @@ -20418,6 +20442,59 @@ private function sequence_currval( string $sequence_name ): ?int { return false === $value || null === $value ? null : (int) $value; } + /** + * Advance a sequence once and return the generated value. + * + * @param string $sequence_name Sequence name. + * @return int Generated value. + */ + private function next_auto_increment_sequence_value( string $sequence_name ): int { + $stmt = $this->execute_duckdb_query( + 'SELECT nextval(' . $this->connection->quote( $sequence_name ) . ') AS value', + 'Failed to advance DuckDB AUTO_INCREMENT sequence' + ); + + $value = $stmt->fetchColumn(); + return false === $value || null === $value ? 0 : (int) $value; + } + + /** + * Advance a sequence so the next generated value follows explicit inserts. + * + * @param string $sequence_name Sequence name. + * @param int[] $explicit_ids Explicit AUTO_INCREMENT IDs that were inserted. + */ + private function advance_auto_increment_sequence_after_explicit_write( string $sequence_name, array $explicit_ids ): void { + $positive_ids = array_filter( + $explicit_ids, + function ( int $explicit_id ): bool { + return $explicit_id > 0; + } + ); + if ( count( $positive_ids ) === 0 ) { + return; + } + + $target_value = max( $positive_ids ); + $current = $this->sequence_currval( $sequence_name ); + if ( null === $current ) { + $current = $this->next_auto_increment_sequence_value( $sequence_name ); + } + if ( $current >= $target_value ) { + return; + } + + $steps = $target_value - $current; + $this->execute_duckdb_query( + 'SELECT max(nextval(' + . $this->connection->quote( $sequence_name ) + . ')) AS value FROM range(' + . $steps + . ')', + 'Failed to advance DuckDB AUTO_INCREMENT sequence' + ); + } + /** * Prime a sequence so the next generated value matches MySQL AUTO_INCREMENT metadata. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 37b937380..cf0e52400 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -7757,6 +7757,9 @@ public function test_insert_id_tracks_generated_auto_increment_values(): void { $driver->query( "INSERT INTO auto_items (id, name) VALUES (42, 'explicit')" ); $this->assertSame( 42, $driver->get_insert_id() ); + $driver->query( "INSERT INTO auto_items (name) VALUES ('after-explicit')" ); + $this->assertSame( 43, $driver->get_insert_id() ); + $driver->query( "INSERT IGNORE INTO auto_items (name) VALUES ('first')" ); $this->assertSame( 0, $driver->get_insert_id() ); @@ -7770,6 +7773,46 @@ public function test_insert_id_tracks_generated_auto_increment_values(): void { $this->fail( 'Expected duplicate insert to fail.' ); } + public function test_explicit_auto_increment_insert_primes_next_generated_value(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE wptests_terms ( + term_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(200) NOT NULL DEFAULT "", + slug VARCHAR(200) NOT NULL DEFAULT "", + term_group BIGINT(10) NOT NULL DEFAULT 0, + PRIMARY KEY (term_id) + )' + ); + + $driver->query( "INSERT INTO wptests_terms (term_id, name, slug, term_group) VALUES (1, 'first', 'first', 0)" ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $insert = $driver->query( "INSERT INTO wptests_terms (name, slug, term_group) VALUES ('foo', 'bar', 0)" ); + $this->assertSame( 1, $insert->rowCount() ); + $this->assertSame( 2, $driver->get_insert_id() ); + $this->assertSame( + array( + array( + 'term_id' => 1, + 'name' => 'first', + ), + array( + 'term_id' => 2, + 'name' => 'foo', + ), + ), + $driver->query( 'SELECT term_id, name FROM wptests_terms ORDER BY term_id' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_insert_id_tracks_wordpress_usermeta_shape(): void { $this->requireDuckDBRuntime(); @@ -7908,6 +7951,8 @@ public function test_insert_ignore_explicit_auto_increment_insert_id_skips_ignor ), $driver->query( 'SELECT id, name FROM ignore_primary_after ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) ); + $driver->query( "INSERT INTO ignore_primary_after (name) VALUES ('generated-after-ignore')" ); + $this->assertSame( 5, $driver->get_insert_id() ); $driver->query( "INSERT INTO ignore_secondary_after (id, name) VALUES (1, 'taken')" ); $inserted_before_secondary_conflict = $driver->query( From 45ed597716225586f808fa0c47fc6fd24aa0711e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 16:35:00 +0000 Subject: [PATCH 152/317] Fix DuckDB WordPress query parity gaps --- .../src/duckdb/class-wp-duckdb-driver.php | 97 ++++++++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 162 +++++++++++++++++- 2 files changed, 250 insertions(+), 9 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 7991897bb..10306ad55 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -2036,7 +2036,7 @@ private function primary_key_group_by_expansion( array $tokens ): ?array { /** * Parse the FROM clause supported by primary-key GROUP BY expansion. * - * Joined tables are accepted only as direct INNER JOIN sources. Projected, + * Joined tables are accepted only as direct INNER/LEFT JOIN sources. Projected, * grouped, and ordered columns must still qualify the primary table. * * @param WP_Parser_Token[] $tokens FROM clause tokens. @@ -2084,6 +2084,14 @@ private function primary_key_group_by_join_chain_is_supported( array $tokens, in if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { return false; } + } elseif ( WP_MySQL_Lexer::LEFT_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::OUTER_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { + return false; + } } elseif ( WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { return false; } @@ -14758,6 +14766,22 @@ private function translate_tokens_to_duckdb_sql( continue; } + $sum_length_function = $this->translate_sum_length_function_call( + $tokens, + $index, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + if ( null !== $sum_length_function ) { + $pieces[] = $sum_length_function; + continue; + } + if ( WP_MySQL_Lexer::NOT_SYMBOL === $token->id && isset( $tokens[ $index + 1 ], $tokens[ $index + 2 ] ) @@ -15349,6 +15373,69 @@ private function translate_field_function_call( return implode( ' ', $cases ); } + /** + * Translate SUM(LENGTH(...)) to a PHP-int-compatible result type. + * + * DuckDB returns HUGEINT for SUM(BIGINT), and its PHP client requires bcmath + * when reading HUGEINT values. WordPress uses this bounded shape for Site + * Health autoloaded option-size checks where MySQL returns an integer. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current token index, advanced on match. + * @return string|null DuckDB SQL, or null when the token does not start SUM(LENGTH(...)). + */ + private function translate_sum_length_function_call( + array $tokens, + int &$index, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints + ): ?string { + if ( + ! isset( $tokens[ $index + 1 ] ) + || $this->is_non_identifier_token( $tokens[ $index ] ) + || 0 !== strcasecmp( $tokens[ $index ]->get_value(), 'SUM' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + return null; + } + + $end_index = $this->skip_balanced_parentheses( $tokens, $index + 1 ); + $body = array_slice( $tokens, $index + 2, $end_index - $index - 3 ); + $items = $this->split_top_level_comma_items( $body ); + if ( 1 !== count( $items ) || count( $items[0] ) < 3 ) { + return null; + } + + $item = $items[0]; + if ( + $this->is_non_identifier_token( $item[0] ) + || 0 !== strcasecmp( $item[0]->get_value(), 'LENGTH' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $item[1]->id + || $this->skip_balanced_parentheses( $item, 1 ) !== count( $item ) + ) { + return null; + } + + $index = $end_index - 1; + return 'CAST(SUM(' + . $this->translate_tokens_to_duckdb_sql( + $item, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ) + . ') AS BIGINT)'; + } + /** * Translate MySQL REPLACE ... VALUES to DuckDB INSERT OR REPLACE. * @@ -18840,7 +18927,7 @@ private function translate_date_time_function_call( } $name = strtoupper( $tokens[ $index ]->get_value() ); - if ( ! in_array( $name, array( 'DATE', 'DATEDIFF', 'DATE_ADD', 'DATE_SUB', 'MONTH', 'YEAR' ), true ) ) { + if ( ! in_array( $name, array( 'DATE', 'DATEDIFF', 'DATE_ADD', 'DATE_SUB', 'DAY', 'DAYOFMONTH', 'MONTH', 'YEAR' ), true ) ) { return null; } @@ -18898,13 +18985,15 @@ private function translate_date_time_function_call( return 'CAST(CAST((' . $start_sql . ') AS DATE) - CAST((' . $end_sql . ') AS DATE) AS BIGINT)'; } - if ( 'MONTH' === $name || 'YEAR' === $name ) { + if ( in_array( $name, array( 'DAY', 'DAYOFMONTH', 'MONTH', 'YEAR' ), true ) ) { if ( 1 !== count( $items ) || count( $items[0] ) === 0 ) { return null; } + $function = 'DAY' === $name ? 'dayofmonth' : strtolower( $name ); + $index = $end_index - 1; - return strtolower( $name ) . '(TRY_CAST((' + return $function . '(TRY_CAST((' . $this->translate_tokens_to_duckdb_sql( $items[0], $rewrite_information_schema_tables, diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index cf0e52400..5eb39deb3 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -957,6 +957,73 @@ public function test_select_joined_group_by_primary_key_rejects_joined_table_pro $this->assertStringNotContainsString( '"wptests_posts"."post_author"', $select_sql ); } + public function test_select_left_join_group_by_primary_key_orders_by_functionally_dependent_column(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $this->create_wordpress_left_join_group_by_tables( $driver ); + + $rows = $driver->query( + "SELECT wptests_posts.ID + FROM wptests_posts + LEFT JOIN wptests_term_relationships + ON (wptests_posts.ID = wptests_term_relationships.object_id) + WHERE wptests_posts.post_type = 'post' + AND wptests_posts.post_status = 'publish' + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date ASC" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + array( 'ID' => 1 ), + array( 'ID' => 2 ), + array( 'ID' => 3 ), + ), + $rows + ); + + $duckdb_queries = $driver->get_last_duckdb_queries(); + $select_sql = end( $duckdb_queries ); + + $this->assertIsString( $select_sql ); + $this->assertStringContainsString( + 'GROUP BY wptests_posts.ID, "wptests_posts"."post_date"', + $select_sql + ); + } + + public function test_sql_calc_found_rows_left_join_group_by_primary_key_expands_group_by_for_count(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $this->create_wordpress_left_join_group_by_tables( $driver ); + + $result = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + LEFT OUTER JOIN wptests_term_relationships + ON (wptests_posts.ID = wptests_term_relationships.object_id) + WHERE wptests_posts.post_type = 'post' + AND wptests_posts.post_status = 'publish' + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date ASC + LIMIT 0, 2" + ); + + $this->assertSame( + array( + array( 'ID' => 1 ), + array( 'ID' => 2 ), + ), + $result->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( + array( array( 'found_rows' => 3 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_select_seeded_rand_literals_are_emulated(): void { $this->requireDuckDBRuntime(); @@ -1312,7 +1379,7 @@ public function test_date_format_function_is_emulated(): void { $this->assertSame( '2026-06-26', $row['formatted_date'] ); } - public function test_year_and_month_functions_translate_wordpress_datetime_strings_with_try_cast(): void { + public function test_date_part_functions_translate_wordpress_datetime_strings_with_try_cast(): void { $connection = new class() extends WP_DuckDB_Connection { public function __construct() {} @@ -1334,7 +1401,7 @@ public function query( string $sql, array $params = array() ): WP_DuckDB_Result_ $tokens = $tokenize->invoke( $driver, - "SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month + "SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month, DAYOFMONTH( post_date ) AS day FROM wp_posts WHERE post_type = 'attachment' ORDER BY post_date DESC" @@ -1343,9 +1410,10 @@ public function query( string $sql, array $params = array() ): WP_DuckDB_Result_ $this->assertStringContainsString( 'year(TRY_CAST((post_date) AS TIMESTAMP)) AS year', $sql ); $this->assertStringContainsString( 'month(TRY_CAST((post_date) AS TIMESTAMP)) AS month', $sql ); + $this->assertStringContainsString( 'dayofmonth(TRY_CAST((post_date) AS TIMESTAMP)) AS day', $sql ); } - public function test_year_and_month_functions_try_cast_wordpress_datetime_strings(): void { + public function test_date_part_functions_try_cast_wordpress_datetime_strings(): void { $this->requireDuckDBRuntime(); $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); @@ -1366,7 +1434,7 @@ public function test_year_and_month_functions_try_cast_wordpress_datetime_string ); $rows = $driver->query( - "SELECT DISTINCT YEAR(post_date) AS year, MONTH(post_date) AS month + "SELECT DISTINCT YEAR(post_date) AS year, MONTH(post_date) AS month, DAYOFMONTH(post_date) AS day FROM wp_posts WHERE post_type = 'attachment' ORDER BY post_date DESC" @@ -1377,27 +1445,76 @@ public function test_year_and_month_functions_try_cast_wordpress_datetime_string array( 'year' => 2026, 'month' => 6, + 'day' => 26, ), array( 'year' => 2026, 'month' => 5, + 'day' => 1, ), ), $rows ); + $this->assertSame( + array( array( 'ID' => 1 ) ), + $driver->query( + "SELECT ID + FROM wp_posts + WHERE YEAR(post_date) = 2026 + AND MONTH(post_date) = 6 + AND DAYOFMONTH(post_date) = 26 + AND post_type = 'attachment'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + $row = $driver->query( "SELECT YEAR('0000-00-00 00:00:00') AS zero_date_year, MONTH('0000-00-00 00:00:00') AS zero_date_month, + DAYOFMONTH('0000-00-00 00:00:00') AS zero_date_day, YEAR(DATE '2026-07-01') AS date_year, - MONTH(TIMESTAMP '2026-08-02 03:04:05') AS timestamp_month" + MONTH(TIMESTAMP '2026-08-02 03:04:05') AS timestamp_month, + DAY(TIMESTAMP '2026-08-02 03:04:05') AS timestamp_day" )->fetch( PDO::FETCH_ASSOC ); $this->assertNull( $row['zero_date_year'] ); $this->assertNull( $row['zero_date_month'] ); + $this->assertNull( $row['zero_date_day'] ); $this->assertSame( 2026, $row['date_year'] ); $this->assertSame( 8, $row['timestamp_month'] ); + $this->assertSame( 2, $row['timestamp_day'] ); + } + + public function test_sum_length_result_does_not_require_bcmath(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wptests_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (option_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES + ('autoload_yes', 'abcd', 'yes'), + ('autoload_on', 'xy', 'on'), + ('autoload_auto', 'z', 'auto'), + ('manual', 'ignored', 'no')" + ); + + $row = $driver->query( + "SELECT SUM(LENGTH(option_value)) FROM wptests_options + WHERE autoload IN ('yes','on','auto-on','auto')" + )->fetch( PDO::FETCH_NUM ); + + $this->assertSame( 7, $row[0] ); + $this->assertStringContainsString( 'CAST(SUM(', $this->lastDuckDBQuery( $driver ) ); + $this->assertStringContainsString( ' AS BIGINT)', $this->lastDuckDBQuery( $driver ) ); } public function test_create_table_insert_update_delete_show_and_describe(): void { @@ -16003,6 +16120,41 @@ private function create_wordpress_joined_group_by_tables( WP_DuckDB_Driver $driv ); } + private function create_wordpress_left_join_group_by_tables( WP_DuckDB_Driver $driver ): void { + $driver->query( + "CREATE TABLE wptests_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_date DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "CREATE TABLE wptests_term_relationships ( + object_id BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + term_taxonomy_id BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + term_order INT(11) NOT NULL DEFAULT '0', + PRIMARY KEY (object_id, term_taxonomy_id), + KEY term_taxonomy_id (term_taxonomy_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_type, post_status) VALUES + (1, '2026-01-01 00:00:00', 'post', 'publish'), + (2, '2026-02-01 00:00:00', 'post', 'publish'), + (3, '2026-03-01 00:00:00', 'post', 'publish'), + (4, '2026-04-01 00:00:00', 'page', 'publish')" + ); + $driver->query( + 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id, term_order) VALUES + (1, 11, 0), + (1, 12, 0), + (2, 11, 0), + (4, 11, 0)' + ); + } + /** * WordPress-style schema statements that exercise core DDL shapes. * From c43bdd9754b8ac626434d535a5b174e6078f340f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 17:11:56 +0000 Subject: [PATCH 153/317] Fix DuckDB date archive group ordering --- .../src/duckdb/class-wp-duckdb-driver.php | 193 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 28 +++ 2 files changed, 215 insertions(+), 6 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 10306ad55..790b18820 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -806,7 +806,8 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $sql_tokens = $seeded_rand_ordering['tokens']; } - $seeded_rand_rewrites = $this->seeded_rand_rewrite_map( $seeded_rand_expressions ); + $seeded_rand_rewrites = $this->seeded_rand_rewrite_map( $seeded_rand_expressions ); + $grouped_date_order_rewrites = $this->grouped_date_order_by_rewrites( $sql_tokens ); $rewrite_information_schema_tables = $this->uses_information_schema_tables( $sql_tokens ); $rewrite_information_schema_columns = $this->uses_information_schema_columns( $sql_tokens ); @@ -848,7 +849,8 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_check_constraints, $seeded_rand_rewrites, false, - $group_by_expansion + $group_by_expansion, + $grouped_date_order_rewrites ); if ( $has_sql_calc_found_rows ) { @@ -863,7 +865,8 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_referential_constraints, $rewrite_information_schema_check_constraints, $seeded_rand_rewrites, - $group_by_expansion + $group_by_expansion, + $grouped_date_order_rewrites ); $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); $result = $this->apply_result_column_metadata( $result, $column_meta ); @@ -2355,6 +2358,175 @@ private function parse_order_by_column_reference( array $tokens ): ?array { return $this->parse_group_by_column_reference( $tokens ); } + /** + * Parse a simple ORDER BY column reference and its optional direction. + * + * @param WP_Parser_Token[] $tokens ORDER BY item tokens. + * @return array{column_name:string,qualifier:string|null,direction:string}|null Column reference, or null when unsupported. + */ + private function parse_order_by_column_reference_with_direction( array $tokens ): ?array { + $direction = 'ASC'; + if ( count( $tokens ) > 0 ) { + $last = $tokens[ count( $tokens ) - 1 ]; + if ( WP_MySQL_Lexer::ASC_SYMBOL === $last->id || WP_MySQL_Lexer::DESC_SYMBOL === $last->id ) { + $direction = WP_MySQL_Lexer::DESC_SYMBOL === $last->id ? 'DESC' : 'ASC'; + array_pop( $tokens ); + } + } + + $column = $this->parse_group_by_column_reference( $tokens ); + if ( null === $column ) { + return null; + } + + return array( + 'column_name' => $column['column_name'], + 'qualifier' => $column['qualifier'], + 'direction' => $direction, + ); + } + + /** + * Build ORDER BY item rewrites for date-bucket GROUP BY queries. + * + * MySQL permits WordPress archive queries to group by YEAR(post_date), MONTH(post_date) + * and order by the raw post_date. DuckDB requires that ORDER BY expression to be + * grouped or aggregated, so use the bucket boundary date for deterministic ordering. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return array Rewrites keyed by ORDER BY item start offset. + */ + private function grouped_date_order_by_rewrites( array $tokens ): array { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return array(); + } + if ( + null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::HAVING_SYMBOL ) + || null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::UNION_SYMBOL ) + ) { + return array(); + } + + $group_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::GROUP_SYMBOL ); + if ( + null === $group_index + || ! isset( $tokens[ $group_index + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_index + 1 ]->id + ) { + return array(); + } + + $group_end = $this->primary_key_group_by_clause_end( $tokens, $group_index + 2 ); + if ( + ! isset( $tokens[ $group_end ] ) + || WP_MySQL_Lexer::ORDER_SYMBOL !== $tokens[ $group_end ]->id + || ! isset( $tokens[ $group_end + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_end + 1 ]->id + ) { + return array(); + } + + $grouped_date_columns = array(); + foreach ( $this->split_top_level_select_item_ranges( $tokens, $group_index + 2, $group_end ) as $item ) { + $column = $this->parse_date_group_by_column_reference( $item['tokens'] ); + if ( null === $column ) { + continue; + } + $grouped_date_columns[ strtolower( $column['column_name'] ) ][] = $column['qualifier']; + } + + if ( count( $grouped_date_columns ) === 0 ) { + return array(); + } + + $rewrites = array(); + $order_end = $this->primary_key_order_by_clause_end( $tokens, $group_end + 2 ); + foreach ( $this->split_top_level_select_item_ranges( $tokens, $group_end + 2, $order_end ) as $item ) { + $column = $this->parse_order_by_column_reference_with_direction( $item['tokens'] ); + if ( + null === $column + || ! $this->grouped_date_column_matches_order_column( $grouped_date_columns, $column ) + ) { + continue; + } + + $column_sql = ''; + if ( null !== $column['qualifier'] ) { + $column_sql .= $this->connection->quote_identifier( $column['qualifier'] ) . '.'; + } + $column_sql .= $this->connection->quote_identifier( $column['column_name'] ); + + $aggregate = 'DESC' === $column['direction'] ? 'MAX' : 'MIN'; + + $rewrites[ $item['start'] ] = array( + 'end' => $item['end'], + 'sql' => $aggregate . '(' . $column_sql . ') ' . $column['direction'], + ); + } + + return $rewrites; + } + + /** + * Parse YEAR/MONTH/DAY/DATE group bucket functions over a simple column. + * + * @param WP_Parser_Token[] $tokens GROUP BY item tokens. + * @return array{column_name:string,qualifier:string|null}|null Column reference, or null when unsupported. + */ + private function parse_date_group_by_column_reference( array $tokens ): ?array { + if ( + count( $tokens ) < 4 + || ! isset( $tokens[1] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $name = strtoupper( $tokens[0]->get_value() ); + if ( ! in_array( $name, array( 'DATE', 'DAY', 'DAYOFMONTH', 'MONTH', 'YEAR' ), true ) ) { + return null; + } + + $end_index = $this->skip_balanced_parentheses( $tokens, 1 ); + if ( count( $tokens ) !== $end_index ) { + return null; + } + + $body = array_slice( $tokens, 2, $end_index - 3 ); + $items = $this->split_top_level_comma_items( $body ); + if ( 1 !== count( $items ) || count( $items[0] ) === 0 ) { + return null; + } + + return $this->parse_group_by_column_reference( $items[0] ); + } + + /** + * Check whether an ORDER BY column is the source of a grouped date bucket. + * + * @param array> $grouped_date_columns Grouped date columns keyed by lower-case column name. + * @param array{column_name:string,qualifier:string|null,direction:string} $order_column Parsed ORDER BY column. + * @return bool Whether the order column is a grouped date bucket source. + */ + private function grouped_date_column_matches_order_column( array $grouped_date_columns, array $order_column ): bool { + $column_key = strtolower( $order_column['column_name'] ); + if ( ! isset( $grouped_date_columns[ $column_key ] ) ) { + return false; + } + + if ( null === $order_column['qualifier'] ) { + return true; + } + + foreach ( $grouped_date_columns[ $column_key ] as $group_qualifier ) { + if ( null === $group_qualifier || 0 === strcasecmp( $group_qualifier, $order_column['qualifier'] ) ) { + return true; + } + } + + return false; + } + /** * Check whether a token stream contains a top-level aggregate function call. * @@ -2918,7 +3090,8 @@ private function count_select_rows( bool $rewrite_information_schema_referential_constraints, bool $rewrite_information_schema_check_constraints, array $seeded_rand_rewrites = array(), - ?array $group_by_expansion = null + ?array $group_by_expansion = null, + array $order_by_item_rewrites = array() ): int { $sql = $this->translate_tokens_to_duckdb_sql( $tokens, @@ -2931,7 +3104,8 @@ private function count_select_rows( $rewrite_information_schema_check_constraints, $seeded_rand_rewrites, false, - $group_by_expansion + $group_by_expansion, + $order_by_item_rewrites ); return (int) $this->execute_duckdb_query( @@ -14664,7 +14838,8 @@ private function translate_tokens_to_duckdb_sql( bool $rewrite_information_schema_check_constraints = false, array $seeded_rand_rewrites = array(), bool $rewrite_option_value_numeric_literal_comparisons = false, - ?array $group_by_expansion = null + ?array $group_by_expansion = null, + array $order_by_item_rewrites = array() ): string { $pieces = array(); @@ -14687,6 +14862,12 @@ private function translate_tokens_to_duckdb_sql( continue; } + if ( isset( $order_by_item_rewrites[ $index ] ) ) { + $pieces[] = $order_by_item_rewrites[ $index ]['sql']; + $index = $order_by_item_rewrites[ $index ]['end'] - 1; + continue; + } + if ( $this->is_configured_database_select_table_qualifier( $tokens, $index ) ) { ++$index; continue; diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 9adbcc4b5..fa69f530d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1227,6 +1227,34 @@ public function test_select_posts_wildcard_group_by_primary_key_matches_sqlite() ); } + public function test_select_archive_year_month_group_order_by_post_date_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wptests_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_date DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wptests_posts (ID, post_date, post_type, post_status) VALUES + (1, '2024-02-01 00:00:00', 'post', 'publish'), + (2, '2024-01-10 00:00:00', 'post', 'publish'), + (3, '2023-12-31 00:00:00', 'post', 'publish'), + (4, '2024-02-02 00:00:00', 'page', 'publish'), + (5, '2024-03-01 00:00:00', 'post', 'draft')", + ) + ); + + $this->assertParityRows( + "SELECT YEAR(post_date) AS `year`, MONTH(post_date) AS `month`, count(ID) as posts + FROM wptests_posts + WHERE post_type = 'post' AND post_status = 'publish' + GROUP BY YEAR(post_date), MONTH(post_date) + ORDER BY post_date DESC" + ); + } + public function test_select_index_hints_match_sqlite(): void { $this->runParitySetup( array( From d81d282994909a2043b9944f8233545b40123f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 17:55:30 +0000 Subject: [PATCH 154/317] Recover DuckDB info schema queries after abort --- .../src/duckdb/class-wp-duckdb-driver.php | 396 ++++++++++++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 44 ++ 2 files changed, 440 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 790b18820..be786becc 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -565,6 +565,10 @@ private function should_rollback_active_transaction_on_error( Throwable $error ) return true; } + if ( $this->is_current_transaction_aborted_error( $error ) ) { + return true; + } + for ( $current = $error; null !== $current; $current = $current->getPrevious() ) { $message = $current->getMessage(); if ( @@ -583,6 +587,54 @@ private function should_rollback_active_transaction_on_error( Throwable $error ) return false; } + /** + * Check whether an error reports a DuckDB-aborted transaction. + * + * @param Throwable $error Query failure. + * @return bool Whether the active transaction is already aborted. + */ + private function is_current_transaction_aborted_error( Throwable $error ): bool { + for ( $current = $error; null !== $current; $current = $current->getPrevious() ) { + if ( false !== strpos( $current->getMessage(), 'Current transaction is aborted' ) ) { + return true; + } + } + + return false; + } + + /** + * Recover a poisoned transaction before rebuilding information_schema tables. + * + * WordPress Site Health queries information_schema.tables after earlier + * PHPUnit failures. If a native DuckDB error left the active transaction + * aborted, the compatibility-table refresh would otherwise cascade with + * "Current transaction is aborted" errors. + */ + private function recover_aborted_transaction_before_information_schema_refresh(): void { + if ( ! $this->connection->inTransaction() ) { + return; + } + + $probe_sql = 'SELECT 1'; + $this->last_duckdb_queries[] = $probe_sql; + try { + $this->connection->query( $probe_sql ); + return; + } catch ( WP_DuckDB_Driver_Exception $e ) { + if ( ! $this->is_current_transaction_aborted_error( $e ) ) { + throw new WP_DuckDB_Driver_Exception( + 'Failed to inspect DuckDB transaction state before information_schema refresh: ' . $e->getMessage(), + 0, + $e + ); + } + } + + $this->last_duckdb_queries[] = 'ROLLBACK'; + $this->connection->rollback(); + } + /** * Get an emulated MySQL session system variable value. * @@ -816,6 +868,20 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_key_column_usage = $this->uses_information_schema_key_column_usage( $sql_tokens ); $rewrite_information_schema_referential_constraints = $this->uses_information_schema_referential_constraints( $sql_tokens ); $rewrite_information_schema_check_constraints = $this->uses_information_schema_check_constraints( $sql_tokens ); + if ( null === $group_by_expansion && $rewrite_information_schema_tables ) { + $group_by_expansion = $this->information_schema_tables_group_by_expansion( $sql_tokens ); + } + if ( + $rewrite_information_schema_tables + || $rewrite_information_schema_columns + || $rewrite_information_schema_statistics + || $rewrite_information_schema_table_constraints + || $rewrite_information_schema_key_column_usage + || $rewrite_information_schema_referential_constraints + || $rewrite_information_schema_check_constraints + ) { + $this->recover_aborted_transaction_before_information_schema_refresh(); + } if ( $rewrite_information_schema_tables ) { $this->refresh_information_schema_tables_table(); } @@ -2036,6 +2102,182 @@ private function primary_key_group_by_expansion( array $tokens ): ?array { ); } + /** + * Build a GROUP BY expansion for MySQL-shaped information_schema.tables. + * + * MySQL accepts WordPress Site Health's table-size query, which groups by + * TABLE_NAME while also selecting table metadata columns. DuckDB requires + * those non-aggregate columns to be grouped explicitly. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return array{group_index:int,group_end:int,table_alias:string,columns:string[]}|null Expansion data, or null when unsupported. + */ + private function information_schema_tables_group_by_expansion( array $tokens ): ?array { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + if ( + null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::HAVING_SYMBOL ) + || null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::UNION_SYMBOL ) + || ! $this->contains_top_level_aggregate_function_call( $tokens ) + ) { + return null; + } + + $from_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::FROM_SYMBOL ); + $group_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::GROUP_SYMBOL ); + if ( + null === $from_index + || null === $group_index + || $group_index <= $from_index + || ! isset( $tokens[ $group_index + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_index + 1 ]->id + ) { + return null; + } + + $table_tokens = array_slice( + $tokens, + $from_index + 1, + $this->simple_select_from_clause_end( $tokens, $from_index + 1 ) - $from_index - 1 + ); + $table = $this->parse_information_schema_tables_group_by_from_clause( $table_tokens ); + if ( null === $table ) { + return null; + } + + $columns_by_key = array(); + $definitions = array(); + foreach ( array_keys( $this->information_schema_table_definitions() ) as $column_name ) { + $definitions[ strtolower( $column_name ) ] = $column_name; + } + + foreach ( $this->split_top_level_comma_items( array_slice( $tokens, 1, $from_index - 1 ) ) as $item ) { + if ( $this->contains_top_level_aggregate_function_call( $item ) ) { + continue; + } + + $column = $this->parse_simple_select_column_reference( $this->select_item_expression_tokens( $item ) ); + if ( + null === $column + || $column['wildcard'] + || ! $this->information_schema_tables_group_by_column_qualifier_matches_table( $column['qualifier'], $table ) + ) { + return null; + } + + $column_key = strtolower( $column['column_name'] ); + if ( ! isset( $definitions[ $column_key ] ) ) { + return null; + } + + $columns_by_key[ $column_key ] = $definitions[ $column_key ]; + } + + $group_end = $this->primary_key_group_by_clause_end( $tokens, $group_index + 2 ); + $group_tokens = array_slice( $tokens, $group_index + 2, $group_end - $group_index - 2 ); + foreach ( $this->split_top_level_comma_items( $group_tokens ) as $item ) { + $column = $this->parse_group_by_column_reference( $item ); + if ( + null === $column + || ! $this->information_schema_tables_group_by_column_qualifier_matches_table( $column['qualifier'], $table ) + ) { + return null; + } + + unset( $columns_by_key[ strtolower( $column['column_name'] ) ] ); + } + + if ( count( $columns_by_key ) === 0 ) { + return null; + } + + return array( + 'group_index' => $group_index, + 'group_end' => $group_end, + 'table_alias' => $table['alias'], + 'columns' => array_values( $columns_by_key ), + ); + } + + /** + * Return the expression portion of a SELECT item with an explicit alias. + * + * @param WP_Parser_Token[] $tokens SELECT item tokens. + * @return WP_Parser_Token[] Expression tokens before a top-level AS alias. + */ + private function select_item_expression_tokens( array $tokens ): array { + $as_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::AS_SYMBOL ); + if ( null === $as_index ) { + return $tokens; + } + + return array_slice( $tokens, 0, $as_index ); + } + + /** + * Parse the FROM clause supported by information_schema.tables GROUP BY expansion. + * + * @param WP_Parser_Token[] $tokens FROM clause tokens. + * @return array{alias:string}|null Parsed table reference, or null when unsupported. + */ + private function parse_information_schema_tables_group_by_from_clause( array $tokens ): ?array { + if ( + count( $tokens ) === 0 + || $this->contains_top_level_token_id( $tokens, WP_MySQL_Lexer::COMMA_SYMBOL ) + || $this->contains_top_level_join_token( $tokens ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id + ) { + return null; + } + + $reference_length = $this->information_schema_tables_reference_length( $tokens, 0 ); + if ( null === $reference_length ) { + return null; + } + + $index = $reference_length; + $alias = self::INFO_SCHEMA_TABLES_TABLE; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + $parsed_alias = $this->metadata_identifier_value( $tokens[ $index ] ?? null ); + if ( null === $parsed_alias ) { + return null; + } + $alias = $parsed_alias; + ++$index; + } elseif ( isset( $tokens[ $index ] ) ) { + $parsed_alias = $this->metadata_identifier_value( $tokens[ $index ] ); + if ( null === $parsed_alias ) { + return null; + } + $alias = $parsed_alias; + ++$index; + } + + if ( count( $tokens ) !== $index ) { + return null; + } + + return array( + 'alias' => $alias, + ); + } + + /** + * Check whether a GROUP BY expansion column belongs to information_schema.tables. + * + * @param string|null $qualifier Optional column qualifier. + * @param array{alias:string} $table Parsed information_schema table reference. + * @return bool Whether the qualifier matches the table reference. + */ + private function information_schema_tables_group_by_column_qualifier_matches_table( ?string $qualifier, array $table ): bool { + return null === $qualifier + || 0 === strcasecmp( $qualifier, $table['alias'] ) + || 0 === strcasecmp( $qualifier, 'tables' ) + || 0 === strcasecmp( $qualifier, self::INFO_SCHEMA_TABLES_TABLE ); + } + /** * Parse the FROM clause supported by primary-key GROUP BY expansion. * @@ -14947,6 +15189,22 @@ private function translate_tokens_to_duckdb_sql( continue; } + $information_schema_sum_function = $this->translate_information_schema_tables_sum_function_call( + $tokens, + $index, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + if ( null !== $information_schema_sum_function ) { + $pieces[] = $information_schema_sum_function; + continue; + } + $sum_length_function = $this->translate_sum_length_function_call( $tokens, $index, @@ -15554,6 +15812,144 @@ private function translate_field_function_call( return implode( ' ', $cases ); } + /** + * Translate SUM() over information_schema.tables numeric metadata to BIGINT. + * + * DuckDB promotes SUM(BIGINT) arithmetic to HUGEINT, and the PHP client + * refuses HUGEINT without bcmath. WordPress Site Health uses + * SUM(data_length + index_length), which MySQL returns as a regular integer. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current token index, advanced on match. + * @return string|null DuckDB SQL, or null when the token does not start a supported SUM(). + */ + private function translate_information_schema_tables_sum_function_call( + array $tokens, + int &$index, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints + ): ?string { + if ( + ! $rewrite_information_schema_tables + || ! isset( $tokens[ $index + 1 ] ) + || $this->is_non_identifier_token( $tokens[ $index ] ) + || 0 !== strcasecmp( $tokens[ $index ]->get_value(), 'SUM' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + return null; + } + + $end_index = $this->skip_balanced_parentheses( $tokens, $index + 1 ); + $body = array_slice( $tokens, $index + 2, $end_index - $index - 3 ); + $items = $this->split_top_level_comma_items( $body ); + if ( 1 !== count( $items ) || ! $this->is_information_schema_tables_numeric_sum_expression( $items[0] ) ) { + return null; + } + + $index = $end_index - 1; + return 'CAST(SUM(' + . $this->translate_tokens_to_duckdb_sql( + $items[0], + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ) + . ') AS BIGINT)'; + } + + /** + * Check whether tokens form a bounded numeric information_schema.tables expression. + * + * @param WP_Parser_Token[] $tokens Expression tokens. + * @return bool Whether the expression only references numeric tables metadata. + */ + private function is_information_schema_tables_numeric_sum_expression( array $tokens ): bool { + if ( count( $tokens ) === 0 ) { + return false; + } + + $has_metadata_column = false; + for ( $index = 0; $index < count( $tokens ); ++$index ) { + $token = $tokens[ $index ]; + if ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id + || WP_MySQL_Lexer::PLUS_OPERATOR === $token->id + || WP_MySQL_Lexer::MINUS_OPERATOR === $token->id + || WP_MySQL_Lexer::MULT_OPERATOR === $token->id + || WP_MySQL_Lexer::DIV_OPERATOR === $token->id + ) { + continue; + } + + if ( $this->is_number_token( $token ) ) { + continue; + } + + $identifier = $this->metadata_identifier_value( $token ); + if ( null === $identifier ) { + return false; + } + + if ( + isset( $tokens[ $index + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index + 1 ]->id + ) { + $identifier = $this->metadata_identifier_value( $tokens[ $index + 2 ] ); + $index += 2; + } + + if ( ! $this->is_numeric_information_schema_tables_column( $identifier ) ) { + return false; + } + $has_metadata_column = true; + } + + return $has_metadata_column; + } + + /** + * Check whether an information_schema.tables column is numeric metadata. + * + * @param string|null $identifier Identifier token value. + * @return bool Whether the identifier maps to a numeric tables column. + */ + private function is_numeric_information_schema_tables_column( ?string $identifier ): bool { + if ( null === $identifier ) { + return false; + } + + $column_name = $this->information_schema_tables_column_name( $identifier ); + if ( null === $column_name ) { + return false; + } + + return in_array( + $column_name, + array( + 'VERSION', + 'TABLE_ROWS', + 'AVG_ROW_LENGTH', + 'DATA_LENGTH', + 'MAX_DATA_LENGTH', + 'INDEX_LENGTH', + 'DATA_FREE', + 'AUTO_INCREMENT', + 'CHECKSUM', + ), + true + ); + } + /** * Translate SUM(LENGTH(...)) to a PHP-int-compatible result type. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 5eb39deb3..5dcee609a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -10295,6 +10295,50 @@ public function test_foreign_keys_referencing_driver_managed_unique_keys_expose_ ); } + public function test_information_schema_tables_recovers_aborted_transaction_before_refresh(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wordpress_develop_tests', + ) + ); + $driver->query( + "CREATE TABLE wptests_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL + )" + ); + + $connection = $driver->get_connection(); + $connection->beginTransaction(); + try { + $connection->query( "SELECT CAST('not-an-integer' AS INTEGER)" ); + $this->fail( 'Expected raw DuckDB query failure to abort the active transaction.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'DuckDB query failed:', $e->getMessage() ); + } + $this->assertTrue( $connection->inTransaction() ); + + $rows = $driver->query( + "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', + SUM(data_length + index_length) as 'bytes' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wordpress_develop_tests' + AND TABLE_NAME IN ('wptests_options') + GROUP BY TABLE_NAME" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertFalse( $connection->inTransaction() ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'wptests_options', $rows[0]['table'] ); + $this->assertSame( 0, (int) $rows[0]['rows'] ); + $this->assertSame( 0, (int) $rows[0]['bytes'] ); + $this->assertContains( 'ROLLBACK', $driver->get_last_duckdb_queries() ); + } + public function test_information_schema_tables_exposes_mysql_shaped_table_metadata(): void { $this->requireDuckDBRuntime(); From 203705b45360362670002a89f178d534031cbe7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 18:22:02 +0000 Subject: [PATCH 155/317] Recover untracked DuckDB aborted transactions --- .../src/duckdb/class-wp-duckdb-connection.php | 20 ++++++++ .../src/duckdb/class-wp-duckdb-driver.php | 14 +++--- .../duckdb/WP_DuckDB_Connection_Tests.php | 15 ++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 46 +++++++++++++++++++ .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 31 +++++++++---- .../wp-includes/duckdb/class-wp-duckdb-db.php | 28 ++++++++++- 6 files changed, 137 insertions(+), 17 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php index fc01beb82..51218150a 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php @@ -166,6 +166,26 @@ public function rollback(): bool { return true; } + /** + * Roll back a native DuckDB transaction even if this wrapper did not open it. + * + * Some recovery paths need to clean up an aborted DuckDB transaction after + * raw SQL opened it through query() without updating the wrapper flag. + * + * @return bool + * + * @throws WP_DuckDB_Driver_Exception When DuckDB rejects ROLLBACK. + */ + public function rollbackNativeTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + try { + $this->query( 'ROLLBACK' ); + } finally { + $this->in_transaction = false; + } + + return true; + } + /** * Check whether a transaction is active. * diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index be786becc..3e190d077 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -543,11 +543,15 @@ public function get_insert_id(): int { * @param Throwable $error Query failure. */ private function rollback_failed_active_transaction( Throwable $error ): void { - if ( ! $this->connection->inTransaction() ) { + if ( ! $this->should_rollback_active_transaction_on_error( $error ) ) { return; } - if ( ! $this->should_rollback_active_transaction_on_error( $error ) ) { + if ( ! $this->connection->inTransaction() ) { + if ( $this->is_current_transaction_aborted_error( $error ) ) { + $this->last_duckdb_queries[] = 'ROLLBACK'; + $this->connection->rollbackNativeTransaction(); + } return; } @@ -612,10 +616,6 @@ private function is_current_transaction_aborted_error( Throwable $error ): bool * "Current transaction is aborted" errors. */ private function recover_aborted_transaction_before_information_schema_refresh(): void { - if ( ! $this->connection->inTransaction() ) { - return; - } - $probe_sql = 'SELECT 1'; $this->last_duckdb_queries[] = $probe_sql; try { @@ -632,7 +632,7 @@ private function recover_aborted_transaction_before_information_schema_refresh() } $this->last_duckdb_queries[] = 'ROLLBACK'; - $this->connection->rollback(); + $this->connection->rollbackNativeTransaction(); } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index 82f92bb43..ee4dd443e 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -1046,6 +1046,21 @@ public function test_transactions_commit_and_rollback(): void { $this->assertSame( 1, $connection->query( 'SELECT COUNT(*) AS c FROM t' )->fetchColumn() ); } + public function test_rollback_native_transaction_recovers_untracked_transaction(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->query( 'CREATE TABLE t (id INTEGER)' ); + + $connection->query( 'BEGIN TRANSACTION' ); + $this->assertFalse( $connection->inTransaction() ); + $connection->query( 'INSERT INTO t VALUES (1)' ); + + $this->assertTrue( $connection->rollbackNativeTransaction() ); + $this->assertFalse( $connection->inTransaction() ); + $this->assertSame( 0, $connection->query( 'SELECT COUNT(*) AS c FROM t' )->fetchColumn() ); + } + public function test_transaction_state_errors_are_explicit(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 5dcee609a..ca0659094 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -10339,6 +10339,52 @@ public function test_information_schema_tables_recovers_aborted_transaction_befo $this->assertContains( 'ROLLBACK', $driver->get_last_duckdb_queries() ); } + public function test_information_schema_tables_recovers_untracked_aborted_native_transaction_before_refresh(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wordpress_develop_tests', + ) + ); + $driver->query( + "CREATE TABLE wptests_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL + )" + ); + + $connection = $driver->get_connection(); + $connection->query( 'BEGIN TRANSACTION' ); + $this->assertFalse( $connection->inTransaction() ); + try { + $connection->query( "SELECT CAST('not-an-integer' AS INTEGER)" ); + $this->fail( 'Expected raw DuckDB query failure to abort the untracked transaction.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'DuckDB query failed:', $e->getMessage() ); + } + $this->assertFalse( $connection->inTransaction() ); + + $rows = $driver->query( + "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', + SUM(data_length + index_length) as 'bytes' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wordpress_develop_tests' + AND TABLE_NAME IN ('wptests_options') + GROUP BY TABLE_NAME" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertFalse( $connection->inTransaction() ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'wptests_options', $rows[0]['table'] ); + $this->assertSame( 0, (int) $rows[0]['rows'] ); + $this->assertSame( 0, (int) $rows[0]['bytes'] ); + $this->assertContains( 'SELECT 1', $driver->get_last_duckdb_queries() ); + $this->assertContains( 'ROLLBACK', $driver->get_last_duckdb_queries() ); + } + public function test_information_schema_tables_exposes_mysql_shaped_table_metadata(): void { $this->requireDuckDBRuntime(); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index d12cee584..3d0a5f8ba 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -526,6 +526,13 @@ public function test_duckdb_wpdb_rolls_back_only_native_active_transaction_failu $this->assertSame( 'DuckDB query failed: inactive transaction.', $cases['inactive_native_query_marker']['last_error'] ); $this->assertSame( array(), $cases['inactive_native_query_marker']['connection_queries'] ); $this->assertFalse( $cases['inactive_native_query_marker']['in_transaction_after'] ); + + $this->assertSame( + 'DuckDB query failed: TransactionContext Error: Current transaction is aborted (please ROLLBACK)', + $cases['inactive_current_aborted_marker']['last_error'] + ); + $this->assertSame( array( 'ROLLBACK' ), $cases['inactive_current_aborted_marker']['connection_queries'] ); + $this->assertFalse( $cases['inactive_current_aborted_marker']['in_transaction_after'] ); } public function test_duckdb_wpdb_db_connect_sets_filtered_sql_mode(): void { @@ -848,15 +855,22 @@ function wp_duckdb_plugin_transaction_cleanup_failure( $case_name ) { case 'preflight_error': return new WP_DuckDB_Driver_Exception( 'DuckDB driver could not parse MySQL statement: syntax error.' ); - case 'plain_exception': - return new RuntimeException( 'Plain runtime failure.' ); + case 'plain_exception': + return new RuntimeException( 'Plain runtime failure.' ); - case 'inactive_native_query_marker': - return new WP_DuckDB_Driver_Exception( - 'DuckDB query failed: inactive transaction.', - 0, - new RuntimeException( 'Inactive native query failure.' ) - ); + case 'inactive_native_query_marker': + return new WP_DuckDB_Driver_Exception( + 'DuckDB query failed: inactive transaction.', + 0, + new RuntimeException( 'Inactive native query failure.' ) + ); + + case 'inactive_current_aborted_marker': + return new WP_DuckDB_Driver_Exception( + 'DuckDB query failed: TransactionContext Error: Current transaction is aborted (please ROLLBACK)', + 0, + new RuntimeException( 'Inactive aborted native transaction.' ) + ); } throw new RuntimeException( 'Unknown cleanup test case: ' . $case_name ); @@ -901,6 +915,7 @@ function wp_duckdb_plugin_transaction_cleanup_case( $case_name, $active_transact 'preflight_error' => wp_duckdb_plugin_transaction_cleanup_case( 'preflight_error', true ), 'plain_exception' => wp_duckdb_plugin_transaction_cleanup_case( 'plain_exception', true ), 'inactive_native_query_marker' => wp_duckdb_plugin_transaction_cleanup_case( 'inactive_native_query_marker', false ), + 'inactive_current_aborted_marker' => wp_duckdb_plugin_transaction_cleanup_case( 'inactive_current_aborted_marker', false ), ); echo json_encode( diff --git a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php index 20ce79952..012f9832c 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/duckdb/class-wp-duckdb-db.php @@ -632,12 +632,16 @@ private function rollback_failed_active_duckdb_transaction( Throwable $error ) { } $connection = $this->get_duckdb_connection(); - if ( ! $connection || ! $connection->inTransaction() ) { + if ( ! $connection ) { return; } try { - $connection->rollback(); + if ( $connection->inTransaction() ) { + $connection->rollback(); + } elseif ( $this->is_current_duckdb_transaction_aborted_error( $error ) ) { + $connection->rollbackNativeTransaction(); + } } catch ( Throwable $rollback_error ) { if ( '' === $this->last_error ) { $this->last_error = $rollback_error->getMessage(); @@ -678,6 +682,10 @@ private function get_duckdb_connection() { * @return bool Whether to roll back the active transaction. */ private function should_rollback_active_duckdb_transaction_on_error( Throwable $error ) { + if ( $this->is_current_duckdb_transaction_aborted_error( $error ) ) { + return true; + } + for ( $current = $error; null !== $current; $current = $current->getPrevious() ) { $message = $current->getMessage(); if ( @@ -696,6 +704,22 @@ private function should_rollback_active_duckdb_transaction_on_error( Throwable $ return false; } + /** + * Check whether an error reports a DuckDB-aborted transaction. + * + * @param Throwable $error Query failure. + * @return bool Whether the native transaction is already aborted. + */ + private function is_current_duckdb_transaction_aborted_error( Throwable $error ) { + for ( $current = $error; null !== $current; $current = $current->getPrevious() ) { + if ( false !== strpos( $current->getMessage(), 'Current transaction is aborted' ) ) { + return true; + } + } + + return false; + } + /** * Normalize fetched DuckDB rows to MySQL/PDO-style scalar values. * From 96d480cd43622475eafb5847545f3e89aa4180e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 18:56:33 +0000 Subject: [PATCH 156/317] Harden DuckDB info schema transaction recovery --- .../src/duckdb/class-wp-duckdb-driver.php | 27 +++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 9 +- .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 87 +++++++++++++++++++ 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 3e190d077..00f9bd4dd 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -36,6 +36,7 @@ class WP_DuckDB_Driver { const INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE = '__wp_duckdb_information_schema_key_column_usage'; const INFO_SCHEMA_REFERENTIAL_CONSTRAINTS_TABLE = '__wp_duckdb_information_schema_referential_constraints'; const INFO_SCHEMA_CHECK_CONSTRAINTS_TABLE = '__wp_duckdb_information_schema_check_constraints'; + const TRANSACTION_RECOVERY_PROBE_TABLE = '__wp_duckdb_transaction_recovery_probe'; const SUPPORTED_SESSION_SYSTEM_VARIABLES = array( 'autocommit' => true, @@ -616,11 +617,11 @@ private function is_current_transaction_aborted_error( Throwable $error ): bool * "Current transaction is aborted" errors. */ private function recover_aborted_transaction_before_information_schema_refresh(): void { - $probe_sql = 'SELECT 1'; + $probe_table = $this->connection->quote_identifier( self::TRANSACTION_RECOVERY_PROBE_TABLE ); + $probe_sql = 'CREATE OR REPLACE TEMP TABLE ' . $probe_table . ' AS SELECT 1 AS ok'; $this->last_duckdb_queries[] = $probe_sql; try { $this->connection->query( $probe_sql ); - return; } catch ( WP_DuckDB_Driver_Exception $e ) { if ( ! $this->is_current_transaction_aborted_error( $e ) ) { throw new WP_DuckDB_Driver_Exception( @@ -629,10 +630,28 @@ private function recover_aborted_transaction_before_information_schema_refresh() $e ); } + + $this->last_duckdb_queries[] = 'ROLLBACK'; + $this->connection->rollbackNativeTransaction(); + return; } - $this->last_duckdb_queries[] = 'ROLLBACK'; - $this->connection->rollbackNativeTransaction(); + $drop_probe_sql = 'DROP TABLE IF EXISTS ' . $probe_table; + $this->last_duckdb_queries[] = $drop_probe_sql; + try { + $this->connection->query( $drop_probe_sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + if ( ! $this->is_current_transaction_aborted_error( $e ) ) { + throw new WP_DuckDB_Driver_Exception( + 'Failed to clean up DuckDB transaction state probe before information_schema refresh: ' . $e->getMessage(), + 0, + $e + ); + } + + $this->last_duckdb_queries[] = 'ROLLBACK'; + $this->connection->rollbackNativeTransaction(); + } } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index ca0659094..bf8e4a594 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -10336,6 +10336,10 @@ public function test_information_schema_tables_recovers_aborted_transaction_befo $this->assertSame( 'wptests_options', $rows[0]['table'] ); $this->assertSame( 0, (int) $rows[0]['rows'] ); $this->assertSame( 0, (int) $rows[0]['bytes'] ); + $this->assertContains( + 'CREATE OR REPLACE TEMP TABLE "__wp_duckdb_transaction_recovery_probe" AS SELECT 1 AS ok', + $driver->get_last_duckdb_queries() + ); $this->assertContains( 'ROLLBACK', $driver->get_last_duckdb_queries() ); } @@ -10381,7 +10385,10 @@ public function test_information_schema_tables_recovers_untracked_aborted_native $this->assertSame( 'wptests_options', $rows[0]['table'] ); $this->assertSame( 0, (int) $rows[0]['rows'] ); $this->assertSame( 0, (int) $rows[0]['bytes'] ); - $this->assertContains( 'SELECT 1', $driver->get_last_duckdb_queries() ); + $this->assertContains( + 'CREATE OR REPLACE TEMP TABLE "__wp_duckdb_transaction_recovery_probe" AS SELECT 1 AS ok', + $driver->get_last_duckdb_queries() + ); $this->assertContains( 'ROLLBACK', $driver->get_last_duckdb_queries() ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index 3d0a5f8ba..817cc41a4 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -535,6 +535,28 @@ public function test_duckdb_wpdb_rolls_back_only_native_active_transaction_failu $this->assertFalse( $cases['inactive_current_aborted_marker']['in_transaction_after'] ); } + public function test_duckdb_wpdb_information_schema_tables_recovers_untracked_aborted_native_transaction(): void { + if ( null !== WP_DuckDB_Runtime::get_unavailable_reason() ) { + $this->markTestSkipped( 'DuckDB runtime is unavailable in this environment.' ); + } + + $result = $this->run_information_schema_recovery_state_script(); + + $this->assertTrue( $result['connected'] ); + $this->assertSame( 1, $result['query_return'] ); + $this->assertSame( '', $result['last_error'] ); + $this->assertFalse( $result['in_transaction_after'] ); + $this->assertCount( 1, $result['rows'] ); + $this->assertSame( 'wptests_options', $result['rows'][0]['table'] ); + $this->assertSame( 0, (int) $result['rows'][0]['rows'] ); + $this->assertSame( 0, (int) $result['rows'][0]['bytes'] ); + $this->assertContains( + 'CREATE OR REPLACE TEMP TABLE "__wp_duckdb_transaction_recovery_probe" AS SELECT 1 AS ok', + $result['duckdb_queries'] + ); + $this->assertContains( 'ROLLBACK', $result['duckdb_queries'] ); + } + public function test_duckdb_wpdb_db_connect_sets_filtered_sql_mode(): void { $result = $this->run_sql_mode_boot_state_script( false ); @@ -928,6 +950,71 @@ function wp_duckdb_plugin_transaction_cleanup_case( $case_name, $active_transact return $this->run_isolated_php( $code ); } + private function run_information_schema_recovery_state_script(): array { + $plugin_dir = $this->get_plugin_dir(); + $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; + $code = $this->get_wordpress_stub_code(); + $code .= "\nrequire_once " . var_export( $driver_load, true ) . ";\n"; + $code .= 'require_once ' . var_export( $plugin_dir . '/wp-includes/duckdb/class-wp-duckdb-db.php', true ) . ";\n"; + $code .= <<<'PHP' + +if ( defined( 'DUCKDB_PHP_AUTOLOAD' ) ) { + require_once DUCKDB_PHP_AUTOLOAD; +} + +$connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); +$GLOBALS['@duckdb'] = $connection; +$db = new WP_DuckDB_DB( 'wordpress_develop_tests' ); +$connected = $db->db_connect( false ); +$db->suppress_errors( true ); +$driver = $GLOBALS['@duckdb_driver']; +$tracked_connection = $driver->get_connection(); + +$db->query( + "CREATE TABLE wptests_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL + )" +); + +$tracked_connection->query( 'BEGIN TRANSACTION' ); +try { + $tracked_connection->query( "SELECT CAST('not-an-integer' AS INTEGER)" ); +} catch ( WP_DuckDB_Driver_Exception $e ) { +} + +$query_return = $db->query( + "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', + SUM(data_length + index_length) as 'bytes' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wordpress_develop_tests' + AND TABLE_NAME IN ('wptests_comments','wptests_options','wptests_posts','wptests_terms','wptests_users') + GROUP BY TABLE_NAME" +); + +$rows = array_map( + function ( $row ) { + return get_object_vars( $row ); + }, + $db->last_result +); + +echo json_encode( + array( + 'connected' => $connected, + 'query_return' => $query_return, + 'last_error' => $db->last_error, + 'rows' => $rows, + 'in_transaction_after' => $tracked_connection->inTransaction(), + 'duckdb_queries' => $driver->get_last_duckdb_queries(), + ) +); +PHP; + + return $this->run_isolated_php( $code ); + } + private function run_raw_query_state_script(): array { $plugin_dir = $this->get_plugin_dir(); $driver_load = dirname( __DIR__, 2 ) . '/src/load.php'; From 0e262dbba41f8ce0e7b683decc07e8b3fe7dc239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 19:14:43 +0000 Subject: [PATCH 157/317] Retry DuckDB info schema refresh after abort --- .../src/duckdb/class-wp-duckdb-driver.php | 23 +++++- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 73 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 00f9bd4dd..c04560502 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -21216,6 +21216,27 @@ private function execute_duckdb_query( string $sql, string $context ): WP_DuckDB } } + /** + * Execute DuckDB SQL, rolling back and retrying once if the native transaction is aborted. + * + * @param string $sql DuckDB SQL. + * @param string $context Failure context. + * @return WP_DuckDB_Result_Statement + */ + private function execute_duckdb_query_recovering_aborted_transaction( string $sql, string $context ): WP_DuckDB_Result_Statement { + try { + return $this->execute_duckdb_query( $sql, $context ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + if ( ! $this->is_current_transaction_aborted_error( $e ) ) { + throw $e; + } + + $this->last_duckdb_queries[] = 'ROLLBACK'; + $this->connection->rollbackNativeTransaction(); + return $this->execute_duckdb_query( $sql, $context ); + } + } + /** * Ensure the internal index metadata table exists. */ @@ -22552,7 +22573,7 @@ private function refresh_information_schema_tables_table(): void { $column_sql[] = $this->connection->quote_identifier( $column_name ) . ' ' . $type; } - $this->execute_duckdb_query( + $this->execute_duckdb_query_recovering_aborted_transaction( 'CREATE OR REPLACE TEMP TABLE ' . $this->connection->quote_identifier( self::INFO_SCHEMA_TABLES_TABLE ) . ' (' diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index bf8e4a594..031272427 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -10392,6 +10392,79 @@ public function test_information_schema_tables_recovers_untracked_aborted_native $this->assertContains( 'ROLLBACK', $driver->get_last_duckdb_queries() ); } + public function test_information_schema_tables_retries_refresh_after_aborted_transaction_probe_miss(): void { + $this->requireDuckDBRuntime(); + + $inner_connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection = new class( $inner_connection ) extends WP_DuckDB_Connection { + public $queries = array(); + private $inner_connection; + private $fail_information_schema_tables_refresh = true; + + public function __construct( WP_DuckDB_Connection $inner_connection ) { + $this->inner_connection = $inner_connection; + } + + public function query( string $sql, array $params = array() ): WP_DuckDB_Result_Statement { + $this->queries[] = $sql; + if ( + $this->fail_information_schema_tables_refresh + && 0 === strpos( $sql, 'CREATE OR REPLACE TEMP TABLE "__wp_duckdb_information_schema_tables"' ) + ) { + $this->fail_information_schema_tables_refresh = false; + throw new WP_DuckDB_Driver_Exception( 'DuckDB query failed: TransactionContext Error: Current transaction is aborted (please ROLLBACK)' ); + } + + return $this->inner_connection->query( $sql, $params ); + } + + public function rollbackNativeTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $this->queries[] = 'ROLLBACK'; + try { + $this->inner_connection->query( 'ROLLBACK' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + } + return true; + } + }; + $driver = new WP_DuckDB_Driver( + array( + 'connection' => $connection, + 'database' => 'wordpress_develop_tests', + ) + ); + $driver->query( + "CREATE TABLE wptests_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL + )" + ); + + $rows = $driver->query( + "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', + SUM(data_length + index_length) as 'bytes' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wordpress_develop_tests' + AND TABLE_NAME IN ('wptests_options') + GROUP BY TABLE_NAME" + )->fetchAll( PDO::FETCH_ASSOC ); + + $refresh_attempts = array_values( + array_filter( + $driver->get_last_duckdb_queries(), + function ( string $sql ): bool { + return 0 === strpos( $sql, 'CREATE OR REPLACE TEMP TABLE "__wp_duckdb_information_schema_tables"' ); + } + ) + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'wptests_options', $rows[0]['table'] ); + $this->assertCount( 2, $refresh_attempts ); + $this->assertContains( 'ROLLBACK', $driver->get_last_duckdb_queries() ); + } + public function test_information_schema_tables_exposes_mysql_shaped_table_metadata(): void { $this->requireDuckDBRuntime(); From 432b1ba4e5bde9db553c63b6719f11ed8fe486de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 19:53:08 +0000 Subject: [PATCH 158/317] Cache DuckDB schema metadata lookups --- .../src/duckdb/class-wp-duckdb-driver.php | 175 ++++++++++++++++-- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 62 +++++++ 2 files changed, 221 insertions(+), 16 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index c04560502..fce398f0b 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -275,6 +275,41 @@ class WP_DuckDB_Driver { */ private $ensured_metadata_tables = array(); + /** + * Cached persistent and temporary table names. + * + * @var array + */ + private $table_name_cache = array(); + + /** + * Cached MySQL-facing table metadata by table set. + * + * @var array>> + */ + private $table_metadata_cache = array(); + + /** + * Cached recorded column metadata by table. + * + * @var array>> + */ + private $column_metadata_cache = array(); + + /** + * Cached effective column metadata by table, including pragma fallback rows. + * + * @var array>> + */ + private $table_column_metadata_cache = array(); + + /** + * Cached AUTO_INCREMENT metadata by table. + * + * @var array + */ + private $auto_increment_metadata_cache = array(); + /** * Whether a MySQL LOCK TABLES statement opened the current transaction. * @@ -10803,11 +10838,17 @@ private function foreign_key_constraint_references_column( array $foreign_key, s * @return array> */ private function table_column_metadata_rows( string $table_name, bool $temporary = false ): array { + $cache_key = $this->metadata_table_cache_key( $table_name, $temporary ); + if ( isset( $this->table_column_metadata_cache[ $cache_key ] ) ) { + return $this->table_column_metadata_cache[ $cache_key ]; + } + $metadata = $this->column_metadata_rows( $table_name, $temporary ); if ( count( $metadata ) === 0 ) { $metadata = $this->pragma_column_metadata_rows( $table_name ); } + $this->table_column_metadata_cache[ $cache_key ] = $metadata; return $metadata; } @@ -11336,6 +11377,8 @@ private function replace_changed_column_metadata( string $table_name, string $ol . $this->connection->quote( $old_column_name ), 'Failed to update DuckDB column metadata' ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -11357,6 +11400,8 @@ private function delete_column_metadata( string $table_name, string $column_name . $this->connection->quote( $column_name ), 'Failed to delete DuckDB column metadata' ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -20865,6 +20910,11 @@ private function auto_increment_value_exists( string $table_name, string $column * @return array{column_name:string,sequence_name:string}|null Metadata, or null when no AUTO_INCREMENT column is known. */ private function auto_increment_metadata_for_table( string $table_name, bool $temporary = false ): ?array { + $cache_key = $this->metadata_table_cache_key( $table_name, $temporary ); + if ( array_key_exists( $cache_key, $this->auto_increment_metadata_cache ) ) { + return $this->auto_increment_metadata_cache[ $cache_key ]; + } + try { $stmt = $this->connection->query( 'SELECT column_name FROM ' @@ -20874,19 +20924,22 @@ private function auto_increment_metadata_for_table( string $table_name, bool $te . " AND extra = 'auto_increment' ORDER BY ordinal_position LIMIT 1" ); } catch ( WP_DuckDB_Driver_Exception $e ) { - return $this->physical_auto_increment_metadata_for_table( $table_name, $temporary ); + $this->auto_increment_metadata_cache[ $cache_key ] = $this->physical_auto_increment_metadata_for_table( $table_name, $temporary ); + return $this->auto_increment_metadata_cache[ $cache_key ]; } $column_name = $stmt->fetchColumn(); if ( false === $column_name || null === $column_name ) { - return $this->physical_auto_increment_metadata_for_table( $table_name, $temporary ); + $this->auto_increment_metadata_cache[ $cache_key ] = $this->physical_auto_increment_metadata_for_table( $table_name, $temporary ); + return $this->auto_increment_metadata_cache[ $cache_key ]; } - $column_name = (string) $column_name; - return array( + $column_name = (string) $column_name; + $this->auto_increment_metadata_cache[ $cache_key ] = array( 'column_name' => $column_name, 'sequence_name' => $this->sequence_name( $table_name, $column_name, $temporary ), ); + return $this->auto_increment_metadata_cache[ $cache_key ]; } /** @@ -21368,6 +21421,50 @@ private function metadata_ensure_key( string $kind, bool $temporary ): string { return ( $temporary ? 'temporary:' : 'persistent:' ) . $kind; } + /** + * Return a cache key for table-scoped metadata. + * + * @param string $table_name Table name. + * @param bool $temporary Whether to use session-local temporary metadata. + * @return string Cache key. + */ + private function metadata_table_cache_key( string $table_name, bool $temporary ): string { + return $this->metadata_ensure_key( strtolower( $table_name ), $temporary ); + } + + /** + * Clear cached schema metadata after DDL or metadata table writes. + * + * @param string|null $table_name Optional table name whose table-scoped metadata changed. + * @param bool|null $temporary Optional table set whose metadata changed. + */ + private function clear_schema_metadata_cache( ?string $table_name = null, ?bool $temporary = null ): void { + if ( null === $table_name ) { + $this->table_name_cache = array(); + $this->table_metadata_cache = array(); + $this->column_metadata_cache = array(); + $this->table_column_metadata_cache = array(); + $this->auto_increment_metadata_cache = array(); + return; + } + + $sets = null === $temporary ? array( false, true ) : array( $temporary ); + foreach ( $sets as $temporary_set ) { + $key = $this->metadata_table_cache_key( $table_name, $temporary_set ); + unset( + $this->column_metadata_cache[ $key ], + $this->table_column_metadata_cache[ $key ], + $this->auto_increment_metadata_cache[ $key ] + ); + + $table_set_key = $this->metadata_ensure_key( 'tables', $temporary_set ); + unset( + $this->table_name_cache[ $table_set_key ], + $this->table_metadata_cache[ $table_set_key ] + ); + } + } + /** * Return the metadata table that stores secondary index rows. * @@ -21494,6 +21591,8 @@ private function record_index_metadata( array $index_definition ): void { $value_rows, 'Failed to store DuckDB index metadata' ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -21633,6 +21732,8 @@ private function record_column_metadata( string $table_name, array $metadata, bo $value_rows, 'Failed to store DuckDB column metadata' ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -21672,6 +21773,8 @@ private function record_table_metadata( string $table_name, array $metadata, boo . ')', 'Failed to store DuckDB table metadata' ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -21708,6 +21811,8 @@ private function record_check_metadata( string $table_name, array $metadata, boo $value_rows, 'Failed to store DuckDB CHECK constraint metadata' ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -21759,6 +21864,8 @@ private function record_foreign_key_metadata( string $table_name, array $metadat $value_rows, 'Failed to store DuckDB FOREIGN KEY metadata' ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -21852,6 +21959,8 @@ private function append_column_metadata( string $table_name, array $column, bool . ')', 'Failed to store DuckDB column metadata' ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -21996,6 +22105,8 @@ private function delete_index_metadata( string $table_name, string $index_name, . $this->connection->quote( $index_name ), 'Failed to delete DuckDB index metadata' ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -22014,6 +22125,8 @@ private function delete_index_metadata_for_table( string $table_name, bool $temp . $this->connection->quote( $table_name ), 'Failed to delete DuckDB table index metadata' ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -22028,15 +22141,15 @@ private function delete_table_lifecycle_metadata( string $table_name, bool $temp $this->ensure_check_metadata_table( $temporary ); $this->ensure_foreign_key_metadata_table( $temporary ); - foreach ( - array( - $this->index_metadata_table_name( $temporary ) => 'index', - $this->column_metadata_table_name( $temporary ) => 'column', - $this->table_metadata_table_name( $temporary ) => 'table', - $this->check_metadata_table_name( $temporary ) => 'CHECK constraint', - $this->foreign_key_metadata_table_name( $temporary ) => 'FOREIGN KEY', - ) as $metadata_table => $label - ) { + $metadata_tables = array( + $this->index_metadata_table_name( $temporary ) => 'index', + $this->column_metadata_table_name( $temporary ) => 'column', + $this->table_metadata_table_name( $temporary ) => 'table', + $this->check_metadata_table_name( $temporary ) => 'CHECK constraint', + $this->foreign_key_metadata_table_name( $temporary ) => 'FOREIGN KEY', + ); + + foreach ( $metadata_tables as $metadata_table => $label ) { $this->execute_duckdb_query( 'DELETE FROM ' . $this->connection->quote_identifier( $metadata_table ) @@ -22045,6 +22158,8 @@ private function delete_table_lifecycle_metadata( string $table_name, bool $temp 'Failed to delete DuckDB ' . $label . ' metadata' ); } + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -22090,6 +22205,8 @@ function ( array $row ): string { 'Failed to refresh DuckDB column metadata' ); } + + $this->clear_schema_metadata_cache( $table_name, $temporary ); } /** @@ -22136,6 +22253,8 @@ private function rebuild_auto_increment_table_with_next_value( string $table_nam * Drop temporary information_schema compatibility snapshots. */ private function invalidate_information_schema_compatibility_tables(): void { + $this->clear_schema_metadata_cache(); + foreach ( array( self::INFO_SCHEMA_TABLES_TABLE, @@ -22280,6 +22399,11 @@ private function is_configured_database_select_table_qualifier( array $tokens, i * @return array> */ private function column_metadata_rows( string $table_name, bool $temporary = false ): array { + $cache_key = $this->metadata_table_cache_key( $table_name, $temporary ); + if ( isset( $this->column_metadata_cache[ $cache_key ] ) ) { + return $this->column_metadata_cache[ $cache_key ]; + } + $this->ensure_column_metadata_table( $temporary ); $stmt = $this->execute_duckdb_query( @@ -22291,7 +22415,8 @@ private function column_metadata_rows( string $table_name, bool $temporary = fal 'Failed to inspect DuckDB column metadata' ); - return $stmt->fetchAll( PDO::FETCH_ASSOC ); + $this->column_metadata_cache[ $cache_key ] = $stmt->fetchAll( PDO::FETCH_ASSOC ); + return $this->column_metadata_cache[ $cache_key ]; } /** @@ -22691,6 +22816,11 @@ private function information_schema_table_row( string $table_name, array $metada * @return array> Metadata keyed by table name. */ private function table_metadata_by_table( bool $temporary = false ): array { + $cache_key = $this->metadata_ensure_key( 'tables', $temporary ); + if ( isset( $this->table_metadata_cache[ $cache_key ] ) ) { + return $this->table_metadata_cache[ $cache_key ]; + } + $this->ensure_table_metadata_table( $temporary ); $stmt = $this->execute_duckdb_query( @@ -22708,6 +22838,7 @@ private function table_metadata_by_table( bool $temporary = false ): array { $metadata[ (string) $row['table_name'] ] = $row; } + $this->table_metadata_cache[ $cache_key ] = $metadata; return $metadata; } @@ -23678,6 +23809,11 @@ private function information_schema_column_rows(): array { * @return string[] Table names. */ private function user_table_names(): array { + $cache_key = $this->metadata_ensure_key( 'tables', false ); + if ( isset( $this->table_name_cache[ $cache_key ] ) ) { + return $this->table_name_cache[ $cache_key ]; + } + $stmt = $this->execute_duckdb_query( 'SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()' . " AND table_type = 'BASE TABLE'" @@ -23709,10 +23845,11 @@ private function user_table_names(): array { 'Failed to inspect DuckDB tables' ); - return array_map( + $this->table_name_cache[ $cache_key ] = array_map( 'strval', $stmt->fetchAll( PDO::FETCH_COLUMN ) ); + return $this->table_name_cache[ $cache_key ]; } /** @@ -23721,6 +23858,11 @@ private function user_table_names(): array { * @return string[] Table names. */ private function temporary_user_table_names(): array { + $cache_key = $this->metadata_ensure_key( 'tables', true ); + if ( isset( $this->table_name_cache[ $cache_key ] ) ) { + return $this->table_name_cache[ $cache_key ]; + } + $stmt = $this->execute_duckdb_query( "SELECT table_name FROM information_schema.tables WHERE table_type = 'LOCAL TEMPORARY'" . ' AND table_name NOT LIKE ' @@ -23730,10 +23872,11 @@ private function temporary_user_table_names(): array { 'Failed to inspect DuckDB temporary tables' ); - return array_map( + $this->table_name_cache[ $cache_key ] = array_map( 'strval', $stmt->fetchAll( PDO::FETCH_COLUMN ) ); + return $this->table_name_cache[ $cache_key ]; } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 031272427..052c1b5e5 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -11272,6 +11272,53 @@ public function test_repeated_metadata_reads_do_not_reensure_metadata_tables(): } } + public function test_repeated_simple_select_metadata_uses_schema_cache_and_invalidates_after_alter(): void { + $this->requireDuckDBRuntime(); + + $queries = array(); + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->set_query_logger( + function ( string $sql, array $params ) use ( &$queries ): void { + unset( $params ); + $queries[] = $sql; + } + ); + + $driver = new WP_DuckDB_Driver( + array( + 'connection' => $connection, + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE metadata_query_cache_items ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + + $queries = array(); + $result = $driver->query( 'SELECT id, name FROM metadata_query_cache_items WHERE id = 0 ORDER BY id' ); + $this->assertSame( 2, $result->columnCount() ); + $this->assertSame( array(), $result->fetchAll( PDO::FETCH_ASSOC ) ); + $result = $driver->query( 'SELECT id, name FROM metadata_query_cache_items WHERE id = 0 ORDER BY id' ); + $this->assertSame( 2, $result->columnCount() ); + $this->assertSame( array(), $result->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( 1, $this->count_duckdb_column_metadata_queries( $queries, 'metadata_query_cache_items' ) ); + + $driver->query( "ALTER TABLE metadata_query_cache_items ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active'" ); + + $queries = array(); + $result = $driver->query( 'SELECT id, name, status FROM metadata_query_cache_items WHERE id = 0 ORDER BY id' ); + $this->assertSame( 3, $result->columnCount() ); + $this->assertSame( array(), $result->fetchAll( PDO::FETCH_ASSOC ) ); + $result = $driver->query( 'SELECT id, name, status FROM metadata_query_cache_items WHERE id = 0 ORDER BY id' ); + $this->assertSame( 3, $result->columnCount() ); + $this->assertSame( array(), $result->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( 1, $this->count_duckdb_column_metadata_queries( $queries, 'metadata_query_cache_items' ) ); + } + public function test_create_table_metadata_inserts_are_batched(): void { $this->requireDuckDBRuntime(); @@ -16241,6 +16288,21 @@ private function lastDuckDBQuery( WP_DuckDB_Driver $driver ): string { // phpcs: return $queries[ count( $queries ) - 1 ]; } + private function count_duckdb_column_metadata_queries( array $queries, string $table_name ): int { + $count = 0; + foreach ( $queries as $query ) { + if ( + false !== strpos( $query, 'FROM "__wp_duckdb_column_metadata"' ) + && false !== strpos( $query, 'ORDER BY ordinal_position' ) + && false !== strpos( $query, "'" . $table_name . "'" ) + ) { + ++$count; + } + } + + return $count; + } + private function lifecycleTableSql( string $table_name ): string { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid return "CREATE TABLE {$table_name} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, From 76af26c3e5016a4fe968439c8072830192bc908f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 20:18:45 +0000 Subject: [PATCH 159/317] Fix DuckDB comment query parity --- .../src/duckdb/class-wp-duckdb-driver.php | 299 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 35 ++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 228 +++++++++++++ 3 files changed, 548 insertions(+), 14 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index fce398f0b..1a896f9d2 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -177,8 +177,14 @@ class WP_DuckDB_Driver { ); const STRING_IMPLICIT_DEFAULT_MAP = array( - 'enum' => '', - 'set' => '', + 'char' => '', + 'enum' => '', + 'longtext' => '', + 'mediumtext' => '', + 'set' => '', + 'text' => '', + 'tinytext' => '', + 'varchar' => '', ); /** @@ -913,6 +919,7 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { } $seeded_rand_rewrites = $this->seeded_rand_rewrite_map( $seeded_rand_expressions ); + $sql_tokens = $this->strip_irrelevant_order_by_for_count_select( $sql_tokens ); $grouped_date_order_rewrites = $this->grouped_date_order_by_rewrites( $sql_tokens ); $rewrite_information_schema_tables = $this->uses_information_schema_tables( $sql_tokens ); @@ -958,6 +965,19 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $this->refresh_information_schema_check_constraints_table(); } + $order_by_item_rewrites = $grouped_date_order_rewrites + $this->primary_key_group_by_order_by_rewrites( + $sql_tokens, + $group_by_expansion, + $grouped_date_order_rewrites, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + $sql = $this->translate_tokens_to_duckdb_sql( $sql_tokens, $rewrite_information_schema_tables, @@ -970,7 +990,7 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $seeded_rand_rewrites, false, $group_by_expansion, - $grouped_date_order_rewrites + $order_by_item_rewrites ); if ( $has_sql_calc_found_rows ) { @@ -986,7 +1006,7 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_check_constraints, $seeded_rand_rewrites, $group_by_expansion, - $grouped_date_order_rewrites + $order_by_item_rewrites ); $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); $result = $this->apply_result_column_metadata( $result, $column_meta ); @@ -2018,7 +2038,7 @@ private function simple_select_column_metadata( array $tokens ): ?array { * Build a bounded GROUP BY expansion for simple SELECTs grouped by a table's full primary key. * * @param WP_Parser_Token[] $tokens MySQL tokens. - * @return array{group_index:int,group_end:int,table_alias:string,columns:string[]}|null Expansion data, or null when outside the supported slice. + * @return array{group_index:int,group_end:int,table_alias:string,table_name:string,joined:bool,columns:string[],grouped_columns:array,metadata_columns:array,selected_aliases:array}|null Expansion data, or null when outside the supported slice. */ private function primary_key_group_by_expansion( array $tokens ): ?array { if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { @@ -2060,6 +2080,7 @@ private function primary_key_group_by_expansion( array $tokens ): ?array { } $selected_columns = array(); + $selected_aliases = array(); $select_has_wildcard = false; foreach ( $select_items as $item ) { $column = $this->parse_simple_select_column_reference( $item ); @@ -2075,6 +2096,7 @@ private function primary_key_group_by_expansion( array $tokens ): ?array { continue; } + $selected_aliases[ strtolower( $column['name'] ) ] = true; $selected_columns[ strtolower( $column['column_name'] ) ] = true; } @@ -2112,6 +2134,7 @@ private function primary_key_group_by_expansion( array $tokens ): ?array { foreach ( $metadata_rows as $metadata ) { $metadata_by_column[ strtolower( (string) $metadata['column_name'] ) ] = $metadata; } + $metadata_columns = array_fill_keys( array_keys( $metadata_by_column ), true ); $columns_by_key = array(); if ( $select_has_wildcard ) { @@ -2144,15 +2167,16 @@ private function primary_key_group_by_expansion( array $tokens ): ?array { $columns[] = $column_name; } - if ( count( $columns ) === 0 ) { - return null; - } - return array( - 'group_index' => $group_index, - 'group_end' => $group_end, - 'table_alias' => $table['alias'], - 'columns' => $columns, + 'group_index' => $group_index, + 'group_end' => $group_end, + 'table_alias' => $table['alias'], + 'table_name' => $table['table_name'], + 'joined' => isset( $table['joined'] ) && $table['joined'], + 'columns' => $columns, + 'grouped_columns' => $grouped_columns, + 'metadata_columns' => $metadata_columns, + 'selected_aliases' => $selected_aliases, ); } @@ -2763,6 +2787,162 @@ private function grouped_date_order_by_rewrites( array $tokens ): array { return $rewrites; } + /** + * Build ORDER BY item rewrites for SELECTs grouped by a full primary key. + * + * MySQL permits WordPress meta queries to group by the primary table ID while + * ordering by joined meta expressions. DuckDB requires those sort expressions + * to be grouped or aggregated, so aggregate only the non-primary ORDER BY + * expressions in this proven primary-key grouping shape. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param array{group_index:int,group_end:int,table_alias:string,table_name:string,joined:bool,columns:string[],grouped_columns:array,metadata_columns:array,selected_aliases:array}|null $group_by_expansion Primary-key grouping metadata. + * @param array $existing_rewrites Existing ORDER BY rewrites keyed by item start offset. + * @return array Rewrites keyed by ORDER BY item start offset. + */ + private function primary_key_group_by_order_by_rewrites( + array $tokens, + ?array $group_by_expansion, + array $existing_rewrites, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints + ): array { + if ( null === $group_by_expansion ) { + return array(); + } + + $group_end = $group_by_expansion['group_end']; + if ( + ! isset( $tokens[ $group_end ] ) + || WP_MySQL_Lexer::ORDER_SYMBOL !== $tokens[ $group_end ]->id + || ! isset( $tokens[ $group_end + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_end + 1 ]->id + ) { + return array(); + } + + $rewrites = array(); + $order_end = $this->primary_key_order_by_clause_end( $tokens, $group_end + 2 ); + foreach ( $this->split_top_level_select_item_ranges( $tokens, $group_end + 2, $order_end ) as $item ) { + if ( isset( $existing_rewrites[ $item['start'] ] ) ) { + continue; + } + + $order_item = $this->order_by_item_expression_and_direction( $item['tokens'] ); + if ( + count( $order_item['tokens'] ) === 0 + || $this->order_by_expression_is_literal_or_ordinal( $order_item['tokens'] ) + || $this->contains_top_level_aggregate_function_call( $order_item['tokens'] ) + ) { + continue; + } + + $column = $this->parse_order_by_column_reference( $item['tokens'] ); + if ( null !== $column ) { + $column_key = strtolower( $column['column_name'] ); + if ( + null === $column['qualifier'] + && isset( $group_by_expansion['selected_aliases'][ $column_key ] ) + && ! isset( $group_by_expansion['metadata_columns'][ $column_key ] ) + ) { + continue; + } + if ( $this->primary_key_group_by_order_column_matches_primary_table( $column, $group_by_expansion ) ) { + continue; + } + } + + $expression_sql = $this->translate_tokens_to_duckdb_sql( + $order_item['tokens'], + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + + $rewrites[ $item['start'] ] = array( + 'end' => $item['end'], + 'sql' => 'ANY_VALUE(' . $expression_sql . ')' . $order_item['direction_sql'], + ); + } + + return $rewrites; + } + + /** + * Split ORDER BY item tokens into the expression and optional direction. + * + * @param WP_Parser_Token[] $tokens ORDER BY item tokens. + * @return array{tokens:array,direction_sql:string} + */ + private function order_by_item_expression_and_direction( array $tokens ): array { + $direction_sql = ''; + if ( count( $tokens ) > 0 ) { + $last = $tokens[ count( $tokens ) - 1 ]; + if ( WP_MySQL_Lexer::ASC_SYMBOL === $last->id || WP_MySQL_Lexer::DESC_SYMBOL === $last->id ) { + $direction_sql = WP_MySQL_Lexer::DESC_SYMBOL === $last->id ? ' DESC' : ' ASC'; + array_pop( $tokens ); + } + } + + return array( + 'tokens' => $tokens, + 'direction_sql' => $direction_sql, + ); + } + + /** + * Check whether an ORDER BY expression is a literal/ordinal that needs no aggregate rewrite. + * + * @param WP_Parser_Token[] $tokens ORDER BY expression tokens. + * @return bool Whether the expression is a single literal. + */ + private function order_by_expression_is_literal_or_ordinal( array $tokens ): bool { + if ( 1 !== count( $tokens ) ) { + return false; + } + + $token = $tokens[0]; + return $this->is_number_token( $token ) + || WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::TRUE_SYMBOL === $token->id + || WP_MySQL_Lexer::FALSE_SYMBOL === $token->id + || WP_MySQL_Lexer::NULL_SYMBOL === $token->id + || WP_MySQL_Lexer::NULL2_SYMBOL === $token->id; + } + + /** + * Check whether a simple ORDER BY column belongs to the grouped primary table. + * + * @param array{column_name:string,qualifier:string|null} $column ORDER BY column. + * @param array{table_alias:string,table_name:string,joined:bool,metadata_columns:array} $group_by_expansion Primary-key grouping metadata. + * @return bool Whether the column is from the grouped primary table. + */ + private function primary_key_group_by_order_column_matches_primary_table( array $column, array $group_by_expansion ): bool { + $column_key = strtolower( $column['column_name'] ); + if ( ! isset( $group_by_expansion['metadata_columns'][ $column_key ] ) ) { + return false; + } + + $qualifier = $column['qualifier']; + if ( $group_by_expansion['joined'] ) { + return null !== $qualifier && 0 === strcasecmp( $qualifier, $group_by_expansion['table_alias'] ); + } + + return null === $qualifier + || 0 === strcasecmp( $qualifier, $group_by_expansion['table_alias'] ) + || 0 === strcasecmp( $qualifier, $group_by_expansion['table_name'] ); + } + /** * Parse YEAR/MONTH/DAY/DATE group bucket functions over a simple column. * @@ -15651,6 +15831,84 @@ private function strip_top_level_limit_clause( array $tokens ): array { return array_slice( $tokens, 0, $limit_index ); } + /** + * Strip ORDER BY from a single aggregate count SELECT where it cannot affect the count. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_Parser_Token[] Tokens without an irrelevant top-level ORDER BY clause. + */ + private function strip_irrelevant_order_by_for_count_select( array $tokens ): array { + if ( + ! $this->is_single_count_aggregate_select( $tokens ) + || null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::GROUP_SYMBOL ) + || null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::HAVING_SYMBOL ) + || null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::UNION_SYMBOL ) + ) { + return $tokens; + } + + $order_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::ORDER_SYMBOL ); + if ( + null === $order_index + || ! isset( $tokens[ $order_index + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_index + 1 ]->id + ) { + return $tokens; + } + + $order_end = $this->primary_key_order_by_clause_end( $tokens, $order_index + 2 ); + return array_merge( + array_slice( $tokens, 0, $order_index ), + array_slice( $tokens, $order_end ) + ); + } + + /** + * Check whether a SELECT list is exactly one COUNT(...) aggregate. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return bool Whether the SELECT is a single count aggregate. + */ + private function is_single_count_aggregate_select( array $tokens ): bool { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $from_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::FROM_SYMBOL ); + if ( null === $from_index || 1 === $from_index ) { + return false; + } + + $select_items = $this->split_top_level_comma_items( array_slice( $tokens, 1, $from_index - 1 ) ); + return 1 === count( $select_items ) && $this->is_count_aggregate_select_item( $select_items[0] ); + } + + /** + * Check whether a SELECT-list item is COUNT(...) with an optional alias. + * + * @param WP_Parser_Token[] $tokens SELECT-list item tokens. + * @return bool Whether the item is COUNT(...). + */ + private function is_count_aggregate_select_item( array $tokens ): bool { + if ( + ! isset( $tokens[0], $tokens[1] ) + || $this->is_non_identifier_token( $tokens[0] ) + || 0 !== strcasecmp( $tokens[0]->get_value(), 'COUNT' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[1]->id + ) { + return false; + } + + $index = $this->skip_balanced_parentheses( $tokens, 1 ); + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $index ]->id ) { + $index += 2; + } elseif ( isset( $tokens[ $index ] ) && null !== $this->metadata_identifier_value( $tokens[ $index ] ) ) { + ++$index; + } + + return count( $tokens ) === $index; + } + /** * Strip MySQL index hints, which are optimizer directives only. * @@ -17952,7 +18210,20 @@ private function coerce_write_value_for_column_sql( array $metadata, array $valu } if ( $this->is_character_write_data_type( $data_type ) ) { - return $this->coerce_character_write_value_sql( $value_tokens, $value_sql ); + $value_sql = $this->coerce_character_write_value_sql( $value_tokens, $value_sql ); + if ( + $coalesce_non_strict_not_null + && ! $this->is_strict_sql_mode_active() + && isset( $metadata['is_nullable'] ) + && 'NO' === strtoupper( (string) $metadata['is_nullable'] ) + ) { + $implicit_default = $this->non_strict_implicit_default( $data_type ); + if ( null !== $implicit_default ) { + $value_sql = 'COALESCE(' . $value_sql . ', ' . $this->connection->quote( $implicit_default ) . ')'; + } + } + + return $value_sql; } if ( $this->is_blob_write_data_type( $data_type ) ) { diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index fa69f530d..bfbb8d58a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -356,6 +356,41 @@ public function test_non_strict_numeric_update_null_not_null_matches_sqlite(): v $this->assertParityRows( 'SELECT id, int_value, decimal_value, float_value FROM numeric_not_null_coercions ORDER BY id' ); } + public function test_non_strict_character_implicit_defaults_match_sqlite(): void { + $this->assertParityRowCount( "SET SESSION sql_mode = ''" ); + $this->runParitySetup( + array( + 'CREATE TABLE character_implicit_defaults ( + id INT PRIMARY KEY, + required_tinytext TINYTEXT NOT NULL, + required_text TEXT NOT NULL, + required_longtext LONGTEXT NOT NULL, + required_varchar VARCHAR(20) NOT NULL + )', + "INSERT INTO character_implicit_defaults + (id, required_tinytext, required_text, required_longtext, required_varchar) + VALUES (3, 'tiny', 'text', 'long', 'varchar')", + ) + ); + + $this->assertParityRowCount( 'INSERT INTO character_implicit_defaults (id) VALUES (1)' ); + $this->assertParityRowCount( 'INSERT INTO character_implicit_defaults SET id = 2' ); + $this->assertParityRowCount( + 'UPDATE character_implicit_defaults + SET required_tinytext = NULL, + required_text = NULL, + required_longtext = NULL, + required_varchar = NULL + WHERE id = 3' + ); + + $this->assertParityRows( + 'SELECT id, required_tinytext, required_text, required_longtext, required_varchar + FROM character_implicit_defaults + ORDER BY id' + ); + } + public function test_non_strict_numeric_insert_select_write_coercions_match_sqlite(): void { $this->assertParityRowCount( "SET SESSION sql_mode = ''" ); $this->runParitySetup( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 052c1b5e5..9a32d0cdc 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1024,6 +1024,193 @@ public function test_sql_calc_found_rows_left_join_group_by_primary_key_expands_ ); } + public function test_select_comments_group_by_primary_key_orders_by_joined_meta_value(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $this->create_wordpress_comment_group_by_tables( $driver ); + + $rows = $driver->query( + "SELECT wptests_comments.comment_ID + FROM wptests_comments INNER JOIN wptests_commentmeta ON ( wptests_comments.comment_ID = wptests_commentmeta.comment_id ) + WHERE ( ( comment_approved = '0' OR comment_approved = '1' ) ) AND ( + wptests_commentmeta.meta_key = 'key' + ) + GROUP BY wptests_comments.comment_ID + ORDER BY wptests_commentmeta.meta_value DESC, wptests_comments.comment_ID DESC" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + array( 'comment_ID' => 2 ), + array( 'comment_ID' => 3 ), + array( 'comment_ID' => 1 ), + ), + $rows + ); + + $duckdb_queries = $driver->get_last_duckdb_queries(); + $select_sql = end( $duckdb_queries ); + + $this->assertIsString( $select_sql ); + $this->assertStringContainsString( 'ANY_VALUE(wptests_commentmeta.meta_value) DESC', $select_sql ); + $this->assertStringContainsString( 'ORDER BY ANY_VALUE(wptests_commentmeta.meta_value) DESC, wptests_comments.comment_ID DESC', $select_sql ); + } + + public function test_select_comments_group_by_primary_key_orders_by_cast_joined_meta_value(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $this->create_wordpress_comment_group_by_tables( $driver ); + + $rows = $driver->query( + "SELECT wptests_comments.comment_ID + FROM wptests_comments INNER JOIN wptests_commentmeta ON ( wptests_comments.comment_ID = wptests_commentmeta.comment_id ) + WHERE ( ( comment_approved = '0' OR comment_approved = '1' ) ) AND ( + wptests_commentmeta.meta_key = 'foo' + ) + GROUP BY wptests_comments.comment_ID + ORDER BY wptests_comments.comment_date ASC, CAST(wptests_commentmeta.meta_value AS CHAR) ASC, wptests_comments.comment_ID ASC" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + array( 'comment_ID' => 1 ), + array( 'comment_ID' => 2 ), + array( 'comment_ID' => 3 ), + ), + $rows + ); + + $duckdb_queries = $driver->get_last_duckdb_queries(); + $select_sql = end( $duckdb_queries ); + + $this->assertIsString( $select_sql ); + $this->assertStringContainsString( 'GROUP BY wptests_comments.comment_ID, "wptests_comments"."comment_date"', $select_sql ); + $this->assertStringContainsString( 'ANY_VALUE(CAST(wptests_commentmeta.meta_value AS VARCHAR)) ASC', $select_sql ); + } + + public function test_select_comments_group_by_primary_key_orders_by_multiple_cast_joined_meta_values(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $this->create_wordpress_comment_group_by_tables( $driver ); + + $rows = $driver->query( + "SELECT wptests_comments.comment_ID + FROM wptests_comments + INNER JOIN wptests_commentmeta ON ( wptests_comments.comment_ID = wptests_commentmeta.comment_id ) + INNER JOIN wptests_commentmeta AS mt1 ON ( wptests_comments.comment_ID = mt1.comment_id ) + WHERE ( ( comment_approved = '0' OR comment_approved = '1' ) ) AND ( + wptests_commentmeta.meta_key = 'foo' + AND + mt1.meta_key = 'bar' + ) + GROUP BY wptests_comments.comment_ID + ORDER BY CAST(wptests_commentmeta.meta_value AS CHAR) ASC, CAST(mt1.meta_value AS CHAR) DESC, wptests_comments.comment_ID DESC" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + array( 'comment_ID' => 1 ), + array( 'comment_ID' => 2 ), + array( 'comment_ID' => 3 ), + ), + $rows + ); + + $duckdb_queries = $driver->get_last_duckdb_queries(); + $select_sql = end( $duckdb_queries ); + + $this->assertIsString( $select_sql ); + $this->assertStringContainsString( 'ANY_VALUE(CAST(wptests_commentmeta.meta_value AS VARCHAR)) ASC', $select_sql ); + $this->assertStringContainsString( 'ANY_VALUE(CAST(mt1.meta_value AS VARCHAR)) DESC', $select_sql ); + } + + public function test_select_count_strips_irrelevant_order_by(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $this->create_wordpress_comment_group_by_tables( $driver ); + + $rows = $driver->query( + "SELECT COUNT(*) + FROM wptests_comments + WHERE ( ( comment_approved = '0' OR comment_approved = '1' ) ) + ORDER BY wptests_comments.comment_date_gmt ASC + LIMIT 0,3" + )->fetchAll( PDO::FETCH_NUM ); + + $this->assertSame( array( array( 3 ) ), $rows ); + + $duckdb_queries = $driver->get_last_duckdb_queries(); + $select_sql = end( $duckdb_queries ); + + $this->assertIsString( $select_sql ); + $this->assertStringNotContainsString( 'ORDER BY', $select_sql ); + $this->assertStringContainsString( 'LIMIT 3 OFFSET 0', $select_sql ); + } + + public function test_non_strict_wordpress_text_defaults_for_comments_and_options(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( "SET SESSION sql_mode = ''" ); + $driver->query( + "CREATE TABLE wptests_comments ( + comment_ID bigint(20) unsigned NOT NULL auto_increment, + comment_post_ID bigint(20) unsigned NOT NULL default '0', + comment_author tinytext NOT NULL, + comment_author_email varchar(100) NOT NULL default '', + comment_author_url varchar(200) NOT NULL default '', + comment_author_IP varchar(100) NOT NULL default '', + comment_date datetime NOT NULL default '0000-00-00 00:00:00', + comment_date_gmt datetime NOT NULL default '0000-00-00 00:00:00', + comment_content text NOT NULL, + comment_karma int(11) NOT NULL default '0', + comment_approved varchar(20) NOT NULL default '1', + comment_agent varchar(255) NOT NULL default '', + comment_type varchar(20) NOT NULL default 'comment', + comment_parent bigint(20) unsigned NOT NULL default '0', + user_id bigint(20) unsigned NOT NULL default '0', + PRIMARY KEY (comment_ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( 'INSERT INTO wptests_comments (comment_ID) VALUES (1)' ); + + $comment = $driver->query( + 'SELECT comment_ID, comment_author, comment_content FROM wptests_comments WHERE comment_ID = 1' + )->fetch( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + 'comment_ID' => 1, + 'comment_author' => '', + 'comment_content' => '', + ), + $comment + ); + + $driver->query( + "CREATE TABLE wptests_options ( + option_id bigint(20) unsigned NOT NULL auto_increment, + option_name varchar(191) NOT NULL default '', + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL default 'yes', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( "INSERT INTO wptests_options (option_id, option_name, option_value) VALUES (1, 'cron', 'payload')" ); + $driver->query( "UPDATE wptests_options SET option_value = NULL WHERE option_name = 'cron'" ); + + $this->assertSame( + '', + $driver->query( "SELECT option_value FROM wptests_options WHERE option_name = 'cron'" )->fetchColumn() + ); + } + public function test_select_seeded_rand_literals_are_emulated(): void { $this->requireDuckDBRuntime(); @@ -16387,6 +16574,47 @@ private function create_wordpress_left_join_group_by_tables( WP_DuckDB_Driver $d ); } + private function create_wordpress_comment_group_by_tables( WP_DuckDB_Driver $driver ): void { + $driver->query( + "CREATE TABLE wptests_comments ( + comment_ID BIGINT(20) UNSIGNED NOT NULL, + comment_date DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + comment_date_gmt DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + comment_approved VARCHAR(20) NOT NULL DEFAULT '1', + PRIMARY KEY (comment_ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "CREATE TABLE wptests_commentmeta ( + meta_id BIGINT(20) UNSIGNED NOT NULL, + comment_id BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + meta_key VARCHAR(255) DEFAULT NULL, + meta_value LONGTEXT, + PRIMARY KEY (meta_id), + KEY comment_id (comment_id), + KEY meta_key (meta_key(191)) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wptests_comments (comment_ID, comment_date, comment_date_gmt, comment_approved) VALUES + (1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', '1'), + (2, '2026-02-01 00:00:00', '2026-02-01 00:00:00', '1'), + (3, '2026-03-01 00:00:00', '2026-03-01 00:00:00', '1')" + ); + $driver->query( + "INSERT INTO wptests_commentmeta (meta_id, comment_id, meta_key, meta_value) VALUES + (1, 1, 'key', 'alpha'), + (2, 2, 'key', 'charlie'), + (3, 3, 'key', 'bravo'), + (4, 1, 'foo', 'alpha'), + (5, 2, 'foo', 'bravo'), + (6, 3, 'foo', 'charlie'), + (7, 1, 'bar', 'zulu'), + (8, 2, 'bar', 'yankee'), + (9, 3, 'bar', 'xray')" + ); + } + /** * WordPress-style schema statements that exercise core DDL shapes. * From 530a626bf1c3b8d6bb77a955b248ba8417f13c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 20:56:23 +0000 Subject: [PATCH 160/317] Cache DuckDB table and key metadata lookups --- .../src/duckdb/class-wp-duckdb-driver.php | 105 ++++++++---------- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 54 +++++++++ 2 files changed, 102 insertions(+), 57 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 1a896f9d2..9f5d12d4f 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -316,6 +316,20 @@ class WP_DuckDB_Driver { */ private $auto_increment_metadata_cache = array(); + /** + * Cached SHOW INDEX-compatible primary-key rows by table. + * + * @var array>> + */ + private $primary_key_index_rows_cache = array(); + + /** + * Cached primary and unique secondary key column sets by table. + * + * @var array> + */ + private $unique_key_column_sets_cache = array(); + /** * Whether a MySQL LOCK TABLES statement opened the current transaction. * @@ -13074,6 +13088,11 @@ function ( array $column ): string { * @return array> */ private function primary_key_index_rows( string $table_name ): array { + $cache_key = $this->metadata_table_cache_key( $table_name, false ); + if ( isset( $this->primary_key_index_rows_cache[ $cache_key ] ) ) { + return $this->primary_key_index_rows_cache[ $cache_key ]; + } + $pragma = $this->execute_duckdb_query( 'SELECT name FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ') WHERE pk > 0 ORDER BY pk, cid', 'Failed to inspect DuckDB primary key' @@ -13091,6 +13110,7 @@ private function primary_key_index_rows( string $table_name ): array { ); } + $this->primary_key_index_rows_cache[ $cache_key ] = $rows; return $rows; } @@ -19436,19 +19456,16 @@ private function select_on_duplicate_conflict_target( string $table_name, bool $ * @return array */ private function unique_key_column_sets( string $table_name, bool $temporary = false ): array { + $cache_key = $this->metadata_table_cache_key( $table_name, $temporary ); + if ( isset( $this->unique_key_column_sets_cache[ $cache_key ] ) ) { + return $this->unique_key_column_sets_cache[ $cache_key ]; + } + $sets = array(); - $primary = $this->execute_duckdb_query( - 'SELECT name FROM pragma_table_info(' . $this->connection->quote( $table_name ) . ') WHERE pk > 0 ORDER BY pk', - 'Failed to inspect DuckDB primary key' - )->fetchAll( PDO::FETCH_ASSOC ); + $primary = $this->primary_key_columns_for_table( $table_name ); if ( count( $primary ) > 0 ) { - $sets[] = array_map( - function ( array $row ): string { - return (string) $row['name']; - }, - $primary - ); + $sets[] = $primary; } $this->ensure_index_metadata_table( $temporary ); @@ -19471,6 +19488,7 @@ function ( array $row ): string { $sets[] = $columns; } + $this->unique_key_column_sets_cache[ $cache_key ] = $sets; return $sets; } @@ -21716,16 +21734,21 @@ private function clear_schema_metadata_cache( ?string $table_name = null, ?bool $this->column_metadata_cache = array(); $this->table_column_metadata_cache = array(); $this->auto_increment_metadata_cache = array(); + $this->primary_key_index_rows_cache = array(); + $this->unique_key_column_sets_cache = array(); return; } + unset( $this->primary_key_index_rows_cache[ $this->metadata_table_cache_key( $table_name, false ) ] ); + $sets = null === $temporary ? array( false, true ) : array( $temporary ); foreach ( $sets as $temporary_set ) { $key = $this->metadata_table_cache_key( $table_name, $temporary_set ); unset( $this->column_metadata_cache[ $key ], $this->table_column_metadata_cache[ $key ], - $this->auto_increment_metadata_cache[ $key ] + $this->auto_increment_metadata_cache[ $key ], + $this->unique_key_column_sets_cache[ $key ] ); $table_set_key = $this->metadata_ensure_key( 'tables', $temporary_set ); @@ -24157,41 +24180,14 @@ private function temporary_user_table_names(): array { * @return string|null Actual table name, or null when no persistent user table matches. */ private function resolve_persistent_user_table_name( string $table_name ): ?string { - $stmt = $this->execute_duckdb_query( - 'SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()' - . " AND table_type = 'BASE TABLE'" - . ' AND lower(table_name) = ' - . $this->connection->quote( strtolower( $table_name ) ) - . ' AND table_name <> ' - . $this->connection->quote( self::INDEX_METADATA_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::COLUMN_METADATA_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::TABLE_METADATA_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::CHECK_METADATA_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::FOREIGN_KEY_METADATA_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_TABLES_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_COLUMNS_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_STATISTICS_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_TABLE_CONSTRAINTS_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_KEY_COLUMN_USAGE_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_REFERENTIAL_CONSTRAINTS_TABLE ) - . ' AND table_name <> ' - . $this->connection->quote( self::INFO_SCHEMA_CHECK_CONSTRAINTS_TABLE ) - . ' LIMIT 1', - 'Failed to inspect DuckDB table' - ); + $normalized = strtolower( $table_name ); + foreach ( $this->user_table_names() as $candidate ) { + if ( strtolower( $candidate ) === $normalized ) { + return $candidate; + } + } - $resolved = $stmt->fetchColumn(); - return false === $resolved || null === $resolved ? null : (string) $resolved; + return null; } /** @@ -24201,19 +24197,14 @@ private function resolve_persistent_user_table_name( string $table_name ): ?stri * @return string|null Actual table name, or null when no temporary user table matches. */ private function resolve_temporary_user_table_name( string $table_name ): ?string { - $stmt = $this->execute_duckdb_query( - "SELECT table_name FROM information_schema.tables WHERE table_type = 'LOCAL TEMPORARY'" - . ' AND lower(table_name) = ' - . $this->connection->quote( strtolower( $table_name ) ) - . ' AND table_name NOT LIKE ' - . $this->connection->quote( '\_\_wp\_duckdb\_%' ) - . " ESCAPE '\\'" - . ' LIMIT 1', - 'Failed to inspect DuckDB temporary table' - ); + $normalized = strtolower( $table_name ); + foreach ( $this->temporary_user_table_names() as $candidate ) { + if ( strtolower( $candidate ) === $normalized ) { + return $candidate; + } + } - $resolved = $stmt->fetchColumn(); - return false === $resolved || null === $resolved ? null : (string) $resolved; + return null; } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 9a32d0cdc..ac809a488 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -11506,6 +11506,49 @@ function ( string $sql, array $params ) use ( &$queries ): void { $this->assertSame( 1, $this->count_duckdb_column_metadata_queries( $queries, 'metadata_query_cache_items' ) ); } + public function test_repeated_simple_select_reuses_table_resolution_cache(): void { + $this->requireDuckDBRuntime(); + + $queries = array(); + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->set_query_logger( + function ( string $sql, array $params ) use ( &$queries ): void { + unset( $params ); + $queries[] = $sql; + } + ); + + $driver = new WP_DuckDB_Driver( + array( + 'connection' => $connection, + 'database' => 'wp', + ) + ); + $driver->query( + 'CREATE TABLE table_resolution_cache_options ( + option_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL, + option_value LONGTEXT NOT NULL, + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name) + )' + ); + + $queries = array(); + $this->assertSame( + array(), + $driver->query( "SELECT option_value FROM table_resolution_cache_options WHERE option_name = 'missing' LIMIT 1" )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( 2, $this->count_duckdb_table_resolution_queries( $queries ) ); + + $queries = array(); + $this->assertSame( + array(), + $driver->query( "SELECT option_value FROM table_resolution_cache_options WHERE option_name = 'missing' LIMIT 1" )->fetchAll( PDO::FETCH_ASSOC ) + ); + $this->assertSame( 0, $this->count_duckdb_table_resolution_queries( $queries ) ); + } + public function test_create_table_metadata_inserts_are_batched(): void { $this->requireDuckDBRuntime(); @@ -16490,6 +16533,17 @@ private function count_duckdb_column_metadata_queries( array $queries, string $t return $count; } + private function count_duckdb_table_resolution_queries( array $queries ): int { + $count = 0; + foreach ( $queries as $query ) { + if ( false !== strpos( $query, 'information_schema.tables' ) ) { + ++$count; + } + } + + return $count; + } + private function lifecycleTableSql( string $table_name ): string { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid return "CREATE TABLE {$table_name} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, From 0054ad08822ddb6e8a98e0fec6c9bd253b71d654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 22:08:59 +0000 Subject: [PATCH 161/317] Improve DuckDB date and regex parity --- .../src/duckdb/class-wp-duckdb-driver.php | 403 ++++++++++++++---- .../duckdb/WP_DuckDB_Connection_Tests.php | 2 +- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 5 +- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 269 +++++++++++- .../WP_DuckDB_Plugin_Dispatcher_Tests.php | 2 +- 5 files changed, 591 insertions(+), 90 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 9f5d12d4f..e0fd2fb35 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -15490,8 +15490,18 @@ private function translate_tokens_to_duckdb_sql( && isset( $tokens[ $index + 1 ], $tokens[ $index + 2 ] ) && $this->is_regexp_operator( $tokens[ $index + 1 ] ) ) { - $pieces[] = $this->translate_regexp_predicate( $pieces, $tokens[ $index + 2 ], true ); - $index += 2; + $pattern_index = $index + 2; + $is_binary = false; + if ( + WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $pattern_index ]->id + && isset( $tokens[ $pattern_index + 1 ] ) + ) { + $is_binary = true; + ++$pattern_index; + } + + $pieces[] = $this->translate_regexp_predicate( $pieces, $tokens[ $pattern_index ], true, $is_binary ); + $index = $pattern_index; continue; } @@ -15499,8 +15509,18 @@ private function translate_tokens_to_duckdb_sql( $this->is_regexp_operator( $token ) && isset( $tokens[ $index + 1 ] ) ) { - $pieces[] = $this->translate_regexp_predicate( $pieces, $tokens[ $index + 1 ], false ); - ++$index; + $pattern_index = $index + 1; + $is_binary = false; + if ( + WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $pattern_index ]->id + && isset( $tokens[ $pattern_index + 1 ] ) + ) { + $is_binary = true; + ++$pattern_index; + } + + $pieces[] = $this->translate_regexp_predicate( $pieces, $tokens[ $pattern_index ], false, $is_binary ); + $index = $pattern_index; continue; } @@ -15596,6 +15616,22 @@ private function translate_tokens_to_duckdb_sql( continue; } + $cast_like_predicate = $this->translate_cast_like_predicate( + $tokens, + $index, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + if ( null !== $cast_like_predicate ) { + $pieces[] = $cast_like_predicate; + continue; + } + $cast_expression = $this->translate_cast_expression( $tokens, $index, @@ -19777,7 +19813,7 @@ private function translate_on_duplicate_value_tokens_to_duckdb_sql( array $token */ private function initialize_session_macros(): void { try { - $this->connection->query( 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(d, f)' ); + $this->connection->query( 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(TRY_CAST(d AS TIMESTAMP), f)' ); } catch ( WP_DuckDB_Driver_Exception $e ) { throw new WP_DuckDB_Driver_Exception( 'Failed to initialize DuckDB MySQL compatibility macros: ' . $e->getMessage(), 0, $e ); } @@ -19799,23 +19835,40 @@ private function is_regexp_operator( WP_Parser_Token $token ): bool { * @param string[] $pieces SQL pieces translated so far. * @param WP_Parser_Token $pattern Pattern token. * @param bool $negated Whether this is NOT REGEXP. + * @param bool $binary Whether this is REGEXP BINARY. * @return string */ - private function translate_regexp_predicate( array &$pieces, WP_Parser_Token $pattern, bool $negated ): string { + private function translate_regexp_predicate( array &$pieces, WP_Parser_Token $pattern, bool $negated, bool $binary = false ): string { if ( count( $pieces ) === 0 ) { throw new WP_DuckDB_Driver_Exception( 'REGEXP requires a left-hand expression in the DuckDB driver.' ); } - $left = array_pop( $pieces ); - $predicate = sprintf( - 'regexp_matches(%s, %s)', - $left, - $this->translate_token_to_duckdb_sql( $pattern ) - ); + $left = 'CAST((' . $this->pop_regexp_left_expression( $pieces ) . ') AS VARCHAR)'; + $pattern_sql = $this->translate_token_to_duckdb_sql( $pattern ); + $predicate = $binary + ? sprintf( 'regexp_matches(%s, %s)', $left, $pattern_sql ) + : sprintf( "regexp_matches(%s, %s, 'i')", $left, $pattern_sql ); return $negated ? 'NOT ' . $predicate : $predicate; } + /** + * Pop the complete left-hand expression for a REGEXP predicate. + * + * @param string[] $pieces SQL pieces translated so far. + * @return string SQL expression. + */ + private function pop_regexp_left_expression( array &$pieces ): string { + $left = array( array_pop( $pieces ) ); + while ( count( $pieces ) >= 2 && '.' === $pieces[ count( $pieces ) - 1 ] ) { + $dot = array_pop( $pieces ); + $qualifier = array_pop( $pieces ); + array_unshift( $left, $qualifier, $dot ); + } + + return $this->join_sql_pieces( $left ); + } + /** * Translate a single token to DuckDB SQL. * @@ -19857,13 +19910,33 @@ private function translate_date_time_function_call( } $name = strtoupper( $tokens[ $index ]->get_value() ); - if ( ! in_array( $name, array( 'DATE', 'DATEDIFF', 'DATE_ADD', 'DATE_SUB', 'DAY', 'DAYOFMONTH', 'MONTH', 'YEAR' ), true ) ) { + if ( ! in_array( $name, array( 'DATE', 'DATEDIFF', 'DATE_ADD', 'DATE_SUB', 'DATE_FORMAT', 'DAY', 'DAYOFMONTH', 'DAYOFWEEK', 'HOUR', 'MINUTE', 'MONTH', 'MONTHNUM', 'SECOND', 'WEEK', 'WEEKDAY', 'YEAR' ), true ) ) { return null; } $end_index = $this->skip_balanced_parentheses( $tokens, $index + 1 ); $body = array_slice( $tokens, $index + 2, $end_index - $index - 3 ); $items = $this->split_top_level_comma_items( $body ); + $translate = function ( array $item ) use ( + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ): string { + return $this->translate_tokens_to_duckdb_sql( + $item, + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + }; if ( 'DATE' === $name ) { if ( 1 !== count( $items ) || count( $items[0] ) === 0 ) { @@ -19871,18 +19944,30 @@ private function translate_date_time_function_call( } $index = $end_index - 1; - return 'strftime(CAST((' - . $this->translate_tokens_to_duckdb_sql( - $items[0], - $rewrite_information_schema_tables, - $rewrite_information_schema_columns, - $rewrite_information_schema_statistics, - $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage, - $rewrite_information_schema_referential_constraints, - $rewrite_information_schema_check_constraints + return 'strftime(TRY_CAST((' . $translate( $items[0] ) . ") AS TIMESTAMP), '%Y-%m-%d')"; + } + + if ( 'DATE_FORMAT' === $name ) { + if ( 2 !== count( $items ) || count( $items[0] ) === 0 || count( $items[1] ) === 0 ) { + return null; + } + + $literal_format = null; + $format_sql = $translate( $items[1] ); + if ( + 1 === count( $items[1] ) + && ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $items[1][0]->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $items[1][0]->id ) - . ") AS TIMESTAMP), '%Y-%m-%d')"; + ) { + $literal_format = $this->token_value( $items[1][0] ); + $format_sql = $this->connection->quote( $this->mysql_date_format_to_duckdb( $literal_format ) ); + } + + $index = $end_index - 1; + $date_format_sql = 'strftime(TRY_CAST((' . $translate( $items[0] ) . ') AS TIMESTAMP), ' . $format_sql . ')'; + return '%H.%i' === $literal_format ? 'CAST(' . $date_format_sql . ' AS DOUBLE)' : $date_format_sql; } if ( 'DATEDIFF' === $name ) { @@ -19890,51 +19975,51 @@ private function translate_date_time_function_call( return null; } - $start_sql = $this->translate_tokens_to_duckdb_sql( - $items[0], - $rewrite_information_schema_tables, - $rewrite_information_schema_columns, - $rewrite_information_schema_statistics, - $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage, - $rewrite_information_schema_referential_constraints, - $rewrite_information_schema_check_constraints - ); - $end_sql = $this->translate_tokens_to_duckdb_sql( - $items[1], - $rewrite_information_schema_tables, - $rewrite_information_schema_columns, - $rewrite_information_schema_statistics, - $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage, - $rewrite_information_schema_referential_constraints, - $rewrite_information_schema_check_constraints - ); + $start_sql = $translate( $items[0] ); + $end_sql = $translate( $items[1] ); $index = $end_index - 1; - return 'CAST(CAST((' . $start_sql . ') AS DATE) - CAST((' . $end_sql . ') AS DATE) AS BIGINT)'; + return 'CAST(TRY_CAST((' . $start_sql . ') AS DATE) - TRY_CAST((' . $end_sql . ') AS DATE) AS BIGINT)'; } - if ( in_array( $name, array( 'DAY', 'DAYOFMONTH', 'MONTH', 'YEAR' ), true ) ) { + if ( in_array( $name, array( 'DAY', 'DAYOFMONTH', 'MONTH', 'MONTHNUM', 'YEAR' ), true ) ) { if ( 1 !== count( $items ) || count( $items[0] ) === 0 ) { return null; } $function = 'DAY' === $name ? 'dayofmonth' : strtolower( $name ); + $function = 'monthnum' === $function ? 'month' : $function; $index = $end_index - 1; - return $function . '(TRY_CAST((' - . $this->translate_tokens_to_duckdb_sql( - $items[0], - $rewrite_information_schema_tables, - $rewrite_information_schema_columns, - $rewrite_information_schema_statistics, - $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage, - $rewrite_information_schema_referential_constraints, - $rewrite_information_schema_check_constraints - ) - . ') AS TIMESTAMP))'; + return $function . '(TRY_CAST((' . $translate( $items[0] ) . ') AS TIMESTAMP))'; + } + + if ( in_array( $name, array( 'HOUR', 'MINUTE', 'SECOND' ), true ) ) { + if ( 1 !== count( $items ) || count( $items[0] ) === 0 ) { + return null; + } + + $index = $end_index - 1; + return strtolower( $name ) . '(TRY_CAST((' . $translate( $items[0] ) . ') AS TIME))'; + } + + if ( 'DAYOFWEEK' === $name || 'WEEKDAY' === $name ) { + if ( 1 !== count( $items ) || count( $items[0] ) === 0 ) { + return null; + } + + $day_of_week = 'dayofweek(TRY_CAST((' . $translate( $items[0] ) . ') AS DATE))'; + $index = $end_index - 1; + return 'DAYOFWEEK' === $name ? '(' . $day_of_week . ' + 1)' : '((' . $day_of_week . ' + 6) % 7)'; + } + + if ( 'WEEK' === $name ) { + if ( count( $items ) < 1 || count( $items ) > 2 || count( $items[0] ) === 0 ) { + return null; + } + + $index = $end_index - 1; + return 'week(TRY_CAST((' . $translate( $items[0] ) . ') AS DATE))'; } if ( 2 !== count( $items ) || count( $items[0] ) === 0 || count( $items[1] ) < 3 ) { @@ -19957,26 +20042,8 @@ private function translate_date_time_function_call( return null; } - $date_sql = $this->translate_tokens_to_duckdb_sql( - $items[0], - $rewrite_information_schema_tables, - $rewrite_information_schema_columns, - $rewrite_information_schema_statistics, - $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage, - $rewrite_information_schema_referential_constraints, - $rewrite_information_schema_check_constraints - ); - $value_sql = $this->translate_tokens_to_duckdb_sql( - $value_tokens, - $rewrite_information_schema_tables, - $rewrite_information_schema_columns, - $rewrite_information_schema_statistics, - $rewrite_information_schema_table_constraints, - $rewrite_information_schema_key_column_usage, - $rewrite_information_schema_referential_constraints, - $rewrite_information_schema_check_constraints - ); + $date_sql = $translate( $items[0] ); + $value_sql = $translate( $value_tokens ); $sign = 'DATE_SUB' === $name ? '-' : '+'; if ( 'WEEK' === $unit ) { $unit = 'DAY'; @@ -19995,6 +20062,49 @@ private function translate_date_time_function_call( . ", '%Y-%m-%d %H:%M:%S')"; } + /** + * Translate common MySQL DATE_FORMAT specifiers to DuckDB strftime specifiers. + * + * @param string $format MySQL DATE_FORMAT() format string. + * @return string DuckDB strftime() format string. + */ + private function mysql_date_format_to_duckdb( string $format ): string { + return strtr( + $format, + array( + '%a' => '%a', + '%b' => '%b', + '%c' => '%-m', + '%d' => '%d', + '%e' => '%-d', + '%H' => '%H', + '%h' => '%I', + '%I' => '%I', + '%i' => '%M', + '%j' => '%j', + '%k' => '%-H', + '%l' => '%-I', + '%m' => '%m', + '%s' => '%S', + '%S' => '%S', + '%p' => '%p', + '%r' => '%I:%M:%S %p', + '%T' => '%H:%M:%S', + '%U' => '%U', + '%u' => '%W', + '%V' => '%V', + '%v' => '%V', + '%w' => '%w', + '%X' => '%Y', + '%x' => '%G', + '%M' => '%B', + '%W' => '%A', + '%Y' => '%Y', + '%y' => '%y', + ) + ); + } + /** * Translate MySQL CAST(expr AS type) expressions. * @@ -20053,6 +20163,105 @@ private function translate_cast_expression( return 'CAST(' . $expr_sql . ' AS ' . $type_sql . ')'; } + /** + * Translate MySQL's implicit string comparison for numeric CAST(...) LIKE patterns. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index, advanced on match. + * @return string|null DuckDB SQL, or null when the token does not start a supported predicate. + */ + private function translate_cast_like_predicate( + array $tokens, + int &$index, + bool $rewrite_information_schema_tables, + bool $rewrite_information_schema_columns, + bool $rewrite_information_schema_statistics, + bool $rewrite_information_schema_table_constraints, + bool $rewrite_information_schema_key_column_usage, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints + ): ?string { + if ( + ! isset( $tokens[ $index + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $index ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + return null; + } + + $end_index = $this->skip_balanced_parentheses( $tokens, $index + 1 ); + $body = array_slice( $tokens, $index + 2, $end_index - $index - 3 ); + $as_index = $this->find_top_level_token_index( $body, 0, WP_MySQL_Lexer::AS_SYMBOL ); + if ( null === $as_index ) { + return null; + } + + $type_tokens = array_slice( $body, $as_index + 1 ); + if ( ! $this->cast_type_is_numeric( $type_tokens ) || ! isset( $tokens[ $end_index ] ) ) { + return null; + } + + $is_not_like = false; + $pattern_index = $end_index + 1; + if ( WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $end_index ]->id ) { + $is_not_like = false; + } elseif ( + in_array( $tokens[ $end_index ]->id, array( WP_MySQL_Lexer::NOT_SYMBOL, WP_MySQL_Lexer::NOT2_SYMBOL ), true ) + && isset( $tokens[ $end_index + 1 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $end_index + 1 ]->id + ) { + $is_not_like = true; + $pattern_index = $end_index + 2; + } else { + return null; + } + + if ( + ! isset( $tokens[ $pattern_index ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $pattern_index ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $pattern_index ]->id + ) + ) { + return null; + } + + $cast_sql = $this->translate_tokens_to_duckdb_sql( + array_slice( $tokens, $index, $end_index - $index ), + $rewrite_information_schema_tables, + $rewrite_information_schema_columns, + $rewrite_information_schema_statistics, + $rewrite_information_schema_table_constraints, + $rewrite_information_schema_key_column_usage, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + + $pattern = $this->token_value( $tokens[ $pattern_index ] ); + $escape = ''; + $index = $pattern_index; + if ( + isset( $tokens[ $pattern_index + 2 ] ) + && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern_index + 1 ]->id + && ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $tokens[ $pattern_index + 2 ]->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[ $pattern_index + 2 ]->id + ) + ) { + $escape = ' ESCAPE ' . $this->connection->quote( $this->token_value( $tokens[ $pattern_index + 2 ] ) ); + $index = $pattern_index + 2; + } elseif ( false !== strpos( $pattern, '\\' ) ) { + $escape = ' ESCAPE ' . $this->connection->quote( '\\' ); + } + + return 'CAST((' + . $cast_sql + . ') AS VARCHAR)' + . ( $is_not_like ? ' NOT LIKE ' : ' LIKE ' ) + . $this->connection->quote( $pattern ) + . $escape; + } + /** * Translate MySQL CONVERT(expr, type) and CONVERT(expr USING charset). * @@ -20349,6 +20558,44 @@ private function cast_type_has_token( array $type_tokens, int $token_id ): bool return false; } + /** + * Check whether a cast type is numeric. + * + * @param WP_Parser_Token[] $type_tokens Cast type tokens. + * @return bool Whether the cast type is numeric. + */ + private function cast_type_is_numeric( array $type_tokens ): bool { + foreach ( $type_tokens as $token ) { + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::DECIMAL_SYMBOL, + WP_MySQL_Lexer::DEC_SYMBOL, + WP_MySQL_Lexer::FIXED_SYMBOL, + WP_MySQL_Lexer::FLOAT_SYMBOL, + WP_MySQL_Lexer::REAL_SYMBOL, + WP_MySQL_Lexer::DOUBLE_SYMBOL, + WP_MySQL_Lexer::SIGNED_SYMBOL, + WP_MySQL_Lexer::UNSIGNED_SYMBOL, + WP_MySQL_Lexer::NUMERIC_SYMBOL, + WP_MySQL_Lexer::INT_SYMBOL, + WP_MySQL_Lexer::INTEGER_SYMBOL, + WP_MySQL_Lexer::BIGINT_SYMBOL, + WP_MySQL_Lexer::TINYINT_SYMBOL, + WP_MySQL_Lexer::SMALLINT_SYMBOL, + WP_MySQL_Lexer::MEDIUMINT_SYMBOL, + ), + true + ) + ) { + return true; + } + } + + return false; + } + /** * Collect the operand for a unary BINARY expression. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index ee4dd443e..167104c31 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -739,7 +739,7 @@ public function query( string $sql ) { $this->assertSame( array( - 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(d, f)', + 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(TRY_CAST(d AS TIMESTAMP), f)', ), $duckdb->queries ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index bfbb8d58a..a61945bc8 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -2181,12 +2181,15 @@ public function test_regexp_and_not_regexp_match_sqlite(): void { $this->runParitySetup( array( 'CREATE TABLE options (option_name VARCHAR(100))', - "INSERT INTO options VALUES ('rss_123'), ('transient')", + "INSERT INTO options VALUES ('rss_123'), ('RSS_456'), ('transient')", ) ); $this->assertParityRows( "SELECT option_name FROM options WHERE option_name REGEXP '^rss_.+$' ORDER BY option_name" ); + $this->assertParityRows( "SELECT option_name FROM options WHERE option_name RLIKE '^rss_.+$' ORDER BY option_name" ); + $this->assertParityRows( "SELECT option_name FROM options WHERE option_name REGEXP BINARY '^rss_.+$' ORDER BY option_name" ); $this->assertParityRows( "SELECT option_name FROM options WHERE option_name NOT REGEXP '^rss_.+$' ORDER BY option_name" ); + $this->assertParityRows( "SELECT option_name FROM options WHERE option_name NOT RLIKE BINARY '^RSS_.+$' ORDER BY option_name" ); } public function test_replace_values_match_sqlite(): void { diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index ac809a488..50d0b4fd4 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1024,6 +1024,81 @@ public function test_sql_calc_found_rows_left_join_group_by_primary_key_expands_ ); } + public function test_sql_calc_found_rows_meta_query_regexp_and_numeric_like_counts(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $this->create_wordpress_meta_query_found_rows_tables( $driver ); + + $regexp = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND ( wptests_postmeta.meta_key = 'foo' AND wptests_postmeta.meta_value REGEXP 'z$' ) + AND ((wptests_posts.post_type = 'post' AND (wptests_posts.post_status = 'publish'))) + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( array( array( 'ID' => 1 ) ), $regexp ); + $this->assertSame( + array( array( 'found_rows' => 1 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $not_regexp = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND ( wptests_postmeta.meta_key = 'foo' AND wptests_postmeta.meta_value NOT REGEXP 'z$' ) + AND ((wptests_posts.post_type = 'post' AND (wptests_posts.post_status = 'publish'))) + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( array( array( 'ID' => 2 ) ), $not_regexp ); + $this->assertSame( + array( array( 'found_rows' => 1 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $numeric_like = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND ( wptests_postmeta.meta_key = 'decimal_value' AND CAST(wptests_postmeta.meta_value AS DECIMAL(10,2)) LIKE '%.3%' ) + AND ((wptests_posts.post_type = 'post' AND (wptests_posts.post_status = 'publish'))) + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( array( array( 'ID' => 1 ) ), $numeric_like ); + $this->assertSame( + array( array( 'found_rows' => 1 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $numeric_not_like = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND ( wptests_postmeta.meta_key = 'decimal_value' AND CAST(wptests_postmeta.meta_id AS SIGNED) NOT LIKE '3' ) + AND ((wptests_posts.post_type = 'post' AND (wptests_posts.post_status = 'publish'))) + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( array( array( 'ID' => 2 ) ), $numeric_not_like ); + $this->assertSame( + array( array( 'found_rows' => 1 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + public function test_select_comments_group_by_primary_key_orders_by_joined_meta_value(): void { $this->requireDuckDBRuntime(); @@ -1561,9 +1636,12 @@ public function test_date_format_function_is_emulated(): void { $this->requireDuckDBRuntime(); $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); - $row = $driver->query( "SELECT DATE_FORMAT(DATE '2026-06-26', '%Y-%m-%d') AS formatted_date" )->fetch( PDO::FETCH_ASSOC ); + $row = $driver->query( "SELECT DATE_FORMAT(DATE '2026-06-26', '%Y-%m-%d') AS formatted_date, DATE_FORMAT('2026-06-26 14:15:16', '%Y-%m-%d %H:%i:%s') AS formatted_datetime, DATE_FORMAT('2026-06-26 14:15:16', '%M %W %h:%i %p') AS formatted_names, DATE_FORMAT('2026-06-26 14:15:16', '%H.%i') AS hm" )->fetch( PDO::FETCH_ASSOC ); $this->assertSame( '2026-06-26', $row['formatted_date'] ); + $this->assertSame( '2026-06-26 14:15:16', $row['formatted_datetime'] ); + $this->assertSame( 'June Friday 02:15 PM', $row['formatted_names'] ); + $this->assertEqualsWithDelta( 14.15, (float) $row['hm'], 0.000001 ); } public function test_date_part_functions_translate_wordpress_datetime_strings_with_try_cast(): void { @@ -1598,6 +1676,36 @@ public function query( string $sql, array $params = array() ): WP_DuckDB_Result_ $this->assertStringContainsString( 'year(TRY_CAST((post_date) AS TIMESTAMP)) AS year', $sql ); $this->assertStringContainsString( 'month(TRY_CAST((post_date) AS TIMESTAMP)) AS month', $sql ); $this->assertStringContainsString( 'dayofmonth(TRY_CAST((post_date) AS TIMESTAMP)) AS day', $sql ); + + $tokens = $tokenize->invoke( + $driver, + 'SELECT DATE(post_date) AS post_day, DATEDIFF(post_modified, post_date) AS days_old, MONTHNUM(post_date) AS monthnum + FROM wp_posts' + ); + $sql = $translate->invoke( $driver, $tokens ); + + $this->assertStringContainsString( "strftime(TRY_CAST((post_date) AS TIMESTAMP), '%Y-%m-%d') AS post_day", $sql ); + $this->assertStringContainsString( 'CAST(TRY_CAST((post_modified) AS DATE) - TRY_CAST((post_date) AS DATE) AS BIGINT) AS days_old', $sql ); + $this->assertStringContainsString( 'month(TRY_CAST((post_date) AS TIMESTAMP)) AS monthnum', $sql ); + + $tokens = $tokenize->invoke( + $driver, + "SELECT HOUR(post_date) AS hour, MINUTE(post_date) AS minute, SECOND(post_date) AS second, + DAYOFWEEK(post_date) AS day_of_week, WEEKDAY(post_date) AS weekday, + WEEK(post_date, 1) AS week, DATE_FORMAT(post_date, '%Y-%m-%d %H:%i:%s') AS formatted, + DATE_FORMAT(post_date, '%H.%i') AS hm + FROM wp_posts" + ); + $sql = $translate->invoke( $driver, $tokens ); + + $this->assertStringContainsString( 'hour(TRY_CAST((post_date) AS TIME)) AS hour', $sql ); + $this->assertStringContainsString( 'minute(TRY_CAST((post_date) AS TIME)) AS minute', $sql ); + $this->assertStringContainsString( 'second(TRY_CAST((post_date) AS TIME)) AS second', $sql ); + $this->assertStringContainsString( '(dayofweek(TRY_CAST((post_date) AS DATE)) + 1) AS day_of_week', $sql ); + $this->assertStringContainsString( '((dayofweek(TRY_CAST((post_date) AS DATE)) + 6) % 7) AS weekday', $sql ); + $this->assertStringContainsString( 'week(TRY_CAST((post_date) AS DATE)) AS week', $sql ); + $this->assertStringContainsString( "strftime(TRY_CAST((post_date) AS TIMESTAMP), '%Y-%m-%d %H:%M:%S') AS formatted", $sql ); + $this->assertStringContainsString( "CAST(strftime(TRY_CAST((post_date) AS TIMESTAMP), '%H.%M') AS DOUBLE) AS hm", $sql ); } public function test_date_part_functions_try_cast_wordpress_datetime_strings(): void { @@ -1650,16 +1758,65 @@ public function test_date_part_functions_try_cast_wordpress_datetime_strings(): FROM wp_posts WHERE YEAR(post_date) = 2026 AND MONTH(post_date) = 6 + AND MONTHNUM(post_date) = 6 AND DAYOFMONTH(post_date) = 26 AND post_type = 'attachment'" )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( + array( array( 'ID' => 1 ) ), + $driver->query( + "SELECT ID + FROM wp_posts + WHERE DATE_FORMAT(post_date, '%H.%i') = 14.15 + AND post_type = 'attachment'" + )->fetchAll( PDO::FETCH_ASSOC ) + ); + + $row = $driver->query( + "SELECT HOUR(post_date) AS hour, MINUTE(post_date) AS minute, SECOND(post_date) AS second, + DAYOFWEEK(post_date) AS day_of_week, WEEKDAY(post_date) AS weekday, + WEEK(post_date, 1) AS week, DATE_FORMAT(post_date, '%Y-%m-%d %H:%i:%s') AS formatted, + DATE_FORMAT(post_date, '%H.%i') AS hm + FROM wp_posts + WHERE post_date = '2026-06-26 14:15:16'" + )->fetch( PDO::FETCH_ASSOC ); + + $hour_minute = $row['hm']; + unset( $row['hm'] ); + + $this->assertSame( + array( + 'hour' => 14, + 'minute' => 15, + 'second' => 16, + 'day_of_week' => 6, + 'weekday' => 4, + 'week' => 26, + 'formatted' => '2026-06-26 14:15:16', + ), + $row + ); + $this->assertEqualsWithDelta( 14.15, (float) $hour_minute, 0.000001 ); + + $time_row = $driver->query( "SELECT HOUR('14:15:16') AS hour, MINUTE('14:15:16') AS minute, SECOND('14:15:16') AS second" )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( + array( + 'hour' => 14, + 'minute' => 15, + 'second' => 16, + ), + $time_row + ); + $row = $driver->query( "SELECT YEAR('0000-00-00 00:00:00') AS zero_date_year, MONTH('0000-00-00 00:00:00') AS zero_date_month, DAYOFMONTH('0000-00-00 00:00:00') AS zero_date_day, + DATE('0000-00-00 00:00:00') AS zero_date_date, + DATEDIFF('0000-00-00 00:00:00', '2026-06-26 14:15:16') AS zero_date_delta, YEAR(DATE '2026-07-01') AS date_year, MONTH(TIMESTAMP '2026-08-02 03:04:05') AS timestamp_month, DAY(TIMESTAMP '2026-08-02 03:04:05') AS timestamp_day" @@ -1668,6 +1825,8 @@ public function test_date_part_functions_try_cast_wordpress_datetime_strings(): $this->assertNull( $row['zero_date_year'] ); $this->assertNull( $row['zero_date_month'] ); $this->assertNull( $row['zero_date_day'] ); + $this->assertNull( $row['zero_date_date'] ); + $this->assertNull( $row['zero_date_delta'] ); $this->assertSame( 2026, $row['date_year'] ); $this->assertSame( 8, $row['timestamp_month'] ); $this->assertSame( 2, $row['timestamp_day'] ); @@ -4545,6 +4704,49 @@ public function rows( bool $assoc ): array { } } + public function test_numeric_cast_like_predicates_are_translated_without_duckdb_runtime(): void { + $duckdb = new class() { + public function query( string $sql ) { + return new class() { + public function columnNames(): ArrayIterator { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return new ArrayIterator( array( 'matched' ) ); + } + + public function rows( bool $assoc ): array { + return array( array( 'matched' => 1 ) ); + } + }; + } + }; + $driver = new WP_DuckDB_Driver( + array( + 'connection' => new WP_DuckDB_Connection( array( 'duckdb' => $duckdb ) ), + ) + ); + + $backslash = chr( 92 ); + $cases = array( + array( + 'mysql' => "SELECT CAST(meta_value AS DECIMAL(10,2)) LIKE '10{$backslash}_%' AS matched FROM postmeta", + 'duckdb' => "SELECT CAST((CAST(meta_value AS DOUBLE)) AS VARCHAR) LIKE '10{$backslash}_%' ESCAPE '{$backslash}' AS matched FROM postmeta", + ), + array( + 'mysql' => "SELECT CAST(meta_id AS SIGNED) NOT LIKE '3' AS matched FROM postmeta", + 'duckdb' => "SELECT CAST((CAST(meta_id AS BIGINT)) AS VARCHAR) NOT LIKE '3' AS matched FROM postmeta", + ), + array( + 'mysql' => "SELECT CAST(meta_id AS UNSIGNED) LIKE '4%' ESCAPE '!' AS matched FROM postmeta", + 'duckdb' => "SELECT CAST((CAST(meta_id AS BIGINT)) AS VARCHAR) LIKE '4%' ESCAPE '!' AS matched FROM postmeta", + ), + ); + + foreach ( $cases as $case ) { + $driver->query( $case['mysql'] ); + + $this->assertSame( array( $case['duckdb'] ), $driver->get_last_duckdb_queries() ); + } + } + public function test_escaped_like_literal_predicates_use_mysql_backslash_semantics(): void { $this->requireDuckDBRuntime(); @@ -7833,13 +8035,25 @@ public function test_regexp_predicates_are_emulated(): void { $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); $driver->query( 'CREATE TABLE options (option_name VARCHAR(100))' ); - $driver->query( "INSERT INTO options VALUES ('rss_123'), ('transient')" ); + $driver->query( "INSERT INTO options VALUES ('rss_123'), ('RSS_456'), ('transient')" ); + + $regexp_rows = $driver->query( "SELECT option_name FROM options WHERE option_name REGEXP '^rss_.+$' ORDER BY lower(option_name), option_name DESC" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( array( 'option_name' => 'rss_123' ), array( 'option_name' => 'RSS_456' ) ), $regexp_rows ); - $regexp_rows = $driver->query( "SELECT option_name FROM options WHERE option_name REGEXP '^rss_.+$'" )->fetchAll( PDO::FETCH_ASSOC ); - $this->assertSame( array( array( 'option_name' => 'rss_123' ) ), $regexp_rows ); + $rlike_rows = $driver->query( "SELECT option_name FROM options WHERE option_name RLIKE '^rss_.+$' ORDER BY lower(option_name), option_name DESC" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( array( 'option_name' => 'rss_123' ), array( 'option_name' => 'RSS_456' ) ), $rlike_rows ); - $not_regexp_rows = $driver->query( "SELECT option_name FROM options WHERE option_name NOT REGEXP '^rss_.+$'" )->fetchAll( PDO::FETCH_ASSOC ); + $binary_regexp_rows = $driver->query( "SELECT option_name FROM options WHERE option_name REGEXP BINARY '^rss_.+$' ORDER BY option_name" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( array( 'option_name' => 'rss_123' ) ), $binary_regexp_rows ); + + $not_regexp_rows = $driver->query( "SELECT option_name FROM options WHERE option_name NOT REGEXP '^rss_.+$' ORDER BY option_name" )->fetchAll( PDO::FETCH_ASSOC ); $this->assertSame( array( array( 'option_name' => 'transient' ) ), $not_regexp_rows ); + + $not_binary_rlike_rows = $driver->query( "SELECT option_name FROM options WHERE option_name NOT RLIKE BINARY '^RSS_.+$' ORDER BY option_name" )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( array( array( 'option_name' => 'rss_123' ), array( 'option_name' => 'transient' ) ), $not_binary_rlike_rows ); + + $numeric_regexp = $driver->query( "SELECT 123 REGEXP '23$' AS matched" )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( array( 'matched' => true ), $numeric_regexp ); } public function test_table_level_primary_key_is_supported(): void { @@ -16621,10 +16835,47 @@ private function create_wordpress_left_join_group_by_tables( WP_DuckDB_Driver $d ); $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id, term_order) VALUES - (1, 11, 0), - (1, 12, 0), - (2, 11, 0), - (4, 11, 0)' + (1, 11, 0), + (1, 12, 0), + (2, 11, 0), + (4, 11, 0)' + ); + } + + private function create_wordpress_meta_query_found_rows_tables( WP_DuckDB_Driver $driver ): void { + $driver->query( + "CREATE TABLE wptests_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_date DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "CREATE TABLE wptests_postmeta ( + meta_id BIGINT(20) UNSIGNED NOT NULL, + post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + meta_key VARCHAR(255) DEFAULT NULL, + meta_value LONGTEXT, + PRIMARY KEY (meta_id), + KEY post_id (post_id), + KEY meta_key (meta_key(191)) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_type, post_status) VALUES + (1, '2026-01-01 00:00:00', 'post', 'publish'), + (2, '2026-02-01 00:00:00', 'post', 'publish'), + (3, '2026-03-01 00:00:00', 'page', 'publish')" + ); + $driver->query( + "INSERT INTO wptests_postmeta (meta_id, post_id, meta_key, meta_value) VALUES + (1, 1, 'foo', 'buzz'), + (2, 2, 'foo', 'bar'), + (3, 1, 'decimal_value', '10.30'), + (4, 2, 'decimal_value', '10.40'), + (5, 3, 'foo', 'fizz')" ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php index 817cc41a4..875cad823 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Plugin_Dispatcher_Tests.php @@ -145,7 +145,7 @@ function ( string $sql ): bool { ); $this->assertSame( array( - 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(d, f)', + 'CREATE OR REPLACE MACRO date_format(d, f) AS strftime(TRY_CAST(d AS TIMESTAMP), f)', 'SELECT 42 AS answer', 'INSERT INTO t VALUES (1), (2)', 'CREATE TABLE "t" ("id" INTEGER)', From 89f2cabd2650b1e7cae791ec192af138d5135129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 22:17:51 +0000 Subject: [PATCH 162/317] Repair DuckDB temporary metadata after rollback --- .../src/duckdb/class-wp-duckdb-driver.php | 138 +++++++++++++++--- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 86 +++++++++++ 2 files changed, 201 insertions(+), 23 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index e0fd2fb35..fbb71f317 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -521,6 +521,7 @@ private function execute_fast_path_statement( string $query ): ?WP_DuckDB_Result if ( $this->connection->inTransaction() ) { $this->last_duckdb_queries[] = 'ROLLBACK'; $this->connection->rollback(); + $this->clear_schema_state_after_rollback(); } $this->table_lock_active = false; return $this->empty_ddl_result(); @@ -607,11 +608,13 @@ private function rollback_failed_active_transaction( Throwable $error ): void { if ( $this->is_current_transaction_aborted_error( $error ) ) { $this->last_duckdb_queries[] = 'ROLLBACK'; $this->connection->rollbackNativeTransaction(); + $this->clear_schema_state_after_rollback(); } return; } $this->connection->rollback(); + $this->clear_schema_state_after_rollback(); } /** @@ -688,6 +691,7 @@ private function recover_aborted_transaction_before_information_schema_refresh() $this->last_duckdb_queries[] = 'ROLLBACK'; $this->connection->rollbackNativeTransaction(); + $this->clear_schema_state_after_rollback(); return; } @@ -706,6 +710,7 @@ private function recover_aborted_transaction_before_information_schema_refresh() $this->last_duckdb_queries[] = 'ROLLBACK'; $this->connection->rollbackNativeTransaction(); + $this->clear_schema_state_after_rollback(); } } @@ -4717,6 +4722,7 @@ private function execute_update_with_seeded_rand_assignments( array $tokens, arr } catch ( Throwable $e ) { if ( $started_transaction && $this->connection->inTransaction() ) { $this->connection->rollback(); + $this->clear_schema_state_after_rollback(); } throw $e; } @@ -6234,6 +6240,7 @@ private function execute_schema_lifecycle_change( callable $callback ): WP_DuckD } catch ( Throwable $e ) { if ( $started_transaction && $this->connection->inTransaction() ) { $this->connection->rollback(); + $this->clear_schema_state_after_rollback(); } throw $e; } @@ -7654,6 +7661,7 @@ private function execute_rollback_statement( array $tokens ): WP_DuckDB_Result_S if ( $this->connection->inTransaction() ) { $this->last_duckdb_queries[] = 'ROLLBACK'; $this->connection->rollback(); + $this->clear_schema_state_after_rollback(); } $this->table_lock_active = false; return $this->empty_ddl_result(); @@ -11546,7 +11554,7 @@ private function replace_changed_column_metadata( string $table_name, string $ol } $this->ensure_column_metadata_table( $temporary ); - $this->execute_duckdb_query( + $this->execute_column_metadata_query( 'UPDATE ' . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' SET column_name = ' @@ -11569,7 +11577,8 @@ private function replace_changed_column_metadata( string $table_name, string $ol . $this->connection->quote( $table_name ) . ' AND column_name = ' . $this->connection->quote( $old_column_name ), - 'Failed to update DuckDB column metadata' + 'Failed to update DuckDB column metadata', + $temporary ); $this->clear_schema_metadata_cache( $table_name, $temporary ); @@ -11585,14 +11594,15 @@ private function replace_changed_column_metadata( string $table_name, string $ol private function delete_column_metadata( string $table_name, string $column_name, bool $temporary = false ): void { $this->ensure_column_metadata_table( $temporary ); - $this->execute_duckdb_query( + $this->execute_column_metadata_query( 'DELETE FROM ' . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) . ' AND column_name = ' . $this->connection->quote( $column_name ), - 'Failed to delete DuckDB column metadata' + 'Failed to delete DuckDB column metadata', + $temporary ); $this->clear_schema_metadata_cache( $table_name, $temporary ); @@ -17113,6 +17123,7 @@ private function execute_replace_select_with_manual_conflict_handling( } catch ( Throwable $e ) { if ( $started_transaction && $this->connection->inTransaction() ) { $this->connection->rollback(); + $this->clear_schema_state_after_rollback(); } throw $e; } @@ -19342,6 +19353,7 @@ private function execute_replace_values_with_manual_conflict_handling( array $to } catch ( Throwable $e ) { if ( $started_transaction && $this->connection->inTransaction() ) { $this->connection->rollback(); + $this->clear_schema_state_after_rollback(); } throw $e; } @@ -21516,13 +21528,14 @@ private function physical_auto_increment_metadata_for_table( string $table_name, private function auto_increment_sequences_for_table( string $table_name, bool $temporary = false ): array { $this->ensure_column_metadata_table( $temporary ); - $stmt = $this->execute_duckdb_query( + $stmt = $this->execute_column_metadata_query( 'SELECT column_name FROM ' . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) . " AND extra = 'auto_increment' ORDER BY ordinal_position", - 'Failed to inspect DuckDB AUTO_INCREMENT metadata' + 'Failed to inspect DuckDB AUTO_INCREMENT metadata', + $temporary ); $sequence_names = array(); @@ -21822,6 +21835,7 @@ private function execute_duckdb_query_recovering_aborted_transaction( string $sq $this->last_duckdb_queries[] = 'ROLLBACK'; $this->connection->rollbackNativeTransaction(); + $this->clear_schema_state_after_rollback(); return $this->execute_duckdb_query( $sql, $context ); } } @@ -21968,6 +21982,33 @@ private function metadata_table_cache_key( string $table_name, bool $temporary ) return $this->metadata_ensure_key( strtolower( $table_name ), $temporary ); } + /** + * Clear cached schema metadata and metadata-table ensure state after rollback. + */ + private function clear_schema_state_after_rollback(): void { + $this->clear_schema_metadata_cache(); + $this->clear_metadata_table_ensure_cache(); + } + + /** + * Clear cached internal metadata table ensure state. + * + * @param bool|null $temporary Optional metadata table set to clear. + */ + private function clear_metadata_table_ensure_cache( ?bool $temporary = null ): void { + if ( null === $temporary ) { + $this->ensured_metadata_tables = array(); + return; + } + + $prefix = $temporary ? 'temporary:' : 'persistent:'; + foreach ( array_keys( $this->ensured_metadata_tables ) as $ensure_key ) { + if ( 0 === strpos( $ensure_key, $prefix ) ) { + unset( $this->ensured_metadata_tables[ $ensure_key ] ); + } + } + } + /** * Clear cached schema metadata after DDL or metadata table writes. * @@ -22232,12 +22273,13 @@ function ( array $column ): string { private function record_column_metadata( string $table_name, array $metadata, bool $temporary = false ): void { $this->ensure_column_metadata_table( $temporary ); - $this->execute_duckdb_query( + $this->execute_column_metadata_query( 'DELETE FROM ' . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ), - 'Failed to reset DuckDB column metadata' + 'Failed to reset DuckDB column metadata', + $temporary ); $value_rows = array(); @@ -22277,6 +22319,48 @@ private function record_column_metadata( string $table_name, array $metadata, bo $this->clear_schema_metadata_cache( $table_name, $temporary ); } + /** + * Execute a column metadata query, repairing stale ensure cache if DuckDB lost the internal metadata table. + * + * @param string $sql DuckDB SQL. + * @param string $context Error context. + * @param bool $temporary Whether the metadata table is temporary. + * @return WP_DuckDB_Result_Statement + */ + private function execute_column_metadata_query( string $sql, string $context, bool $temporary ): WP_DuckDB_Result_Statement { + try { + return $this->execute_duckdb_query( $sql, $context ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + if ( ! $this->is_missing_internal_metadata_table_exception( $e, $this->column_metadata_table_name( $temporary ) ) ) { + throw $e; + } + + $this->clear_metadata_table_ensure_cache( $temporary ); + $this->column_metadata_cache = array(); + $this->table_column_metadata_cache = array(); + $this->ensure_column_metadata_table( $temporary ); + + return $this->execute_duckdb_query( $sql, $context ); + } + } + + /** + * Check whether a DuckDB exception reports a missing internal metadata table. + * + * @param WP_DuckDB_Driver_Exception $exception Driver exception. + * @param string $metadata_table Internal metadata table name. + * @return bool Whether the exception is a missing metadata table error. + */ + private function is_missing_internal_metadata_table_exception( WP_DuckDB_Driver_Exception $exception, string $metadata_table ): bool { + $message = $exception->getMessage(); + if ( false === strpos( $message, $metadata_table ) ) { + return false; + } + + return false !== stripos( $message, 'does not exist' ) + || false !== stripos( $message, 'not found' ); + } + /** * Record MySQL table metadata for information_schema.tables. * @@ -22462,19 +22546,20 @@ private function foreign_key_metadata_rows( string $table_name, bool $temporary private function append_column_metadata( string $table_name, array $column, bool $temporary = false ): void { $this->ensure_column_metadata_table( $temporary ); - $stmt = $this->execute_duckdb_query( + $stmt = $this->execute_column_metadata_query( 'SELECT COUNT(*) AS column_count, COALESCE(MAX(ordinal_position), 0) AS max_ordinal FROM ' . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ), - 'Failed to inspect DuckDB column metadata' + 'Failed to inspect DuckDB column metadata', + $temporary ); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if ( ! is_array( $row ) || 0 === (int) $row['column_count'] ) { return; } - $this->execute_duckdb_query( + $this->execute_column_metadata_query( 'INSERT INTO ' . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' (table_name, ordinal_position, column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment) VALUES (' @@ -22498,7 +22583,8 @@ private function append_column_metadata( string $table_name, array $column, bool . ', ' . $this->connection->quote( $column['comment'] ) . ')', - 'Failed to store DuckDB column metadata' + 'Failed to store DuckDB column metadata', + $temporary ); $this->clear_schema_metadata_cache( $table_name, $temporary ); @@ -22691,13 +22777,17 @@ private function delete_table_lifecycle_metadata( string $table_name, bool $temp ); foreach ( $metadata_tables as $metadata_table => $label ) { - $this->execute_duckdb_query( - 'DELETE FROM ' - . $this->connection->quote_identifier( $metadata_table ) - . ' WHERE table_name = ' - . $this->connection->quote( $table_name ), - 'Failed to delete DuckDB ' . $label . ' metadata' - ); + $sql = 'DELETE FROM ' + . $this->connection->quote_identifier( $metadata_table ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ); + $context = 'Failed to delete DuckDB ' . $label . ' metadata'; + + if ( $this->column_metadata_table_name( $temporary ) === $metadata_table ) { + $this->execute_column_metadata_query( $sql, $context, $temporary ); + } else { + $this->execute_duckdb_query( $sql, $context ); + } } $this->clear_schema_metadata_cache( $table_name, $temporary ); @@ -22734,7 +22824,7 @@ function ( array $row ): string { ); foreach ( $metadata as $column ) { - $this->execute_duckdb_query( + $this->execute_column_metadata_query( 'UPDATE ' . $metadata_table . ' SET column_key = ' @@ -22743,7 +22833,8 @@ function ( array $row ): string { . $this->connection->quote( $table_name ) . ' AND column_name = ' . $this->connection->quote( $column['column_name'] ), - 'Failed to refresh DuckDB column metadata' + 'Failed to refresh DuckDB column metadata', + $temporary ); } @@ -22947,13 +23038,14 @@ private function column_metadata_rows( string $table_name, bool $temporary = fal $this->ensure_column_metadata_table( $temporary ); - $stmt = $this->execute_duckdb_query( + $stmt = $this->execute_column_metadata_query( 'SELECT ordinal_position, column_name, column_type, is_nullable, column_key, column_default, extra, collation_name, comment FROM ' . $this->connection->quote_identifier( $this->column_metadata_table_name( $temporary ) ) . ' WHERE table_name = ' . $this->connection->quote( $table_name ) . ' ORDER BY ordinal_position', - 'Failed to inspect DuckDB column metadata' + 'Failed to inspect DuckDB column metadata', + $temporary ); $this->column_metadata_cache[ $cache_key ] = $stmt->fetchAll( PDO::FETCH_ASSOC ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 50d0b4fd4..edbae326d 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -11638,6 +11638,92 @@ public function test_temporary_table_lifecycle_uses_session_metadata(): void { $this->assertSame( array(), $driver->query( 'SHOW CREATE TABLE temp_items' )->fetchAll( PDO::FETCH_ASSOC ) ); } + public function test_temporary_column_metadata_recovers_after_internal_table_disappears(): void { + $this->requireDuckDBRuntime(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $driver = new WP_DuckDB_Driver( + array( + 'connection' => $connection, + 'database' => 'wp', + ) + ); + $driver->query( 'CREATE TEMPORARY TABLE temp_metadata_seed (id INT, name VARCHAR(20))' ); + + $connection->query( 'DROP TABLE "__wp_duckdb_temp_column_metadata"' ); + + $driver->query( + 'CREATE TEMPORARY TABLE temp_metadata_recovered ( + id INT, + label VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + + $columns = array_column( + $driver->query( 'SHOW FULL COLUMNS FROM temp_metadata_recovered' )->fetchAll( PDO::FETCH_ASSOC ), + null, + 'Field' + ); + + $this->assertSame( 'int', $columns['id']['Type'] ); + $this->assertSame( 'varchar(20)', $columns['label']['Type'] ); + $this->assertSame( 'utf8mb4_unicode_ci', $columns['label']['Collation'] ); + } + + public function test_temporary_metadata_tables_are_reensured_after_rollback(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( 'BEGIN' ); + $driver->query( + 'CREATE TEMPORARY TABLE rollback_temp_metadata ( + c VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + $driver->query( 'ROLLBACK' ); + + $driver->query( + 'CREATE TEMPORARY TABLE temp_metadata_after_rollback ( + c VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + + $create_queries = $driver->get_last_duckdb_queries(); + $this->assertNotEmpty( + array_filter( + $create_queries, + function ( string $sql ): bool { + return false !== strpos( $sql, 'CREATE TEMP TABLE IF NOT EXISTS "__wp_duckdb_temp_column_metadata"' ); + } + ) + ); + $this->assertNotEmpty( + array_filter( + $create_queries, + function ( string $sql ): bool { + return false !== strpos( $sql, 'CREATE TEMP TABLE IF NOT EXISTS "__wp_duckdb_temp_table_metadata"' ); + } + ) + ); + + $columns = $driver->query( 'SHOW FULL COLUMNS FROM temp_metadata_after_rollback' )->fetchAll( PDO::FETCH_ASSOC ); + $this->assertSame( 'c', $columns[0]['Field'] ); + $this->assertSame( 'utf8mb4_unicode_ci', $columns[0]['Collation'] ); + + try { + $driver->query( 'SHOW FULL COLUMNS FROM missing_temp_table' ); + $this->fail( 'Expected missing user table to remain an error.' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->assertStringContainsString( 'DuckDB table does not exist: missing_temp_table.', $e->getMessage() ); + } + } + public function test_repeated_metadata_reads_do_not_reensure_metadata_tables(): void { $this->requireDuckDBRuntime(); From a3258a4334441b114b3a1dc9d005632b7892c0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 22:33:20 +0000 Subject: [PATCH 163/317] Emulate DuckDB charset sanitizer selects --- .../src/duckdb/class-wp-duckdb-driver.php | 474 ++++++++++++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 107 ++++ 2 files changed, 581 insertions(+) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index fbb71f317..1efe2b17b 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -906,6 +906,11 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { return $this->record_found_rows_from_result( $variable_expression_select ); } + $charset_convert_select = $this->execute_charset_convert_select( $tokens ); + if ( null !== $charset_convert_select ) { + return $this->record_found_rows_from_result( $charset_convert_select ); + } + $has_sql_calc_found_rows = $this->has_top_level_sql_calc_found_rows( $tokens ); $tokens = $this->normalize_select_helper_tokens( $tokens ); $column_meta = $this->simple_select_column_metadata( $tokens ); @@ -3692,6 +3697,475 @@ private function execute_variable_expression_select( array $tokens ): ?WP_DuckDB ); } + /** + * Execute WordPress charset sanitizer SELECTs without constructing invalid DuckDB VARCHAR values. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement|null Statement when emulated, null for generic SELECT handling. + */ + private function execute_charset_convert_select( array $tokens ): ?WP_DuckDB_Result_Statement { + $items = $this->parse_charset_convert_select_items( $tokens ); + if ( null === $items ) { + return null; + } + + $columns = array(); + $row = array(); + foreach ( $items as $item ) { + $columns[] = $item['alias']; + $row[] = $item['value']; + } + + return new WP_DuckDB_Result_Statement( $columns, array( $row ), 0 ); + } + + /** + * Parse a SELECT list made only of WordPress charset sanitizer expressions. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return array|null Parsed items. + */ + private function parse_charset_convert_select_items( array $tokens ): ?array { + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $select_list_end = $this->top_level_select_list_end( $tokens ); + if ( 1 >= $select_list_end || count( $tokens ) !== $select_list_end ) { + return null; + } + + $items = array(); + foreach ( $this->split_top_level_select_item_ranges( $tokens, 1, $select_list_end ) as $range ) { + $item = $this->parse_charset_convert_select_item( $range['tokens'] ); + if ( null === $item ) { + return null; + } + $items[] = $item; + } + + return count( $items ) > 0 ? $items : null; + } + + /** + * Parse one charset sanitizer SELECT item. + * + * @param WP_Parser_Token[] $tokens SELECT item tokens. + * @return array{alias:string,value:string}|null Parsed item. + */ + private function parse_charset_convert_select_item( array $tokens ): ?array { + $as_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::AS_SYMBOL ); + if ( null !== $as_index ) { + if ( 0 === $as_index || count( $tokens ) !== $as_index + 2 ) { + return null; + } + + $alias = $this->identifier_value( $tokens[ $as_index + 1 ] ); + $expr_tokens = array_slice( $tokens, 0, $as_index ); + } else { + if ( $this->select_item_has_implicit_alias( $tokens ) ) { + return null; + } + $alias = $this->concatenate_token_bytes( $tokens ); + $expr_tokens = $tokens; + } + + $value = $this->evaluate_charset_convert_expression( $expr_tokens ); + if ( null === $value ) { + return null; + } + + return array( + 'alias' => $alias, + 'value' => $value, + ); + } + + /** + * Evaluate a supported nested CONVERT(... USING charset) expression. + * + * @param WP_Parser_Token[] $tokens Expression tokens. + * @return string|null Evaluated value, or null when unsupported. + */ + private function evaluate_charset_convert_expression( array $tokens ): ?string { + $outer = $this->parse_convert_using_expression( $tokens ); + if ( null === $outer || ! $this->is_supported_charset_convert_name( $outer['charset'] ) ) { + return null; + } + + $length = null; + $expr_tokens = $outer['expr_tokens']; + $left = $this->parse_left_function_expression( $expr_tokens ); + if ( null !== $left ) { + $length = $left['length']; + $expr_tokens = $left['expr_tokens']; + } + + $inner = $this->parse_convert_using_expression( $expr_tokens ); + if ( null === $inner || ! $this->is_supported_charset_convert_name( $inner['charset'] ) ) { + return null; + } + + $value = $this->parse_single_quoted_literal_expression( $inner['expr_tokens'] ); + if ( null === $value ) { + return null; + } + + if ( null !== $length ) { + $byte_length = 'binary' === $inner['charset']; + $charset = $byte_length ? $outer['charset'] : $inner['charset']; + $value = $this->truncate_mysql_charset_bytes( $value, $charset, $length, $byte_length ); + } + + return $value; + } + + /** + * Parse CONVERT(expr USING charset). + * + * @param WP_Parser_Token[] $tokens Expression tokens. + * @return array{expr_tokens:array,charset:string}|null Parsed expression. + */ + private function parse_convert_using_expression( array $tokens ): ?array { + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $end_index = $this->skip_balanced_parentheses( $tokens, 1 ); + if ( count( $tokens ) !== $end_index ) { + return null; + } + + $body = array_slice( $tokens, 2, $end_index - 3 ); + $using_index = $this->find_top_level_token_index( $body, 0, WP_MySQL_Lexer::USING_SYMBOL ); + if ( null === $using_index || 0 === $using_index ) { + return null; + } + + $charset = $this->parse_charset_name( array_slice( $body, $using_index + 1 ) ); + if ( null === $charset ) { + return null; + } + + return array( + 'expr_tokens' => array_slice( $body, 0, $using_index ), + 'charset' => $charset, + ); + } + + /** + * Parse LEFT(expr, integer). + * + * @param WP_Parser_Token[] $tokens Expression tokens. + * @return array{expr_tokens:array,length:int}|null Parsed expression. + */ + private function parse_left_function_expression( array $tokens ): ?array { + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::LEFT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $end_index = $this->skip_balanced_parentheses( $tokens, 1 ); + if ( count( $tokens ) !== $end_index ) { + return null; + } + + $items = $this->split_top_level_comma_items( array_slice( $tokens, 2, $end_index - 3 ) ); + if ( 2 !== count( $items ) ) { + return null; + } + + $length = $this->parse_non_negative_integer_literal( $items[1] ); + if ( null === $length ) { + return null; + } + + return array( + 'expr_tokens' => $items[0], + 'length' => $length, + ); + } + + /** + * Parse a one-token charset name. + * + * @param WP_Parser_Token[] $tokens Charset tokens. + * @return string|null Charset name. + */ + private function parse_charset_name( array $tokens ): ?string { + if ( 1 !== count( $tokens ) ) { + return null; + } + + $name = strtolower( $this->token_value( $tokens[0] ) ); + return '' === $name ? null : $name; + } + + /** + * Parse a one-token quoted string literal. + * + * @param WP_Parser_Token[] $tokens Expression tokens. + * @return string|null Literal value. + */ + private function parse_single_quoted_literal_expression( array $tokens ): ?string { + if ( + 1 !== count( $tokens ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[0]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[0]->id + ) + ) { + return null; + } + + return $this->token_value( $tokens[0] ); + } + + /** + * Parse a non-negative integer literal. + * + * @param WP_Parser_Token[] $tokens Expression tokens. + * @return int|null Integer value. + */ + private function parse_non_negative_integer_literal( array $tokens ): ?int { + if ( 1 !== count( $tokens ) || ! $this->is_number_token( $tokens[0] ) ) { + return null; + } + + $value = $this->token_value( $tokens[0] ); + if ( ! preg_match( '/^\d+$/', $value ) ) { + return null; + } + + return (int) $value; + } + + /** + * Check whether a charset is handled by the literal sanitizer emulator. + * + * @param string $charset Charset name. + * @return bool Whether the charset is supported. + */ + private function is_supported_charset_convert_name( string $charset ): bool { + return in_array( + $charset, + array( + 'ascii', + 'binary', + 'big5', + 'cp1251', + 'hebrew', + 'koi8r', + 'latin1', + 'tis620', + 'ujis', + 'utf8', + 'utf8mb3', + 'utf8mb4', + ), + true + ); + } + + /** + * Truncate a byte string with MySQL-like character-boundary behavior for supported charsets. + * + * @param string $value Value bytes. + * @param string $charset Charset name. + * @param int $length Length. + * @param bool $byte_length Whether length is measured in bytes. + * @return string Truncated value. + */ + private function truncate_mysql_charset_bytes( string $value, string $charset, int $length, bool $byte_length ): string { + if ( $length <= 0 ) { + return ''; + } + + if ( $byte_length ) { + if ( 'big5' === $charset ) { + return $this->big5_prefix_by_bytes( $value, $length ); + } + if ( $this->is_utf8_charset_name( $charset ) ) { + return $this->utf8_prefix_by_bytes( $value, $length ); + } + return substr( $value, 0, $length ); + } + + if ( 'big5' === $charset ) { + return $this->big5_prefix_by_chars( $value, $length ); + } + if ( $this->is_utf8_charset_name( $charset ) || ( 'ujis' === $charset && $this->is_valid_utf8( $value ) ) ) { + return $this->utf8_prefix_by_chars( $value, $length ); + } + + return substr( $value, 0, $length ); + } + + /** + * Check whether a charset name is UTF-8 compatible. + * + * @param string $charset Charset name. + * @return bool Whether charset is UTF-8. + */ + private function is_utf8_charset_name( string $charset ): bool { + return 'utf8' === $charset || 'utf8mb3' === $charset || 'utf8mb4' === $charset; + } + + /** + * Check whether bytes are valid UTF-8. + * + * @param string $value Value bytes. + * @return bool Whether bytes are valid UTF-8. + */ + private function is_valid_utf8( string $value ): bool { + return 1 === preg_match( '//u', $value ); + } + + /** + * Return a UTF-8 prefix measured in characters. + * + * @param string $value Value bytes. + * @param int $length Character length. + * @return string Prefix. + */ + private function utf8_prefix_by_chars( string $value, int $length ): string { + if ( function_exists( 'mb_substr' ) ) { + return mb_substr( $value, 0, $length, 'UTF-8' ); + } + + if ( 1 !== preg_match_all( '/./us', $value, $matches ) ) { + return substr( $value, 0, $length ); + } + + return implode( '', array_slice( $matches[0], 0, $length ) ); + } + + /** + * Return a valid UTF-8 prefix measured in bytes. + * + * @param string $value Value bytes. + * @param int $length Byte length. + * @return string Prefix. + */ + private function utf8_prefix_by_bytes( string $value, int $length ): string { + $prefix = substr( $value, 0, $length ); + while ( '' !== $prefix && ! $this->is_valid_utf8( $prefix ) ) { + $prefix = substr( $prefix, 0, -1 ); + } + return $prefix; + } + + /** + * Return a Big5 prefix measured in characters. + * + * @param string $value Value bytes. + * @param int $length Character length. + * @return string Prefix. + */ + private function big5_prefix_by_chars( string $value, int $length ): string { + $offset = 0; + $chars = 0; + $size = strlen( $value ); + + while ( $offset < $size && $chars < $length ) { + $byte = ord( $value[ $offset ] ); + if ( $byte <= 0x7f ) { + ++$offset; + ++$chars; + continue; + } + + if ( + $this->is_big5_lead_byte( $byte ) + && $offset + 1 < $size + && $this->is_big5_trail_byte( ord( $value[ $offset + 1 ] ) ) + ) { + $offset += 2; + ++$chars; + continue; + } + + break; + } + + return substr( $value, 0, $offset ); + } + + /** + * Return a Big5 prefix measured in bytes without a partial trailing character. + * + * @param string $value Value bytes. + * @param int $length Byte length. + * @return string Prefix. + */ + private function big5_prefix_by_bytes( string $value, int $length ): string { + $prefix = substr( $value, 0, $length ); + return substr( $prefix, 0, $this->big5_valid_prefix_length( $prefix ) ); + } + + /** + * Return the valid Big5 prefix length in bytes. + * + * @param string $value Value bytes. + * @return int Valid prefix byte length. + */ + private function big5_valid_prefix_length( string $value ): int { + $offset = 0; + $valid = 0; + $size = strlen( $value ); + + while ( $offset < $size ) { + $byte = ord( $value[ $offset ] ); + if ( $byte <= 0x7f ) { + ++$offset; + $valid = $offset; + continue; + } + + if ( + $this->is_big5_lead_byte( $byte ) + && $offset + 1 < $size + && $this->is_big5_trail_byte( ord( $value[ $offset + 1 ] ) ) + ) { + $offset += 2; + + $valid = $offset; + continue; + } + + break; + } + + return $valid; + } + + /** + * Check whether a byte can start a Big5 double-byte sequence. + * + * @param int $byte Byte value. + * @return bool Whether byte is a Big5 lead byte. + */ + private function is_big5_lead_byte( int $byte ): bool { + return $byte >= 0x81 && $byte <= 0xfe; + } + + /** + * Check whether a byte can end a Big5 double-byte sequence. + * + * @param int $byte Byte value. + * @return bool Whether byte is a Big5 trail byte. + */ + private function is_big5_trail_byte( int $byte ): bool { + return ( $byte >= 0x40 && $byte <= 0x7e ) || ( $byte >= 0xa1 && $byte <= 0xfe ); + } + /** * Parse a simple variable-expression SELECT list. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index edbae326d..c81dd6acd 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -62,6 +62,79 @@ public function __construct() {} } } + public function test_charset_convert_sanitizer_select_returns_invalid_bytes_without_duckdb_runtime(): void { + $driver = $this->new_byte_safe_duckdb_driver(); + $big5 = "a\xa6\x40b"; + $ujis = "\xe8\x87\xaa\xe5\x8b\x95\xe4\xb8\x8b\xe6\x9b\xb8\xe3\x81\x8d"; + + $cases = array( + 'cp1251_no_length' => array( + "SELECT CONVERT( CONVERT( '\xd8ord\xd0ress' USING cp1251 ) USING cp1251 ) AS x_0", + "\xd8ord\xd0ress", + ), + 'koi8r_char_length' => array( + "SELECT CONVERT( LEFT( CONVERT( '" . str_repeat( "\xfd\xf2", 10 ) . "' USING koi8r ), 10 ) USING koi8r ) AS x_0", + str_repeat( "\xfd\xf2", 5 ), + ), + 'hebrew_byte_length' => array( + "SELECT CONVERT( LEFT( CONVERT( '" . str_repeat( "\xf9\xf7", 10 ) . "' USING binary ), 10 ) USING hebrew ) AS x_0", + str_repeat( "\xf9\xf7", 5 ), + ), + 'tis620_char_length' => array( + "SELECT CONVERT( LEFT( CONVERT( '" . str_repeat( "\xcc\xe3", 10 ) . "' USING tis620 ), 10 ) USING tis620 ) AS x_0", + str_repeat( "\xcc\xe3", 5 ), + ), + 'big5_char_length' => array( + "SELECT CONVERT( LEFT( CONVERT( '" . str_repeat( $big5, 10 ) . "' USING big5 ), 10 ) USING big5 ) AS x_0", + str_repeat( $big5, 3 ) . 'a', + ), + 'big5_byte_length' => array( + "SELECT CONVERT( LEFT( CONVERT( '" . str_repeat( $big5, 10 ) . "' USING binary ), 10 ) USING big5 ) AS x_0", + str_repeat( $big5, 2 ) . 'a', + ), + 'ujis_utf8_length' => array( + "SELECT CONVERT( LEFT( CONVERT( '{$ujis}' USING ujis ), 4 ) USING utf8 ) AS x_0", + "\xe8\x87\xaa\xe5\x8b\x95\xe4\xb8\x8b\xe6\x9b\xb8", + ), + ); + + foreach ( $cases as $label => $case ) { + $row = $driver->query( $case[0] )->fetch( PDO::FETCH_ASSOC ); + $this->assertSame( bin2hex( $case[1] ), bin2hex( $row['x_0'] ), $label ); + } + } + + public function test_charset_convert_sanitizer_select_handles_multiple_columns(): void { + $driver = $this->new_byte_safe_duckdb_driver(); + $sql = 'SELECT ' + . "CONVERT( CONVERT( '\xd8ord\xd0ress' USING cp1251 ) USING cp1251 ) AS x_0, " + . "CONVERT( LEFT( CONVERT( '" . str_repeat( "\xf9\xf7", 10 ) . "' USING binary ), 10 ) USING hebrew ) AS x_1"; + + $row = $driver->query( $sql )->fetch( PDO::FETCH_ASSOC ); + + $this->assertSame( bin2hex( "\xd8ord\xd0ress" ), bin2hex( $row['x_0'] ) ); + $this->assertSame( bin2hex( str_repeat( "\xf9\xf7", 5 ) ), bin2hex( $row['x_1'] ) ); + } + + public function test_charset_convert_sanitizer_select_does_not_intercept_generic_convert_using(): void { + $driver = $this->new_byte_safe_duckdb_driver(); + $invalid_byte = "\xA1"; + $queries = array( + 'simple_convert' => "SELECT CONVERT( 'Customer' USING utf8mb4 ) AS v", + 'mixed_select' => "SELECT CONVERT( CONVERT( '{$invalid_byte}' USING cp1251 ) USING cp1251 ) AS x_0, 1 AS one", + 'from_expression' => "SELECT CONVERT( CONVERT( '{$invalid_byte}' USING cp1251 ) USING cp1251 ) AS x_0 FROM DUAL", + ); + + foreach ( $queries as $label => $sql ) { + try { + $driver->query( $sql ); + $this->fail( $label . ' should have used the generic DuckDB query path.' ); + } catch ( RuntimeException $e ) { + $this->assertStringContainsString( 'Unexpected DuckDB query:', $e->getMessage(), $label ); + } + } + } + public function test_invalid_byte_write_display_sql_uses_byte_safe_token_value(): void { $connection = new class() extends WP_DuckDB_Connection { public function __construct() {} @@ -110,6 +183,40 @@ function ( WP_Parser_Token $token ): bool { $this->assertSame( bin2hex( "'{$invalid_byte}'" ), bin2hex( $display->invoke( $driver, $value_tokens, "'{$invalid_byte}'" ) ) ); } + private function new_byte_safe_duckdb_driver(): WP_DuckDB_Driver { + $connection = new class() extends WP_DuckDB_Connection { + public function __construct() {} + + public function query( string $sql, array $params = array() ): WP_DuckDB_Result_Statement { + throw new RuntimeException( 'Unexpected DuckDB query: ' . $sql ); + } + }; + $driver = ( new ReflectionClass( WP_DuckDB_Driver::class ) )->newInstanceWithoutConstructor(); + + foreach ( + array( + 'mysql_version' => WP_DuckDB_Driver::DEFAULT_MYSQL_VERSION, + 'connection' => $connection, + 'database' => 'wp', + 'current_database' => 'wp', + ) as $property => $value + ) { + $reflection_property = new ReflectionProperty( WP_DuckDB_Driver::class, $property ); + if ( PHP_VERSION_ID < 80100 ) { + $reflection_property->setAccessible( true ); + } + $reflection_property->setValue( $driver, $value ); + } + + $grammar = new ReflectionProperty( WP_DuckDB_Driver::class, 'mysql_grammar' ); + if ( PHP_VERSION_ID < 80100 ) { + $grammar->setAccessible( true ); + } + $grammar->setValue( null, new WP_Parser_Grammar( require WP_DuckDB_Driver::MYSQL_GRAMMAR_PATH ) ); + + return $driver; + } + public function test_auto_increment_insert_id_falls_back_to_max_when_currval_is_unavailable(): void { $connection = new class() extends WP_DuckDB_Connection { public $queries = array(); From 029b5d770130f8aa64bdfa399f1ab5bc4820e190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 22:44:54 +0000 Subject: [PATCH 164/317] Coerce DuckDB metadata value numeric comparisons --- .../src/duckdb/class-wp-duckdb-driver.php | 57 +++++--- .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 132 ++++++++++++++++++ 2 files changed, 171 insertions(+), 18 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 1efe2b17b..21314fd5a 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1012,7 +1012,7 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $rewrite_information_schema_referential_constraints, $rewrite_information_schema_check_constraints, $seeded_rand_rewrites, - false, + true, $group_by_expansion, $order_by_item_rewrites ); @@ -3603,7 +3603,7 @@ private function count_select_rows( $rewrite_information_schema_referential_constraints, $rewrite_information_schema_check_constraints, $seeded_rand_rewrites, - false, + true, $group_by_expansion, $order_by_item_rewrites ); @@ -15827,7 +15827,7 @@ private function translate_tokens_to_duckdb_sql( bool $rewrite_information_schema_referential_constraints = false, bool $rewrite_information_schema_check_constraints = false, array $seeded_rand_rewrites = array(), - bool $rewrite_option_value_numeric_literal_comparisons = false, + bool $rewrite_text_value_numeric_literal_comparisons = false, ?array $group_by_expansion = null, array $order_by_item_rewrites = array() ): string { @@ -16170,10 +16170,10 @@ private function translate_tokens_to_duckdb_sql( continue; } - if ( $rewrite_option_value_numeric_literal_comparisons ) { - $option_value_comparison = $this->translate_option_value_numeric_literal_comparison( $tokens, $index ); - if ( null !== $option_value_comparison ) { - $pieces[] = $option_value_comparison; + if ( $rewrite_text_value_numeric_literal_comparisons ) { + $text_value_comparison = $this->translate_text_value_numeric_literal_comparison( $tokens, $index ); + if ( null !== $text_value_comparison ) { + $pieces[] = $text_value_comparison; continue; } } @@ -21287,14 +21287,14 @@ private function translate_unix_timestamp_comparison( array $tokens, int &$index } /** - * Translate WordPress transient timeout string comparisons in multi-table DELETE collection. + * Translate WordPress text-value string comparisons against integer literals. * * @param WP_Parser_Token[] $tokens Token stream. * @param int $index Current index, advanced on match. * @return string|null Translated comparison, or null when the pattern does not match. */ - private function translate_option_value_numeric_literal_comparison( array $tokens, int &$index ): ?string { - $operand = $this->option_value_numeric_comparison_operand_sql( $tokens, $index ); + private function translate_text_value_numeric_literal_comparison( array $tokens, int &$index ): ?string { + $operand = $this->text_value_numeric_comparison_operand_sql( $tokens, $index ); if ( null === $operand ) { return null; } @@ -21311,7 +21311,7 @@ private function translate_option_value_numeric_literal_comparison( array $token if ( isset( $tokens[ $literal_index + 1 ] ) - && ! $this->is_option_value_numeric_comparison_boundary_token( $tokens[ $literal_index + 1 ] ) + && ! $this->is_text_value_numeric_comparison_boundary_token( $tokens[ $literal_index + 1 ] ) ) { return null; } @@ -21326,13 +21326,13 @@ private function translate_option_value_numeric_literal_comparison( array $token } /** - * Build an option_value operand SQL fragment for a bounded numeric comparison. + * Build a WordPress text-value operand SQL fragment for a bounded numeric comparison. * * @param WP_Parser_Token[] $tokens Token stream. * @param int $index Current index. * @return array{sql:string,next_index:int}|null Operand SQL and next token index, or null when not matched. */ - private function option_value_numeric_comparison_operand_sql( array $tokens, int $index ): ?array { + private function text_value_numeric_comparison_operand_sql( array $tokens, int $index ): ?array { if ( ! isset( $tokens[ $index ] ) || $this->is_non_identifier_token( $tokens[ $index ] ) ) { return null; } @@ -21343,7 +21343,7 @@ private function option_value_numeric_comparison_operand_sql( array $tokens, int && ! $this->is_non_identifier_token( $tokens[ $index + 2 ] ) ) { $column_name = $this->identifier_value( $tokens[ $index + 2 ] ); - if ( 0 !== strcasecmp( $column_name, 'option_value' ) ) { + if ( ! $this->is_text_value_numeric_comparison_column( $column_name ) ) { return null; } @@ -21356,7 +21356,7 @@ private function option_value_numeric_comparison_operand_sql( array $tokens, int } $column_name = $this->identifier_value( $tokens[ $index ] ); - if ( 0 !== strcasecmp( $column_name, 'option_value' ) ) { + if ( ! $this->is_text_value_numeric_comparison_column( $column_name ) ) { return null; } @@ -21366,6 +21366,23 @@ private function option_value_numeric_comparison_operand_sql( array $tokens, int ); } + /** + * Check whether a column should receive text-to-number comparison parity. + * + * @param string $column_name Column name. + * @return bool Whether the column is a supported WordPress text value column. + */ + private function is_text_value_numeric_comparison_column( string $column_name ): bool { + return in_array( + strtolower( $column_name ), + array( + 'option_value', + 'meta_value', + ), + true + ); + } + /** * Check whether a token is a comparison operator that coerces strings numerically in MySQL. * @@ -21388,19 +21405,23 @@ private function is_numeric_comparison_operator_token( $token ): bool { } /** - * Check whether a token can end an option_value numeric literal predicate. + * Check whether a token can end a WordPress text-value numeric literal predicate. * * @param WP_Parser_Token $token Token. * @return bool Whether the token is a predicate boundary. */ - private function is_option_value_numeric_comparison_boundary_token( WP_Parser_Token $token ): bool { + private function is_text_value_numeric_comparison_boundary_token( WP_Parser_Token $token ): bool { return in_array( $token->id, array( WP_MySQL_Lexer::AND_SYMBOL, - WP_MySQL_Lexer::OR_SYMBOL, WP_MySQL_Lexer::XOR_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, ), true ); diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index c81dd6acd..4483af9ed 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1739,6 +1739,84 @@ public function test_delete_seeded_rand_where_literal_is_emulated(): void { ); } + public function test_meta_value_numeric_literal_predicates_coerce_strings(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( 'CREATE TABLE wptests_postmeta (post_id BIGINT(20), meta_key VARCHAR(255), meta_value LONGTEXT)' ); + $driver->query( 'CREATE TABLE wptests_commentmeta (comment_id BIGINT(20), meta_key VARCHAR(255), meta_value LONGTEXT)' ); + $driver->query( + "INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES + (101, '_wp_trash_meta_time', '1780093651'), + (102, '_wp_trash_meta_time', '1780093652'), + (103, '_wp_trash_meta_time', '1780093653'), + (104, '_wp_trash_meta_status', 'publish')" + ); + $driver->query( + "INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES + (201, '_wp_trash_meta_time', '1780093651'), + (202, '_wp_trash_meta_time', '1780093652'), + (203, '_wp_trash_meta_time', '1780093653'), + (204, '_wp_trash_meta_status', 'approve')" + ); + + $cases = array( + array( + 'sql' => "SELECT post_id FROM wptests_postmeta + WHERE meta_key = '_wp_trash_meta_time' AND meta_value < 1780093652 + ORDER BY post_id", + 'expected' => array( array( 'post_id' => 101 ) ), + 'cast' => 'TRY_CAST("meta_value" AS BIGINT) < 1780093652', + ), + array( + 'sql' => "SELECT wptests_postmeta.post_id FROM wptests_postmeta + WHERE wptests_postmeta.meta_key = '_wp_trash_meta_time' + AND wptests_postmeta.meta_value < 1780093652 + ORDER BY wptests_postmeta.post_id", + 'expected' => array( array( 'post_id' => 101 ) ), + 'cast' => 'TRY_CAST("wptests_postmeta"."meta_value" AS BIGINT) < 1780093652', + ), + array( + 'sql' => "SELECT pm.post_id FROM wptests_postmeta pm + WHERE pm.meta_key = '_wp_trash_meta_time' AND pm.meta_value < 1780093652 + ORDER BY pm.post_id", + 'expected' => array( array( 'post_id' => 101 ) ), + 'cast' => 'TRY_CAST("pm"."meta_value" AS BIGINT) < 1780093652', + ), + array( + 'sql' => "SELECT comment_id FROM wptests_commentmeta + WHERE meta_key = '_wp_trash_meta_time' AND meta_value < 1780093652 + ORDER BY comment_id", + 'expected' => array( array( 'comment_id' => 201 ) ), + 'cast' => 'TRY_CAST("meta_value" AS BIGINT) < 1780093652', + ), + array( + 'sql' => "SELECT wptests_commentmeta.comment_id FROM wptests_commentmeta + WHERE wptests_commentmeta.meta_key = '_wp_trash_meta_time' + AND wptests_commentmeta.meta_value < 1780093652 + ORDER BY wptests_commentmeta.comment_id", + 'expected' => array( array( 'comment_id' => 201 ) ), + 'cast' => 'TRY_CAST("wptests_commentmeta"."meta_value" AS BIGINT) < 1780093652', + ), + array( + 'sql' => "SELECT cm.comment_id FROM wptests_commentmeta cm + WHERE cm.meta_key = '_wp_trash_meta_time' AND cm.meta_value < 1780093652 + ORDER BY cm.comment_id", + 'expected' => array( array( 'comment_id' => 201 ) ), + 'cast' => 'TRY_CAST("cm"."meta_value" AS BIGINT) < 1780093652', + ), + ); + + foreach ( $cases as $case ) { + $this->assertSame( + $case['expected'], + $driver->query( $case['sql'] )->fetchAll( PDO::FETCH_ASSOC ), + $case['sql'] + ); + $this->assertStringContainsString( $case['cast'], $this->lastDuckDBQuery( $driver ), $case['sql'] ); + } + } + public function test_date_format_function_is_emulated(): void { $this->requireDuckDBRuntime(); @@ -4854,6 +4932,60 @@ public function rows( bool $assoc ): array { } } + public function test_text_value_numeric_literal_predicates_are_translated_without_duckdb_runtime(): void { + $duckdb = new class() { + public function query( string $sql ) { + return new class() { + public function columnNames(): ArrayIterator { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return new ArrayIterator( array( 'id' ) ); + } + + public function rows( bool $assoc ): array { + return array(); + } + }; + } + }; + $driver = new WP_DuckDB_Driver( + array( + 'connection' => new WP_DuckDB_Connection( array( 'duckdb' => $duckdb ) ), + ) + ); + + $rewrite_cases = array( + array( + 'mysql' => 'SELECT id FROM postmeta WHERE meta_value < 1780093652 ORDER BY id', + 'duckdb' => 'SELECT id FROM postmeta WHERE TRY_CAST("meta_value" AS BIGINT) < 1780093652 ORDER BY id', + ), + array( + 'mysql' => 'SELECT pm.id FROM postmeta pm WHERE pm.meta_value < 1780093652 ORDER BY pm.id', + 'duckdb' => 'SELECT pm.id FROM postmeta pm WHERE TRY_CAST("pm"."meta_value" AS BIGINT) < 1780093652 ORDER BY pm.id', + ), + array( + 'mysql' => 'SELECT b.id FROM options b WHERE b.option_value < 1782556962 ORDER BY b.id', + 'duckdb' => 'SELECT b.id FROM options b WHERE TRY_CAST("b"."option_value" AS BIGINT) < 1782556962 ORDER BY b.id', + ), + ); + + foreach ( $rewrite_cases as $case ) { + $driver->query( $case['mysql'] ); + + $this->assertSame( $case['duckdb'], $this->lastDuckDBQuery( $driver ) ); + } + + foreach ( + array( + 'SELECT id FROM postmeta WHERE title < 1780093652 ORDER BY id', + "SELECT id FROM postmeta WHERE meta_value < '1780093652' ORDER BY id", + 'SELECT id FROM postmeta WHERE meta_value < 1780093652.5 ORDER BY id', + ) as $sql + ) { + $driver->query( $sql ); + + $this->assertStringNotContainsString( 'TRY_CAST(', $this->lastDuckDBQuery( $driver ), $sql ); + } + } + public function test_escaped_like_literal_predicates_use_mysql_backslash_semantics(): void { $this->requireDuckDBRuntime(); From 4c59cbc953254fb4b8390845e788680357a56ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 22:52:01 +0000 Subject: [PATCH 165/317] Handle DuckDB weekly archive date grouping --- .../src/duckdb/class-wp-duckdb-driver.php | 255 +++++++++++++++++- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 31 +++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 58 ++++ 3 files changed, 338 insertions(+), 6 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 21314fd5a..933465d4b 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -942,9 +942,10 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $sql_tokens = $seeded_rand_ordering['tokens']; } - $seeded_rand_rewrites = $this->seeded_rand_rewrite_map( $seeded_rand_expressions ); - $sql_tokens = $this->strip_irrelevant_order_by_for_count_select( $sql_tokens ); - $grouped_date_order_rewrites = $this->grouped_date_order_by_rewrites( $sql_tokens ); + $seeded_rand_rewrites = $this->seeded_rand_rewrite_map( $seeded_rand_expressions ); + $sql_tokens = $this->strip_irrelevant_order_by_for_count_select( $sql_tokens ); + $grouped_date_order_rewrites = $this->grouped_date_order_by_rewrites( $sql_tokens ); + $grouped_date_select_rewrites = $this->grouped_date_select_item_rewrites( $sql_tokens ); $rewrite_information_schema_tables = $this->uses_information_schema_tables( $sql_tokens ); $rewrite_information_schema_columns = $this->uses_information_schema_columns( $sql_tokens ); @@ -989,7 +990,7 @@ private function execute_select( array $tokens ): WP_DuckDB_Result_Statement { $this->refresh_information_schema_check_constraints_table(); } - $order_by_item_rewrites = $grouped_date_order_rewrites + $this->primary_key_group_by_order_by_rewrites( + $order_by_item_rewrites = $grouped_date_select_rewrites + $grouped_date_order_rewrites + $this->primary_key_group_by_order_by_rewrites( $sql_tokens, $group_by_expansion, $grouped_date_order_rewrites, @@ -2811,6 +2812,117 @@ private function grouped_date_order_by_rewrites( array $tokens ): array { return $rewrites; } + /** + * Build SELECT item rewrites for date-bucket GROUP BY queries. + * + * WordPress weekly archive queries select DATE_FORMAT(post_date, ...) + * while grouping by WEEK(post_date, mode), YEAR(post_date). DuckDB requires + * that selected date expression to be grouped or aggregated, so use the same + * bucket boundary aggregate selected for ORDER BY post_date. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return array Rewrites keyed by SELECT item start offset. + */ + private function grouped_date_select_item_rewrites( array $tokens ): array { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return array(); + } + if ( + null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::HAVING_SYMBOL ) + || null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::UNION_SYMBOL ) + ) { + return array(); + } + + $group_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::GROUP_SYMBOL ); + if ( + null === $group_index + || ! isset( $tokens[ $group_index + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_index + 1 ]->id + ) { + return array(); + } + + $group_end = $this->primary_key_group_by_clause_end( $tokens, $group_index + 2 ); + if ( + ! isset( $tokens[ $group_end ] ) + || WP_MySQL_Lexer::ORDER_SYMBOL !== $tokens[ $group_end ]->id + || ! isset( $tokens[ $group_end + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_end + 1 ]->id + ) { + return array(); + } + + $grouped_date_columns = array(); + foreach ( $this->split_top_level_select_item_ranges( $tokens, $group_index + 2, $group_end ) as $item ) { + $column = $this->parse_date_group_by_column_reference( $item['tokens'] ); + if ( null === $column ) { + continue; + } + $grouped_date_columns[ strtolower( $column['column_name'] ) ][] = $column['qualifier']; + } + + if ( count( $grouped_date_columns ) === 0 ) { + return array(); + } + + $order_columns = array(); + $order_end = $this->primary_key_order_by_clause_end( $tokens, $group_end + 2 ); + foreach ( $this->split_top_level_select_item_ranges( $tokens, $group_end + 2, $order_end ) as $item ) { + $column = $this->parse_order_by_column_reference_with_direction( $item['tokens'] ); + if ( + null === $column + || ! $this->grouped_date_column_matches_order_column( $grouped_date_columns, $column ) + ) { + continue; + } + + $order_columns[] = array( + 'column' => $column, + 'aggregate' => 'DESC' === $column['direction'] ? 'MAX' : 'MIN', + ); + } + + if ( count( $order_columns ) === 0 ) { + return array(); + } + + $rewrites = array(); + $select_end = $this->top_level_select_list_end( $tokens ); + foreach ( $this->split_top_level_select_item_ranges( $tokens, 1, $select_end ) as $item ) { + $date_format = $this->parse_grouped_date_format_select_item( $item['tokens'] ); + if ( null === $date_format ) { + continue; + } + + foreach ( $order_columns as $order_column ) { + if ( ! $this->grouped_date_select_column_matches_order_column( $date_format, $order_column['column'] ) ) { + continue; + } + + $aggregate_sql = $order_column['aggregate'] . '(' + . $this->grouped_date_aggregate_column_sql( $date_format, $order_column['column'] ) + . ')'; + $date_format_sql = 'strftime(TRY_CAST((' + . $aggregate_sql + . ') AS TIMESTAMP), ' + . $date_format['format_sql'] + . ')'; + if ( '%H.%i' === $date_format['literal_format'] ) { + $date_format_sql = 'CAST(' . $date_format_sql . ' AS DOUBLE)'; + } + + $rewrites[ $item['start'] ] = array( + 'end' => $item['end'], + 'sql' => $date_format_sql . $date_format['alias_sql'], + ); + break; + } + } + + return $rewrites; + } + /** * Build ORDER BY item rewrites for SELECTs grouped by a full primary key. * @@ -2968,7 +3080,7 @@ private function primary_key_group_by_order_column_matches_primary_table( array } /** - * Parse YEAR/MONTH/DAY/DATE group bucket functions over a simple column. + * Parse YEAR/MONTH/DAY/DATE/WEEK group bucket functions over a simple column. * * @param WP_Parser_Token[] $tokens GROUP BY item tokens. * @return array{column_name:string,qualifier:string|null}|null Column reference, or null when unsupported. @@ -2983,7 +3095,7 @@ private function parse_date_group_by_column_reference( array $tokens ): ?array { } $name = strtoupper( $tokens[0]->get_value() ); - if ( ! in_array( $name, array( 'DATE', 'DAY', 'DAYOFMONTH', 'MONTH', 'YEAR' ), true ) ) { + if ( ! in_array( $name, array( 'DATE', 'DAY', 'DAYOFMONTH', 'MONTH', 'WEEK', 'YEAR' ), true ) ) { return null; } @@ -2994,6 +3106,21 @@ private function parse_date_group_by_column_reference( array $tokens ): ?array { $body = array_slice( $tokens, 2, $end_index - 3 ); $items = $this->split_top_level_comma_items( $body ); + if ( 'WEEK' === $name ) { + if ( + ( 1 !== count( $items ) && 2 !== count( $items ) ) + || count( $items[0] ) === 0 + || ( + 2 === count( $items ) + && ( 1 !== count( $items[1] ) || ! $this->is_integer_number_token( $items[1][0] ) ) + ) + ) { + return null; + } + + return $this->parse_group_by_column_reference( $items[0] ); + } + if ( 1 !== count( $items ) || count( $items[0] ) === 0 ) { return null; } @@ -3001,6 +3128,122 @@ private function parse_date_group_by_column_reference( array $tokens ): ?array { return $this->parse_group_by_column_reference( $items[0] ); } + /** + * Parse a DATE_FORMAT() SELECT item over a grouped date column. + * + * @param WP_Parser_Token[] $tokens SELECT item tokens. + * @return array{column_name:string,qualifier:string|null,format_sql:string,literal_format:string|null,alias_sql:string}|null Parsed item, or null when unsupported. + */ + private function parse_grouped_date_format_select_item( array $tokens ): ?array { + if ( + count( $tokens ) < 6 + || ! isset( $tokens[0], $tokens[1] ) + || $this->is_non_identifier_token( $tokens[0] ) + || 0 !== strcasecmp( $tokens[0]->get_value(), 'DATE_FORMAT' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $end_index = $this->skip_balanced_parentheses( $tokens, 1 ); + $body = array_slice( $tokens, 2, $end_index - 3 ); + $items = $this->split_top_level_comma_items( $body ); + if ( 2 !== count( $items ) || count( $items[0] ) === 0 || count( $items[1] ) === 0 ) { + return null; + } + + $column = $this->parse_group_by_column_reference( $items[0] ); + if ( null === $column ) { + return null; + } + + $alias_sql = $this->grouped_date_select_alias_sql( array_slice( $tokens, $end_index ) ); + if ( null === $alias_sql ) { + return null; + } + + $literal_format = null; + $format_sql = $this->translate_tokens_to_duckdb_sql( $items[1] ); + if ( + 1 === count( $items[1] ) + && ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $items[1][0]->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $items[1][0]->id + ) + ) { + $literal_format = $this->token_value( $items[1][0] ); + $format_sql = $this->connection->quote( $this->mysql_date_format_to_duckdb( $literal_format ) ); + } + + return array( + 'column_name' => $column['column_name'], + 'qualifier' => $column['qualifier'], + 'format_sql' => $format_sql, + 'literal_format' => $literal_format, + 'alias_sql' => $alias_sql, + ); + } + + /** + * Translate the supported alias tail for a grouped date SELECT item. + * + * @param WP_Parser_Token[] $tokens Alias tail tokens. + * @return string|null SQL alias suffix, or null when unsupported. + */ + private function grouped_date_select_alias_sql( array $tokens ): ?string { + if ( 0 === count( $tokens ) ) { + return ''; + } + + if ( + 2 === count( $tokens ) + && WP_MySQL_Lexer::AS_SYMBOL === $tokens[0]->id + && ! $this->is_non_identifier_token( $tokens[1] ) + ) { + return ' AS ' . $this->translate_tokens_to_duckdb_sql( array( $tokens[1] ) ); + } + + if ( 1 === count( $tokens ) && ! $this->is_non_identifier_token( $tokens[0] ) ) { + return ' ' . $this->translate_tokens_to_duckdb_sql( array( $tokens[0] ) ); + } + + return null; + } + + /** + * Check whether a grouped DATE_FORMAT() column follows an ORDER BY date column. + * + * @param array{column_name:string,qualifier:string|null} $select_column SELECT DATE_FORMAT() column. + * @param array{column_name:string,qualifier:string|null,direction:string} $order_column ORDER BY column. + * @return bool Whether the columns match. + */ + private function grouped_date_select_column_matches_order_column( array $select_column, array $order_column ): bool { + if ( 0 !== strcasecmp( $select_column['column_name'], $order_column['column_name'] ) ) { + return false; + } + + return null === $select_column['qualifier'] + || null === $order_column['qualifier'] + || 0 === strcasecmp( $select_column['qualifier'], $order_column['qualifier'] ); + } + + /** + * Build a quoted date column SQL fragment for grouped date aggregation. + * + * @param array{column_name:string,qualifier:string|null} $select_column SELECT DATE_FORMAT() column. + * @param array{column_name:string,qualifier:string|null,direction:string} $order_column ORDER BY column. + * @return string Quoted column SQL. + */ + private function grouped_date_aggregate_column_sql( array $select_column, array $order_column ): string { + $qualifier = $order_column['qualifier'] ?? $select_column['qualifier']; + $sql = ''; + if ( null !== $qualifier ) { + $sql .= $this->connection->quote_identifier( $qualifier ) . '.'; + } + + return $sql . $this->connection->quote_identifier( $select_column['column_name'] ); + } + /** * Check whether an ORDER BY column is the source of a grouped date bucket. * diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index a61945bc8..9a5d702e6 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -1290,6 +1290,37 @@ public function test_select_archive_year_month_group_order_by_post_date_matches_ ); } + public function test_select_archive_week_group_date_format_order_by_post_date_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wptests_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_date DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wptests_posts (ID, post_date, post_type, post_status) VALUES + (1, '2024-02-01 00:00:00', 'post', 'publish'), + (2, '2024-01-10 00:00:00', 'post', 'publish'), + (3, '2023-12-31 00:00:00', 'post', 'publish'), + (4, '2024-02-02 00:00:00', 'page', 'publish'), + (5, '2024-03-01 00:00:00', 'post', 'draft')", + ) + ); + + $this->assertParityRows( + "SELECT DISTINCT WEEK( `post_date`, 1 ) AS `week`, + YEAR( `post_date` ) AS `yr`, + DATE_FORMAT( `post_date`, '%Y-%m-%d' ) AS `yyyymmdd`, + count( `ID` ) AS `posts` + FROM `wptests_posts` + WHERE post_type = 'post' AND post_status = 'publish' + GROUP BY WEEK( `post_date`, 1 ), YEAR( `post_date` ) + ORDER BY `post_date` DESC" + ); + } + public function test_select_index_hints_match_sqlite(): void { $this->runParitySetup( array( diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 4483af9ed..3c6331e86 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -2017,6 +2017,64 @@ public function test_date_part_functions_try_cast_wordpress_datetime_strings(): $this->assertSame( 2, $row['timestamp_day'] ); } + public function test_weekly_archive_date_format_select_uses_grouped_date_boundary(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wptests_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_date DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_type, post_status) VALUES + (1, '2024-02-01 00:00:00', 'post', 'publish'), + (2, '2024-02-03 00:00:00', 'post', 'publish'), + (3, '2024-01-10 00:00:00', 'post', 'publish'), + (4, '2024-02-04 00:00:00', 'page', 'publish'), + (5, '2024-03-01 00:00:00', 'post', 'draft')" + ); + + $rows = $driver->query( + "SELECT DISTINCT WEEK( `post_date`, 1 ) AS `week`, + YEAR( `post_date` ) AS `yr`, + DATE_FORMAT( `post_date`, '%Y-%m-%d' ) AS `yyyymmdd`, + count( `ID` ) AS `posts` + FROM `wptests_posts` + WHERE post_type = 'post' AND post_status = 'publish' + GROUP BY WEEK( `post_date`, 1 ), YEAR( `post_date` ) + ORDER BY `post_date` DESC" + )->fetchAll( PDO::FETCH_ASSOC ); + + $this->assertSame( + array( + array( + 'week' => 5, + 'yr' => 2024, + 'yyyymmdd' => '2024-02-03', + 'posts' => 2, + ), + array( + 'week' => 2, + 'yr' => 2024, + 'yyyymmdd' => '2024-01-10', + 'posts' => 1, + ), + ), + $rows + ); + + $this->assertStringContainsString( + 'strftime(TRY_CAST((MAX("post_date")) AS TIMESTAMP), \'%Y-%m-%d\') AS "yyyymmdd"', + $this->lastDuckDBQuery( $driver ) + ); + $this->assertStringContainsString( 'ORDER BY MAX("post_date") DESC', $this->lastDuckDBQuery( $driver ) ); + } + public function test_sum_length_result_does_not_require_bcmath(): void { $this->requireDuckDBRuntime(); From 29bffc22e04213eb7dd131ef4569afd01529b8e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 22:57:22 +0000 Subject: [PATCH 166/317] Apply limits after DuckDB seeded RAND ordering --- .../src/duckdb/class-wp-duckdb-driver.php | 77 +++++++++++++++++-- .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 1 + .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 50 ++++++++++++ 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php index 933465d4b..2a89683cc 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -1406,7 +1406,7 @@ private function select_item_is_wildcard( array $tokens ): bool { * Parse a bounded SELECT ORDER BY RAND(seed) clause. * * @param WP_Parser_Token[] $tokens SELECT tokens. - * @return array{seed:int,descending:bool,tokens:array}|null Parsed ordering, or null when unsupported/not present. + * @return array{seed:int,descending:bool,limit:array{offset:int,count:int}|null,tokens:array}|null Parsed ordering, or null when unsupported/not present. */ private function parse_seeded_rand_order_by_clause( array $tokens ): ?array { $order_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::ORDER_SYMBOL ); @@ -1414,12 +1414,12 @@ private function parse_seeded_rand_order_by_clause( array $tokens ): ?array { null === $order_index || ! isset( $tokens[ $order_index + 1 ] ) || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_index + 1 ]->id - || null !== $this->find_top_level_token_index( $tokens, $order_index + 2, WP_MySQL_Lexer::LIMIT_SYMBOL ) ) { return null; } - $order_tokens = array_slice( $tokens, $order_index + 2 ); + $order_end = $this->primary_key_order_by_clause_end( $tokens, $order_index + 2 ); + $order_tokens = array_slice( $tokens, $order_index + 2, $order_end - $order_index - 2 ); $order_items = $this->split_top_level_comma_items( $order_tokens ); if ( 1 !== count( $order_items ) ) { return null; @@ -1438,10 +1438,74 @@ private function parse_seeded_rand_order_by_clause( array $tokens ): ?array { return null; } + $limit = null; + $tail_index = $order_end; + if ( isset( $tokens[ $order_end ] ) ) { + if ( WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $order_end ]->id ) { + return null; + } + + $limit = $this->parse_seeded_rand_order_by_limit_clause( $tokens, $order_end ); + if ( null === $limit ) { + return null; + } + $tail_index = $limit['end']; + } + return array( 'seed' => $seed, 'descending' => $descending, - 'tokens' => array_slice( $tokens, 0, $order_index ), + 'limit' => null === $limit ? null : array( + 'offset' => $limit['offset'], + 'count' => $limit['count'], + ), + 'tokens' => array_merge( + array_slice( $tokens, 0, $order_index ), + array_slice( $tokens, $tail_index ) + ), + ); + } + + /** + * Parse a literal LIMIT clause after ORDER BY RAND(seed). + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @param int $limit_index LIMIT token offset. + * @return array{offset:int,count:int,end:int}|null Parsed limit, or null when unsupported. + */ + private function parse_seeded_rand_order_by_limit_clause( array $tokens, int $limit_index ): ?array { + if ( + ! isset( $tokens[ $limit_index + 1 ] ) + || WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $limit_index ]->id + || ! $this->is_integer_number_token( $tokens[ $limit_index + 1 ] ) + ) { + return null; + } + + $offset = 0; + $count = (int) $this->number_token_value( $tokens[ $limit_index + 1 ] ); + $end = $limit_index + 2; + if ( isset( $tokens[ $limit_index + 2 ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $limit_index + 2 ]->id ) { + if ( + ! isset( $tokens[ $limit_index + 3 ] ) + || ! $this->is_integer_number_token( $tokens[ $limit_index + 3 ] ) + ) { + return null; + } + + $offset = $count; + $count = (int) $this->number_token_value( $tokens[ $limit_index + 3 ] ); + $end = $limit_index + 4; + } + + if ( $offset < 0 || $count < 0 ) { + return null; + } + + return array( + 'offset' => $offset, + 'count' => $count, + 'end' => $end, ); } @@ -1710,7 +1774,7 @@ private function seeded_rand_select_expression_label( array $tokens ): string { * Apply a bounded seeded RAND() ORDER BY to materialized SELECT rows. * * @param WP_DuckDB_Result_Statement $result Result statement. - * @param array{seed:int,descending:bool,tokens:array}|null $ordering Parsed ordering. + * @param array{seed:int,descending:bool,limit:array{offset:int,count:int}|null,tokens:array}|null $ordering Parsed ordering. * @return WP_DuckDB_Result_Statement Result with rows sorted. */ private function apply_seeded_rand_ordering( WP_DuckDB_Result_Statement $result, ?array $ordering ): WP_DuckDB_Result_Statement { @@ -1752,6 +1816,9 @@ function ( array $left, array $right ) use ( $ordering ): int { foreach ( $decorated as $item ) { $rows[] = $item['row']; } + if ( null !== $ordering['limit'] ) { + $rows = array_slice( $rows, $ordering['limit']['offset'], $ordering['limit']['count'] ); + } return new WP_DuckDB_Result_Statement( $columns, $rows, $result->rowCount(), $column_meta ); } diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 9a5d702e6..48c24df69 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -154,6 +154,7 @@ public function test_select_order_by_seeded_rand_literal_matches_sqlite(): void $this->assertParityRows( 'SELECT id FROM seeded_rand_order ORDER BY RAND(1)' ); $this->assertParityRows( 'SELECT id FROM seeded_rand_order ORDER BY RAND(1) DESC' ); + $this->assertParityRows( 'SELECT id FROM seeded_rand_order ORDER BY RAND(1) DESC LIMIT 1, 2' ); } public function test_seeded_rand_select_and_update_where_match_sqlite(): void { diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php index 3c6331e86..7cd84d00a 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Tests.php @@ -1457,6 +1457,56 @@ public function test_select_order_by_seeded_rand_literal_is_emulated(): void { $descending = $driver->query( 'SELECT id FROM seeded_rand_order ORDER BY RAND(1) DESC' )->fetchAll( PDO::FETCH_COLUMN ); $this->assertSame( array( 2, 1, 3, 4, 5 ), array_map( 'intval', $descending ) ); + + $limited = $driver->query( 'SELECT id FROM seeded_rand_order ORDER BY RAND(1) DESC LIMIT 1, 2' )->fetchAll( PDO::FETCH_COLUMN ); + $this->assertSame( array( 1, 3 ), array_map( 'intval', $limited ) ); + } + + public function test_sql_calc_found_rows_order_by_seeded_rand_literal_with_limit_is_emulated(): void { + $this->requireDuckDBRuntime(); + + $driver = new WP_DuckDB_Driver( array( 'path' => ':memory:' ) ); + $driver->query( + "CREATE TABLE wptests_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( + "INSERT INTO wptests_posts (ID, post_type, post_status) VALUES + (1, 'post', 'publish'), + (2, 'post', 'publish'), + (3, 'post', 'publish'), + (4, 'post', 'publish'), + (5, 'post', 'publish'), + (6, 'post', 'publish'), + (7, 'post', 'publish'), + (8, 'post', 'publish'), + (9, 'post', 'publish'), + (10, 'post', 'publish'), + (11, 'post', 'publish'), + (12, 'post', 'publish')" + ); + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1=1 + AND ((wptests_posts.post_type = 'post' + AND (wptests_posts.post_status = 'publish'))) + ORDER BY RAND(5) DESC + LIMIT 0, 10" + )->fetchAll( PDO::FETCH_COLUMN ); + + $this->assertSame( array( 6, 2, 7, 12, 8, 10, 1, 11, 5, 3 ), array_map( 'intval', $rows ) ); + $this->assertStringNotContainsString( 'RAND(', $this->lastDuckDBQuery( $driver ) ); + $this->assertStringNotContainsString( 'LIMIT', $this->lastDuckDBQuery( $driver ) ); + $this->assertSame( + array( array( 'found_rows' => 12 ) ), + $driver->query( 'SELECT FOUND_ROWS() AS found_rows' )->fetchAll( PDO::FETCH_ASSOC ) + ); } public function test_select_seeded_rand_where_literal_is_emulated(): void { From d1e02463d8b2611c994e0e6c92b2dabc6ef77b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sun, 28 Jun 2026 23:25:54 +0000 Subject: [PATCH 167/317] Preserve DuckDB string literals with NUL bytes --- .../src/duckdb/class-wp-duckdb-connection.php | 17 +++- .../duckdb/WP_DuckDB_Connection_Tests.php | 8 ++ .../duckdb/WP_DuckDB_Driver_Parity_Tests.php | 60 +++++++++++++ .../tests/duckdb/WP_DuckDB_Driver_Tests.php | 84 +++++++++++++++++++ 4 files changed, 168 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php index 51218150a..10865b053 100644 --- a/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php @@ -309,7 +309,22 @@ public function quote( $value ): string { if ( is_int( $value ) || is_float( $value ) ) { return (string) $value; } - return "'" . str_replace( "'", "''", (string) $value ) . "'"; + $value = (string) $value; + if ( false === strpos( $value, "\0" ) ) { + return "'" . str_replace( "'", "''", $value ) . "'"; + } + + $pieces = array(); + foreach ( explode( "\0", $value ) as $offset => $part ) { + if ( 0 !== $offset ) { + $pieces[] = 'chr(0)'; + } + if ( '' !== $part ) { + $pieces[] = "'" . str_replace( "'", "''", $part ) . "'"; + } + } + + return implode( ' || ', $pieces ); } /** diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php index 167104c31..e6911d40f 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -1119,6 +1119,14 @@ public function test_quote_identifier_uses_duckdb_double_quotes(): void { $this->assertSame( '"table""name"', $duckdb->quote_identifier( 'table"name' ) ); } + public function test_quote_uses_chr_expression_for_nul_bytes(): void { + $duckdb = new WP_DuckDB_Connection( array( 'duckdb' => new stdClass() ) ); + + $this->assertSame( 'chr(0)', $duckdb->quote( "\0" ) ); + $this->assertSame( "'a' || chr(0) || 'b''c'", $duckdb->quote( "a\0b'c" ) ); + $this->assertSame( "'a' || chr(0) || chr(0) || 'b'", $duckdb->quote( "a\0\0b" ) ); + } + private function createDuckDBResult( array $columns, array $rows ) { return new class( $columns, $rows ) { private $columns; diff --git a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php index 48c24df69..697ed29e3 100644 --- a/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -2268,6 +2268,49 @@ public function test_on_duplicate_key_update_values_match_sqlite(): void { $this->assertParityRows( 'SELECT id, name, hits FROM items ORDER BY id' ); } + public function test_on_duplicate_key_update_serialized_nul_payload_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wp_options ( + option_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL DEFAULT '', + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name) + )", + ) + ); + + $payload = serialize( + array( + "\0*\0data" => "line\n