diff --git a/.github/workflows/duckdb-package-fast-fail-tests.txt b/.github/workflows/duckdb-package-fast-fail-tests.txt new file mode 100644 index 000000000..f37618114 --- /dev/null +++ b/.github/workflows/duckdb-package-fast-fail-tests.txt @@ -0,0 +1,46 @@ +# Recent package-local DuckDB PHPUnit defects to run before broad DuckDB suites. +WP_DuckDB_Driver_Parity_Tests::test_coalesce_date_text_numeric_comparisons_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_post_text_numeric_identifier_predicates_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_numeric_identifier_text_value_predicates_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_invalid_literal_regexp_patterns_preserve_left_expression_errors +WP_DuckDB_Driver_Parity_Tests::test_invalid_literal_regexp_patterns_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_select_list_date_text_numeric_boundaries_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_sum_aggregate_results_match_sqlite_and_remain_fetchable +WP_DuckDB_Driver_Parity_Tests::test_constant_expression_text_value_scalar_comparisons_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_constant_expression_in_list_items_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_constant_expression_between_bounds_match_sqlite +WP_DuckDB_Driver_Tests::test_text_value_numeric_literal_predicates_are_translated_without_duckdb_runtime +WP_DuckDB_Driver_Parity_Tests::test_datediff_numeric_literal_comparisons_match_sqlite +WP_DuckDB_Driver_Tests::test_datediff_numeric_literal_comparisons_are_translated_without_duckdb_runtime +WP_DuckDB_Driver_Parity_Tests::test_date_add_sub_numeric_literal_comparisons_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_date_add_sub_numeric_literal_comparisons_preserve_expression_errors +WP_DuckDB_Driver_Parity_Tests::test_date_part_quoted_string_comparisons_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_date_part_quoted_string_comparisons_match_sqlite_for_invalid_strings +WP_DuckDB_Driver_Parity_Tests::test_date_part_quoted_string_comparisons_preserve_expression_errors +WP_DuckDB_Driver_Parity_Tests::test_date_function_numeric_literal_comparisons_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_date_format_numeric_literal_comparisons_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_datediff_invalid_string_inputs_match_sqlite +WP_DuckDB_Driver_Tests::test_multi_table_delete_removes_expired_transient_alias_rows_with_literal_timeout +WP_DuckDB_Driver_Tests::test_wordpress_options_on_duplicate_key_update_insert_branch_uses_returning +WP_DuckDB_Driver_Tests::test_wordpress_options_multi_name_fast_path_uses_one_native_query_on_fresh_driver +WP_DuckDB_Driver_Tests::test_insert_id_tracks_on_duplicate_key_update_policy +WP_DuckDB_Driver_Tests::test_stored_procedure_emulation_rejects_unsupported_bodies +WP_DuckDB_Driver_Tests::test_no_argument_select_stored_procedure_is_emulated +WP_DuckDB_Driver_Tests::test_select_mysql_functions_are_emulated +WP_DuckDB_Runtime_Gate_Tests::test_native_duckdb_rejects_savepoints_without_corrupting_transaction +WP_DuckDB_Driver_Parity_Tests::test_regexp_and_not_regexp_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_select_archive_year_month_group_order_by_post_date_matches_sqlite +WP_DuckDB_Driver_Parity_Tests::test_select_archive_week_group_date_format_order_by_post_date_matches_sqlite +WP_DuckDB_Driver_Parity_Tests::test_posts_slug_status_date_order_ties_match_sqlite_index_order +WP_DuckDB_Driver_Parity_Tests::test_select_cast_convert_binary_expressions_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_select_date_time_literal_functions_match_sqlite +WP_DuckDB_Driver_Parity_Tests::test_woocommerce_lookup_table_schema_metadata_matches_sqlite +WP_DuckDB_Driver_Parity_Tests::test_woocommerce_lookup_queries_match_sqlite +WP_DuckDB_Driver_Tests::test_regexp_predicates_are_emulated +WP_DuckDB_Driver_Parity_Tests::test_alter_table_drop_primary_key_temporary_shadow_matches_sqlite +WP_DuckDB_Driver_Tests::test_wordpress_term_relationships_post_count_fast_path_uses_one_native_query +WP_DuckDB_Driver_Tests::test_wordpress_shared_term_split_group_by_fast_path_uses_bounded_native_query +WP_DuckDB_Driver_Tests::test_information_schema_statistics_exact_projection_fast_path_skips_stage_table +WP_DuckDB_Driver_Tests::test_select_comments_group_by_primary_key_orders_by_joined_meta_value +WP_DuckDB_Driver_Tests::test_select_comments_group_by_primary_key_orders_by_cast_joined_meta_value +WP_DuckDB_Driver_Tests::test_select_comments_group_by_primary_key_orders_by_multiple_cast_joined_meta_values diff --git a/.github/workflows/duckdb-phpunit-tests.yml b/.github/workflows/duckdb-phpunit-tests.yml new file mode 100644 index 000000000..e59da8aec --- /dev/null +++ b/.github/workflows/duckdb-phpunit-tests.yml @@ -0,0 +1,1017 @@ +name: DuckDB Tests + +on: + push: + branches: + - trunk + paths: + - '.github/workflows/duckdb-phpunit-tests.yml' + - '.github/workflows/duckdb-package-fast-fail-tests.txt' + - '.github/workflows/duckdb-wordpress-phpunit-fast-fail-tests.txt' + - '.github/workflows/duckdb-wordpress-e2e-fast-fail-specs.txt' + - '.github/workflows/wp-tests-phpunit-run.js' + - '.github/workflows/wp-tests-phpunit-native-extension-setup.sh' + - 'bin/wp-test-duckdb-last-failures.sh' + - 'bin/wp-duckdb-local-smoke.sh' + - 'bin/wp-test-duckdb-smoke.sh' + - 'bin/wp-test-ensure-env-duckdb.sh' + - 'composer.json' + - 'wp-setup.sh' + - 'packages/php-ext-wp-mysql-parser/**' + - '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: + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled + paths: + - '.github/workflows/duckdb-phpunit-tests.yml' + - '.github/workflows/duckdb-package-fast-fail-tests.txt' + - '.github/workflows/duckdb-wordpress-phpunit-fast-fail-tests.txt' + - '.github/workflows/duckdb-wordpress-e2e-fast-fail-specs.txt' + - '.github/workflows/wp-tests-phpunit-run.js' + - '.github/workflows/wp-tests-phpunit-native-extension-setup.sh' + - 'bin/wp-test-duckdb-last-failures.sh' + - 'bin/wp-duckdb-local-smoke.sh' + - 'bin/wp-test-duckdb-smoke.sh' + - 'bin/wp-test-ensure-env-duckdb.sh' + - 'composer.json' + - 'wp-setup.sh' + - 'packages/php-ext-wp-mysql-parser/**' + - '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: + inputs: + run_wordpress_fast_fail: + description: 'Run setup-backed WordPress PHPUnit/E2E fast-fail gates after the package last-failure gate' + required: false + default: false + type: boolean + run_full_wp_phpunit: + description: 'Run the full WordPress PHPUnit suite with DuckDB after the fast-fail gate' + required: false + default: false + type: boolean + run_full_wp_e2e: + description: 'Run the full WordPress E2E suite with DuckDB after the fast-fail gate' + required: false + default: false + type: boolean + duckdb_query_profile: + description: 'Enable DuckDB query profile output during the full WordPress PHPUnit step' + required: false + default: true + type: boolean + duckdb_runtime_counters: + description: 'Enable DuckDB helper/native runtime counter output during the full WordPress PHPUnit step' + required: false + default: false + type: boolean + duckdb_query_profile_interval: + description: 'DuckDB query profile interval for manual full WordPress PHPUnit runs' + required: false + default: '2500' + type: string + full_wp_phpunit_baseline_seconds: + description: 'Runtime baseline seconds for manual full WordPress PHPUnit runs; use 0 for diagnostics' + required: false + default: '380' + type: string + full_wp_phpunit_max_seconds: + description: 'Maximum command seconds for manual full WordPress PHPUnit runs' + required: false + default: '420' + type: string + +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: {} + +env: + WP_SQLITE_DUCKDB_PHPUNIT_SETUP_BACKED_FAST_FAIL: ${{ github.ref == 'refs/heads/trunk' || (github.event_name == 'workflow_dispatch' && (inputs.run_wordpress_fast_fail == true || inputs.run_full_wp_phpunit == true)) || (github.event_name == 'pull_request' && github.event.pull_request.number == 442 && contains(github.event.pull_request.labels.*.name, 'duckdb-setup-fast-fail')) }} + WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL: ${{ github.ref == 'refs/heads/trunk' || (github.event_name == 'workflow_dispatch' && (inputs.run_wordpress_fast_fail == true || inputs.run_full_wp_e2e == true)) || (github.event_name == 'pull_request' && github.event.pull_request.number == 442 && contains(github.event.pull_request.labels.*.name, 'duckdb-setup-fast-fail')) }} + +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 + 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 + composer dump-autoload + ./vendor/bin/install-c-lib + 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 recent DuckDB package failures first + timeout-minutes: 2 + env: + WP_DUCKDB_LAST_FAILURE_REQUIRED: '1' + WP_DUCKDB_LAST_FAILURE_MIN_TESTS: '20' + WP_DUCKDB_LAST_FAILURE_MAX_TESTS: '42' + WP_DUCKDB_LAST_FAILURE_TIMEOUT: '60' + WP_DUCKDB_LAST_FAILURE_LIST: ${{ github.workspace }}/.github/workflows/duckdb-package-fast-fail-tests.txt + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_DUCKDB_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + run: composer run test-duckdb-last-failures + + - name: Run local DuckDB smoke runtime sample + timeout-minutes: 2 + env: + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_DUCKDB_LOCAL_SMOKE_RUNTIME_SAMPLE: '1' + WP_DUCKDB_LOCAL_SMOKE_RUNTIME_ITERATIONS: '1000' + WP_DUCKDB_LOCAL_SMOKE_RUNTIME_WARMUPS: '20' + WP_DUCKDB_LOCAL_SMOKE_RUNTIME_MAX_RATIO: '3' + run: composer run wp-smoke-duckdb-local + + - 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 + 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 + needs: duckdb-test + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read # Required to clone the repo. + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate DuckDB PHPUnit fast-fail list + timeout-minutes: 1 + shell: bash + env: + WP_SQLITE_PHPUNIT_MIN_FILTER_PATTERNS: '9' + run: | + set -euo pipefail + + fast_fail_filter_file=".github/workflows/duckdb-wordpress-phpunit-fast-fail-tests.txt" + min_fast_fail_patterns="${WP_SQLITE_PHPUNIT_MIN_FILTER_PATTERNS:-1}" + + case "$min_fast_fail_patterns" in + ''|*[!0-9]*) + echo "::error title=DuckDB fast-fail pattern floor invalid::WP_SQLITE_PHPUNIT_MIN_FILTER_PATTERNS must be a positive integer." + exit 1 + ;; + esac + + if [ "$min_fast_fail_patterns" -lt 1 ]; then + echo "::error title=DuckDB fast-fail pattern floor invalid::WP_SQLITE_PHPUNIT_MIN_FILTER_PATTERNS must be greater than zero." + exit 1 + fi + + invalid_fast_fail_entries="$( + awk ' + /^[[:space:]]*($|#)/ { next } + { + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + if ($0 !~ /^[A-Za-z_][A-Za-z0-9_]*(\\[A-Za-z_][A-Za-z0-9_]*)*::[A-Za-z_][A-Za-z0-9_]*$/) { + print + } + } + ' "$fast_fail_filter_file" + )" + + if [ -n "$invalid_fast_fail_entries" ]; then + echo "::error title=DuckDB fast-fail filter invalid::${fast_fail_filter_file} must contain literal PHPUnit Class::method names only." + printf '%s\n' "$invalid_fast_fail_entries" + exit 1 + fi + + fast_fail_count="$( + awk ' + /^[[:space:]]*($|#)/ { next } + { + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + if (seen[$0]++) { + next + } + count++ + } + END { + print count + 0 + } + ' "$fast_fail_filter_file" + )" + + if [ "$fast_fail_count" -lt "$min_fast_fail_patterns" ]; then + echo "::error title=DuckDB fast-fail filter too small::${fast_fail_filter_file} lists ${fast_fail_count} unique tests; expected at least ${min_fast_fail_patterns}." + exit 1 + fi + + echo "DuckDB fast-fail filter file: ${fast_fail_filter_file}" + echo "DuckDB fast-fail filter entries: ${fast_fail_count}" + echo "DuckDB fast-fail filter min entries: ${min_fast_fail_patterns}" + + - 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 + 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 + composer dump-autoload + ./vendor/bin/install-c-lib + 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 recent DuckDB package failures first + timeout-minutes: 2 + env: + WP_DUCKDB_LAST_FAILURE_REQUIRED: '1' + WP_DUCKDB_LAST_FAILURE_MIN_TESTS: '20' + WP_DUCKDB_LAST_FAILURE_MAX_TESTS: '42' + WP_DUCKDB_LAST_FAILURE_TIMEOUT: '60' + WP_DUCKDB_LAST_FAILURE_LIST: ${{ github.workspace }}/.github/workflows/duckdb-package-fast-fail-tests.txt + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_DUCKDB_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + run: composer run test-duckdb-last-failures + + - name: Skip setup-backed WordPress PHPUnit gates + if: ${{ success() && env.WP_SQLITE_DUCKDB_PHPUNIT_SETUP_BACKED_FAST_FAIL != 'true' }} + run: | + echo "::notice title=DuckDB WordPress PHPUnit gates skipped::Setup-backed WordPress PHPUnit / DuckDB gates are skipped for this last-failures-only run." + echo "Run this workflow manually with run_wordpress_fast_fail=true or run_full_wp_phpunit=true, push to trunk, or apply the duckdb-setup-fast-fail label to PR #442, to prepare WordPress and run the setup-backed gates." + + - name: Set up Docker Buildx + if: ${{ env.WP_SQLITE_DUCKDB_PHPUNIT_SETUP_BACKED_FAST_FAIL == 'true' }} + uses: docker/setup-buildx-action@v3 + + - name: Set UID and GID for PHP in WordPress images + if: ${{ env.WP_SQLITE_DUCKDB_PHPUNIT_SETUP_BACKED_FAST_FAIL == 'true' }} + run: | + echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV + echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV + + - name: Prepare WordPress DuckDB PHPUnit environment + if: ${{ env.WP_SQLITE_DUCKDB_PHPUNIT_SETUP_BACKED_FAST_FAIL == 'true' }} + 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 + if: ${{ env.WP_SQLITE_DUCKDB_PHPUNIT_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 2 + env: + WP_DUCKDB_SMOKE_TIMEOUT: '90' + run: ./bin/wp-test-duckdb-smoke.sh + + - name: Run recent DuckDB WordPress PHPUnit failures before native parser build + if: ${{ env.WP_SQLITE_DUCKDB_PHPUNIT_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 3 + env: + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS: '0' + WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_VERBOSE: '0' + WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_STDERR: '0' + WP_SQLITE_DUCKDB_CHILD_DB_COPY: '1' + WP_SQLITE_DUCKDB_PREPARE_OBJECT_DIAGNOSTICS: '0' + WP_DUCKDB_QUERY_PROFILE: '0' + WP_DUCKDB_QUERY_PROFILE_INTERVAL: '0' + WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY: '1' + WP_SQLITE_DISABLE_EXPECTED_RESULTS: '0' + WP_SQLITE_IGNORE_MISSING_EXPECTED_RESULTS: '1' + WP_SQLITE_PHPUNIT_ENSURE_ENV_COMMAND: 'true' + WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION: '0' + WP_SQLITE_PHPUNIT_TIMING_LABEL: duckdb-fast-fail-pre-native + WP_SQLITE_PHPUNIT_MAX_SECONDS: '60' + WP_SQLITE_PHPUNIT_MIN_TESTS: '15' + WP_SQLITE_PHPUNIT_MIN_FILTER_PATTERNS: '9' + WP_SQLITE_PHPUNIT_JUNIT_PATH: wordpress/phpunit-duckdb-fast-fail-pre-native-results.xml + WP_DUCKDB_FAST_FAIL_PHPUNIT_TIMEOUT: '60' + WP_DUCKDB_FAST_FAIL_PREPARED: '1' + run: composer run wp-test-duckdb-fast-fail -- phpunit + + - name: Build and load parser extension in WordPress DuckDB PHP containers + if: ${{ env.WP_SQLITE_DUCKDB_PHPUNIT_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 8 + env: + WP_DUCKDB_NATIVE_EXTENSION_BUILD_TIMEOUT_SECONDS: '300' + WP_DUCKDB_NATIVE_EXTENSION_VERIFY_TIMEOUT_SECONDS: '60' + run: bash .github/workflows/wp-tests-phpunit-native-extension-setup.sh + + - name: Run recent DuckDB WordPress PHPUnit failures first + if: ${{ env.WP_SQLITE_DUCKDB_PHPUNIT_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 4 + env: + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS: '0' + WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_VERBOSE: '0' + WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_STDERR: '0' + WP_SQLITE_DUCKDB_CHILD_DB_COPY: '1' + WP_SQLITE_DUCKDB_PREPARE_OBJECT_DIAGNOSTICS: '0' + WP_DUCKDB_QUERY_PROFILE: '0' + WP_DUCKDB_QUERY_PROFILE_INTERVAL: '0' + WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY: '1' + WP_SQLITE_DISABLE_EXPECTED_RESULTS: '0' + WP_SQLITE_IGNORE_MISSING_EXPECTED_RESULTS: '1' + WP_SQLITE_PHPUNIT_ENSURE_ENV_COMMAND: 'true' + WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION: '1' + WP_SQLITE_PHPUNIT_TIMING_LABEL: duckdb-fast-fail + WP_SQLITE_PHPUNIT_MAX_SECONDS: '90' + WP_SQLITE_PHPUNIT_MIN_TESTS: '15' + WP_SQLITE_PHPUNIT_MIN_FILTER_PATTERNS: '9' + WP_SQLITE_PHPUNIT_JUNIT_PATH: wordpress/phpunit-duckdb-fast-fail-results.xml + WP_SQLITE_NATIVE_PARSER_VERIFY_TIMEOUT_SECONDS: '60' + WP_DUCKDB_FAST_FAIL_PHPUNIT_TIMEOUT: '90' + WP_DUCKDB_FAST_FAIL_PREPARED: '1' + run: composer run wp-test-duckdb-fast-fail -- phpunit + + - name: Run full WordPress PHPUnit tests with DuckDB + if: ${{ success() && (github.ref == 'refs/heads/trunk' || (github.event_name == 'workflow_dispatch' && inputs.run_full_wp_phpunit == true)) }} + timeout-minutes: 25 + env: + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS: '0' + WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_VERBOSE: '0' + WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_STDERR: '0' + WP_SQLITE_DUCKDB_CHILD_DB_COPY: '1' + WP_SQLITE_DUCKDB_PREPARE_OBJECT_DIAGNOSTICS: '0' + WP_DUCKDB_QUERY_PROFILE: ${{ github.event_name == 'workflow_dispatch' && inputs.duckdb_query_profile == false && '0' || '1' }} + WP_DUCKDB_RUNTIME_COUNTERS: ${{ github.event_name == 'workflow_dispatch' && inputs.duckdb_runtime_counters == true && '1' || '0' }} + WP_DUCKDB_QUERY_PROFILE_INTERVAL: ${{ github.event_name == 'workflow_dispatch' && inputs.duckdb_query_profile_interval || '2500' }} + WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY: '1' + WP_SQLITE_DISABLE_EXPECTED_RESULTS: '0' + WP_SQLITE_PHPUNIT_ENSURE_ENV_COMMAND: 'true' + WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION: '1' + WP_SQLITE_PHPUNIT_TIMING_LABEL: duckdb-full + WP_SQLITE_PHPUNIT_BASELINE_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.full_wp_phpunit_baseline_seconds || '380' }} + WP_SQLITE_PHPUNIT_MAX_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.full_wp_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: | + set -euo pipefail + + for numeric_env_name in WP_DUCKDB_QUERY_PROFILE_INTERVAL WP_SQLITE_PHPUNIT_BASELINE_SECONDS WP_SQLITE_PHPUNIT_MAX_SECONDS; do + numeric_env_value="${!numeric_env_name}" + case "$numeric_env_value" in + ''|*[!0-9]*) + echo "::error title=DuckDB full PHPUnit input invalid::${numeric_env_name} must be a non-negative integer." + exit 1 + ;; + esac + done + + echo "DuckDB full query profile: ${WP_DUCKDB_QUERY_PROFILE}" + echo "DuckDB full runtime counters: ${WP_DUCKDB_RUNTIME_COUNTERS}" + echo "DuckDB full query profile interval: ${WP_DUCKDB_QUERY_PROFILE_INTERVAL}" + echo "DuckDB full PHPUnit baseline seconds: ${WP_SQLITE_PHPUNIT_BASELINE_SECONDS}" + echo "DuckDB full PHPUnit max seconds: ${WP_SQLITE_PHPUNIT_MAX_SECONDS}" + + node .github/workflows/wp-tests-phpunit-run.js + + - name: Skip full WordPress PHPUnit tests with DuckDB + if: ${{ success() && github.ref != 'refs/heads/trunk' && (github.event_name != 'workflow_dispatch' || inputs.run_full_wp_phpunit != true) }} + run: | + echo "::notice title=DuckDB full WordPress PHPUnit skipped::Full WordPress PHPUnit / DuckDB is skipped for this bounded CI run." + echo "Run this workflow manually with run_full_wp_phpunit=true, or push to trunk, to execute the full suite." + + - name: Dump WordPress DuckDB failure diagnostics + if: ${{ (failure() || cancelled()) && env.WP_SQLITE_DUCKDB_PHPUNIT_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 5 + 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() && env.WP_SQLITE_DUCKDB_PHPUNIT_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 5 + run: composer run wp-test-clean + + wp-duckdb-e2e-test: + name: WordPress E2E / DuckDB + needs: duckdb-test + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read # Required to clone the repo. + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate DuckDB E2E fast-fail list + timeout-minutes: 1 + shell: bash + env: + WP_DUCKDB_E2E_FAST_FAIL_MIN_SPECS: '4' + run: | + set -euo pipefail + + fast_fail_specs_file=".github/workflows/duckdb-wordpress-e2e-fast-fail-specs.txt" + min_fast_fail_specs="${WP_DUCKDB_E2E_FAST_FAIL_MIN_SPECS:-1}" + + case "$min_fast_fail_specs" in + ''|*[!0-9]*) + echo "::error title=DuckDB E2E fast-fail spec floor invalid::WP_DUCKDB_E2E_FAST_FAIL_MIN_SPECS must be a positive integer." + exit 1 + ;; + esac + + if [ "$min_fast_fail_specs" -lt 1 ]; then + echo "::error title=DuckDB E2E fast-fail spec floor invalid::WP_DUCKDB_E2E_FAST_FAIL_MIN_SPECS must be greater than zero." + exit 1 + fi + + mapfile -t fast_fail_specs < <( + awk ' + /^[[:space:]]*($|#)/ { next } + { + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + if (seen[$0]++) { + next + } + print + } + ' "$fast_fail_specs_file" + ) + + if [ "${#fast_fail_specs[@]}" -lt "$min_fast_fail_specs" ]; then + echo "::error title=DuckDB E2E fast-fail specs too small::${fast_fail_specs_file} lists ${#fast_fail_specs[@]} unique specs; expected at least ${min_fast_fail_specs}." + exit 1 + fi + + for spec in "${fast_fail_specs[@]}"; do + if [[ ! "$spec" =~ ^tests/e2e/specs/[A-Za-z0-9._-]+\.test\.js$ ]]; then + echo "::error title=DuckDB E2E fast-fail spec invalid::${fast_fail_specs_file} must contain literal tests/e2e/specs/*.test.js paths only." + printf '%s\n' "$spec" + exit 1 + fi + done + + echo "DuckDB E2E fast-fail specs file: ${fast_fail_specs_file}" + echo "DuckDB E2E fast-fail spec entries: ${#fast_fail_specs[@]}" + echo "DuckDB E2E fast-fail min entries: ${min_fast_fail_specs}" + + - 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 + 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 + composer dump-autoload + ./vendor/bin/install-c-lib + 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 recent DuckDB package failures first + timeout-minutes: 2 + env: + WP_DUCKDB_LAST_FAILURE_REQUIRED: '1' + WP_DUCKDB_LAST_FAILURE_MIN_TESTS: '20' + WP_DUCKDB_LAST_FAILURE_MAX_TESTS: '42' + WP_DUCKDB_LAST_FAILURE_TIMEOUT: '60' + WP_DUCKDB_LAST_FAILURE_LIST: ${{ github.workspace }}/.github/workflows/duckdb-package-fast-fail-tests.txt + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_DUCKDB_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + run: composer run test-duckdb-last-failures + + - name: Skip setup-backed WordPress E2E gates + if: ${{ success() && env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL != 'true' }} + run: | + echo "::notice title=DuckDB WordPress E2E gates skipped::Setup-backed WordPress E2E / DuckDB gates are skipped for this last-failures-only run." + echo "Run this workflow manually with run_wordpress_fast_fail=true or run_full_wp_e2e=true, push to trunk, or apply the duckdb-setup-fast-fail label to PR #442, to prepare WordPress and run the setup-backed gates." + + - name: Set up Docker Buildx + if: ${{ env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL == 'true' }} + uses: docker/setup-buildx-action@v3 + + - name: Set UID and GID for PHP in WordPress images + if: ${{ env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL == 'true' }} + run: | + echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV + echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV + + - name: Prepare WordPress DuckDB end-to-end environment + if: ${{ env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL == 'true' }} + 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 + if: ${{ env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 2 + env: + WP_DUCKDB_SMOKE_TIMEOUT: '90' + run: ./bin/wp-test-duckdb-smoke.sh + + - name: Validate DuckDB E2E fast-fail spec paths + if: ${{ env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 1 + shell: bash + env: + WP_DUCKDB_E2E_FAST_FAIL_MIN_SPECS: '4' + run: | + set -euo pipefail + + fast_fail_specs_file=".github/workflows/duckdb-wordpress-e2e-fast-fail-specs.txt" + min_fast_fail_specs="${WP_DUCKDB_E2E_FAST_FAIL_MIN_SPECS:-1}" + + case "$min_fast_fail_specs" in + ''|*[!0-9]*) + echo "::error title=DuckDB E2E fast-fail spec floor invalid::WP_DUCKDB_E2E_FAST_FAIL_MIN_SPECS must be a positive integer." + exit 1 + ;; + esac + + if [ "$min_fast_fail_specs" -lt 1 ]; then + echo "::error title=DuckDB E2E fast-fail spec floor invalid::WP_DUCKDB_E2E_FAST_FAIL_MIN_SPECS must be greater than zero." + exit 1 + fi + + mapfile -t fast_fail_specs < <( + awk ' + /^[[:space:]]*($|#)/ { next } + { + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + if (seen[$0]++) { + next + } + print + } + ' "$fast_fail_specs_file" + ) + + if [ "${#fast_fail_specs[@]}" -lt "$min_fast_fail_specs" ]; then + echo "::error title=DuckDB E2E fast-fail specs too small::${fast_fail_specs_file} lists ${#fast_fail_specs[@]} unique specs; expected at least ${min_fast_fail_specs}." + exit 1 + fi + + for spec in "${fast_fail_specs[@]}"; do + if [[ ! "$spec" =~ ^tests/e2e/specs/[A-Za-z0-9._-]+\.test\.js$ ]]; then + echo "::error title=DuckDB E2E fast-fail spec invalid::${fast_fail_specs_file} must contain literal tests/e2e/specs/*.test.js paths only." + printf '%s\n' "$spec" + exit 1 + fi + + if [ ! -f "wordpress/${spec}" ]; then + echo "::error title=DuckDB E2E fast-fail spec missing::wordpress/${spec} does not exist." + exit 1 + fi + done + + echo "DuckDB E2E fast-fail specs file: ${fast_fail_specs_file}" + echo "DuckDB E2E fast-fail spec entries: ${#fast_fail_specs[@]}" + echo "DuckDB E2E fast-fail min entries: ${min_fast_fail_specs}" + printf 'DuckDB E2E fast-fail spec: %s\n' "${fast_fail_specs[@]}" + + - name: Install Playwright browsers + if: ${{ env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 10 + run: npx playwright install --with-deps + + - name: Serialize WordPress PHP-FPM for DuckDB end-to-end tests + if: ${{ env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 5 + shell: bash + 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 + compose=( docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.duckdb-e2e.yml ) + "${compose[@]}" config --quiet + "${compose[@]}" up -d --no-deps --force-recreate php + + for attempt in $(seq 1 20); do + if "${compose[@]}" 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' \ + && "${compose[@]}" exec -T php php -r 'exit( extension_loaded( "ffi" ) ? 0 : 1 );'; then + exit 0 + fi + + echo "PHP-FPM serialization probe failed on attempt ${attempt}; retrying..." + sleep 1 + done + + echo "PHP-FPM serialization probe did not pass after retries." >&2 + "${compose[@]}" ps || true + "${compose[@]}" logs --no-color --tail=120 php || true + exit 1 + + - name: Install WordPress 500 response diagnostics + if: ${{ env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 5 + 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' => '', + '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(), + ); + + 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->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 ) ) { + $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 recent DuckDB WordPress E2E failures first + if: ${{ env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 6 + shell: bash + env: + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + WP_DUCKDB_E2E_FAST_FAIL_MIN_SPECS: '4' + WP_DUCKDB_FAST_FAIL_E2E_TIMEOUT: '180' + WP_DUCKDB_FAST_FAIL_PREPARED: '1' + run: composer run wp-test-duckdb-fast-fail -- e2e + + - name: Run full WordPress end-to-end tests with DuckDB + if: ${{ success() && (github.ref == 'refs/heads/trunk' || (github.event_name == 'workflow_dispatch' && inputs.run_full_wp_e2e == true)) }} + timeout-minutes: 12 + env: + DUCKDB_PHP_AUTOLOAD: ${{ github.workspace }}/packages/mysql-on-sqlite/vendor/autoload.php + run: npm --prefix wordpress run test:e2e -- + + - name: Skip full WordPress end-to-end tests with DuckDB + if: ${{ success() && github.ref != 'refs/heads/trunk' && (github.event_name != 'workflow_dispatch' || inputs.run_full_wp_e2e != true) }} + run: | + echo "::notice title=DuckDB full WordPress E2E skipped::Full WordPress E2E / DuckDB is skipped for this bounded CI run." + echo "Run this workflow manually with run_full_wp_e2e=true, or push to trunk, to execute the full E2E suite." + + - name: Dump WordPress DuckDB failure diagnostics + if: ${{ (failure() || cancelled()) && env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 5 + 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() && env.WP_SQLITE_DUCKDB_E2E_SETUP_BACKED_FAST_FAIL == 'true' }} + timeout-minutes: 5 + run: composer run wp-test-clean diff --git a/.github/workflows/duckdb-wordpress-e2e-fast-fail-specs.txt b/.github/workflows/duckdb-wordpress-e2e-fast-fail-specs.txt new file mode 100644 index 000000000..29ad38009 --- /dev/null +++ b/.github/workflows/duckdb-wordpress-e2e-fast-fail-specs.txt @@ -0,0 +1,6 @@ +# Recent DuckDB WordPress E2E failures and high-value setup probes that should run before any full suite. +# Keep this list small; a green run is not full browser parity. +tests/e2e/specs/hello.test.js +tests/e2e/specs/dashboard.test.js +tests/e2e/specs/edit-posts.test.js +tests/e2e/specs/empty-trash-restore-trashed-posts.test.js diff --git a/.github/workflows/duckdb-wordpress-phpunit-fast-fail-tests.txt b/.github/workflows/duckdb-wordpress-phpunit-fast-fail-tests.txt new file mode 100644 index 000000000..93da27051 --- /dev/null +++ b/.github/workflows/duckdb-wordpress-phpunit-fast-fail-tests.txt @@ -0,0 +1,11 @@ +# Recent DuckDB WordPress PHPUnit failures that should run before any full suite. +# Keep this list method-level and small; a green run is not full parity. +Tests_Admin_ExportWp::test_should_include_attachments +Tests_Filesystem_WpFilesystemDirect_Chmod::test_should_handle_set_mode_when_not_passed +Tests_Admin_wpPostsListTable::test_search_hierarchical_pages_first_page +Tests_Post_GetAvailablePostMimeTypes::test_should_return_expected_post_mime_types +WP_Test_REST_Posts_Controller::test_get_items_valid_date +WP_Test_REST_Posts_Controller::test_get_items_orderby_modified_query +WP_Test_REST_Posts_Controller::test_get_items_tags_exclude_query +WP_Test_REST_Users_Controller::test_update_item_existing_email_case +Tests_DB::test_mysqli_flush_sync diff --git a/.github/workflows/publish-wasm-extension-artifact.yml b/.github/workflows/publish-wasm-extension-artifact.yml index 75f655c1e..7d0c8983a 100644 --- a/.github/workflows/publish-wasm-extension-artifact.yml +++ b/.github/workflows/publish-wasm-extension-artifact.yml @@ -22,6 +22,10 @@ on: # Any needed permissions should be configured at the job level. permissions: {} +env: + WASMTIME_VERSION: v46.0.1 + WASMTIME_X86_64_LINUX_SHA256: 9ae0b17ea298bcc52277a8208d6ab7fae8e1a89579672f9d82f9d86c116edb62 + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -137,6 +141,18 @@ jobs: - name: Load Playground base image run: docker load --input "${RUNNER_TEMP}/playground-base-image/playground-php-wasm-base.tar" + - name: Install Wasmtime CLI + run: | + set -euo pipefail + archive="wasmtime-${WASMTIME_VERSION}-x86_64-linux.tar.xz" + url="https://github.com/bytecodealliance/wasmtime/releases/download/${WASMTIME_VERSION}/${archive}" + curl -fsSL "$url" --output "${RUNNER_TEMP}/${archive}" + echo "${WASMTIME_X86_64_LINUX_SHA256} ${RUNNER_TEMP}/${archive}" | sha256sum --check - + mkdir -p "${RUNNER_TEMP}/wasmtime" + tar -xJf "${RUNNER_TEMP}/${archive}" -C "${RUNNER_TEMP}/wasmtime" --strip-components=1 + "${RUNNER_TEMP}/wasmtime/wasmtime" --version + echo "${RUNNER_TEMP}/wasmtime" >> "$GITHUB_PATH" + - name: Install Playground deps working-directory: wordpress-playground run: npm ci --ignore-scripts @@ -165,6 +181,14 @@ jobs: ASYNC_MODE: jspi run: node check-playground-web-compat.mjs + - name: Validate the side module with Wasmtime CLI + working-directory: sqlite-database-integration/packages/php-ext-wp-mysql-parser/wasm-spike + env: + PHP_VERSION: ${{ matrix.php }} + ASYNC_MODE: jspi + WASMTIME_EXPECTED_VERSION: ${{ env.WASMTIME_VERSION }} + run: node check-wasmtime-cli.mjs + - name: Upload side module uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/wasm-spike.yml b/.github/workflows/wasm-spike.yml index 1fee946c2..03399ed12 100644 --- a/.github/workflows/wasm-spike.yml +++ b/.github/workflows/wasm-spike.yml @@ -22,6 +22,10 @@ on: # Any needed permissions should be configured at the job level. permissions: {} +env: + WASMTIME_VERSION: v46.0.1 + WASMTIME_X86_64_LINUX_SHA256: 9ae0b17ea298bcc52277a8208d6ab7fae8e1a89579672f9d82f9d86c116edb62 + jobs: base-image: name: Build shared Playground base image @@ -62,7 +66,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: @@ -134,6 +138,18 @@ jobs: - name: Load Playground base image run: docker load --input "${RUNNER_TEMP}/playground-base-image/playground-php-wasm-base.tar" + - name: Install Wasmtime CLI + run: | + set -euo pipefail + archive="wasmtime-${WASMTIME_VERSION}-x86_64-linux.tar.xz" + url="https://github.com/bytecodealliance/wasmtime/releases/download/${WASMTIME_VERSION}/${archive}" + curl -fsSL "$url" --output "${RUNNER_TEMP}/${archive}" + echo "${WASMTIME_X86_64_LINUX_SHA256} ${RUNNER_TEMP}/${archive}" | sha256sum --check - + mkdir -p "${RUNNER_TEMP}/wasmtime" + tar -xJf "${RUNNER_TEMP}/${archive}" -C "${RUNNER_TEMP}/wasmtime" --strip-components=1 + "${RUNNER_TEMP}/wasmtime/wasmtime" --version + echo "${RUNNER_TEMP}/wasmtime" >> "$GITHUB_PATH" + - name: Install Playground deps working-directory: wordpress-playground run: npm ci --ignore-scripts @@ -175,6 +191,14 @@ jobs: ASYNC_MODE: ${{ matrix.async-mode }} run: node check-playground-web-compat.mjs + - name: Validate the side module with Wasmtime CLI + working-directory: sqlite-database-integration/packages/php-ext-wp-mysql-parser/wasm-spike + env: + PHP_VERSION: ${{ matrix.php }} + ASYNC_MODE: ${{ matrix.async-mode }} + WASMTIME_EXPECTED_VERSION: ${{ env.WASMTIME_VERSION }} + run: node check-wasmtime-cli.mjs + - name: Load the extension in Playground and parse a query working-directory: sqlite-database-integration/packages/php-ext-wp-mysql-parser/wasm-spike env: diff --git a/.github/workflows/wp-tests-phpunit-native-extension-setup.sh b/.github/workflows/wp-tests-phpunit-native-extension-setup.sh index 943b06c59..1359a3333 100644 --- a/.github/workflows/wp-tests-phpunit-native-extension-setup.sh +++ b/.github/workflows/wp-tests-phpunit-native-extension-setup.sh @@ -8,12 +8,74 @@ RUNTIME_DIR="$ROOT_DIR/tmp-native-extension" EXTENSION_SOURCE_VOLUME=" - ../packages/php-ext-wp-mysql-parser:/var/native-parser-extension-src" EXTENSION_RUNTIME_VOLUME=" - ../tmp-native-extension:/var/native-parser-extension:ro" EXTENSION_INI_VOLUME=" - ../tmp-native-extension/wp-mysql-parser.ini:/usr/local/etc/php/conf.d/wp-mysql-parser.ini:ro" +WP_DUCKDB_NATIVE_EXTENSION_BUILD_TIMEOUT_SECONDS="${WP_DUCKDB_NATIVE_EXTENSION_BUILD_TIMEOUT_SECONDS:-300}" +WP_DUCKDB_NATIVE_EXTENSION_VERIFY_TIMEOUT_SECONDS="${WP_DUCKDB_NATIVE_EXTENSION_VERIFY_TIMEOUT_SECONDS:-60}" if [ ! -f "$COMPOSE_OVERRIDE" ]; then echo "Missing $COMPOSE_OVERRIDE. Run composer run wp-setup first." >&2 exit 1 fi +native_phase_id() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '_' | tr -cd '[:alnum:]_-' +} + +emit_native_progress() { + local phase="$1" + local status="$2" + local elapsed_seconds="$3" + local timeout_seconds="$4" + local message + + message="WP_DUCKDB_NATIVE_EXTENSION_PROGRESS phase=$phase status=$status elapsed_seconds=$elapsed_seconds timeout_seconds=$timeout_seconds" + printf '%s\n' "$message" + + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + printf '::notice title=WordPress native parser setup::%s\n' "$message" + fi +} + +run_with_timeout() { + local seconds="$1" + shift + + if command -v timeout > /dev/null; then + timeout "${seconds}s" "$@" + return $? + fi + + "$@" +} + +run_phase() { + local label="$1" + local seconds="$2" + local status started_at ended_at elapsed_seconds phase_id phase_status + shift 2 + + phase_id="$(native_phase_id "$label")" + started_at="$(date +%s)" + emit_native_progress "$phase_id" start 0 "$seconds" + + set +e + run_with_timeout "$seconds" "$@" + status=$? + set -e + + ended_at="$(date +%s)" + elapsed_seconds=$(( ended_at - started_at )) + phase_status="success" + if [ "$status" -eq 124 ]; then + phase_status="timeout" + echo "Error: $label timed out after $seconds seconds." >&2 + elif [ "$status" -ne 0 ]; then + phase_status="failure" + fi + emit_native_progress "$phase_id" "$phase_status" "$elapsed_seconds" "$seconds" + + return "$status" +} + add_volume_to_service() { local service="$1" local volume="$2" @@ -99,8 +161,10 @@ EOF chmod +x "$WP_DIR/native-build-extension.sh" -cd "$WP_DIR" -node tools/local-env/scripts/docker.js run --rm php sh /var/www/native-build-extension.sh +run_phase \ + 'Build native parser extension in WordPress PHP container' \ + "$WP_DUCKDB_NATIVE_EXTENSION_BUILD_TIMEOUT_SECONDS" \ + bash -c 'cd "$1" && node tools/local-env/scripts/docker.js run --rm php sh /var/www/native-build-extension.sh' bash "$WP_DIR" mkdir -p "$RUNTIME_DIR" cp "$ROOT_DIR/packages/php-ext-wp-mysql-parser/target/release/libwp_mysql_parser.so" "$RUNTIME_DIR/libwp_mysql_parser.so" @@ -207,5 +271,11 @@ contents = contents.replace( marker, `${ marker }\n\n${ guard }` ); fs.writeFileSync( file, contents ); NODE -node tools/local-env/scripts/docker.js run --rm php php -m | grep -qx 'wp_mysql_parser' -node tools/local-env/scripts/docker.js run --rm php php /var/www/native-verify-extension.php +run_phase \ + 'Verify native parser extension module is loaded' \ + "$WP_DUCKDB_NATIVE_EXTENSION_VERIFY_TIMEOUT_SECONDS" \ + bash -c 'cd "$1" && node tools/local-env/scripts/docker.js run --rm php php -m | grep -qx wp_mysql_parser' bash "$WP_DIR" +run_phase \ + 'Verify native parser extension runtime behavior' \ + "$WP_DUCKDB_NATIVE_EXTENSION_VERIFY_TIMEOUT_SECONDS" \ + bash -c 'cd "$1" && node tools/local-env/scripts/docker.js run --rm php php /var/www/native-verify-extension.php' bash "$WP_DIR" diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index ae4f5f6a3..0a9d42ccc 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -9,14 +9,53 @@ const { execSync } = require( 'child_process' ); 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 phpunitMinTests = getPositiveNumberEnv( 'WP_SQLITE_PHPUNIT_MIN_TESTS' ); +const nativeParserVerifyTimeoutSeconds = getPositiveNumberEnv( 'WP_SQLITE_NATIVE_PARSER_VERIFY_TIMEOUT_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'; +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 + : path.join( repoRoot, junitOutputPath ); +const phpunitRequiredFiltersPath = process.env.WP_SQLITE_PHPUNIT_REQUIRED_FILTERS_FILE || ''; +const phpunitRequiredFiltersFile = phpunitRequiredFiltersPath + ? ( + path.isAbsolute( phpunitRequiredFiltersPath ) + ? phpunitRequiredFiltersPath + : path.join( repoRoot, phpunitRequiredFiltersPath ) + ) + : ''; +const phpunitCompatibilityPrependPath = path.join( repoRoot, 'wordpress', 'phpunit-runner-compat-prepend.php' ); +const duckdbAutoloadCompatibilityWrapperPath = path.join( repoRoot, 'wordpress', 'phpunit-duckdb-autoload-wrapper.php' ); +const duckdbChildDiagnosticsPath = path.join( repoRoot, 'wordpress', 'duckdb-child-process-diagnostics.php' ); +const duckdbChildDiagnosticsLogPath = path.join( repoRoot, 'wordpress', 'duckdb-child-process-diagnostics.log' ); +const phpunitCompatibilityPrependContainerPath = '/var/www/phpunit-runner-compat-prepend.php'; +const duckdbAutoloadCompatibilityWrapperContainerPath = '/var/www/phpunit-duckdb-autoload-wrapper.php'; +const duckdbChildDiagnosticsContainerPath = '/var/www/duckdb-child-process-diagnostics.php'; +const duckdbChildDiagnosticsLogContainerPath = '/var/www/duckdb-child-process-diagnostics.log'; +const wordPressPhpunitBootstrapContainerPath = '/var/www/tests/phpunit/includes/bootstrap.php'; +const enableDuckDBChildDiagnostics = isDuckDBPhpunitRun && process.env.WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS === '1'; +const enableDuckDBChildDatabaseCopy = isDuckDBPhpunitRun && process.env.WP_SQLITE_DUCKDB_CHILD_DB_COPY === '1'; +const enableDuckDBChildProcessPatches = enableDuckDBChildDiagnostics || enableDuckDBChildDatabaseCopy; -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', @@ -90,12 +129,1887 @@ const expectedFailures = [ 'WP_Test_REST_Posts_Controller::test_get_items_orderby_modified_query', ]; +const duckdbExpectedFailuresToPrune = new Set( [ + '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', + 'Tests_DB_Charset::test_strip_invalid_text with data set #24', + 'Tests_DB_Charset::test_strip_invalid_text with data set #25', + 'Tests_DB_Charset::test_strip_invalid_text with data set #26', + 'Tests_DB_Charset::test_strip_invalid_text with data set #27', + 'Tests_DB_Charset::test_strip_invalid_text with data set #28', + 'Tests_DB_Charset::test_strip_invalid_text with data set #30', + 'Tests_DB_Charset::test_strip_invalid_text with data set #31', + 'Tests_DB_Charset::test_strip_invalid_text with data set #32', + 'Tests_DB_Charset::test_strip_invalid_text with data set #33', + 'Tests_DB_Charset::test_strip_invalid_text with data set #34', + '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', + 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #4', + 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #5', + 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #6', + 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #7', + 'Tests_Menu_wpNavMenu::test_wp_nav_menu_should_not_have_has_children_class_with_custom_depth', + 'WP_Test_REST_Posts_Controller::test_get_items_orderby_modified_query', +] ); + +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 ) { 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( 'PHPUnit timing label:', phpunitTimingLabel ); +console.log( 'PHPUnit baseline seconds:', phpunitBaselineSeconds || 'none' ); +console.log( 'PHPUnit max seconds:', phpunitMaxSeconds || 'none' ); +console.log( 'PHPUnit min tests:', phpunitMinTests || 'none' ); +console.log( 'PHPUnit required filters file:', phpunitRequiredFiltersFile || 'none' ); +if ( disableExpectedResults ) { + console.log( 'Expected-result allowlist disabled.' ); +} +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 isDuckDBPhpunitRun + ? 'composer run wp-test-ensure-env-duckdb' + : '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 readRequiredPhpunitFilters() { + if ( ! phpunitRequiredFiltersFile ) { + return []; + } + + if ( ! fs.existsSync( phpunitRequiredFiltersFile ) ) { + console.error( `Error: PHPUnit required filters file not found at ${ phpunitRequiredFiltersFile }.` ); + process.exit( 1 ); + } + + const filters = []; + const seen = new Set(); + const invalidFilters = []; + for ( const rawLine of fs.readFileSync( phpunitRequiredFiltersFile, 'utf8' ).split( /\r?\n/ ) ) { + const line = rawLine.trim(); + if ( ! line || line.startsWith( '#' ) ) { + continue; + } + if ( ! /^[A-Za-z_][A-Za-z0-9_]*(\\[A-Za-z_][A-Za-z0-9_]*)*::[A-Za-z_][A-Za-z0-9_]*$/.test( line ) ) { + invalidFilters.push( line ); + continue; + } + if ( ! seen.has( line ) ) { + seen.add( line ); + filters.push( line ); + } + } + + if ( invalidFilters.length ) { + console.error( '\n❌ PHPUnit required filters file contains invalid entries:' ); + invalidFilters.forEach( filter => console.error( ` - ${ filter }` ) ); + process.exit( 1 ); + } + + if ( ! filters.length ) { + console.error( `Error: PHPUnit required filters file has no method entries: ${ phpunitRequiredFiltersFile }.` ); + process.exit( 1 ); + } + + return filters; +} + +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; + } + + writePhpunitCompatibilityFiles(); + patchPhpunitParentProcessIsolationHooks(); + if ( enableDuckDBChildDiagnostics ) { + writeDuckDBChildDiagnosticsFile(); + } + if ( enableDuckDBChildProcessPatches ) { + patchWordPressPhpunitBootstrapForChildDiagnostics(); + patchPhpunitChildProcessTemplatesForDiagnostics(); + } + verifyPhpunitCompatibilityFiles(); + if ( enableDuckDBChildDiagnostics ) { + resetDuckDBChildDiagnosticsLog(); + } + + 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 && + isDuckDBPhpunitRun + ); +} + +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 writeDuckDBChildDiagnosticsFile() { + resetDuckDBChildDiagnosticsLog(); + fs.writeFileSync( + duckdbChildDiagnosticsPath, + ` ob_get_level() ) { +\t\t\treturn null; +\t\t} + +\t\t$contents = ob_get_contents(); +\t\tif ( ! is_string( $contents ) ) { +\t\t\treturn null; +\t\t} + +\t\t$length = strlen( $contents ); +\t\t$tail = $contents; +\t\tif ( $length > $max_length ) { +\t\t\t$tail = substr( $contents, -$max_length ); +\t\t} + +\t\treturn array( +\t\t\t'length' => $length, +\t\t\t'sha1' => sha1( $contents ), +\t\t\t'tail_truncated' => $length > $max_length, +\t\t\t'tail_base64' => base64_encode( $tail ), +\t\t); +\t} + +\tfunction wp_sqlite_duckdb_child_diagnostics_truncate_string( $value, $max_length = 500 ) { +\t\tif ( ! is_string( $value ) ) { +\t\t\treturn $value; +\t\t} + +\t\tif ( strlen( $value ) <= $max_length ) { +\t\t\treturn $value; +\t\t} + +\t\treturn substr( $value, 0, $max_length ) . '...'; +\t} + +\tfunction wp_sqlite_duckdb_child_diagnostics_test_identity( $test ) { +\t\t$identity = array( +\t\t\t'class' => is_object( $test ) ? get_class( $test ) : null, +\t\t\t'name' => null, +\t\t\t'to_string' => null, +\t\t\t'data_name' => null, +\t\t\t'data_description' => null, +\t\t); + +\t\tif ( ! is_object( $test ) ) { +\t\t\treturn $identity; +\t\t} + +\t\ttry { +\t\t\tif ( method_exists( $test, 'getName' ) ) { +\t\t\t\t$identity['name'] = $test->getName( false ); +\t\t\t} +\t\t} catch ( Throwable $e ) { +\t\t\t$identity['name'] = get_class( $e ) . ': ' . $e->getMessage(); +\t\t} + +\t\ttry { +\t\t\tif ( method_exists( $test, 'toString' ) ) { +\t\t\t\t$identity['to_string'] = $test->toString(); +\t\t\t} +\t\t} catch ( Throwable $e ) { +\t\t\t$identity['to_string'] = get_class( $e ) . ': ' . $e->getMessage(); +\t\t} + +\t\ttry { +\t\t\tif ( method_exists( $test, 'dataName' ) ) { +\t\t\t\t$identity['data_name'] = $test->dataName(); +\t\t\t} +\t\t} catch ( Throwable $e ) { +\t\t\t$identity['data_name'] = get_class( $e ) . ': ' . $e->getMessage(); +\t\t} + +\t\ttry { +\t\t\tif ( method_exists( $test, 'dataDescription' ) ) { +\t\t\t\t$identity['data_description'] = $test->dataDescription(); +\t\t\t} +\t\t} catch ( Throwable $e ) { +\t\t\t$identity['data_description'] = get_class( $e ) . ': ' . $e->getMessage(); +\t\t} + +\t\tforeach ( $identity as $key => $value ) { +\t\t\t$identity[ $key ] = wp_sqlite_duckdb_child_diagnostics_truncate_string( $value ); +\t\t} + +\t\treturn $identity; +\t} + +\tfunction wp_sqlite_duckdb_child_diagnostics_report( $stage, $force = false ) { +\t\tif ( ! wp_sqlite_duckdb_child_diagnostics_enabled() ) { +\t\t\treturn; +\t\t} + +\t\t$error = error_get_last(); +\t\t$is_fatal = wp_sqlite_duckdb_child_diagnostics_fatal_error( $error ); +\t\t$db_engine = defined( 'DB_ENGINE' ) ? DB_ENGINE : null; +\t\t$autoload = defined( 'DUCKDB_PHP_AUTOLOAD' ) ? DUCKDB_PHP_AUTOLOAD : null; +\t\t$wpdb = isset( $GLOBALS['wpdb'] ) && is_object( $GLOBALS['wpdb'] ) ? get_class( $GLOBALS['wpdb'] ) : null; +\t\t$wp_tests_skip_install = getenv( 'WP_TESTS_SKIP_INSTALL' ); +\t\tif ( ! is_string( $wp_tests_skip_install ) ) { +\t\t\t$wp_tests_skip_install = null; +\t\t} +\t\t$duckdb_file = defined( 'DUCKDB_FILE' ) ? DUCKDB_FILE : null; +\t\t$fqduckdb = defined( 'FQDUCKDB' ) ? FQDUCKDB : null; +\t\t$reason = null; +\t\t$previous_stage = isset( $GLOBALS['wp_sqlite_duckdb_child_last_stage'] ) && is_string( $GLOBALS['wp_sqlite_duckdb_child_last_stage'] ) +\t\t\t? $GLOBALS['wp_sqlite_duckdb_child_last_stage'] +\t\t\t: null; +\t\t$last_stage = $previous_stage; + +\t\tif ( 'shutdown' !== $stage ) { +\t\t\t$GLOBALS['wp_sqlite_duckdb_child_last_stage'] = $stage; +\t\t\t$last_stage = $stage; +\t\t} + +\t\t$result_file = isset( $GLOBALS['wp_sqlite_duckdb_child_result_file'] ) && is_string( $GLOBALS['wp_sqlite_duckdb_child_result_file'] ) +\t\t\t? $GLOBALS['wp_sqlite_duckdb_child_result_file'] +\t\t\t: null; +\t\t$result_file_exists = is_string( $result_file ) && file_exists( $result_file ); +\t\t$result_file_readable = is_string( $result_file ) && is_readable( $result_file ); +\t\t$result_file_size = $result_file_exists ? filesize( $result_file ) : null; +\t\tif ( false === $result_file_size ) { +\t\t\t$result_file_size = null; +\t\t} +\t\t$result_file_sha1 = null; +\t\tif ( $result_file_readable && is_int( $result_file_size ) && $result_file_size <= 1048576 ) { +\t\t\t$result_file_sha1 = sha1_file( $result_file ); +\t\t\tif ( false === $result_file_sha1 ) { +\t\t\t\t$result_file_sha1 = null; +\t\t\t} +\t\t} +\t\t$result_file_dir = is_string( $result_file ) ? dirname( $result_file ) : null; +\t\t$bootstrap_global = isset( $GLOBALS['__PHPUNIT_BOOTSTRAP'] ) && is_string( $GLOBALS['__PHPUNIT_BOOTSTRAP'] ) +\t\t\t? $GLOBALS['__PHPUNIT_BOOTSTRAP'] +\t\t\t: null; +\t\t$bootstrap_realpath = is_string( $bootstrap_global ) ? realpath( $bootstrap_global ) : null; +\t\tif ( ! is_string( $bootstrap_realpath ) ) { +\t\t\t$bootstrap_realpath = null; +\t\t} +\t\t$bootstrap_included = false; +\t\tif ( is_string( $bootstrap_realpath ) ) { +\t\t\tforeach ( get_included_files() as $included_file ) { +\t\t\t\t$included_realpath = realpath( $included_file ); +\t\t\t\tif ( is_string( $included_realpath ) && $included_realpath === $bootstrap_realpath ) { +\t\t\t\t\t$bootstrap_included = true; +\t\t\t\t\tbreak; +\t\t\t\t} +\t\t\t} +\t\t} + +\t\t$wpdb_object = isset( $GLOBALS['wpdb'] ) && is_object( $GLOBALS['wpdb'] ) +\t\t\t? $GLOBALS['wpdb'] +\t\t\t: null; +\t\t$wpdb_last_error = is_object( $wpdb_object ) && isset( $wpdb_object->last_error ) +\t\t\t? wp_sqlite_duckdb_child_diagnostics_truncate_string( $wpdb_object->last_error ) +\t\t\t: null; +\t\t$wpdb_last_query = is_object( $wpdb_object ) && isset( $wpdb_object->last_query ) +\t\t\t? wp_sqlite_duckdb_child_diagnostics_truncate_string( $wpdb_object->last_query ) +\t\t\t: null; +\t\t$included_files = get_included_files(); +\t\t$included_files_tail = array_map( +\t\t\t'basename', +\t\t\tarray_slice( $included_files, -5 ) +\t\t); +\t\t$test_identity = isset( $GLOBALS['wp_sqlite_duckdb_child_test_identity'] ) && is_array( $GLOBALS['wp_sqlite_duckdb_child_test_identity'] ) +\t\t\t? $GLOBALS['wp_sqlite_duckdb_child_test_identity'] +\t\t\t: null; +\t\t$result_write = isset( $GLOBALS['wp_sqlite_duckdb_child_result_write'] ) && is_array( $GLOBALS['wp_sqlite_duckdb_child_result_write'] ) +\t\t\t? $GLOBALS['wp_sqlite_duckdb_child_result_write'] +\t\t\t: null; +\t\t$factory_sequence_alignment = isset( $GLOBALS['wp_sqlite_duckdb_child_factory_sequence_alignment'] ) && is_array( $GLOBALS['wp_sqlite_duckdb_child_factory_sequence_alignment'] ) +\t\t\t? $GLOBALS['wp_sqlite_duckdb_child_factory_sequence_alignment'] +\t\t\t: null; +\t\t$child_database_copy = isset( $GLOBALS['wp_sqlite_duckdb_child_database_copy'] ) && is_array( $GLOBALS['wp_sqlite_duckdb_child_database_copy'] ) +\t\t\t? $GLOBALS['wp_sqlite_duckdb_child_database_copy'] +\t\t\t: null; + +\t\tif ( class_exists( 'WP_DuckDB_Runtime', false ) && method_exists( 'WP_DuckDB_Runtime', 'get_unavailable_reason' ) ) { +\t\t\ttry { +\t\t\t\t$reason = WP_DuckDB_Runtime::get_unavailable_reason( false ); +\t\t\t} catch ( Throwable $e ) { +\t\t\t\t$reason = get_class( $e ) . ': ' . $e->getMessage(); +\t\t\t} +\t\t} + +\t\t$bootstrap_failed = 'wp_bootstrap' === $stage && ( +\t\t\t'duckdb' !== $db_engine +\t\t\t|| ! is_string( $autoload ) +\t\t\t|| ! is_readable( $autoload ) +\t\t\t|| ! class_exists( 'WP_DuckDB_Runtime', false ) +\t\t\t|| null !== $reason +\t\t\t|| 'WP_DuckDB_DB' !== $wpdb +\t\t); + +\t\tif ( ! $force && ! wp_sqlite_duckdb_child_diagnostics_verbose() && ! $bootstrap_failed && ! ( 'shutdown' === $stage && $is_fatal ) ) { +\t\t\treturn; +\t\t} + +\t\t$output_buffer_snapshot = 'shutdown' === $stage || $is_fatal +\t\t\t? wp_sqlite_duckdb_child_diagnostics_output_buffer_snapshot() +\t\t\t: null; +\t\t$output_buffer_size = is_array( $output_buffer_snapshot ) && isset( $output_buffer_snapshot['length'] ) +\t\t\t? $output_buffer_snapshot['length'] +\t\t\t: null; +\t\t$output_buffer_sha1 = is_array( $output_buffer_snapshot ) && isset( $output_buffer_snapshot['sha1'] ) +\t\t\t? $output_buffer_snapshot['sha1'] +\t\t\t: null; +\t\t$output_buffer_tail_truncated = is_array( $output_buffer_snapshot ) && isset( $output_buffer_snapshot['tail_truncated'] ) +\t\t\t? $output_buffer_snapshot['tail_truncated'] +\t\t\t: null; +\t\t$output_buffer_tail_base64 = is_array( $output_buffer_snapshot ) && isset( $output_buffer_snapshot['tail_base64'] ) +\t\t\t? $output_buffer_snapshot['tail_base64'] +\t\t\t: null; + +\t\t$payload = array( +\t\t\t'stage' => $stage, +\t\t\t'last_stage' => $last_stage, +\t\t\t'pid' => getmypid(), +\t\t\t'ppid' => function_exists( 'posix_getppid' ) ? posix_getppid() : null, +\t\t\t'php_sapi' => PHP_SAPI, +\t\t\t'php_version' => PHP_VERSION, +\t\t\t'php_binary' => PHP_BINARY, +\t\t\t'cwd' => getcwd(), +\t\t\t'argv' => isset( $_SERVER['argv'] ) ? $_SERVER['argv'] : null, +\t\t\t'wp_tests_skip_install' => $wp_tests_skip_install, +\t\t\t'db_engine_defined' => defined( 'DB_ENGINE' ), +\t\t\t'db_engine' => $db_engine, +\t\t\t'duckdb_file_defined' => defined( 'DUCKDB_FILE' ), +\t\t\t'duckdb_file' => $duckdb_file, +\t\t\t'fqduckdb_defined' => defined( 'FQDUCKDB' ), +\t\t\t'fqduckdb' => $fqduckdb, +\t\t\t'duckdb_autoload_defined' => defined( 'DUCKDB_PHP_AUTOLOAD' ), +\t\t\t'duckdb_autoload' => $autoload, +\t\t\t'duckdb_autoload_readable' => is_string( $autoload ) && is_readable( $autoload ), +\t\t\t'ffi_loaded' => extension_loaded( 'ffi' ), +\t\t\t'ffi_enable' => ini_get( 'ffi.enable' ), +\t\t\t'runtime_class_loaded' => class_exists( 'WP_DuckDB_Runtime', false ), +\t\t\t'runtime_unavailable' => $reason, +\t\t\t'duckdb_class_loaded' => class_exists( 'Saturio\\\\DuckDB\\\\DuckDB', false ), +\t\t\t'wpdb_class' => $wpdb, +\t\t\t'wpdb_last_error' => $wpdb_last_error, +\t\t\t'wpdb_last_query' => $wpdb_last_query, +\t\t\t'wpdb_has_dbh_property' => is_object( $wpdb_object ) && property_exists( $wpdb_object, 'dbh' ), +\t\t\t'bootstrap_global' => $bootstrap_global, +\t\t\t'bootstrap_global_readable' => is_string( $bootstrap_global ) && is_readable( $bootstrap_global ), +\t\t\t'bootstrap_realpath' => $bootstrap_realpath, +\t\t\t'bootstrap_included' => $bootstrap_included, +\t\t\t'wp_did_wp_settings' => defined( 'WPINC' ) && function_exists( 'wp' ), +\t\t\t'included_files_count' => count( $included_files ), +\t\t\t'included_files_tail' => $included_files_tail, +\t\t\t'test_identity' => $test_identity, +\t\t\t'result_write' => $result_write, +\t\t\t'factory_sequence_alignment' => $factory_sequence_alignment, +\t\t\t'child_database_copy' => $child_database_copy, +\t\t\t'output_buffer_level' => ob_get_level(), +\t\t\t'output_buffer_size' => $output_buffer_size, +\t\t\t'output_buffer_sha1' => $output_buffer_sha1, +\t\t\t'output_buffer_tail_truncated' => $output_buffer_tail_truncated, +\t\t\t'output_buffer_tail_base64' => $output_buffer_tail_base64, +\t\t\t'result_file' => $result_file, +\t\t\t'result_file_exists' => $result_file_exists, +\t\t\t'result_file_readable' => $result_file_readable, +\t\t\t'result_file_size' => $result_file_size, +\t\t\t'result_file_sha1' => $result_file_sha1, +\t\t\t'result_file_dir_writable' => is_string( $result_file_dir ) && is_writable( $result_file_dir ), +\t\t\t'fatal' => $is_fatal ? $error : null, +\t\t); + +\t\t$encoded = json_encode( $payload ); +\t\tif ( ! is_string( $encoded ) ) { +\t\t\t$encoded = json_encode( +\t\t\t\tarray( +\t\t\t\t\t'stage' => $stage, +\t\t\t\t\t'pid' => getmypid(), +\t\t\t\t\t'json_error' => json_last_error_msg(), +\t\t\t\t\t'fatal' => $is_fatal ? $error : null, +\t\t\t\t) +\t\t\t); +\t\t} + +\t\t$line = 'WP_SQLITE_DUCKDB_CHILD_DIAGNOSTIC ' . $encoded . PHP_EOL; +\t\t@file_put_contents( ${ phpSingleQuote( duckdbChildDiagnosticsLogContainerPath ) }, $line, FILE_APPEND | LOCK_EX ); + +\t\tif ( wp_sqlite_duckdb_child_diagnostics_stderr_enabled() ) { +\t\t\tfwrite( STDERR, $line ); +\t\t} +\t} + +\tregister_shutdown_function( +\t\tfunction () { +\t\t\twp_sqlite_duckdb_child_diagnostics_report( 'shutdown', true ); +\t\t} +\t); +} +` + ); +} + +function resetDuckDBChildDiagnosticsLog() { + if ( enableDuckDBChildDiagnostics ) { + fs.rmSync( duckdbChildDiagnosticsLogPath, { force: true } ); + } +} + +function printDuckDBChildDiagnosticsLog() { + if ( ! enableDuckDBChildDiagnostics ) { + return; + } + + if ( ! fs.existsSync( duckdbChildDiagnosticsLogPath ) ) { + console.log( `No DuckDB child diagnostics log found at ${ duckdbChildDiagnosticsLogPath }.` ); + return; + } + + const contents = fs.readFileSync( duckdbChildDiagnosticsLogPath, 'utf8' ).trimEnd(); + if ( '' === contents ) { + console.log( `DuckDB child diagnostics log is empty at ${ duckdbChildDiagnosticsLogPath }.` ); + return; + } + + const lines = contents.split( /\r?\n/ ); + const maxLines = 1200; + const tail = lines.slice( -maxLines ); + console.log( `WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_LOG path=${ duckdbChildDiagnosticsLogPath} lines=${ lines.length } emitted_lines=${ tail.length }` ); + console.log( 'WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_LOG_BEGIN' ); + if ( lines.length > maxLines ) { + console.log( `... truncated ${ lines.length - maxLines } earlier diagnostic lines ...` ); + } + console.log( tail.join( '\n' ) ); + console.log( 'WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_LOG_END' ); +} + +function getDuckDBChildDiagnosticsPrependPhp() { + if ( ! enableDuckDBChildDiagnostics ) { + return ''; + } + + return [ + `if ( is_readable( ${ phpSingleQuote( duckdbChildDiagnosticsContainerPath ) } ) ) {`, + `\trequire_once ${ phpSingleQuote( duckdbChildDiagnosticsContainerPath ) };`, + "\twp_sqlite_duckdb_child_diagnostics_report( 'prepend' );", + '}', + ].join( '\n' ); +} + +function patchWordPressPhpunitBootstrapForChildDiagnostics() { + const file = path.join( repoRoot, 'wordpress', 'tests', 'phpunit', 'includes', 'bootstrap.php' ); + const marker = "require_once ABSPATH . 'wp-settings.php';"; + const entryGuard = [ + ' [ + `${ indent }if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {`, + `${ indent }\twp_sqlite_duckdb_child_diagnostics_report( '${ stage }', true );`, + `${ indent }}`, + ].join( '\n' ); + const beforeBootstrapReport = ( stage, needle, indent = '' ) => [ + bootstrapReport( stage, indent ), + needle, + ].join( '\n' ); + const aroundBootstrapReport = ( beforeStage, afterStage, needle, indent = '' ) => [ + bootstrapReport( beforeStage, indent ), + needle, + bootstrapReport( afterStage, indent ), + ].join( '\n' ); + const earlyBootstrapReplacements = [ + { + stage: 'bootstrap_before_config_readable_check', + needle: 'if ( ! is_readable( $config_file_path ) ) {', + replacement: beforeBootstrapReport( 'bootstrap_before_config_readable_check', 'if ( ! is_readable( $config_file_path ) ) {' ), + }, + { + stage: 'bootstrap_config_missing_exit', + needle: "\techo 'Error: wp-tests-config.php is missing! Please use wp-tests-config-sample.php to create a config file.' . PHP_EOL;", + replacement: beforeBootstrapReport( 'bootstrap_config_missing_exit', "\techo 'Error: wp-tests-config.php is missing! Please use wp-tests-config-sample.php to create a config file.' . PHP_EOL;", '\t' ), + }, + { + stage: 'bootstrap_after_config_require', + needle: 'require_once $config_file_path;', + replacement: aroundBootstrapReport( 'bootstrap_before_config_require', 'bootstrap_after_config_require', 'require_once $config_file_path;' ), + }, + { + stage: 'bootstrap_after_functions_require', + needle: "require_once __DIR__ . '/functions.php';", + replacement: aroundBootstrapReport( 'bootstrap_before_functions_require', 'bootstrap_after_functions_require', "require_once __DIR__ . '/functions.php';" ), + }, + { + stage: 'bootstrap_before_core_path_check', + needle: "if ( defined( 'WP_RUN_CORE_TESTS' ) && WP_RUN_CORE_TESTS && ! is_dir( ABSPATH ) ) {", + replacement: beforeBootstrapReport( 'bootstrap_before_core_path_check', "if ( defined( 'WP_RUN_CORE_TESTS' ) && WP_RUN_CORE_TESTS && ! is_dir( ABSPATH ) ) {" ), + }, + { + stage: 'bootstrap_before_phpunit_version', + needle: '$phpunit_version = tests_get_phpunit_version();', + replacement: beforeBootstrapReport( 'bootstrap_before_phpunit_version', '$phpunit_version = tests_get_phpunit_version();' ), + }, + { + stage: 'bootstrap_before_polyfills_check', + needle: "if ( ! class_exists( 'Yoast\\PHPUnitPolyfills\\Autoload' ) ) {", + replacement: beforeBootstrapReport( 'bootstrap_before_polyfills_check', "if ( ! class_exists( 'Yoast\\PHPUnitPolyfills\\Autoload' ) ) {" ), + }, + { + stage: 'bootstrap_polyfills_missing_exit', + needle: "\tif ( $phpunit_polyfills_error || ! file_exists( $phpunit_polyfills_autoloader ) ) {", + replacement: beforeBootstrapReport( 'bootstrap_polyfills_missing_exit', "\tif ( $phpunit_polyfills_error || ! file_exists( $phpunit_polyfills_autoloader ) ) {", '\t' ), + }, + { + stage: 'bootstrap_before_required_constants_check', + needle: '$required_constants = array(', + replacement: beforeBootstrapReport( 'bootstrap_before_required_constants_check', '$required_constants = array(' ), + }, + { + stage: 'bootstrap_before_tests_reset_server', + needle: 'tests_reset__SERVER();', + replacement: beforeBootstrapReport( 'bootstrap_before_tests_reset_server', 'tests_reset__SERVER();' ), + }, + { + stage: 'bootstrap_before_install_php_branch', + needle: "if ( '1' !== getenv( 'WP_TESTS_SKIP_INSTALL' ) ) {", + replacement: beforeBootstrapReport( 'bootstrap_before_install_php_branch', "if ( '1' !== getenv( 'WP_TESTS_SKIP_INSTALL' ) ) {" ), + }, + { + stage: 'bootstrap_after_install_php_process', + needle: "\tsystem( WP_PHP_BINARY . ' ' . escapeshellarg( __DIR__ . '/install.php' ) . ' ' . escapeshellarg( $config_file_path ) . ' ' . $ms_tests . ' ' . $core_tests, $retval );", + replacement: aroundBootstrapReport( + 'bootstrap_before_install_php_process', + 'bootstrap_after_install_php_process', + "\tsystem( WP_PHP_BINARY . ' ' . escapeshellarg( __DIR__ . '/install.php' ) . ' ' . escapeshellarg( $config_file_path ) . ' ' . $ms_tests . ' ' . $core_tests, $retval );", + '\t' + ), + }, + { + stage: 'bootstrap_install_php_failed_exit', + needle: "\tif ( 0 !== $retval ) {", + replacement: beforeBootstrapReport( 'bootstrap_install_php_failed_exit', "\tif ( 0 !== $retval ) {", '\t' ), + }, + { + stage: 'bootstrap_before_multisite_branch', + needle: 'if ( $multisite ) {', + replacement: beforeBootstrapReport( 'bootstrap_before_multisite_branch', 'if ( $multisite ) {' ), + }, + { + stage: 'bootstrap_before_wp_settings_filters', + needle: "$GLOBALS['_wp_die_disabled'] = false;", + replacement: beforeBootstrapReport( 'bootstrap_before_wp_settings_filters', "$GLOBALS['_wp_die_disabled'] = false;" ), + }, + ]; + + if ( ! fs.existsSync( file ) ) { + console.error( `Error: WordPress PHPUnit bootstrap file not found at ${ file }.` ); + process.exit( 1 ); + } + + let contents = fs.readFileSync( file, 'utf8' ); + let changed = false; + let patchedBootstrapTargets = 0; + const missingBootstrapTargets = []; + + if ( ! contents.includes( 'bootstrap_file_entry' ) ) { + if ( ! contents.startsWith( ' true, +\t\t\t'cleanup_success' => true, +\t\t\t'cleanup_paths' => array(), +\t\t); + +\t\ttry { +\t\t\tif ( isset( $GLOBALS['wpdb'] ) && is_object( $GLOBALS['wpdb'] ) ) { +\t\t\t\tif ( method_exists( $GLOBALS['wpdb'], 'flush' ) ) { +\t\t\t\t\t$GLOBALS['wpdb']->flush(); +\t\t\t\t} +\t\t\t\tif ( method_exists( $GLOBALS['wpdb'], 'close' ) ) { +\t\t\t\t\t$GLOBALS['wpdb']->close(); +\t\t\t\t} +\t\t\t} +\t\t} catch ( Throwable $e ) { +\t\t\t$cleanup['close_error'] = get_class( $e ) . ': ' . $e->getMessage(); +\t\t} + +\t\tunset( $GLOBALS['@duckdb_driver'], $GLOBALS['@duckdb'] ); +\t\tif ( function_exists( 'gc_collect_cycles' ) ) { +\t\t\t$cleanup['gc_cycles'] = array( gc_collect_cycles(), gc_collect_cycles() ); +\t\t} + +\t\tforeach ( array_reverse( $paths ) as $path ) { +\t\t\t$item = array( +\t\t\t\t'path' => $path, +\t\t\t\t'existed_before' => is_string( $path ) && file_exists( $path ), +\t\t\t\t'deleted' => false, +\t\t\t); +\t\t\tif ( is_string( $path ) && is_file( $path ) ) { +\t\t\t\t$item['deleted'] = @unlink( $path ); +\t\t\t\tif ( ! $item['deleted'] ) { +\t\t\t\t\t$cleanup['cleanup_success'] = false; +\t\t\t\t} +\t\t\t} +\t\t\t$item['exists_after'] = is_string( $path ) && file_exists( $path ); +\t\t\t$cleanup['cleanup_paths'][] = $item; +\t\t} + +\t\twp_sqlite_duckdb_child_database_copy_record( $cleanup ); +\t\tif ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) { +\t\t\twp_sqlite_duckdb_child_diagnostics_report( 'child_db_copy_cleanup', true ); +\t\t} +\t} + +\tfunction wp_sqlite_duckdb_prepare_child_database_copy() { +\t\tif ( '1' !== getenv( 'WP_SQLITE_DUCKDB_CHILD_DB_COPY' ) ) { +\t\t\treturn; +\t\t} +\t\tif ( '1' !== getenv( 'WP_TESTS_SKIP_INSTALL' ) ) { +\t\t\treturn; +\t\t} +\t\tif ( defined( 'DB_ENGINE' ) && 'duckdb' !== strtolower( (string) DB_ENGINE ) ) { +\t\t\treturn; +\t\t} +\t\t$existing_copy = isset( $GLOBALS['wp_sqlite_duckdb_child_database_copy'] ) && is_array( $GLOBALS['wp_sqlite_duckdb_child_database_copy'] ) +\t\t\t? $GLOBALS['wp_sqlite_duckdb_child_database_copy'] +\t\t\t: array(); +\t\t$existing_duckdb_file = isset( $existing_copy['duckdb_file'] ) && is_string( $existing_copy['duckdb_file'] ) +\t\t\t? $existing_copy['duckdb_file'] +\t\t\t: null; +\t\t$existing_target_path = isset( $existing_copy['target_path'] ) && is_string( $existing_copy['target_path'] ) +\t\t\t? $existing_copy['target_path'] +\t\t\t: null; +\t\tif ( +\t\t\tisset( $existing_copy['copy_success'] ) && +\t\t\ttrue === $existing_copy['copy_success'] && +\t\t\tdefined( 'DUCKDB_FILE' ) && +\t\t\tis_string( $existing_duckdb_file ) && +\t\t\tis_string( $existing_target_path ) && +\t\t\tis_file( $existing_target_path ) && +\t\t\tDUCKDB_FILE === $existing_duckdb_file +\t\t) { +\t\t\twp_sqlite_duckdb_child_database_copy_record( +\t\t\t\tarray( +\t\t\t\t\t'reused_existing_copy' => true, +\t\t\t\t) +\t\t\t); +\t\t\treturn; +\t\t} +\t\tif ( defined( 'FQDUCKDB' ) ) { +\t\t\twp_sqlite_duckdb_child_database_copy_record( +\t\t\t\tarray( +\t\t\t\t\t'enabled' => true, +\t\t\t\t\t'attempted' => false, +\t\t\t\t\t'copy_success' => false, +\t\t\t\t\t'copy_error' => 'FQDUCKDB was already defined before the child copy hook.', +\t\t\t\t\t'fqduckdb' => FQDUCKDB, +\t\t\t\t) +\t\t\t); +\t\t\tif ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) { +\t\t\t\twp_sqlite_duckdb_child_diagnostics_report( 'after_child_db_copy', true ); +\t\t\t} +\t\t\tfwrite( STDERR, 'Error: DuckDB child DB copy hook ran after FQDUCKDB was defined.' . PHP_EOL ); +\t\t\texit( 1 ); +\t\t} +\t\tif ( defined( 'DUCKDB_FILE' ) ) { +\t\t\twp_sqlite_duckdb_child_database_copy_record( +\t\t\t\tarray( +\t\t\t\t\t'enabled' => true, +\t\t\t\t\t'attempted' => false, +\t\t\t\t\t'copy_success' => false, +\t\t\t\t\t'copy_error' => 'DUCKDB_FILE was already defined before the child copy hook.', +\t\t\t\t\t'duckdb_file' => DUCKDB_FILE, +\t\t\t\t) +\t\t\t); +\t\t\tif ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) { +\t\t\t\twp_sqlite_duckdb_child_diagnostics_report( 'after_child_db_copy', true ); +\t\t\t} +\t\t\tfwrite( STDERR, 'Error: DuckDB child DB copy hook cannot override an existing DUCKDB_FILE.' . PHP_EOL ); +\t\t\texit( 1 ); +\t\t} + +\t\t$result_file = isset( $GLOBALS['wp_sqlite_duckdb_child_result_file'] ) && is_string( $GLOBALS['wp_sqlite_duckdb_child_result_file'] ) +\t\t\t? $GLOBALS['wp_sqlite_duckdb_child_result_file'] +\t\t\t: ''; +\t\t$database_dir = wp_sqlite_duckdb_child_database_copy_dir(); +\t\t$hash = substr( sha1( $result_file . '|' . getmypid() ), 0, 16 ); +\t\t$duckdb_file = '.ht.duckdb.phpunit-' . getmypid() . '-' . $hash . '.duckdb'; +\t\t$source_path = is_string( $database_dir ) ? $database_dir . '.ht.duckdb' : null; +\t\t$target_path = is_string( $database_dir ) ? $database_dir . $duckdb_file : null; +\t\t$copy_paths = array(); +\t\t$sidecars = array(); +\t\t$copy_error = null; + +\t\twp_sqlite_duckdb_child_database_copy_record( +\t\t\tarray( +\t\t\t\t'enabled' => true, +\t\t\t\t'attempted' => true, +\t\t\t\t'result_file' => $result_file, +\t\t\t\t'database_dir' => $database_dir, +\t\t\t\t'source_path' => $source_path, +\t\t\t\t'source_exists' => is_string( $source_path ) && file_exists( $source_path ), +\t\t\t\t'source_readable' => is_string( $source_path ) && is_readable( $source_path ), +\t\t\t\t'source_size' => wp_sqlite_duckdb_child_database_copy_size( $source_path ), +\t\t\t\t'duckdb_file' => $duckdb_file, +\t\t\t\t'target_path' => $target_path, +\t\t\t\t'target_exists_before' => is_string( $target_path ) && file_exists( $target_path ), +\t\t\t\t'fqduckdb_defined_before_copy' => defined( 'FQDUCKDB' ), +\t\t\t\t'duckdb_file_defined_before_copy' => defined( 'DUCKDB_FILE' ), +\t\t\t) +\t\t); +\t\tif ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) { +\t\t\twp_sqlite_duckdb_child_diagnostics_report( 'before_child_db_copy', true ); +\t\t} + +\t\tif ( '' === $result_file ) { +\t\t\t$copy_error = 'Missing PHPUnit process result file for child DB copy.'; +\t\t} elseif ( ! is_string( $database_dir ) || '' === $database_dir ) { +\t\t\t$copy_error = 'Unable to resolve WordPress database directory for child DB copy.'; +\t\t} elseif ( ! is_string( $source_path ) || ! is_file( $source_path ) || ! is_readable( $source_path ) ) { +\t\t\t$copy_error = 'Source DuckDB database is not readable for child DB copy.'; +\t\t} elseif ( ! is_dir( $database_dir ) || ! is_writable( $database_dir ) ) { +\t\t\t$copy_error = 'WordPress database directory is not writable for child DB copy.'; +\t\t} elseif ( ! is_string( $target_path ) || ! copy( $source_path, $target_path ) ) { +\t\t\t$copy_error = 'Failed to copy source DuckDB database for child process.'; +\t\t} else { +\t\t\t$copy_paths[] = $target_path; +\t\t\tforeach ( array( '.wal' ) as $suffix ) { +\t\t\t\t$source_sidecar = $source_path . $suffix; +\t\t\t\t$target_sidecar = $target_path . $suffix; +\t\t\t\t$sidecar = array( +\t\t\t\t\t'suffix' => $suffix, +\t\t\t\t\t'source_path' => $source_sidecar, +\t\t\t\t\t'source_exists' => file_exists( $source_sidecar ), +\t\t\t\t\t'source_size' => wp_sqlite_duckdb_child_database_copy_size( $source_sidecar ), +\t\t\t\t\t'target_path' => $target_sidecar, +\t\t\t\t\t'copied' => false, +\t\t\t\t); +\t\t\t\tif ( is_file( $source_sidecar ) ) { +\t\t\t\t\t$sidecar['copied'] = copy( $source_sidecar, $target_sidecar ); +\t\t\t\t\t$sidecar['target_size'] = wp_sqlite_duckdb_child_database_copy_size( $target_sidecar ); +\t\t\t\t\tif ( $sidecar['copied'] ) { +\t\t\t\t\t\t$copy_paths[] = $target_sidecar; +\t\t\t\t\t} else { +\t\t\t\t\t\t$copy_error = 'Failed to copy DuckDB sidecar for child process: ' . $suffix; +\t\t\t\t\t} +\t\t\t\t} +\t\t\t\t$sidecars[] = $sidecar; +\t\t\t} +\t\t} + +\t\tif ( null === $copy_error ) { +\t\t\tdefine( 'DUCKDB_FILE', $duckdb_file ); +\t\t\tputenv( 'DUCKDB_FILE=' . $duckdb_file ); +\t\t\t$_ENV['DUCKDB_FILE'] = $duckdb_file; +\t\t\t$_SERVER['DUCKDB_FILE'] = $duckdb_file; +\t\t\tregister_shutdown_function( 'wp_sqlite_duckdb_child_database_copy_cleanup' ); +\t\t} + +\t\twp_sqlite_duckdb_child_database_copy_record( +\t\t\tarray( +\t\t\t\t'copy_success' => null === $copy_error, +\t\t\t\t'copy_error' => $copy_error, +\t\t\t\t'target_exists_after' => is_string( $target_path ) && file_exists( $target_path ), +\t\t\t\t'target_size' => wp_sqlite_duckdb_child_database_copy_size( $target_path ), +\t\t\t\t'sidecars' => $sidecars, +\t\t\t\t'cleanup_paths' => $copy_paths, +\t\t\t\t'cleanup_registered' => null === $copy_error, +\t\t\t\t'duckdb_file_defined_after_copy' => defined( 'DUCKDB_FILE' ), +\t\t\t) +\t\t); +\t\tif ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) { +\t\t\twp_sqlite_duckdb_child_diagnostics_report( 'after_child_db_copy', true ); +\t\t} + +\t\tif ( null !== $copy_error ) { +\t\t\tforeach ( array_reverse( $copy_paths ) as $path ) { +\t\t\t\tif ( is_string( $path ) && is_file( $path ) ) { +\t\t\t\t\t@unlink( $path ); +\t\t\t\t} +\t\t\t} +\t\t\tfwrite( STDERR, 'Error: ' . $copy_error . PHP_EOL ); +\t\t\texit( 1 ); +\t\t} +\t} +}`; +} + +function getDuckDBChildFactorySequenceAlignmentPhp() { + return `if ( ! function_exists( 'wp_sqlite_duckdb_align_child_factory_sequence_with_database' ) ) { +\tfunction wp_sqlite_duckdb_align_child_factory_sequence_with_database() { +\t\tif ( '1' !== getenv( 'WP_TESTS_SKIP_INSTALL' ) ) { +\t\t\treturn; +\t\t} +\t\tif ( ! class_exists( 'WP_UnitTest_Generator_Sequence', false ) ) { +\t\t\treturn; +\t\t} +\t\tif ( ! isset( $GLOBALS['wpdb'] ) || ! is_object( $GLOBALS['wpdb'] ) ) { +\t\t\treturn; +\t\t} + +\t\t$wpdb = $GLOBALS['wpdb']; +\t\tif ( empty( $wpdb->users ) || ! method_exists( $wpdb, 'get_col' ) ) { +\t\t\treturn; +\t\t} + +\t\ttry { +\t\t\t$existing_values = $wpdb->get_col( +\t\t\t\t"SELECT user_login FROM {$wpdb->users} WHERE user_login LIKE 'User %' +\t\t\t\tUNION ALL +\t\t\t\tSELECT user_email FROM {$wpdb->users} WHERE user_email LIKE 'user_%@example.org'" +\t\t\t); +\t\t} catch ( Throwable $e ) { +\t\t\t$GLOBALS['wp_sqlite_duckdb_child_factory_sequence_alignment'] = array( +\t\t\t\t'error' => get_class( $e ) . ': ' . $e->getMessage(), +\t\t\t); +\t\t\treturn; +\t\t} + +\t\t$max_suffix = null; +\t\tforeach ( (array) $existing_values as $value ) { +\t\t\tif ( preg_match( '/(?:^User |^user_)([0-9]+)(?:@example\\.org)?$/', (string) $value, $matches ) ) { +\t\t\t\t$suffix = (int) $matches[1]; +\t\t\t\t$max_suffix = null === $max_suffix ? $suffix : max( $max_suffix, $suffix ); +\t\t\t} +\t\t} + +\t\tif ( null === $max_suffix ) { +\t\t\t$GLOBALS['wp_sqlite_duckdb_child_factory_sequence_alignment'] = array( +\t\t\t\t'values_inspected' => count( (array) $existing_values ), +\t\t\t\t'max_existing_suffix' => null, +\t\t\t); +\t\t\treturn; +\t\t} + +\t\t$current_incr = is_numeric( WP_UnitTest_Generator_Sequence::$incr ) +\t\t\t? (int) WP_UnitTest_Generator_Sequence::$incr +\t\t\t: -1; +\t\t$new_incr = max( $current_incr, $max_suffix ); +\t\tWP_UnitTest_Generator_Sequence::$incr = $new_incr; + +\t\t$GLOBALS['wp_sqlite_duckdb_child_factory_sequence_alignment'] = array( +\t\t\t'values_inspected' => count( (array) $existing_values ), +\t\t\t'previous_incr' => $current_incr, +\t\t\t'max_existing_suffix' => $max_suffix, +\t\t\t'new_incr' => $new_incr, +\t\t); + +\t\tif ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) { +\t\t\twp_sqlite_duckdb_child_diagnostics_report( 'after_factory_sequence_alignment', true ); +\t\t} +\t} + }`; +} + +function getDuckDBChildTemplateDiagnosticsPhp() { + if ( ! enableDuckDBChildDiagnostics ) { + return ''; + } + + const verbose = process.env.WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_VERBOSE || '0'; + const stderr = process.env.WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_STDERR || '0'; + + return [ + "putenv( 'WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS=1' );", + `putenv( ${ phpSingleQuote( `WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_VERBOSE=${ verbose }` ) } );`, + `putenv( ${ phpSingleQuote( `WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_STDERR=${ stderr }` ) } );`, + `if ( is_readable( ${ phpSingleQuote( duckdbChildDiagnosticsContainerPath ) } ) ) {`, + `\trequire_once ${ phpSingleQuote( duckdbChildDiagnosticsContainerPath ) };`, + "\twp_sqlite_duckdb_child_diagnostics_report( 'phpunit_child_template_start', true );", + '}', + ].join( '\n' ); +} + +function getDuckDBChildInstrumentationIsolationPhp() { + return [ + "if ( '1' !== getenv( 'WP_SQLITE_DUCKDB_CHILD_QUERY_PROFILE' ) ) {", + "\tputenv( 'WP_DUCKDB_QUERY_PROFILE=0' );", + "\t$_ENV[ 'WP_DUCKDB_QUERY_PROFILE' ] = '0';", + "\t$_SERVER[ 'WP_DUCKDB_QUERY_PROFILE' ] = '0';", + "\tputenv( 'WP_DUCKDB_QUERY_PROFILE_INTERVAL=0' );", + "\t$_ENV[ 'WP_DUCKDB_QUERY_PROFILE_INTERVAL' ] = '0';", + "\t$_SERVER[ 'WP_DUCKDB_QUERY_PROFILE_INTERVAL' ] = '0';", + '}', + "if ( '1' !== getenv( 'WP_SQLITE_DUCKDB_CHILD_RUNTIME_COUNTERS' ) ) {", + "\tputenv( 'WP_DUCKDB_RUNTIME_COUNTERS=0' );", + "\t$_ENV[ 'WP_DUCKDB_RUNTIME_COUNTERS' ] = '0';", + "\t$_SERVER[ 'WP_DUCKDB_RUNTIME_COUNTERS' ] = '0';", + '}', + ].join( '\n' ); +} + +function patchPhpunitChildProcessTemplatesForDiagnostics() { + const templateDir = '/var/www/vendor/phpunit/phpunit/src/Util/PHP/Template'; + const templateNames = [ 'TestCaseClass.tpl', 'TestCaseMethod.tpl' ]; + const insertionPoint = "if (!defined('STDOUT')) {"; + const snippet = [ + '/* DuckDB child-process diagnostics. Generated by the SQLite integration workflow. */', + `if ( ! defined( 'DUCKDB_PHP_AUTOLOAD' ) ) {`, + `\tdefine( 'DUCKDB_PHP_AUTOLOAD', ${ phpSingleQuote( duckdbAutoloadCompatibilityWrapperContainerPath ) } );`, + '}', + `putenv( ${ phpSingleQuote( `DUCKDB_PHP_AUTOLOAD=${ duckdbAutoloadCompatibilityWrapperContainerPath }` ) } );`, + `$_ENV[ 'DUCKDB_PHP_AUTOLOAD' ] = ${ phpSingleQuote( duckdbAutoloadCompatibilityWrapperContainerPath ) };`, + `$_SERVER[ 'DUCKDB_PHP_AUTOLOAD' ] = ${ phpSingleQuote( duckdbAutoloadCompatibilityWrapperContainerPath ) };`, + "if ( ! defined( 'DB_ENGINE' ) ) {", + "\tdefine( 'DB_ENGINE', 'duckdb' );", + '}', + "putenv( 'DB_ENGINE=duckdb' );", + "$_ENV[ 'DB_ENGINE' ] = 'duckdb';", + "$_SERVER[ 'DB_ENGINE' ] = 'duckdb';", + "putenv( 'WP_TESTS_SKIP_INSTALL=1' );", + "$_ENV[ 'WP_TESTS_SKIP_INSTALL' ] = '1';", + "$_SERVER[ 'WP_TESTS_SKIP_INSTALL' ] = '1';", + getPhpunitForwardedEnvironmentPhp(), + getDuckDBChildInstrumentationIsolationPhp(), + "if ( defined( 'DUCKDB_PHP_AUTOLOAD' ) && is_readable( DUCKDB_PHP_AUTOLOAD ) ) {", + "\trequire_once DUCKDB_PHP_AUTOLOAD;", + '}', + `if ( empty( $GLOBALS['__PHPUNIT_BOOTSTRAP'] ) && is_readable( ${ phpSingleQuote( wordPressPhpunitBootstrapContainerPath ) } ) ) {`, + `\t$GLOBALS['__PHPUNIT_BOOTSTRAP'] = ${ phpSingleQuote( wordPressPhpunitBootstrapContainerPath ) };`, + '}', + "$GLOBALS['wp_sqlite_duckdb_child_result_file'] = '{processResultFile}';", + getDuckDBChildTemplateDiagnosticsPhp(), + getDuckDBChildDatabaseCopyPhp(), + getDuckDBChildFactorySequenceAlignmentPhp(), + '', + ].join( '\n' ); + const lifecycleReplacements = [ + [ + "ini_set('display_errors', 'stderr');", + [ + "if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'before_display_errors_setup', true );", + '}', + "ini_set('display_errors', 'stderr');", + "if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'after_display_errors_setup', true );", + '}', + ].join( '\n' ), + ], + [ + "if ($composerAutoload) {", + [ + "if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'before_composer_autoload', true );", + '}', + "if ($composerAutoload) {", + ].join( '\n' ), + ], + [ + 'function __phpunit_run_isolated_test()', + [ + "if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'after_composer_autoload', true );", + '}', + 'function __phpunit_run_isolated_test()', + ].join( '\n' ), + ], + [ + ' $test->run($result);', + [ + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'before_test_run', true );", + ' }', + ' $test->run($result);', + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'after_test_run', true );", + ' }', + ].join( '\n' ), + ], + [ + [ + ' file_put_contents(', + " '{processResultFile}',", + ' serialize(', + ' [', + " 'testResult' => $test->getResult(),", + " 'numAssertions' => $test->getNumAssertions(),", + " 'result' => $result,", + " 'output' => $output", + ' ]', + ' )', + ' );', + ].join( '\n' ), + [ + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'before_process_result_write', true );", + ' }', + ' $__wp_sqlite_duckdb_child_result_payload = serialize(', + ' [', + " 'testResult' => $test->getResult(),", + " 'numAssertions' => $test->getNumAssertions(),", + " 'result' => $result,", + " 'output' => $output", + ' ]', + ' );', + ' $__wp_sqlite_duckdb_child_result_bytes = file_put_contents(', + " '{processResultFile}',", + ' $__wp_sqlite_duckdb_child_result_payload', + ' );', + " $GLOBALS['wp_sqlite_duckdb_child_result_write'] = [", + " 'payload_length' => strlen( $__wp_sqlite_duckdb_child_result_payload ),", + " 'bytes' => $__wp_sqlite_duckdb_child_result_bytes,", + " 'success' => false !== $__wp_sqlite_duckdb_child_result_bytes,", + " 'file_size' => is_file( '{processResultFile}' ) ? filesize( '{processResultFile}' ) : null,", + " 'file_sha1' => is_file( '{processResultFile}' ) ? sha1_file( '{processResultFile}' ) : null,", + ' ];', + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'after_process_result_write', true );", + ' }', + ].join( '\n' ), + ], + [ + '{included_files}', + [ + "if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'before_included_files_restore', true );", + '}', + '{included_files}', + "if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'after_included_files_restore', true );", + '}', + ].join( '\n' ), + ], + [ + '{globals}', + [ + "if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'before_globals_restore', true );", + '}', + '{globals}', + "if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'after_globals_restore', true );", + '}', + ].join( '\n' ), + ], + [ + "if (isset($GLOBALS['__PHPUNIT_BOOTSTRAP'])) {", + [ + "if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'before_bootstrap_check', true );", + '}', + "if (isset($GLOBALS['__PHPUNIT_BOOTSTRAP'])) {", + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'before_bootstrap_require', true );", + ' }', + ].join( '\n' ), + ], + [ + " unset($GLOBALS['__PHPUNIT_BOOTSTRAP']);", + [ + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'after_bootstrap_require', true );", + ' }', + " if ( function_exists( 'wp_sqlite_duckdb_align_child_factory_sequence_with_database' ) ) {", + ' wp_sqlite_duckdb_align_child_factory_sequence_with_database();', + ' }', + " unset($GLOBALS['__PHPUNIT_BOOTSTRAP']);", + ].join( '\n' ), + ], + [ + '__phpunit_run_isolated_test();', + [ + "if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'before_isolated_test_entry', true );", + '}', + '__phpunit_run_isolated_test();', + "if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'after_isolated_test_entry', true );", + '}', + ].join( '\n' ), + ], + ]; + const optionalLifecycleReplacements = [ + [ + " $test = new {className}('{name}', unserialize('{data}'), '{dataName}');", + [ + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'before_test_construct', true );", + ' }', + " $test = new {className}('{name}', unserialize('{data}'), '{dataName}');", + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_test_identity' ) ) {", + " $GLOBALS['wp_sqlite_duckdb_child_test_identity'] = wp_sqlite_duckdb_child_diagnostics_test_identity( $test );", + ' }', + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'after_test_construct', true );", + ' }', + ].join( '\n' ), + ], + [ + " $test = new {className}('{methodName}', unserialize('{data}'), '{dataName}');", + [ + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'before_test_construct', true );", + ' }', + " $test = new {className}('{methodName}', unserialize('{data}'), '{dataName}');", + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_test_identity' ) ) {", + " $GLOBALS['wp_sqlite_duckdb_child_test_identity'] = wp_sqlite_duckdb_child_diagnostics_test_identity( $test );", + ' }', + " if ( function_exists( 'wp_sqlite_duckdb_child_diagnostics_report' ) ) {", + " wp_sqlite_duckdb_child_diagnostics_report( 'after_test_construct', true );", + ' }', + ].join( '\n' ), + ], + ]; + const patchScript = [ + `$template_dir = ${ phpSingleQuote( templateDir ) };`, + `$template_names = array( ${ templateNames.map( phpSingleQuote ).join( ', ' ) } );`, + `$insertion_point = ${ phpSingleQuote( insertionPoint ) };`, + `$snippet = ${ phpSingleQuote( snippet ) };`, + '$lifecycle_replacements = array(', + ...lifecycleReplacements.map( ( [ needle, replacement ] ) => ( + `\t${ phpSingleQuote( needle ) } => ${ phpSingleQuote( replacement ) },` + ) ), + ');', + '$optional_lifecycle_replacements = array(', + ...optionalLifecycleReplacements.map( ( [ needle, replacement ] ) => ( + `\t${ phpSingleQuote( needle ) } => ${ phpSingleQuote( replacement ) },` + ) ), + ');', + '$patched_templates = 0;', + 'if ( ! is_dir( $template_dir ) ) {', + '\tfwrite( STDERR, "Error: PHPUnit child process template directory not found at {$template_dir}." . PHP_EOL );', + '\texit( 1 );', + '}', + 'foreach ( $template_names as $template_name ) {', + '\t$file = $template_dir . DIRECTORY_SEPARATOR . $template_name;', + '\tif ( ! is_file( $file ) ) {', + '\t\tcontinue;', + '\t}', + '\t$contents = file_get_contents( $file );', + '\tif ( ! is_string( $contents ) ) {', + '\t\tfwrite( STDERR, "Error: Unable to read PHPUnit child process template {$file}." . PHP_EOL );', + '\t\texit( 1 );', + '\t}', + '\t$replace_count = 0;', + "\tif ( false === strpos( $contents, 'phpunit_child_template_start' ) ) {", + '\t\tif ( false !== strpos( $contents, $insertion_point ) ) {', + '\t\t\t$contents = str_replace( $insertion_point, $snippet . $insertion_point, $contents, $replace_count );', + '\t\t} else {', + "\t\t\tif ( 0 !== strpos( $contents, ' $replace_count ) {', + '\t\t\tfwrite( STDERR, "Error: Unable to patch PHPUnit child process template {$file}." . PHP_EOL );', + '\t\t\texit( 1 );', + '\t\t}', + '\t}', + "\tif ( false === strpos( $contents, 'before_process_result_write' ) ) {", + '\t\tforeach ( $lifecycle_replacements as $needle => $replacement ) {', + '\t\t\t$lifecycle_replace_count = 0;', + '\t\t\tif ( false === strpos( $contents, $needle ) ) {', + '\t\t\t\tfwrite( STDERR, "Error: Unable to find lifecycle marker needle in PHPUnit child process template {$file}." . PHP_EOL );', + '\t\t\t\texit( 1 );', + '\t\t\t}', + '\t\t\t$contents = str_replace( $needle, $replacement, $contents, $lifecycle_replace_count );', + '\t\t\tif ( 1 > $lifecycle_replace_count ) {', + '\t\t\t\tfwrite( STDERR, "Error: Unable to patch lifecycle marker in PHPUnit child process template {$file}." . PHP_EOL );', + '\t\t\t\texit( 1 );', + '\t\t\t}', + '\t\t}', + '\t\t$optional_lifecycle_replace_count = 0;', + '\t\tforeach ( $optional_lifecycle_replacements as $needle => $replacement ) {', + '\t\t\t$lifecycle_replace_count = 0;', + '\t\t\t$contents = str_replace( $needle, $replacement, $contents, $lifecycle_replace_count );', + '\t\t\t$optional_lifecycle_replace_count += $lifecycle_replace_count;', + '\t\t}', + '\t\tif ( 1 > $optional_lifecycle_replace_count ) {', + '\t\t\tfwrite( STDERR, "Error: Unable to patch test identity lifecycle marker in PHPUnit child process template {$file}." . PHP_EOL );', + '\t\t\texit( 1 );', + '\t\t}', + '\t}', + '\tif ( false === file_put_contents( $file, $contents ) ) {', + '\t\tfwrite( STDERR, "Error: Unable to write PHPUnit child process template {$file}." . PHP_EOL );', + '\t\texit( 1 );', + '\t}', + '\t++$patched_templates;', + '}', + 'if ( 0 === $patched_templates ) {', + '\tfwrite( STDERR, "Error: No PHPUnit child process templates were patched under {$template_dir}." . PHP_EOL );', + '\texit( 1 );', + '}', + ].join( '\n' ); + + runWordPressDockerCompose( [ 'run', '--rm', 'php', 'php', '-r', patchScript ], { stdio: 'inherit' } ); +} + +function patchPhpunitParentProcessIsolationHooks() { + const file = '/var/www/vendor/phpunit/phpunit/src/Framework/TestCase.php'; + const needle = [ + ' $php = AbstractPhpProcess::factory();', + ' $php->runTestJob($template->render(), $this, $result, $processResultFile);', + ].join( '\n' ); + const replacement = [ + ' $php = AbstractPhpProcess::factory();', + " if (function_exists('wp_sqlite_duckdb_release_parent_connection_for_isolated_child')) {", + ' wp_sqlite_duckdb_release_parent_connection_for_isolated_child($processResultFile, $this);', + ' }', + ' try {', + ' $php->runTestJob($template->render(), $this, $result, $processResultFile);', + ' } finally {', + " if (function_exists('wp_sqlite_duckdb_restore_parent_connection_after_isolated_child')) {", + ' wp_sqlite_duckdb_restore_parent_connection_after_isolated_child($processResultFile, $this);', + ' }', + ' }', + ].join( '\n' ); + const patchScript = [ + `$file = ${ phpSingleQuote( file ) };`, + `$needle = ${ phpSingleQuote( needle ) };`, + `$replacement = ${ phpSingleQuote( replacement ) };`, + 'if ( ! is_readable( $file ) ) {', + '\tfwrite( STDERR, "Error: PHPUnit TestCase.php is not readable at {$file}." . PHP_EOL );', + '\texit( 1 );', + '}', + '$contents = file_get_contents( $file );', + 'if ( false === $contents ) {', + '\tfwrite( STDERR, "Error: Unable to read PHPUnit TestCase.php at {$file}." . PHP_EOL );', + '\texit( 1 );', + '}', + "if ( false === strpos( $contents, 'wp_sqlite_duckdb_release_parent_connection_for_isolated_child' ) ) {", + '\t$count = 0;', + '\t$contents = str_replace( $needle, $replacement, $contents, $count );', + '\tif ( 1 !== $count ) {', + '\t\tfwrite( STDERR, "Error: Unable to patch PHPUnit parent process-isolation hook in {$file}." . PHP_EOL );', + '\t\texit( 1 );', + '\t}', + '\tif ( false === file_put_contents( $file, $contents ) ) {', + '\t\tfwrite( STDERR, "Error: Unable to write patched PHPUnit TestCase.php at {$file}." . PHP_EOL );', + '\t\texit( 1 );', + '\t}', + '}', + ].join( '\n' ); + + runWordPressDockerCompose( [ 'run', '--rm', 'php', 'php', '-r', patchScript ], { stdio: 'inherit' } ); +} + +function getPhpunitForwardedEnvironmentPhp() { + const environmentNames = [ + 'WP_DUCKDB_QUERY_PROFILE', + 'WP_DUCKDB_RUNTIME_COUNTERS', + 'WP_DUCKDB_QUERY_PROFILE_INTERVAL', + 'WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS', + 'WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_VERBOSE', + 'WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_STDERR', + 'WP_SQLITE_DUCKDB_CHILD_DB_COPY', + 'WP_SQLITE_DUCKDB_PREPARE_OBJECT_DIAGNOSTICS', + 'WP_SQLITE_DUCKDB_CHILD_QUERY_PROFILE', + 'WP_SQLITE_DUCKDB_CHILD_RUNTIME_COUNTERS', + ]; + + return environmentNames + .filter( name => Object.prototype.hasOwnProperty.call( process.env, name ) ) + .map( name => { + const value = process.env[ name ]; + return [ + `putenv( ${ phpSingleQuote( `${ name }=${ value }` ) } );`, + `$_ENV[ ${ phpSingleQuote( name ) } ] = ${ phpSingleQuote( value ) };`, + `$_SERVER[ ${ phpSingleQuote( name ) } ] = ${ phpSingleQuote( value ) };`, + ].join( '\n' ); + } ) + .join( '\n' ); +} + +function getDuckDBParentProcessIsolationPhp() { + return `if ( ! function_exists( 'wp_sqlite_duckdb_is_active_parent_process' ) ) { +\tfunction wp_sqlite_duckdb_is_active_parent_process() { +\t\tif ( ! defined( 'DB_ENGINE' ) || 'duckdb' !== strtolower( (string) DB_ENGINE ) ) { +\t\t\treturn false; +\t\t} + +\t\treturn isset( $GLOBALS['wpdb'] ) && is_object( $GLOBALS['wpdb'] ); +\t} + +\tfunction wp_sqlite_duckdb_parent_process_isolation_diagnostics_enabled() { +\t\t$value = getenv( 'WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS' ); +\t\treturn is_string( $value ) && '' !== $value && '0' !== $value && 'false' !== strtolower( $value ); +\t} + +\tfunction wp_sqlite_duckdb_parent_process_isolation_test_identity( $test ) { +\t\tif ( ! is_object( $test ) ) { +\t\t\treturn null; +\t\t} + +\t\t$identity = array( +\t\t\t'class' => get_class( $test ), +\t\t\t'name' => null, +\t\t\t'to_string' => null, +\t\t); + +\t\ttry { +\t\t\tif ( method_exists( $test, 'getName' ) ) { +\t\t\t\t$identity['name'] = $test->getName( false ); +\t\t\t} +\t\t} catch ( Throwable $e ) { +\t\t\t$identity['name'] = get_class( $e ) . ': ' . $e->getMessage(); +\t\t} + +\t\ttry { +\t\t\tif ( method_exists( $test, 'toString' ) ) { +\t\t\t\t$identity['to_string'] = $test->toString(); +\t\t\t} +\t\t} catch ( Throwable $e ) { +\t\t\t$identity['to_string'] = get_class( $e ) . ': ' . $e->getMessage(); +\t\t} + +\t\treturn $identity; +\t} + +\tfunction wp_sqlite_duckdb_parent_process_isolation_report( $stage, $process_result_file = null, $test = null, $extra = array() ) { +\t\tif ( ! wp_sqlite_duckdb_parent_process_isolation_diagnostics_enabled() ) { +\t\t\treturn; +\t\t} + +\t\t$wpdb = isset( $GLOBALS['wpdb'] ) && is_object( $GLOBALS['wpdb'] ) +\t\t\t? $GLOBALS['wpdb'] +\t\t\t: null; +\t\t$result_file_size = is_string( $process_result_file ) && file_exists( $process_result_file ) +\t\t\t? filesize( $process_result_file ) +\t\t\t: null; +\t\tif ( false === $result_file_size ) { +\t\t\t$result_file_size = null; +\t\t} + +\t\t$payload = array( +\t\t\t'stage' => $stage, +\t\t\t'pid' => getmypid(), +\t\t\t'process_result_file' => is_string( $process_result_file ) ? $process_result_file : null, +\t\t\t'process_result_file_size' => $result_file_size, +\t\t\t'test_identity' => wp_sqlite_duckdb_parent_process_isolation_test_identity( $test ), +\t\t\t'wpdb_class' => is_object( $wpdb ) ? get_class( $wpdb ) : null, +\t\t\t'wpdb_last_error' => is_object( $wpdb ) && isset( $wpdb->last_error ) ? $wpdb->last_error : null, +\t\t\t'wpdb_has_dbh' => is_object( $wpdb ) && isset( $wpdb->dbh ) && is_object( $wpdb->dbh ), +\t\t\t'extra' => $extra, +\t\t); +\t\t$encoded = json_encode( $payload ); +\t\tif ( is_string( $encoded ) ) { +\t\t\t@file_put_contents( ${ phpSingleQuote( duckdbChildDiagnosticsLogContainerPath ) }, 'WP_SQLITE_DUCKDB_PARENT_DIAGNOSTIC ' . $encoded . PHP_EOL, FILE_APPEND | LOCK_EX ); +\t\t} +\t} + +\tfunction wp_sqlite_duckdb_release_parent_connection_for_isolated_child( $process_result_file = null, $test = null ) { +\t\tif ( ! wp_sqlite_duckdb_is_active_parent_process() ) { +\t\t\treturn; +\t\t} + +\t\t$wpdb = $GLOBALS['wpdb']; +\t\t$extra = array( +\t\t\t'flush_called' => false, +\t\t\t'checkpoint_called' => false, +\t\t\t'close_called' => false, +\t\t\t'gc_cycles' => array(), +\t\t\t'release_sleep_us' => 250000, +\t\t); + +\t\twp_sqlite_duckdb_parent_process_isolation_report( 'parent_before_release_for_child', $process_result_file, $test ); + +\t\ttry { +\t\t\tif ( method_exists( $wpdb, 'flush' ) ) { +\t\t\t\t$wpdb->flush(); +\t\t\t\t$extra['flush_called'] = true; +\t\t\t} +\t\t} catch ( Throwable $e ) { +\t\t\t$extra['flush_error'] = get_class( $e ) . ': ' . $e->getMessage(); +\t\t} + +\t\ttry { +\t\t\tif ( isset( $wpdb->dbh ) && is_object( $wpdb->dbh ) && method_exists( $wpdb->dbh, 'get_connection' ) ) { +\t\t\t\t$connection = $wpdb->dbh->get_connection(); +\t\t\t\tif ( is_object( $connection ) && method_exists( $connection, 'query' ) ) { +\t\t\t\t\t$connection->query( 'CHECKPOINT' ); +\t\t\t\t\t$extra['checkpoint_called'] = true; +\t\t\t\t} +\t\t\t} +\t\t} catch ( Throwable $e ) { +\t\t\t// The child may still be able to open the database if no checkpoint is needed. +\t\t\t$extra['checkpoint_error'] = get_class( $e ) . ': ' . $e->getMessage(); +\t\t} + +\t\tif ( method_exists( $wpdb, 'close' ) ) { +\t\t\t$wpdb->close(); +\t\t\t$extra['close_called'] = true; +\t\t} + +\t\ttry { +\t\t\t$wpdb->dbh = null; +\t\t} catch ( Throwable $e ) { +\t\t} + +\t\ttry { +\t\t\t$wpdb->last_error = ''; +\t\t} catch ( Throwable $e ) { +\t\t} + +\t\tunset( $GLOBALS['@duckdb_driver'], $GLOBALS['@duckdb'] ); + +\t\tif ( function_exists( 'gc_collect_cycles' ) ) { +\t\t\t$extra['gc_cycles'][] = gc_collect_cycles(); +\t\t\t$extra['gc_cycles'][] = gc_collect_cycles(); +\t\t} + +\t\tif ( function_exists( 'usleep' ) ) { +\t\t\tusleep( $extra['release_sleep_us'] ); +\t\t} + +\t\twp_sqlite_duckdb_parent_process_isolation_report( 'parent_after_release_for_child', $process_result_file, $test, $extra ); +\t} + +\tfunction wp_sqlite_duckdb_restore_parent_connection_after_isolated_child( $process_result_file = null, $test = null ) { +\t\tif ( ! wp_sqlite_duckdb_is_active_parent_process() ) { +\t\t\treturn; +\t\t} + +\t\t$wpdb = $GLOBALS['wpdb']; +\t\tif ( ! method_exists( $wpdb, 'db_connect' ) ) { +\t\t\treturn; +\t\t} + +\t\twp_sqlite_duckdb_parent_process_isolation_report( 'parent_before_restore_after_child', $process_result_file, $test ); + +\t\t$last_message = 'unknown DuckDB reconnect error'; +\t\tfor ( $attempt = 1; $attempt <= 5; ++$attempt ) { +\t\t\ttry { +\t\t\t\t$wpdb->last_error = ''; +\t\t\t} catch ( Throwable $e ) { +\t\t\t} + +\t\t\ttry { +\t\t\t\tif ( false !== $wpdb->db_connect( false ) ) { +\t\t\t\t\twp_sqlite_duckdb_parent_process_isolation_report( +\t\t\t\t\t\t'parent_after_restore_after_child', +\t\t\t\t\t\t$process_result_file, +\t\t\t\t\t\t$test, +\t\t\t\t\t\tarray( +\t\t\t\t\t\t\t'attempt' => $attempt, +\t\t\t\t\t\t\t'success' => true, +\t\t\t\t\t\t) +\t\t\t\t\t); +\t\t\t\t\treturn; +\t\t\t\t} +\t\t\t} catch ( Throwable $e ) { +\t\t\t\t$last_message = get_class( $e ) . ': ' . $e->getMessage(); +\t\t\t} + +\t\t\tif ( isset( $wpdb->last_error ) && is_string( $wpdb->last_error ) && '' !== $wpdb->last_error ) { +\t\t\t\t$last_message = $wpdb->last_error; +\t\t\t} + +\t\t\twp_sqlite_duckdb_parent_process_isolation_report( +\t\t\t\t'parent_restore_retry_after_child', +\t\t\t\t$process_result_file, +\t\t\t\t$test, +\t\t\t\tarray( +\t\t\t\t\t'attempt' => $attempt, +\t\t\t\t\t'error' => $last_message, +\t\t\t\t) +\t\t\t); + +\t\t\tif ( function_exists( 'usleep' ) ) { +\t\t\t\tusleep( 100000 * $attempt ); +\t\t\t} +\t\t} + +\t\tfwrite( STDERR, 'Error: Unable to reconnect DuckDB after isolated PHPUnit child: ' . $last_message . PHP_EOL ); +\t\texit( 1 ); +\t} +}`; +} + +function verifyPhpunitCompatibilityFiles() { + const check = [ + `require ${ phpSingleQuote( phpunitCompatibilityPrependContainerPath ) };`, + 'require_once DUCKDB_PHP_AUTOLOAD;', + "exit( method_exists( 'PHPUnit\\\\TextUI\\\\TestRunner', 'run' ) ? 0 : 1 );", + ].join( ' ' ); + const directWrapperCheck = [ + `require ${ phpSingleQuote( duckdbAutoloadCompatibilityWrapperContainerPath ) };`, + "exit( method_exists( 'PHPUnit\\\\TextUI\\\\TestRunner', 'run' ) ? 0 : 1 );", + ].join( ' ' ); + + runWordPressDockerCompose( [ 'run', '--rm', 'php', 'php', '-r', check ], { stdio: 'inherit' } ); + runWordPressDockerCompose( [ 'run', '--rm', 'php', 'php', '-r', directWrapperCheck ], { 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' ); @@ -104,39 +2018,222 @@ function verifyNativeParserExtension() { process.exit( 1 ); } - execSync( 'composer run wp-test-ensure-env', { stdio: 'inherit' } ); - execSync( + markProgress( 'native_parser_verify_start', { max_seconds: nativeParserVerifyTimeoutSeconds || 'none' } ); + execSyncWithOptionalTimeout( + phpunitEnsureEnvironmentCommand, + { stdio: 'inherit' }, + nativeParserVerifyTimeoutSeconds, + 'native parser environment verification' + ); + markProgress( 'native_parser_verify_env_done' ); + execSyncWithOptionalTimeout( 'cd wordpress && node tools/local-env/scripts/docker.js run --rm php php /var/www/native-verify-extension.php', + { stdio: 'inherit' }, + nativeParserVerifyTimeoutSeconds, + 'native parser runtime verification' + ); + markProgress( 'native_parser_verify_done' ); +} + +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 }...` + ); + ensureWordPressGitSafeDirectory(); + installCompatiblePhpunitRunner(); + + if ( ! hasCompatiblePhpunitRunner() ) { + console.error( 'Error: WordPress PHPUnit runner is still incompatible after Composer require.' ); + process.exit( 1 ); + } +} + +function ensureWordPressGitSafeDirectory() { + 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' } ); } +function installCompatiblePhpunitRunner() { + const phpunitPackage = `phpunit/phpunit:${ phpunitCompatibilityConstraint }`; + + runWordPressDockerCompose( + [ + 'run', + '--rm', + 'php', + 'composer', + 'require', + '--dev', + '--no-interaction', + '--no-progress', + '--with-all-dependencies', + phpunitPackage, + ], + { stdio: 'inherit' } + ); +} + +function hasCompatiblePhpunitRunner() { + const check = "require 'vendor/autoload.php'; exit( method_exists( 'PHPUnit\\\\TextUI\\\\TestRunner', 'run' ) ? 0 : 1 );"; + + try { + 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 execSyncWithOptionalTimeout( command, options, timeoutSeconds, label ) { + const effectiveOptions = { ...options }; + if ( timeoutSeconds ) { + effectiveOptions.timeout = timeoutSeconds * 1000; + effectiveOptions.killSignal = 'SIGTERM'; + } + + try { + execSync( command, effectiveOptions ); + } catch ( error ) { + const timedOut = Boolean( + timeoutSeconds && + ( + error.signal === 'SIGTERM' || + String( error.message || '' ).includes( 'ETIMEDOUT' ) + ) + ); + + if ( timedOut ) { + console.error( `Error: ${ label } exceeded ${ timeoutSeconds }s.` ); + } + + throw error; + } +} + +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, "'\\''" ) }'`; +} + +function phpSingleQuote( value ) { + return `'${ String( value ).replace( /\\/g, '\\\\' ).replace( /'/g, "\\'" ) }'`; +} + try { + const requiredPhpunitFilters = readRequiredPhpunitFilters(); + if ( requiredPhpunitFilters.length ) { + console.log( 'PHPUnit required filter entries:', requiredPhpunitFilters.length ); + } + if ( requiresNativeParserExtension ) { verifyNativeParserExtension(); } + markProgress( 'compatibility_start' ); + ensureCompatiblePhpunitRunner(); + markProgress( 'compatibility_done' ); + const effectivePhpunitCommand = preparePhpunitCommand(); + markProgress( 'command_prepared' ); + + let phpunitTimedOut = false; + let phpunitCommandSeconds = 0; + let phpunitStartedAt = 0; + try { + markProgress( 'phpunit_start', { max_seconds: phpunitMaxSeconds || 'none' } ); + phpunitStartedAt = Date.now(); execSync( - `composer run wp-test-php -- --log-junit=phpunit-results.xml --verbose`, - { stdio: 'inherit' } + effectivePhpunitCommand, + { + stdio: 'inherit', + timeout: phpunitMaxSeconds ? phpunitMaxSeconds * 1000 : undefined, + killSignal: 'SIGTERM', + } ); + phpunitCommandSeconds = ( Date.now() - phpunitStartedAt ) / 1000; + markProgress( 'phpunit_exit_zero', { command_seconds: phpunitCommandSeconds.toFixed( 3 ) } ); + printDuckDBChildDiagnosticsLog(); 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 ) } + ); + printDuckDBChildDiagnosticsLog(); + 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...' ); } // 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 ); } 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]; @@ -146,6 +2243,14 @@ 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.warn( + `\n⚠️ PHPUnit command took ${ phpunitCommandSeconds.toFixed( 3 ) }s, above ${ phpunitBaselineSeconds }s baseline.` + ); + } + + if ( phpunitMinTests && actualTests.length < phpunitMinTests ) { + console.error( + `\n❌ PHPUnit command ran ${ actualTests.length } tests, below required minimum ${ phpunitMinTests }.` + ); + isSuccess = false; + } + + if ( requiredPhpunitFilters.length ) { + const missingRequiredFilters = requiredPhpunitFilters.filter( + filter => ! actualTests.some( + test => test === filter || test.startsWith( `${ filter } ` ) + ) + ); + + if ( missingRequiredFilters.length ) { + console.error( '\n❌ PHPUnit command did not run every required filter entry:' ); + missingRequiredFilters.forEach( filter => console.error( ` - ${ filter }` ) ); + isSuccess = false; + } + } + // Check if all expected errors actually errored - const unexpectedNonErrors = expectedErrors.filter( test => ! 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}` ) ); @@ -169,13 +2320,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 ) { @@ -203,3 +2362,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 ) ); +} 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. 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..417d30be3 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: @@ -53,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 @@ -64,6 +73,101 @@ 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. 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 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 +``` + +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, 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 + 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/bin/wp-duckdb-local-smoke.sh b/bin/wp-duckdb-local-smoke.sh new file mode 100755 index 000000000..ceee695d6 --- /dev/null +++ b/bin/wp-duckdb-local-smoke.sh @@ -0,0 +1,346 @@ +#!/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" +WP_VERSION="${WP_DUCKDB_LOCAL_SMOKE_WP_VERSION:-6.7.2}" +DUCKDB_PHP_AUTOLOAD="${DUCKDB_PHP_AUTOLOAD:-}" + +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" +} + +ensure_wordpress_checkout() { + if [ -f "$WP_DIR/src/wp-load.php" ] && [ -f "$WP_DIR/src/wp-includes/class-wpdb.php" ]; then + return + fi + + command -v git > /dev/null 2>&1 || { + echo 'Error: Git is required to prepare the WordPress smoke checkout.' >&2 + exit 1 + } + + rm -rf "$WP_DIR" + git clone --depth 1 --branch "$WP_VERSION" https://github.com/WordPress/wordpress-develop.git "$WP_DIR" +} + +write_duckdb_dropin() { + mkdir -p "$WP_DIR/src/wp-content" + cp "$ROOT_DIR/packages/plugin-sqlite-database-integration/db-duckdb.copy" "$WP_DIR/src/wp-content/db.php" + sed -i.bak "s#'{SQLITE_IMPLEMENTATION_FOLDER_PATH}'#__DIR__.'/plugins/sqlite-database-integration'#g" "$WP_DIR/src/wp-content/db.php" + rm -f "$WP_DIR/src/wp-content/db.php.bak" + insert_duckdb_autoload_constant "$WP_DIR/src/wp-content/db.php" "$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")" + +ensure_wordpress_checkout + +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 + write_duckdb_dropin +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' + $row ) { + foreach ( $row as $column => $value ) { + if ( null === $value ) { + $rows[ $row_index ][ $column ] = null; + } elseif ( is_bool( $value ) ) { + $rows[ $row_index ][ $column ] = $value ? '1' : '0'; + } else { + $rows[ $row_index ][ $column ] = (string) $value; + } + } + } + return $rows; +} + +function wp_duckdb_local_smoke_run_runtime_probe(): array { + $iterations = wp_duckdb_local_smoke_positive_int_env( 'WP_DUCKDB_LOCAL_SMOKE_RUNTIME_ITERATIONS', 100 ); + $warmups = wp_duckdb_local_smoke_positive_int_env( 'WP_DUCKDB_LOCAL_SMOKE_RUNTIME_WARMUPS', 5 ); + $max_ratio = wp_duckdb_local_smoke_positive_float_env( 'WP_DUCKDB_LOCAL_SMOKE_MAX_RATIO' ); + if ( null === $max_ratio ) { + $max_ratio = wp_duckdb_local_smoke_positive_float_env( 'WP_DUCKDB_LOCAL_SMOKE_RUNTIME_MAX_RATIO' ); + } + + $smoke_wpdb_was_set = array_key_exists( 'wpdb', $GLOBALS ); + $smoke_wpdb = $smoke_wpdb_was_set ? $GLOBALS['wpdb'] : null; + unset( $GLOBALS['wpdb'] ); + if ( ! defined( 'WP_CLI' ) ) { + define( 'WP_CLI', true ); + } + + try { + $sqlite = new WP_SQLite_Driver( + new WP_SQLite_Connection( + array( + 'path' => ':memory:', + 'journal_mode' => 'MEMORY', + ) + ), + 'wp' + ); + + $duckdb_native_queries = 0; + $duckdb_connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $duckdb_connection->set_query_logger( + static function ( string $sql, array $params ) use ( &$duckdb_native_queries ): void { + unset( $sql, $params ); + ++$duckdb_native_queries; + } + ); + $duckdb = new WP_DuckDB_Driver( + array( + 'connection' => $duckdb_connection, + 'database' => 'wp', + ) + ); + + foreach ( array( $sqlite, $duckdb ) as $driver ) { + $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) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ); + $driver->query( "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('siteurl', 'https://example.test', 'yes')" ); + $driver->query( "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('home', 'https://example.test', 'yes')" ); + $driver->query( "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('blogname', 'DuckDB Smoke', 'yes')" ); + } + + $query = "SELECT option_name, option_value FROM wp_options WHERE option_name IN ('siteurl', 'home') ORDER BY option_name"; + $sqlite_rows = wp_duckdb_local_smoke_normalize_rows( $sqlite->query( $query, PDO::FETCH_ASSOC ) ); + $duckdb_rows = wp_duckdb_local_smoke_normalize_rows( $duckdb->query( $query )->fetchAll( PDO::FETCH_ASSOC ) ); + $rows_equal = $sqlite_rows === $duckdb_rows; + + if ( ! $rows_equal ) { + return array( + 'enabled' => true, + 'rows_equal' => false, + 'sqlite_rows' => $sqlite_rows, + 'duckdb_rows' => $duckdb_rows, + ); + } + + for ( $i = 0; $i < $warmups; $i++ ) { + $sqlite->query( $query, PDO::FETCH_ASSOC ); + $duckdb->query( $query )->fetchAll( PDO::FETCH_ASSOC ); + } + + $sqlite_started_at = hrtime( true ); + for ( $i = 0; $i < $iterations; $i++ ) { + $sqlite->query( $query, PDO::FETCH_ASSOC ); + } + $sqlite_seconds = ( hrtime( true ) - $sqlite_started_at ) / 1000000000; + + $duckdb_native_queries = 0; + $duckdb_started_at = hrtime( true ); + for ( $i = 0; $i < $iterations; $i++ ) { + $duckdb->query( $query )->fetchAll( PDO::FETCH_ASSOC ); + } + $duckdb_seconds = ( hrtime( true ) - $duckdb_started_at ) / 1000000000; + $ratio = $sqlite_seconds > 0.0 ? $duckdb_seconds / $sqlite_seconds : null; + $within_ratio = null === $max_ratio || ( null !== $ratio && $ratio <= $max_ratio ); + + return array( + 'enabled' => true, + 'iterations' => $iterations, + 'warmups' => $warmups, + 'sqlite_ms' => round( $sqlite_seconds * 1000, 3 ), + 'duckdb_ms' => round( $duckdb_seconds * 1000, 3 ), + 'ratio' => null === $ratio ? null : round( $ratio, 6 ), + 'max_ratio' => $max_ratio, + 'within_max_ratio' => $within_ratio, + 'rows_equal' => true, + 'duckdb_native_queries' => $duckdb_native_queries, + ); + } finally { + if ( $smoke_wpdb_was_set ) { + $GLOBALS['wpdb'] = $smoke_wpdb; + } + } +} + +define( 'ABSPATH', $root . '/wordpress/src/' ); +define( 'WPINC', 'wp-includes' ); +define( 'WP_CONTENT_DIR', ABSPATH . 'wp-content' ); +define( 'FQDBDIR', WP_CONTENT_DIR . '/database/' ); +define( 'FQDUCKDB', FQDBDIR . '.ht.duckdb' ); +define( 'DB_NAME', 'wordpress' ); +define( 'DB_USER', '' ); +define( 'DB_PASSWORD', '' ); +define( 'DB_HOST', '' ); +define( 'WP_DEBUG', false ); + +require_once ABSPATH . WPINC . '/class-wp-error.php'; +require_once ABSPATH . WPINC . '/plugin.php'; +require_once ABSPATH . WPINC . '/formatting.php'; +require_once ABSPATH . WPINC . '/functions.php'; +require_once ABSPATH . WPINC . '/class-wpdb.php'; +require_once WP_CONTENT_DIR . '/db.php'; + +if ( ! isset( $GLOBALS['wpdb'] ) || ! $GLOBALS['wpdb'] instanceof WP_DuckDB_DB ) { + fwrite( STDERR, "Error: WP_DuckDB_DB was not loaded from the generated drop-in.\n" ); + exit( 1 ); +} + +$connected = $GLOBALS['wpdb']->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 ); +} + +$diagnostics = 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, +); + +if ( wp_duckdb_local_smoke_env_enabled( 'WP_DUCKDB_LOCAL_SMOKE_RUNTIME_SAMPLE' ) ) { + $diagnostics['runtime_probe'] = wp_duckdb_local_smoke_run_runtime_probe(); + if ( + false === $diagnostics['runtime_probe']['rows_equal'] || + false === $diagnostics['runtime_probe']['within_max_ratio'] + ) { + fwrite( STDERR, "Error: DuckDB local smoke runtime probe failed.\n" ); + fwrite( STDERR, json_encode( $diagnostics, JSON_UNESCAPED_SLASHES ) . "\n" ); + exit( 1 ); + } +} + +echo json_encode( $diagnostics, JSON_UNESCAPED_SLASHES ) . "\n"; +PHP diff --git a/bin/wp-test-duckdb-fast-fail.sh b/bin/wp-test-duckdb-fast-fail.sh new file mode 100755 index 000000000..147bf1a4e --- /dev/null +++ b/bin/wp-test-duckdb-fast-fail.sh @@ -0,0 +1,438 @@ +#!/bin/bash + +## +# Run only the committed DuckDB WordPress recent-failure gates. +# +# This is intentionally bounded. By default it assumes the WordPress DuckDB +# Docker environment is already prepared, so a "fast-fail" command cannot +# silently turn into a long setup or full-suite run. +## + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd -P)" +PHPUNIT_LIST_FILE="$ROOT_DIR/.github/workflows/duckdb-wordpress-phpunit-fast-fail-tests.txt" +E2E_LIST_FILE="$ROOT_DIR/.github/workflows/duckdb-wordpress-e2e-fast-fail-specs.txt" + +MODE="${WP_DUCKDB_FAST_FAIL_MODE:-phpunit}" +DRY_RUN="${WP_DUCKDB_FAST_FAIL_DRY_RUN:-0}" +PREPARED="${WP_DUCKDB_FAST_FAIL_PREPARED:-1}" +PHPUNIT_TIMEOUT_SECONDS="${WP_DUCKDB_FAST_FAIL_PHPUNIT_TIMEOUT:-90}" +E2E_TIMEOUT_SECONDS="${WP_DUCKDB_FAST_FAIL_E2E_TIMEOUT:-180}" +MIN_PHPUNIT_FILTERS="${WP_SQLITE_PHPUNIT_MIN_FILTER_PATTERNS:-9}" +PHPUNIT_MIN_TESTS="${WP_SQLITE_PHPUNIT_MIN_TESTS:-15}" +MIN_E2E_SPECS="${WP_DUCKDB_E2E_FAST_FAIL_MIN_SPECS:-4}" + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: bin/wp-test-duckdb-fast-fail.sh [phpunit|e2e|all|validate] + +Runs the committed DuckDB recent-failure gates instead of any full suite. + +Environment: + WP_DUCKDB_FAST_FAIL_DRY_RUN=1 Validate lists and print commands only. + WP_DUCKDB_FAST_FAIL_PREPARED=0 Allow composer to prepare the DuckDB env. + WP_DUCKDB_FAST_FAIL_PHPUNIT_TIMEOUT Seconds for the PHPUnit gate. Default: 90. + WP_DUCKDB_FAST_FAIL_E2E_TIMEOUT Seconds for the E2E gate. Default: 180. + WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION=0 + Run the cheaper pre-native PHPUnit gate. +EOF +} + +is_positive_integer() { + case "${1:-}" in + ''|*[!0-9]*) + return 1 + ;; + esac + + [ "$1" -gt 0 ] +} + +run_with_timeout() { + local seconds="$1" + shift + + if command -v timeout > /dev/null 2>&1; then + timeout "${seconds}s" "$@" + return $? + fi + + local command_pid timer_pid timeout_file status + timeout_file="${TMPDIR:-/tmp}/wp-duckdb-fast-fail-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 5 + 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" +} + +read_phpunit_filter() { + local invalid_entries count filter + + [ -f "$PHPUNIT_LIST_FILE" ] || fail "Missing PHPUnit fast-fail list: $PHPUNIT_LIST_FILE" + is_positive_integer "$MIN_PHPUNIT_FILTERS" || fail 'WP_SQLITE_PHPUNIT_MIN_FILTER_PATTERNS must be a positive integer.' + + invalid_entries="$( + awk ' + /^[[:space:]]*($|#)/ { next } + { + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + if ($0 !~ /^[A-Za-z_][A-Za-z0-9_]*(\\[A-Za-z_][A-Za-z0-9_]*)*::[A-Za-z_][A-Za-z0-9_]*$/) { + print + } + } + ' "$PHPUNIT_LIST_FILE" + )" + + if [ -n "$invalid_entries" ]; then + printf '%s\n' "$invalid_entries" >&2 + fail "$PHPUNIT_LIST_FILE must contain literal PHPUnit Class::method names only." + fi + + count="$( + awk ' + /^[[:space:]]*($|#)/ { next } + { + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + if (seen[$0]++) { + next + } + count++ + } + END { + print count + 0 + } + ' "$PHPUNIT_LIST_FILE" + )" + + if [ "$count" -lt 1 ]; then + fail "$PHPUNIT_LIST_FILE does not list any PHPUnit tests." + fi + + if [ "$count" -lt "$MIN_PHPUNIT_FILTERS" ]; then + fail "$PHPUNIT_LIST_FILE lists $count unique tests; expected at least $MIN_PHPUNIT_FILTERS." + fi + + filter="$( + awk ' + /^[[:space:]]*($|#)/ { next } + { + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + if (seen[$0]++) { + next + } + printf "%s%s", separator, $0 + separator="\\|" + } + ' "$PHPUNIT_LIST_FILE" + )" + + printf '%s\n' "$count" "$filter" +} + +read_e2e_specs() { + local invalid_entries count + + [ -f "$E2E_LIST_FILE" ] || fail "Missing E2E fast-fail list: $E2E_LIST_FILE" + is_positive_integer "$MIN_E2E_SPECS" || fail 'WP_DUCKDB_E2E_FAST_FAIL_MIN_SPECS must be a positive integer.' + + invalid_entries="$( + awk ' + /^[[:space:]]*($|#)/ { next } + { + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + if (seen[$0]++) { + next + } + if ($0 !~ /^tests\/e2e\/specs\/[A-Za-z0-9._-]+\.test\.js$/) { + print + } + } + ' "$E2E_LIST_FILE" + )" + + if [ -n "$invalid_entries" ]; then + printf '%s\n' "$invalid_entries" >&2 + fail "$E2E_LIST_FILE must contain literal tests/e2e/specs/*.test.js paths only." + fi + + count="$( + awk ' + /^[[:space:]]*($|#)/ { next } + { + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + if (seen[$0]++) { + next + } + count++ + } + END { + print count + 0 + } + ' "$E2E_LIST_FILE" + )" + + if [ "$count" -lt 1 ]; then + fail "$E2E_LIST_FILE does not list any E2E specs." + fi + + if [ "$count" -lt "$MIN_E2E_SPECS" ]; then + fail "$E2E_LIST_FILE lists $count unique specs; expected at least $MIN_E2E_SPECS." + fi + + awk ' + /^[[:space:]]*($|#)/ { next } + { + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + if (seen[$0]++) { + next + } + print + } + ' "$E2E_LIST_FILE" +} + +preflight_prepared_wordpress() { + if [ "$PREPARED" != '1' ]; then + return + fi + + [ -f "$ROOT_DIR/wordpress/src/wp-load.php" ] \ + || fail 'WordPress checkout is not prepared. Run composer run wp-test-ensure-env-duckdb, or set WP_DUCKDB_FAST_FAIL_PREPARED=0 to allow setup.' + + [ -f "$ROOT_DIR/wordpress/src/wp-content/db.php" ] \ + || fail 'DuckDB db.php drop-in is missing. Run composer run wp-test-ensure-env-duckdb, or set WP_DUCKDB_FAST_FAIL_PREPARED=0 to allow setup.' + + grep -q "define( 'DB_ENGINE', 'duckdb' );" "$ROOT_DIR/wordpress/src/wp-content/db.php" \ + || fail 'WordPress db.php drop-in is not configured for DB_ENGINE=duckdb.' +} + +preflight_docker() { + command -v docker > /dev/null 2>&1 || fail 'Docker is required for WordPress PHPUnit/E2E gates.' + docker info > /dev/null 2>&1 || fail 'Docker is not available or not running.' +} + +print_command() { + printf 'Command:' + printf ' %q' "$@" + printf '\n' +} + +run_phpunit_fast_fail() { + local phpunit_count phpunit_filter phpunit_script + local phpunit_command_string native_parser_required outer_timeout_seconds timing_label + local duckdb_php_autoload phpunit_ensure_env_command phpunit_junit_basename + local phpunit_junit_path phpunit_timing_label + local phpunit_result + local -a phpunit_wrapper_command + + phpunit_result="$(read_phpunit_filter)" + phpunit_count="$(printf '%s\n' "$phpunit_result" | sed -n '1p')" + phpunit_filter="$(printf '%s\n' "$phpunit_result" | sed -n '2p')" + + if [ "$PREPARED" = '1' ]; then + phpunit_script='wp-test-php-duckdb-prepared' + phpunit_ensure_env_command="${WP_SQLITE_PHPUNIT_ENSURE_ENV_COMMAND:-true}" + else + phpunit_script='wp-test-php-duckdb' + phpunit_ensure_env_command="${WP_SQLITE_PHPUNIT_ENSURE_ENV_COMMAND:-composer run wp-test-ensure-env-duckdb}" + fi + + case "$phpunit_filter" in + *\'*) + fail "$PHPUNIT_LIST_FILE generated a filter containing a single quote." + ;; + esac + + native_parser_required="${WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION:-1}" + case "$native_parser_required" in + 0) + timing_label='duckdb-fast-fail-pre-native-local' + ;; + 1) + timing_label='duckdb-fast-fail-local' + ;; + *) + fail 'WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION must be 0 or 1.' + ;; + esac + + duckdb_php_autoload="${DUCKDB_PHP_AUTOLOAD:-$ROOT_DIR/packages/mysql-on-sqlite/vendor/autoload.php}" + phpunit_junit_path="${WP_SQLITE_PHPUNIT_JUNIT_PATH:-${WP_DUCKDB_FAST_FAIL_PHPUNIT_JUNIT_PATH:-wordpress/phpunit-duckdb-fast-fail-local-results.xml}}" + phpunit_junit_basename="${WP_DUCKDB_FAST_FAIL_PHPUNIT_JUNIT_BASENAME:-$(basename "$phpunit_junit_path")}" + phpunit_timing_label="${WP_SQLITE_PHPUNIT_TIMING_LABEL:-$timing_label}" + case "$phpunit_junit_basename" in + ''|*/*|*\\*|*[!A-Za-z0-9._-]*) + fail 'PHPUnit JUnit basename must be a simple filename containing only letters, numbers, dots, underscores, and dashes.' + ;; + esac + case "$phpunit_junit_basename" in + *.xml) + ;; + *) + fail 'PHPUnit JUnit basename must end in .xml.' + ;; + esac + phpunit_command_string="composer run ${phpunit_script} -- --log-junit=${phpunit_junit_basename} --verbose --stop-on-error --stop-on-failure --filter '${phpunit_filter}'" + outer_timeout_seconds=$(( PHPUNIT_TIMEOUT_SECONDS + 30 )) + phpunit_wrapper_command=( + env + "DUCKDB_PHP_AUTOLOAD=$duckdb_php_autoload" + "WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS=${WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS:-0}" + "WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_VERBOSE=${WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_VERBOSE:-0}" + "WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_STDERR=${WP_SQLITE_DUCKDB_CHILD_DIAGNOSTICS_STDERR:-0}" + "WP_SQLITE_DUCKDB_CHILD_DB_COPY=${WP_SQLITE_DUCKDB_CHILD_DB_COPY:-1}" + "WP_SQLITE_DUCKDB_PREPARE_OBJECT_DIAGNOSTICS=${WP_SQLITE_DUCKDB_PREPARE_OBJECT_DIAGNOSTICS:-0}" + "WP_DUCKDB_QUERY_PROFILE=${WP_DUCKDB_QUERY_PROFILE:-0}" + "WP_DUCKDB_QUERY_PROFILE_INTERVAL=${WP_DUCKDB_QUERY_PROFILE_INTERVAL:-0}" + "WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY=${WP_SQLITE_ENSURE_PHPUNIT_COMPATIBILITY:-1}" + "WP_SQLITE_DISABLE_EXPECTED_RESULTS=${WP_SQLITE_DISABLE_EXPECTED_RESULTS:-0}" + "WP_SQLITE_PHPUNIT_ENSURE_ENV_COMMAND=$phpunit_ensure_env_command" + "WP_SQLITE_NATIVE_PARSER_VERIFY_TIMEOUT_SECONDS=${WP_SQLITE_NATIVE_PARSER_VERIFY_TIMEOUT_SECONDS:-60}" + "WP_SQLITE_PHPUNIT_REQUIRED_FILTERS_FILE=${WP_SQLITE_PHPUNIT_REQUIRED_FILTERS_FILE:-$PHPUNIT_LIST_FILE}" + "WP_SQLITE_PHPUNIT_COMMAND=$phpunit_command_string" + "WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION=$native_parser_required" + "WP_SQLITE_PHPUNIT_TIMING_LABEL=$phpunit_timing_label" + "WP_SQLITE_PHPUNIT_MAX_SECONDS=$PHPUNIT_TIMEOUT_SECONDS" + "WP_SQLITE_PHPUNIT_MIN_TESTS=$PHPUNIT_MIN_TESTS" + "WP_SQLITE_IGNORE_MISSING_EXPECTED_RESULTS=1" + "WP_SQLITE_PHPUNIT_JUNIT_PATH=$phpunit_junit_path" + node .github/workflows/wp-tests-phpunit-run.js + ) + + echo "DuckDB PHPUnit fast-fail list: $PHPUNIT_LIST_FILE" + echo "DuckDB PHPUnit fast-fail entries: $phpunit_count" + echo "DuckDB PHPUnit fast-fail timeout: ${PHPUNIT_TIMEOUT_SECONDS}s" + echo "DuckDB PHPUnit fast-fail min tests: $PHPUNIT_MIN_TESTS" + echo "DuckDB PHPUnit native parser required: $native_parser_required" + echo "DuckDB PHPUnit timing label: $phpunit_timing_label" + echo "DuckDB PHPUnit JUnit path: $phpunit_junit_path" + echo "DuckDB PHPUnit command: $phpunit_command_string" + print_command "${phpunit_wrapper_command[@]}" + + if [ "$DRY_RUN" = '1' ]; then + return + fi + + preflight_prepared_wordpress + preflight_docker + ( + cd "$ROOT_DIR" + run_with_timeout "$outer_timeout_seconds" "${phpunit_wrapper_command[@]}" + ) +} + +run_e2e_fast_fail() { + local -a e2e_specs e2e_command + local spec + + mapfile -t e2e_specs < <(read_e2e_specs) + + for spec in "${e2e_specs[@]}"; do + if [ "$DRY_RUN" != '1' ] && [ ! -f "$ROOT_DIR/wordpress/$spec" ]; then + fail "Missing WordPress E2E spec: wordpress/$spec" + fi + done + + e2e_command=( + npm --prefix wordpress run test:e2e -- + "${e2e_specs[@]}" + ) + + echo "DuckDB E2E fast-fail list: $E2E_LIST_FILE" + echo "DuckDB E2E fast-fail entries: ${#e2e_specs[@]}" + echo "DuckDB E2E fast-fail timeout: ${E2E_TIMEOUT_SECONDS}s" + printf 'DuckDB E2E fast-fail spec: %s\n' "${e2e_specs[@]}" + print_command "${e2e_command[@]}" + + if [ "$DRY_RUN" = '1' ]; then + return + fi + + preflight_prepared_wordpress + preflight_docker + ( + cd "$ROOT_DIR" + run_with_timeout "$E2E_TIMEOUT_SECONDS" "${e2e_command[@]}" + ) +} + +if [ "${1:-}" = '--help' ] || [ "${1:-}" = '-h' ]; then + usage + exit 0 +fi + +if [ "${1:-}" = '--dry-run' ]; then + DRY_RUN=1 + shift +fi + +if [ "${1:-}" != '' ]; then + MODE="$1" + shift +fi + +if [ "$#" -gt 0 ]; then + fail 'Unexpected arguments. Use phpunit, e2e, or all.' +fi + +is_positive_integer "$PHPUNIT_TIMEOUT_SECONDS" || fail 'WP_DUCKDB_FAST_FAIL_PHPUNIT_TIMEOUT must be a positive integer.' +is_positive_integer "$E2E_TIMEOUT_SECONDS" || fail 'WP_DUCKDB_FAST_FAIL_E2E_TIMEOUT must be a positive integer.' +is_positive_integer "$PHPUNIT_MIN_TESTS" || fail 'WP_SQLITE_PHPUNIT_MIN_TESTS must be a positive integer.' + +case "$MODE" in + phpunit) + run_phpunit_fast_fail + ;; + e2e) + run_e2e_fast_fail + ;; + all) + run_phpunit_fast_fail + run_e2e_fast_fail + ;; + validate) + DRY_RUN=1 + run_phpunit_fast_fail + run_e2e_fast_fail + ;; + *) + usage >&2 + fail 'Mode must be phpunit, e2e, all, or validate.' + ;; +esac diff --git a/bin/wp-test-duckdb-last-failures.sh b/bin/wp-test-duckdb-last-failures.sh new file mode 100755 index 000000000..ab61de1a3 --- /dev/null +++ b/bin/wp-test-duckdb-last-failures.sh @@ -0,0 +1,333 @@ +#!/bin/bash + +## +# Run the most recently cached package-local DuckDB PHPUnit defects. +# +# This is a local iteration gate. It does not prepare WordPress, start Docker, +# run E2E, or expand to the package-wide DuckDB group after cached defects pass. +## + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd -P)" +PACKAGE_DIR="$ROOT_DIR/packages/mysql-on-sqlite" +CACHE_FILE="${WP_DUCKDB_LAST_FAILURE_CACHE:-$PACKAGE_DIR/.phpunit.result.cache}" +LIST_FILE="${WP_DUCKDB_LAST_FAILURE_LIST:-$ROOT_DIR/.github/workflows/duckdb-package-fast-fail-tests.txt}" +REQUIRED="${WP_DUCKDB_LAST_FAILURE_REQUIRED:-0}" +MIN_TESTS="${WP_DUCKDB_LAST_FAILURE_MIN_TESTS:-1}" +MODE="${WP_DUCKDB_LAST_FAILURE_MODE:-run}" +DRY_RUN="${WP_DUCKDB_LAST_FAILURE_DRY_RUN:-0}" +MAX_TESTS="${WP_DUCKDB_LAST_FAILURE_MAX_TESTS:-20}" +TIMEOUT_SECONDS="${WP_DUCKDB_LAST_FAILURE_TIMEOUT:-60}" +CLASS_PATTERN="${WP_DUCKDB_LAST_FAILURE_CLASS_PATTERN:-/^WP_DuckDB_[A-Za-z0-9_]*$/}" +IGNORE_CACHE="${WP_DUCKDB_LAST_FAILURE_IGNORE_CACHE:-0}" + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: bin/wp-test-duckdb-last-failures.sh [run|validate] + +Runs only the most recently cached package-local DuckDB PHPUnit defects recorded in +packages/mysql-on-sqlite/.phpunit.result.cache. + +Environment: + WP_DUCKDB_LAST_FAILURE_DRY_RUN=1 Print the selected command only. + WP_DUCKDB_LAST_FAILURE_MAX_TESTS Maximum cached defects to run. Default: 20. + WP_DUCKDB_LAST_FAILURE_TIMEOUT Timeout in seconds. Default: 60. + WP_DUCKDB_LAST_FAILURE_CACHE Override the PHPUnit cache path. + WP_DUCKDB_LAST_FAILURE_IGNORE_CACHE Ignore the PHPUnit cache and use the fallback list. Default: 0. + WP_DUCKDB_LAST_FAILURE_LIST Fallback test list when the cache is absent. + WP_DUCKDB_LAST_FAILURE_REQUIRED Fail if no cache/list entries are found. Default: 0. + WP_DUCKDB_LAST_FAILURE_MIN_TESTS Minimum selected tests. Default: 1. + WP_DUCKDB_LAST_FAILURE_CLASS_PATTERN + PHP regex for class names. Default: /^WP_DuckDB_[A-Za-z0-9_]*$/. +EOF +} + +is_positive_integer() { + case "${1:-}" in + ''|*[!0-9]*) + return 1 + ;; + esac + + [ "$1" -gt 0 ] +} + +print_command() { + printf 'Command:' + printf ' %q' "$@" + printf '\n' +} + +run_with_timeout() { + local seconds="$1" + shift + + if command -v timeout > /dev/null 2>&1; then + timeout "${seconds}s" "$@" + return $? + fi + + "$@" +} + +read_phpunit_test_count() { + php -r ' + $output = stream_get_contents( STDIN ); + + if ( preg_match( "/^Tests:\s*([0-9]+)/m", $output, $matches ) ) { + echo $matches[1], "\n"; + exit( 0 ); + } + + if ( preg_match( "/\bOK(?:,[^(\\n]+)?\s*\(\s*([0-9]+)\s+tests?/m", $output, $matches ) ) { + echo $matches[1], "\n"; + exit( 0 ); + } + + exit( 1 ); + ' +} + +read_duckdb_failure_filter() { + if [ "$IGNORE_CACHE" != '1' ] && [ -f "$CACHE_FILE" ]; then + printf '%s\n' "$CACHE_FILE" + + WP_DUCKDB_LAST_FAILURE_CACHE_FILE="$CACHE_FILE" \ + WP_DUCKDB_LAST_FAILURE_MAX_TESTS="$MAX_TESTS" \ + WP_DUCKDB_LAST_FAILURE_CLASS_PATTERN="$CLASS_PATTERN" \ + php -r ' + $cache_file = getenv( "WP_DUCKDB_LAST_FAILURE_CACHE_FILE" ); + $max_tests = (int) getenv( "WP_DUCKDB_LAST_FAILURE_MAX_TESTS" ); + $class_pattern = getenv( "WP_DUCKDB_LAST_FAILURE_CLASS_PATTERN" ); + $cache = json_decode( file_get_contents( $cache_file ), true ); + if ( ! is_array( $cache ) || ! isset( $cache["defects"] ) || ! is_array( $cache["defects"] ) ) { + fwrite( STDERR, "Error: PHPUnit cache does not contain a defects map: {$cache_file}\n" ); + exit( 2 ); + } + if ( 1 !== ( $cache["version"] ?? null ) ) { + fwrite( STDERR, "Error: Unsupported PHPUnit cache version in {$cache_file}\n" ); + exit( 2 ); + } + if ( false === @preg_match( $class_pattern, "WP_DuckDB_Driver_Tests" ) ) { + fwrite( STDERR, "Error: WP_DUCKDB_LAST_FAILURE_CLASS_PATTERN is not a valid PHP regex.\n" ); + exit( 2 ); + } + + $tests = array(); + foreach ( array_reverse( array_keys( $cache["defects"] ) ) as $test_id ) { + if ( ! is_string( $test_id ) || ! preg_match( "/^([A-Za-z_][A-Za-z0-9_]*)::([A-Za-z_][A-Za-z0-9_]*)$/", $test_id, $matches ) ) { + continue; + } + if ( ! preg_match( $class_pattern, $matches[1] ) ) { + continue; + } + $tests[] = $test_id; + if ( count( $tests ) >= $max_tests ) { + break; + } + } + + echo count( $tests ), "\n"; + echo implode( "|", array_map( static function ( $test_id ) { + return preg_quote( $test_id, "/" ); + }, $tests ) ), "\n"; + echo implode( "\n", $tests ), "\n"; + ' + return + fi + + if [ -f "$LIST_FILE" ]; then + printf '%s\n' "$LIST_FILE" + + WP_DUCKDB_LAST_FAILURE_LIST_FILE="$LIST_FILE" \ + WP_DUCKDB_LAST_FAILURE_MAX_TESTS="$MAX_TESTS" \ + WP_DUCKDB_LAST_FAILURE_CLASS_PATTERN="$CLASS_PATTERN" \ + php -r ' + $list_file = getenv( "WP_DUCKDB_LAST_FAILURE_LIST_FILE" ); + $max_tests = (int) getenv( "WP_DUCKDB_LAST_FAILURE_MAX_TESTS" ); + $class_pattern = getenv( "WP_DUCKDB_LAST_FAILURE_CLASS_PATTERN" ); + if ( false === @preg_match( $class_pattern, "WP_DuckDB_Driver_Tests" ) ) { + fwrite( STDERR, "Error: WP_DUCKDB_LAST_FAILURE_CLASS_PATTERN is not a valid PHP regex.\n" ); + exit( 2 ); + } + + $tests = array(); + $seen = array(); + foreach ( file( $list_file, FILE_IGNORE_NEW_LINES ) as $line_number => $line ) { + $test_id = trim( $line ); + if ( "" === $test_id || "#" === $test_id[0] ) { + continue; + } + if ( ! preg_match( "/^([A-Za-z_][A-Za-z0-9_]*)::([A-Za-z_][A-Za-z0-9_]*)$/", $test_id, $matches ) ) { + fwrite( STDERR, "Error: Invalid DuckDB last-failure test on line " . ( $line_number + 1 ) . " of {$list_file}: {$test_id}\n" ); + exit( 2 ); + } + if ( ! preg_match( $class_pattern, $matches[1] ) || isset( $seen[ $test_id ] ) ) { + continue; + } + $seen[ $test_id ] = true; + $tests[] = $test_id; + if ( count( $tests ) >= $max_tests ) { + break; + } + } + + echo count( $tests ), "\n"; + echo implode( "|", array_map( static function ( $test_id ) { + return preg_quote( $test_id, "/" ); + }, $tests ) ), "\n"; + echo implode( "\n", $tests ), "\n"; + ' + return + fi + + printf '\n0\n\n' +} + +run_last_failures() { + local failure_result failure_source failure_count failure_filter duckdb_php_autoload wp_duckdb_autoload + local phpunit_output phpunit_status phpunit_test_count + local -a failure_lines phpunit_command selected_tests + + failure_result="$(read_duckdb_failure_filter)" + mapfile -t failure_lines <<< "$failure_result" + failure_source="${failure_lines[0]:-}" + failure_count="${failure_lines[1]:-0}" + failure_filter="${failure_lines[2]:-}" + selected_tests=( "${failure_lines[@]:3}" ) + + if [ "$failure_count" -eq 0 ]; then + if [ "$REQUIRED" = '1' ]; then + fail "No package-local DuckDB PHPUnit defects found in $CACHE_FILE or $LIST_FILE." + fi + echo "No package-local DuckDB PHPUnit defects found in $CACHE_FILE or $LIST_FILE." + return + fi + + duckdb_php_autoload="${DUCKDB_PHP_AUTOLOAD:-}" + if [ -z "$duckdb_php_autoload" ] && [ -f /tmp/wp-duckdb-php-runtime/vendor/autoload.php ]; then + duckdb_php_autoload=/tmp/wp-duckdb-php-runtime/vendor/autoload.php + fi + if [ -z "$duckdb_php_autoload" ]; then + duckdb_php_autoload="$PACKAGE_DIR/vendor/autoload.php" + fi + wp_duckdb_autoload="${WP_DUCKDB_AUTOLOAD:-$duckdb_php_autoload}" + + phpunit_command=( + env + WP_DUCKDB_TESTS=1 + "WP_DUCKDB_AUTOLOAD=$wp_duckdb_autoload" + "DUCKDB_PHP_AUTOLOAD=$duckdb_php_autoload" + php -d ffi.enable=1 ./vendor/bin/phpunit + -c ./phpunit.xml.dist + --filter "$failure_filter" + --stop-on-error + --stop-on-failure + --do-not-cache-result + ) + + if [ "$failure_count" -lt "$MIN_TESTS" ]; then + fail "DuckDB last-failure source selected $failure_count tests; expected at least $MIN_TESTS." + fi + + echo "DuckDB cached failure cache: $CACHE_FILE" + echo "DuckDB cached failure fallback list: $LIST_FILE" + echo "DuckDB cached failure source: $failure_source" + echo "DuckDB cached failure entries: $failure_count" + echo "DuckDB cached failure min entries: $MIN_TESTS" + echo "DuckDB cached failure max entries: $MAX_TESTS" + echo "DuckDB cached failure timeout: ${TIMEOUT_SECONDS}s" + echo "DuckDB cached failure filter: $failure_filter" + printf 'DuckDB cached failure test: %s\n' "${selected_tests[@]}" + print_command "${phpunit_command[@]}" + + if [ "$DRY_RUN" = '1' ]; then + return + fi + + [ -x "$PACKAGE_DIR/vendor/bin/phpunit" ] || fail "Missing PHPUnit binary: $PACKAGE_DIR/vendor/bin/phpunit" + [ -f "$duckdb_php_autoload" ] || fail "DuckDB PHP autoload is not readable: $duckdb_php_autoload" + [ -f "$wp_duckdb_autoload" ] || fail "WP_DUCKDB_AUTOLOAD is not readable: $wp_duckdb_autoload" + + set +e + phpunit_output="$( + cd "$PACKAGE_DIR" && run_with_timeout "$TIMEOUT_SECONDS" "${phpunit_command[@]}" 2>&1 + )" + phpunit_status=$? + set -e + + printf '%s\n' "$phpunit_output" + + if printf '%s\n' "$phpunit_output" | grep -q 'No tests executed!'; then + fail 'Cached DuckDB failure filter matched zero PHPUnit tests.' + fi + + if [ "$phpunit_status" -eq 0 ]; then + if ! phpunit_test_count="$(printf '%s\n' "$phpunit_output" | read_phpunit_test_count)"; then + fail 'Could not parse executed PHPUnit test count from cached DuckDB failure output.' + fi + + if [ "$phpunit_test_count" -lt "$MIN_TESTS" ]; then + fail "Cached DuckDB failure filter executed $phpunit_test_count tests; expected at least $MIN_TESTS." + fi + fi + + return "$phpunit_status" +} + +if [ "${1:-}" = '--help' ] || [ "${1:-}" = '-h' ]; then + usage + exit 0 +fi + +if [ "${1:-}" = '--dry-run' ]; then + DRY_RUN=1 + shift +fi + +if [ "${1:-}" != '' ]; then + MODE="$1" + shift +fi + +if [ "$#" -gt 0 ]; then + fail 'Unexpected arguments. Use run or validate.' +fi + +is_positive_integer "$MAX_TESTS" || fail 'WP_DUCKDB_LAST_FAILURE_MAX_TESTS must be a positive integer.' +is_positive_integer "$TIMEOUT_SECONDS" || fail 'WP_DUCKDB_LAST_FAILURE_TIMEOUT must be a positive integer.' +is_positive_integer "$MIN_TESTS" || fail 'WP_DUCKDB_LAST_FAILURE_MIN_TESTS must be a positive integer.' +case "$REQUIRED" in + 0|1) + ;; + *) + fail 'WP_DUCKDB_LAST_FAILURE_REQUIRED must be 0 or 1.' + ;; +esac +case "$IGNORE_CACHE" in + 0|1) + ;; + *) + fail 'WP_DUCKDB_LAST_FAILURE_IGNORE_CACHE must be 0 or 1.' + ;; +esac + +case "$MODE" in + run) + run_last_failures + ;; + validate) + DRY_RUN=1 + run_last_failures + ;; + *) + usage >&2 + fail 'Mode must be run or validate.' + ;; +esac diff --git a/bin/wp-test-duckdb-smoke.sh b/bin/wp-test-duckdb-smoke.sh new file mode 100755 index 000000000..a1ecff050 --- /dev/null +++ b/bin/wp-test-duckdb-smoke.sh @@ -0,0 +1,125 @@ +#!/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...' + compose_services="$(docker compose "${COMPOSE_ARGS[@]}" config --services)" + printf '%s\n' "$compose_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 new file mode 100755 index 000000000..62627cef0 --- /dev/null +++ b/bin/wp-test-ensure-env-duckdb.sh @@ -0,0 +1,267 @@ +#!/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" + +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" +} + +setup_phase_id() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '_' | tr -cd '[:alnum:]_-' +} + +emit_setup_progress() { + local phase="$1" + local status="$2" + local elapsed_seconds="$3" + local timeout_seconds="$4" + local message + + message="WP_DUCKDB_SETUP_PROGRESS phase=$phase status=$status elapsed_seconds=$elapsed_seconds timeout_seconds=$timeout_seconds" + printf '%s\n' "$message" + + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + printf '::notice title=WordPress DuckDB setup::%s\n' "$message" + fi +} + +run_phase() { + local label="$1" + local seconds="$2" + local status started_at ended_at elapsed_seconds phase_id phase_status + shift 2 + + phase_id="$(setup_phase_id "$label")" + started_at="$(date +%s)" + + start_group "$label" + printf 'Timeout: %s seconds\n' "$seconds" + emit_setup_progress "$phase_id" start 0 "$seconds" + + set +e + run_with_timeout "$seconds" "$@" + status=$? + set -e + + ended_at="$(date +%s)" + elapsed_seconds=$(( ended_at - started_at )) + phase_status="success" + if [ "$status" -eq 124 ]; then + phase_status="timeout" + echo "Error: $label timed out after $seconds seconds." >&2 + elif [ "$status" -ne 0 ]; then + phase_status="failure" + fi + emit_setup_progress "$phase_id" "$phase_status" "$elapsed_seconds" "$seconds" + + end_group + return "$status" +} + +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 + run_phase \ + 'WordPress DuckDB setup' \ + "$WP_DUCKDB_SETUP_TIMEOUT_SECONDS" \ + bash -c 'cd "$1" && composer run wp-setup-duckdb' bash "$ROOT_DIR" + 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 + 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 + 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 +} + +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 + 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 + + 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 ! 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 ! 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 +} + +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 689b44ed7..db7ce08bb 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,18 +67,54 @@ "@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": [ + "./bin/wp-test-ensure-env-duckdb.sh @no_additional_args" + ], "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-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-test-duckdb-fast-fail": [ + "./bin/wp-test-duckdb-fast-fail.sh @additional_args" + ], + "wp-test-duckdb-fast-fail-with-setup": [ + "WP_DUCKDB_FAST_FAIL_PREPARED=0 ./bin/wp-test-duckdb-fast-fail.sh @additional_args" + ], + "test-duckdb-last-failures": [ + "./bin/wp-test-duckdb-last-failures.sh @additional_args" + ], + "test-duckdb-last-failures-only": [ + "WP_DUCKDB_LAST_FAILURE_REQUIRED=1 WP_DUCKDB_LAST_FAILURE_MIN_TESTS=20 WP_DUCKDB_LAST_FAILURE_MAX_TESTS=20 WP_DUCKDB_LAST_FAILURE_TIMEOUT=60 ./bin/wp-test-duckdb-last-failures.sh", + "WP_DUCKDB_LAST_FAILURE_IGNORE_CACHE=1 WP_DUCKDB_LAST_FAILURE_REQUIRED=1 WP_DUCKDB_LAST_FAILURE_MIN_TESTS=20 WP_DUCKDB_LAST_FAILURE_MAX_TESTS=42 WP_DUCKDB_LAST_FAILURE_TIMEOUT=60 ./bin/wp-test-duckdb-last-failures.sh" + ], + "test-duckdb-fail-fast": [ + "@test-duckdb-last-failures-only", + "./bin/wp-test-duckdb-fast-fail.sh validate" + ], + "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" ], + "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" + "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/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..10865b053 --- /dev/null +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-connection.php @@ -0,0 +1,395 @@ +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 ), $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 ), $sql ); + } catch ( Throwable $e ) { + throw new WP_DuckDB_Driver_Exception( 'Failed to prepare DuckDB query: ' . $e->getMessage(), 0, $e ); + } + } + + /** + * 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; + } + + /** + * 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. + * + * @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. + * + * @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, ?string $sql = null ): 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 && $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( '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. + * + * @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; + } + $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 ); + } + + /** + * 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..7e2253eb6 --- /dev/null +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver-exception.php @@ -0,0 +1,18 @@ +code = ( 0 === $code && null !== $previous ) ? 'HY000' : $code; + } +} 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 new file mode 100644 index 000000000..06d72a496 --- /dev/null +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-driver.php @@ -0,0 +1,36086 @@ + 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( + 'version' => true, + '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; + + 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::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( + 'date' => '0000-00-00', + 'time' => '00:00:00', + 'datetime' => '0000-00-00 00:00:00', + 'timestamp' => '0000-00-00 00:00:00', + ); + + const STRING_IMPLICIT_DEFAULT_MAP = array( + 'char' => '', + 'enum' => '', + 'longtext' => '', + 'mediumtext' => '', + 'set' => '', + 'text' => '', + 'tinytext' => '', + 'varchar' => '', + ); + + /** + * @var WP_Parser_Grammar|null + */ + private static $mysql_grammar; + + /** + * @var WP_DuckDB_Connection + */ + private $connection; + + /** + * @var string + */ + private $database; + + /** + * @var string + */ + private $current_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(); + + /** + * @var int + */ + 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. + * + * 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. + * + * @var array + */ + private $session_system_variables = array(); + + /** + * MySQL user variables emulated by this driver. + * + * @var array + */ + private $user_variables = array(); + + /** + * Connection-local stored procedures for the WordPress mysqli flush sync test. + * + * @var array + */ + private $stored_procedures = array(); + + /** + * Internal metadata tables already initialized for this connection. + * + * @var array + */ + private $ensured_metadata_tables = array(); + + /** + * Cached persistent and temporary table names. + * + * @var array + */ + private $table_name_cache = array(); + + /** + * Cached visible user table references by requested table name. + * + * @var array + */ + private $visible_table_reference_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 MySQL result metadata for wildcard table projections. + * + * @var array>> + */ + private $wildcard_result_column_metadata_cache = array(); + + /** + * Cached AUTO_INCREMENT metadata by table. + * + * @var array + */ + 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 the optional query profiler is enabled. + * + * @var bool|null + */ + private static $query_profile_enabled; + + /** + * Whether the optional query profiler shutdown hook has been registered. + * + * @var bool + */ + private static $query_profile_shutdown_registered = false; + + /** + * Aggregate optional query profiler counters. + * + * @var array + */ + private static $query_profile = array( + 'queries' => 0, + 'errors' => 0, + 'total_seconds' => 0.0, + 'parse_seconds' => 0.0, + 'native_seconds' => 0.0, + 'native_queries' => 0, + 'shapes' => array(), + ); + + /** + * Whether optional runtime counters are enabled. + * + * @var bool|null + */ + private static $runtime_counters_enabled; + + /** + * Whether the optional runtime counter shutdown hook has been registered. + * + * @var bool + */ + private static $runtime_counters_shutdown_registered = false; + + /** + * Aggregate optional runtime counters. + * + * @var array + */ + private static $runtime_counters = array( + 'counters' => array(), + 'native_contexts' => array(), + 'native_shapes' => array(), + ); + + /** + * Native DuckDB time accumulated while handling the current MySQL query. + * + * @var float + */ + private $profile_current_native_seconds = 0.0; + + /** + * Native DuckDB query count accumulated while handling the current MySQL query. + * + * @var int + */ + private $profile_current_native_queries = 0; + + /** + * Tokenize/parse time accumulated while handling the current MySQL query. + * + * @var float + */ + private $profile_current_parse_seconds = 0.0; + + /** + * Whether a MySQL LOCK TABLES statement opened the current transaction. + * + * @var bool + */ + private $table_lock_active = false; + + /** + * 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.' ); + } + $this->current_database = $this->database; + + 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(); + $this->last_insert_id = 0; + + $profile_enabled = self::query_profile_enabled(); + $profile_started_at = $profile_enabled ? microtime( true ) : 0.0; + $profile_error = false; + $runtime_counters_enabled = self::runtime_counters_enabled(); + $runtime_counter_started_at = $runtime_counters_enabled ? microtime( true ) : 0.0; + $this->profile_current_native_seconds = 0.0; + $this->profile_current_native_queries = 0; + $this->profile_current_parse_seconds = 0.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.' ); + } + + 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::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: + $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; + if ( $this->is_alter_view_statement( $tokens ) ) { + return $this->execute_alter_view( $tokens ); + } + 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_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: + 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 ) { + $profile_error = true; + $this->found_rows = 0; + $this->rollback_failed_active_transaction( $e ); + throw $e; + } finally { + if ( $profile_enabled ) { + $this->record_query_profile( + $query, + microtime( true ) - $profile_started_at, + $profile_error + ); + } + if ( $runtime_counters_enabled ) { + self::record_runtime_counter( 'mysql_query', microtime( true ) - $runtime_counter_started_at ); + } + } + } + + /** + * 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 { + $stored_procedure_result = $this->execute_stored_procedure_fast_path_statement( $query ); + if ( null !== $stored_procedure_result ) { + return $stored_procedure_result; + } + + $wordpress_options_update_result = $this->execute_wordpress_options_update_fast_path_statement( $query ); + if ( null !== $wordpress_options_update_result ) { + return $wordpress_options_update_result; + } + + $wordpress_options_odku_result = $this->execute_wordpress_options_on_duplicate_key_update_fast_path_statement( $query ); + if ( null !== $wordpress_options_odku_result ) { + return $wordpress_options_odku_result; + } + + $wordpress_usermeta_insert_result = $this->execute_wordpress_usermeta_insert_fast_path_statement( $query ); + if ( null !== $wordpress_usermeta_insert_result ) { + return $wordpress_usermeta_insert_result; + } + + $normalized = $this->normalize_fast_path_statement( $query ); + if ( null === $normalized ) { + return null; + } + + $information_schema_columns_name_collation_result = $this->execute_information_schema_columns_name_collation_fast_path_statement( $normalized ); + if ( null !== $information_schema_columns_name_collation_result ) { + return $information_schema_columns_name_collation_result; + } + + $information_schema_statistics_projection_result = $this->execute_information_schema_statistics_projection_fast_path_statement( $normalized ); + if ( null !== $information_schema_statistics_projection_result ) { + return $information_schema_statistics_projection_result; + } + + $information_schema_constraint_projection_result = $this->execute_information_schema_constraint_projection_fast_path_statement( $normalized ); + if ( null !== $information_schema_constraint_projection_result ) { + return $information_schema_constraint_projection_result; + } + + $wordpress_options_autoload_result = $this->execute_wordpress_options_autoload_fast_path_statement( $normalized ); + if ( null !== $wordpress_options_autoload_result ) { + return $wordpress_options_autoload_result; + } + + $wordpress_options_multi_name_result = $this->execute_wordpress_options_multi_name_fast_path_statement( $normalized ); + if ( null !== $wordpress_options_multi_name_result ) { + return $wordpress_options_multi_name_result; + } + + $wordpress_options_single_option_result = $this->execute_wordpress_options_single_option_fast_path_statement( $normalized ); + if ( null !== $wordpress_options_single_option_result ) { + return $wordpress_options_single_option_result; + } + + $wordpress_posts_id_lookup_result = $this->execute_wordpress_posts_id_lookup_fast_path_statement( $normalized ); + if ( null !== $wordpress_posts_id_lookup_result ) { + return $wordpress_posts_id_lookup_result; + } + + $wordpress_posts_slug_status_lookup_result = $this->execute_wordpress_posts_slug_status_lookup_fast_path_statement( $normalized ); + if ( null !== $wordpress_posts_slug_status_lookup_result ) { + return $wordpress_posts_slug_status_lookup_result; + } + + $wordpress_term_taxonomy_lookup_result = $this->execute_wordpress_term_taxonomy_lookup_fast_path_statement( $normalized ); + if ( null !== $wordpress_term_taxonomy_lookup_result ) { + return $wordpress_term_taxonomy_lookup_result; + } + + $wordpress_shared_term_group_result = $this->execute_wordpress_shared_term_group_fast_path_statement( $normalized ); + if ( null !== $wordpress_shared_term_group_result ) { + return $wordpress_shared_term_group_result; + } + + $wordpress_term_relationships_distinct_terms_result = $this->execute_wordpress_term_relationships_distinct_terms_fast_path_statement( $normalized ); + if ( null !== $wordpress_term_relationships_distinct_terms_result ) { + return $wordpress_term_relationships_distinct_terms_result; + } + + $wordpress_term_relationships_post_count_result = $this->execute_wordpress_term_relationships_post_count_fast_path_statement( $normalized ); + if ( null !== $wordpress_term_relationships_post_count_result ) { + return $wordpress_term_relationships_post_count_result; + } + + $wordpress_meta_cache_load_result = $this->execute_wordpress_meta_cache_load_fast_path_statement( $normalized ); + if ( null !== $wordpress_meta_cache_load_result ) { + return $wordpress_meta_cache_load_result; + } + + $wordpress_comments_group_by_meta_order_result = $this->execute_wordpress_comments_group_by_meta_order_fast_path_statement( $normalized ); + if ( null !== $wordpress_comments_group_by_meta_order_result ) { + return $wordpress_comments_group_by_meta_order_result; + } + + $wordpress_comments_group_by_cast_meta_order_result = $this->execute_wordpress_comments_group_by_cast_meta_order_fast_path_statement( $normalized ); + if ( null !== $wordpress_comments_group_by_cast_meta_order_result ) { + return $wordpress_comments_group_by_cast_meta_order_result; + } + + $wordpress_comments_group_by_two_cast_meta_orders_result = $this->execute_wordpress_comments_group_by_two_cast_meta_orders_fast_path_statement( $normalized ); + if ( null !== $wordpress_comments_group_by_two_cast_meta_orders_result ) { + return $wordpress_comments_group_by_two_cast_meta_orders_result; + } + + 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->clear_schema_state_after_rollback(); + } + $this->table_lock_active = false; + return $this->empty_ddl_result(); + } + + return null; + } + + /** + * Execute a high-frequency WordPress metadata information_schema.columns query. + * + * The generic information_schema.columns path remains responsible for broad + * predicates and projections. This path only avoids rebuilding the temporary + * compatibility table for the exact persistent-table metadata lookup used in + * WordPress probes. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_information_schema_columns_name_collation_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\])*\''; + $source_pattern = '(?:(?:`?information_schema`?)\s*\.\s*)?`?columns`?'; + + if ( + ! preg_match( + '/^SELECT\s+`?COLUMN_NAME`?\s*,\s*`?COLLATION_NAME`?\s+FROM\s+' + . $source_pattern + . '\s+WHERE\s+(?:(?`?TABLE_SCHEMA`?)\s*=\s*(?' . $literal_pattern . ')|(?' . $literal_pattern . ')\s*=\s*(?`?TABLE_SCHEMA`?))' + . '\s+AND\s+(?`?TABLE_NAME`?)\s+IN\s*\((?' . $literal_pattern . '(?:\s*,\s*' . $literal_pattern . ')*)\)' + . '\s+ORDER\s+BY\s+`?TABLE_NAME`?\s*,\s*`?COLUMN_NAME`?$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + if ( + ! $this->fast_path_identifier_matches( 'TABLE_NAME', $matches['table_column'] ) + || ( + isset( $matches['schema_column'] ) + && '' !== $matches['schema_column'] + && ! $this->fast_path_identifier_matches( 'TABLE_SCHEMA', $matches['schema_column'] ) + ) + || ( + isset( $matches['schema_column_right'] ) + && '' !== $matches['schema_column_right'] + && ! $this->fast_path_identifier_matches( 'TABLE_SCHEMA', $matches['schema_column_right'] ) + ) + ) { + return null; + } + + $schema_literal = '' !== ( $matches['schema_value'] ?? '' ) + ? $matches['schema_value'] + : $matches['schema_value_left']; + $schema_name = $this->fast_path_mysql_single_quoted_literal_value( $schema_literal ); + + $table_names = array(); + if ( false !== preg_match_all( '/' . $literal_pattern . '/', $matches['table_names'], $table_matches ) ) { + foreach ( $table_matches[0] as $table_literal ) { + $table_names[] = $this->fast_path_mysql_single_quoted_literal_value( $table_literal ); + } + } + + if ( count( $table_names ) === 0 ) { + return null; + } + + $rows = array(); + if ( 0 === strcasecmp( $schema_name, $this->database ) ) { + $rows = $this->information_schema_column_rows( $table_names ); + } + + usort( + $rows, + function ( array $left, array $right ): int { + $table_compare = strcasecmp( (string) $left['TABLE_NAME'], (string) $right['TABLE_NAME'] ); + if ( 0 !== $table_compare ) { + return $table_compare; + } + + $column_compare = strcasecmp( (string) $left['COLUMN_NAME'], (string) $right['COLUMN_NAME'] ); + if ( 0 !== $column_compare ) { + return $column_compare; + } + + return strcmp( (string) $left['COLUMN_NAME'], (string) $right['COLUMN_NAME'] ); + } + ); + + $result_rows = array_map( + function ( array $row ): array { + return array( + $row['COLUMN_NAME'], + $row['COLLATION_NAME'], + ); + }, + $rows + ); + + return $this->record_found_rows_from_result( + new WP_DuckDB_Result_Statement( + array( 'COLUMN_NAME', 'COLLATION_NAME' ), + $result_rows + ) + ); + } + + /** + * Execute high-frequency exact information_schema.statistics projections. + * + * The generic information_schema.statistics path still owns aliases, broad + * projections, wildcard predicates, and complex filters. This path only avoids + * rebuilding the temporary compatibility table for exact index metadata probes. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_information_schema_statistics_projection_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\])*\''; + $source_pattern = '`?information_schema`?\s*\.\s*`?statistics`?'; + + if ( + preg_match( + '/^SELECT\s+(?:(?`?TABLE_NAME`?)\s*,\s*)?`?INDEX_NAME`?\s*,\s*`?COLUMN_NAME`?(?\s*,\s*`?NON_UNIQUE`?(?\s*,\s*`?SEQ_IN_INDEX`?)?)?\s+FROM\s+' + . $source_pattern + . '\s+WHERE\s+(?`?TABLE_SCHEMA`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+AND\s+(?`?TABLE_NAME`?)\s+IN\s*\((?' . $literal_pattern . '(?:\s*,\s*' . $literal_pattern . ')*)\)' + . '\s+ORDER\s+BY\s+`?TABLE_NAME`?\s*,\s*`?INDEX_NAME`?\s*,\s*`?SEQ_IN_INDEX`?$/i', + $normalized_query, + $matches + ) + ) { + if ( + ! $this->fast_path_identifier_matches( 'TABLE_SCHEMA', $matches['schema_column'] ) + || ! $this->fast_path_identifier_matches( 'TABLE_NAME', $matches['table_column'] ) + || ( + isset( $matches['table_projection'] ) + && '' !== $matches['table_projection'] + && ! $this->fast_path_identifier_matches( 'TABLE_NAME', $matches['table_projection'] ) + ) + ) { + return null; + } + + $table_names = array(); + if ( false !== preg_match_all( '/' . $literal_pattern . '/', $matches['table_names'], $table_matches ) ) { + foreach ( $table_matches[0] as $table_literal ) { + $table_names[] = $this->fast_path_mysql_single_quoted_literal_value( $table_literal ); + } + } + + if ( count( $table_names ) === 0 ) { + return null; + } + + $columns = isset( $matches['table_projection'] ) && '' !== $matches['table_projection'] + ? array( 'TABLE_NAME', 'INDEX_NAME', 'COLUMN_NAME' ) + : array( 'INDEX_NAME', 'COLUMN_NAME' ); + if ( isset( $matches['non_unique_projection'] ) && '' !== $matches['non_unique_projection'] ) { + $columns[] = 'NON_UNIQUE'; + } + if ( isset( $matches['seq_in_index_projection'] ) && '' !== $matches['seq_in_index_projection'] ) { + $columns[] = 'SEQ_IN_INDEX'; + } + + return $this->information_schema_statistics_projection_result( + $this->fast_path_mysql_single_quoted_literal_value( $matches['schema_value'] ), + $table_names, + $columns, + true + ); + } + + if ( + preg_match( + '/^SELECT\s+`?INDEX_NAME`?\s*,\s*`?COLUMN_NAME`?(?\s*,\s*`?NON_UNIQUE`?\s*,\s*`?SEQ_IN_INDEX`?)?\s+FROM\s+' + . $source_pattern + . '\s+WHERE\s+(?`?TABLE_SCHEMA`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+AND\s+(?`?TABLE_NAME`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+ORDER\s+BY\s+`?INDEX_NAME`?\s*,\s*`?SEQ_IN_INDEX`?$/i', + $normalized_query, + $matches + ) + ) { + if ( + ! $this->fast_path_identifier_matches( 'TABLE_SCHEMA', $matches['schema_column'] ) + || ! $this->fast_path_identifier_matches( 'TABLE_NAME', $matches['table_column'] ) + ) { + return null; + } + + return $this->information_schema_statistics_projection_result( + $this->fast_path_mysql_single_quoted_literal_value( $matches['schema_value'] ), + array( $this->fast_path_mysql_single_quoted_literal_value( $matches['table_name'] ) ), + isset( $matches['extra_projection'] ) && '' !== $matches['extra_projection'] + ? array( 'INDEX_NAME', 'COLUMN_NAME', 'NON_UNIQUE', 'SEQ_IN_INDEX' ) + : array( 'INDEX_NAME', 'COLUMN_NAME' ), + false + ); + } + + return null; + } + + /** + * Build an exact information_schema.statistics projection result. + * + * @param string $schema_name Requested schema. + * @param string[] $table_names Requested table names. + * @param string[] $columns Projected statistics columns. + * @param bool $order_by_table Whether TABLE_NAME participates in sorting. + * @return WP_DuckDB_Result_Statement Result statement. + */ + private function information_schema_statistics_projection_result( string $schema_name, array $table_names, array $columns, bool $order_by_table ): WP_DuckDB_Result_Statement { + $rows = 0 === strcasecmp( $schema_name, $this->database ) + ? $this->information_schema_statistics_rows( $table_names ) + : array(); + + usort( + $rows, + function ( array $left, array $right ) use ( $order_by_table ): int { + if ( $order_by_table ) { + $table_compare = strcasecmp( (string) $left['TABLE_NAME'], (string) $right['TABLE_NAME'] ); + if ( 0 !== $table_compare ) { + return $table_compare; + } + } + + $index_compare = strcasecmp( (string) $left['INDEX_NAME'], (string) $right['INDEX_NAME'] ); + if ( 0 !== $index_compare ) { + return $index_compare; + } + + $seq_compare = (int) $left['SEQ_IN_INDEX'] <=> (int) $right['SEQ_IN_INDEX']; + if ( 0 !== $seq_compare ) { + return $seq_compare; + } + + return strcmp( (string) $left['INDEX_NAME'], (string) $right['INDEX_NAME'] ); + } + ); + + $result_rows = array_map( + function ( array $row ) use ( $columns ): array { + return array_map( + function ( string $column ) use ( $row ) { + return $row[ $column ]; + }, + $columns + ); + }, + $rows + ); + + return $this->record_found_rows_from_result( + new WP_DuckDB_Result_Statement( + $columns, + $result_rows + ) + ); + } + + /** + * Execute exact information_schema constraint metadata projections. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_information_schema_constraint_projection_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\])*\''; + $table_constraints_source = '`?information_schema`?\s*\.\s*`?table_constraints`?'; + $key_usage_source = '`?information_schema`?\s*\.\s*`?key_column_usage`?'; + + if ( + preg_match( + '/^SELECT\s+`?CONSTRAINT_NAME`?(?\s*,\s*`?CONSTRAINT_TYPE`?(?:\s*,\s*`?ENFORCED`?)?)?\s+FROM\s+' + . $table_constraints_source + . '\s+WHERE\s+(?`?TABLE_SCHEMA`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+AND\s+(?`?TABLE_NAME`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+ORDER\s+BY\s+`?CONSTRAINT_NAME`?$/i', + $normalized_query, + $matches + ) + ) { + if ( + ! $this->fast_path_identifier_matches( 'TABLE_SCHEMA', $matches['schema_column'] ) + || ! $this->fast_path_identifier_matches( 'TABLE_NAME', $matches['table_column'] ) + ) { + return null; + } + + $columns = array( 'CONSTRAINT_NAME' ); + if ( isset( $matches['extra_projection'] ) && '' !== $matches['extra_projection'] ) { + $columns[] = 'CONSTRAINT_TYPE'; + if ( false !== stripos( $matches['extra_projection'], 'ENFORCED' ) ) { + $columns[] = 'ENFORCED'; + } + } + + return $this->information_schema_table_constraints_projection_result( + $this->fast_path_mysql_single_quoted_literal_value( $matches['schema_value'] ), + $this->fast_path_mysql_single_quoted_literal_value( $matches['table_name'] ), + $columns + ); + } + + if ( + preg_match( + '/^SELECT\s+`?CONSTRAINT_NAME`?\s*,\s*`?COLUMN_NAME`?\s*,\s*`?ORDINAL_POSITION`?(?\s*,\s*`?REFERENCED_TABLE_NAME`?)?\s+FROM\s+' + . $key_usage_source + . '\s+WHERE\s+(?`?TABLE_SCHEMA`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+AND\s+(?`?TABLE_NAME`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+ORDER\s+BY\s+`?CONSTRAINT_NAME`?\s*,\s*`?ORDINAL_POSITION`?$/i', + $normalized_query, + $matches + ) + ) { + if ( + ! $this->fast_path_identifier_matches( 'TABLE_SCHEMA', $matches['schema_column'] ) + || ! $this->fast_path_identifier_matches( 'TABLE_NAME', $matches['table_column'] ) + ) { + return null; + } + + $columns = array( 'CONSTRAINT_NAME', 'COLUMN_NAME', 'ORDINAL_POSITION' ); + if ( isset( $matches['referenced_projection'] ) && '' !== $matches['referenced_projection'] ) { + $columns[] = 'REFERENCED_TABLE_NAME'; + } + + return $this->information_schema_key_column_usage_projection_result( + $this->fast_path_mysql_single_quoted_literal_value( $matches['schema_value'] ), + $this->fast_path_mysql_single_quoted_literal_value( $matches['table_name'] ), + $columns + ); + } + + $check_constraints_source = '`?information_schema`?\s*\.\s*`?check_constraints`?'; + if ( + preg_match( + '/^SELECT\s+(?`?CONSTRAINT_CATALOG`?\s*,\s*`?CONSTRAINT_SCHEMA`?\s*,\s*)?`?CONSTRAINT_NAME`?(?\s*,\s*`?CHECK_CLAUSE`?)?\s+FROM\s+' + . $check_constraints_source + . '\s+WHERE\s+(?`?CONSTRAINT_SCHEMA`?)\s*=\s*(?' . $literal_pattern . ')' + . '(?:\s+AND\s+(?`?CONSTRAINT_NAME`?)\s*=\s*(?' . $literal_pattern . '))?' + . '\s+ORDER\s+BY\s+`?CONSTRAINT_NAME`?$/i', + $normalized_query, + $matches + ) + ) { + if ( + ! $this->fast_path_identifier_matches( 'CONSTRAINT_SCHEMA', $matches['schema_column'] ) + || ( + isset( $matches['constraint_column'] ) + && '' !== $matches['constraint_column'] + && ! $this->fast_path_identifier_matches( 'CONSTRAINT_NAME', $matches['constraint_column'] ) + ) + ) { + return null; + } + + $columns = isset( $matches['check_catalog_projection'] ) && '' !== $matches['check_catalog_projection'] + ? array( 'CONSTRAINT_CATALOG', 'CONSTRAINT_SCHEMA', 'CONSTRAINT_NAME' ) + : array( 'CONSTRAINT_NAME' ); + if ( isset( $matches['check_clause_projection'] ) && '' !== $matches['check_clause_projection'] ) { + $columns[] = 'CHECK_CLAUSE'; + } + + return $this->information_schema_check_constraints_projection_result( + $this->fast_path_mysql_single_quoted_literal_value( $matches['schema_value'] ), + isset( $matches['constraint_name'] ) && '' !== $matches['constraint_name'] + ? array( $this->fast_path_mysql_single_quoted_literal_value( $matches['constraint_name'] ) ) + : null, + $columns + ); + } + + $referential_constraints_source = '`?information_schema`?\s*\.\s*`?referential_constraints`?'; + if ( + preg_match( + '/^SELECT\s+`?CONSTRAINT_CATALOG`?\s*,\s*`?CONSTRAINT_SCHEMA`?\s*,\s*`?CONSTRAINT_NAME`?\s*,\s*`?UNIQUE_CONSTRAINT_CATALOG`?\s*,\s*`?UNIQUE_CONSTRAINT_SCHEMA`?\s*,\s*`?UNIQUE_CONSTRAINT_NAME`?\s*,\s*`?MATCH_OPTION`?\s*,\s*`?UPDATE_RULE`?\s*,\s*`?DELETE_RULE`?\s*,\s*`?TABLE_NAME`?\s*,\s*`?REFERENCED_TABLE_NAME`?\s+FROM\s+' + . $referential_constraints_source + . '\s+WHERE\s+(?`?CONSTRAINT_SCHEMA`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+AND\s+(?`?TABLE_NAME`?)\s+IN\s*\((?' . $literal_pattern . '(?:\s*,\s*' . $literal_pattern . ')*)\)' + . '\s+ORDER\s+BY\s+`?TABLE_NAME`?\s*,\s*`?CONSTRAINT_NAME`?$/i', + $normalized_query, + $matches + ) + ) { + if ( + ! $this->fast_path_identifier_matches( 'CONSTRAINT_SCHEMA', $matches['schema_column'] ) + || ! $this->fast_path_identifier_matches( 'TABLE_NAME', $matches['table_column'] ) + ) { + return null; + } + + $table_names = array(); + if ( false !== preg_match_all( '/' . $literal_pattern . '/', $matches['table_names'], $table_matches ) ) { + foreach ( $table_matches[0] as $table_literal ) { + $table_names[] = $this->fast_path_mysql_single_quoted_literal_value( $table_literal ); + } + } + + if ( count( $table_names ) === 0 ) { + return null; + } + + return $this->information_schema_referential_constraints_projection_result( + $this->fast_path_mysql_single_quoted_literal_value( $matches['schema_value'] ), + $table_names, + array( + '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', + ), + true + ); + } + + if ( + preg_match( + '/^SELECT\s+`?CONSTRAINT_NAME`?\s*,\s*`?UNIQUE_CONSTRAINT_NAME`?\s*,\s*`?MATCH_OPTION`?\s*,\s*`?UPDATE_RULE`?\s*,\s*`?DELETE_RULE`?\s*,\s*`?TABLE_NAME`?\s*,\s*`?REFERENCED_TABLE_NAME`?\s+FROM\s+' + . $referential_constraints_source + . '\s+WHERE\s+(?`?CONSTRAINT_SCHEMA`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+AND\s+(?`?TABLE_NAME`?)\s+IN\s*\((?' . $literal_pattern . '(?:\s*,\s*' . $literal_pattern . ')*)\)' + . '\s+ORDER\s+BY\s+`?TABLE_NAME`?\s*,\s*`?CONSTRAINT_NAME`?$/i', + $normalized_query, + $matches + ) + ) { + if ( + ! $this->fast_path_identifier_matches( 'CONSTRAINT_SCHEMA', $matches['schema_column'] ) + || ! $this->fast_path_identifier_matches( 'TABLE_NAME', $matches['table_column'] ) + ) { + return null; + } + + $table_names = array(); + if ( false !== preg_match_all( '/' . $literal_pattern . '/', $matches['table_names'], $table_matches ) ) { + foreach ( $table_matches[0] as $table_literal ) { + $table_names[] = $this->fast_path_mysql_single_quoted_literal_value( $table_literal ); + } + } + + if ( count( $table_names ) === 0 ) { + return null; + } + + return $this->information_schema_referential_constraints_projection_result( + $this->fast_path_mysql_single_quoted_literal_value( $matches['schema_value'] ), + $table_names, + array( 'CONSTRAINT_NAME', 'UNIQUE_CONSTRAINT_NAME', 'MATCH_OPTION', 'UPDATE_RULE', 'DELETE_RULE', 'TABLE_NAME', 'REFERENCED_TABLE_NAME' ), + true + ); + } + + if ( + preg_match( + '/^SELECT\s+`?CONSTRAINT_NAME`?\s*,\s*`?UNIQUE_CONSTRAINT_NAME`?\s*,\s*`?TABLE_NAME`?\s*,\s*`?REFERENCED_TABLE_NAME`?\s+FROM\s+' + . $referential_constraints_source + . '\s+WHERE\s+(?`?CONSTRAINT_SCHEMA`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+AND\s+(?`?TABLE_NAME`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+ORDER\s+BY\s+`?CONSTRAINT_NAME`?$/i', + $normalized_query, + $matches + ) + ) { + if ( + ! $this->fast_path_identifier_matches( 'CONSTRAINT_SCHEMA', $matches['schema_column'] ) + || ! $this->fast_path_identifier_matches( 'TABLE_NAME', $matches['table_column'] ) + ) { + return null; + } + + return $this->information_schema_referential_constraints_projection_result( + $this->fast_path_mysql_single_quoted_literal_value( $matches['schema_value'] ), + array( $this->fast_path_mysql_single_quoted_literal_value( $matches['table_name'] ) ), + array( 'CONSTRAINT_NAME', 'UNIQUE_CONSTRAINT_NAME', 'TABLE_NAME', 'REFERENCED_TABLE_NAME' ), + false + ); + } + + if ( + preg_match( + '/^SELECT\s+`?CONSTRAINT_NAME`?\s*,\s*`?TABLE_NAME`?(?\s*,\s*`?REFERENCED_TABLE_NAME`?\s*,\s*`?UPDATE_RULE`?\s*,\s*`?DELETE_RULE`?)?\s+FROM\s+' + . $referential_constraints_source + . '\s+WHERE\s+(?`?CONSTRAINT_SCHEMA`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+AND\s+(?`?TABLE_NAME`?)\s*=\s*(?' . $literal_pattern . ')' + . '\s+ORDER\s+BY\s+`?CONSTRAINT_NAME`?$/i', + $normalized_query, + $matches + ) + ) { + if ( + ! $this->fast_path_identifier_matches( 'CONSTRAINT_SCHEMA', $matches['schema_column'] ) + || ! $this->fast_path_identifier_matches( 'TABLE_NAME', $matches['table_column'] ) + ) { + return null; + } + + return $this->information_schema_referential_constraints_projection_result( + $this->fast_path_mysql_single_quoted_literal_value( $matches['schema_value'] ), + array( $this->fast_path_mysql_single_quoted_literal_value( $matches['table_name'] ) ), + isset( $matches['referential_single_extra'] ) && '' !== $matches['referential_single_extra'] + ? array( 'CONSTRAINT_NAME', 'TABLE_NAME', 'REFERENCED_TABLE_NAME', 'UPDATE_RULE', 'DELETE_RULE' ) + : array( 'CONSTRAINT_NAME', 'TABLE_NAME' ), + false + ); + } + + return null; + } + + /** + * Build an exact information_schema.table_constraints projection result. + * + * @param string $schema_name Requested schema. + * @param string $table_name Requested table name. + * @param string[] $columns Projected columns. + * @return WP_DuckDB_Result_Statement Result statement. + */ + private function information_schema_table_constraints_projection_result( string $schema_name, string $table_name, array $columns ): WP_DuckDB_Result_Statement { + $rows = 0 === strcasecmp( $schema_name, $this->database ) + ? $this->information_schema_table_constraints_rows( array( $table_name ) ) + : array(); + + usort( + $rows, + function ( array $left, array $right ): int { + $name_compare = strcasecmp( (string) $left['CONSTRAINT_NAME'], (string) $right['CONSTRAINT_NAME'] ); + if ( 0 !== $name_compare ) { + return $name_compare; + } + + return strcmp( (string) $left['CONSTRAINT_NAME'], (string) $right['CONSTRAINT_NAME'] ); + } + ); + + return $this->record_found_rows_from_result( + new WP_DuckDB_Result_Statement( + $columns, + $this->project_information_schema_rows( $rows, $columns ) + ) + ); + } + + /** + * Build an exact information_schema.key_column_usage projection result. + * + * @param string $schema_name Requested schema. + * @param string $table_name Requested table name. + * @param string[] $columns Projected columns. + * @return WP_DuckDB_Result_Statement Result statement. + */ + private function information_schema_key_column_usage_projection_result( string $schema_name, string $table_name, array $columns ): WP_DuckDB_Result_Statement { + $rows = 0 === strcasecmp( $schema_name, $this->database ) + ? $this->information_schema_key_column_usage_rows( array( $table_name ) ) + : array(); + + usort( + $rows, + function ( array $left, array $right ): int { + $name_compare = strcasecmp( (string) $left['CONSTRAINT_NAME'], (string) $right['CONSTRAINT_NAME'] ); + if ( 0 !== $name_compare ) { + return $name_compare; + } + + $position_compare = (int) $left['ORDINAL_POSITION'] <=> (int) $right['ORDINAL_POSITION']; + if ( 0 !== $position_compare ) { + return $position_compare; + } + + return strcmp( (string) $left['COLUMN_NAME'], (string) $right['COLUMN_NAME'] ); + } + ); + + return $this->record_found_rows_from_result( + new WP_DuckDB_Result_Statement( + $columns, + $this->project_information_schema_rows( $rows, $columns ) + ) + ); + } + + /** + * Build an exact information_schema.check_constraints projection result. + * + * @param string $schema_name Requested schema. + * @param string[]|null $constraint_names Optional requested constraint names. + * @param string[] $columns Projected columns. + * @return WP_DuckDB_Result_Statement Result statement. + */ + private function information_schema_check_constraints_projection_result( string $schema_name, ?array $constraint_names, array $columns ): WP_DuckDB_Result_Statement { + $rows = 0 === strcasecmp( $schema_name, $this->database ) + ? $this->information_schema_check_constraints_rows( $constraint_names ) + : array(); + + usort( + $rows, + function ( array $left, array $right ): int { + $name_compare = strcasecmp( (string) $left['CONSTRAINT_NAME'], (string) $right['CONSTRAINT_NAME'] ); + if ( 0 !== $name_compare ) { + return $name_compare; + } + + return strcmp( (string) $left['CONSTRAINT_NAME'], (string) $right['CONSTRAINT_NAME'] ); + } + ); + + return $this->record_found_rows_from_result( + new WP_DuckDB_Result_Statement( + $columns, + $this->project_information_schema_rows( $rows, $columns ) + ) + ); + } + + /** + * Build an exact information_schema.referential_constraints projection result. + * + * @param string $schema_name Requested schema. + * @param string[] $table_names Requested table names. + * @param string[] $columns Projected columns. + * @param bool $order_by_table Whether TABLE_NAME participates in sorting. + * @return WP_DuckDB_Result_Statement Result statement. + */ + private function information_schema_referential_constraints_projection_result( string $schema_name, array $table_names, array $columns, bool $order_by_table ): WP_DuckDB_Result_Statement { + $rows = 0 === strcasecmp( $schema_name, $this->database ) + ? $this->information_schema_referential_constraints_rows( $table_names ) + : array(); + + usort( + $rows, + function ( array $left, array $right ) use ( $order_by_table ): int { + if ( $order_by_table ) { + $table_compare = strcasecmp( (string) $left['TABLE_NAME'], (string) $right['TABLE_NAME'] ); + if ( 0 !== $table_compare ) { + return $table_compare; + } + } + + $name_compare = strcasecmp( (string) $left['CONSTRAINT_NAME'], (string) $right['CONSTRAINT_NAME'] ); + if ( 0 !== $name_compare ) { + return $name_compare; + } + + return strcmp( (string) $left['CONSTRAINT_NAME'], (string) $right['CONSTRAINT_NAME'] ); + } + ); + + return $this->record_found_rows_from_result( + new WP_DuckDB_Result_Statement( + $columns, + $this->project_information_schema_rows( $rows, $columns ) + ) + ); + } + + /** + * Project MySQL-shaped information_schema rows into statement rows. + * + * @param array> $rows Source rows. + * @param string[] $columns Projected columns. + * @return array> Projected statement rows. + */ + private function project_information_schema_rows( array $rows, array $columns ): array { + return array_map( + function ( array $row ) use ( $columns ): array { + return array_map( + function ( string $column ) use ( $row ) { + return $row[ $column ]; + }, + $columns + ); + }, + $rows + ); + } + + /** + * Execute WordPress' high-frequency alloptions SELECT without parser/metadata fanout. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_options_autoload_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + if ( + ! preg_match( + '/^SELECT\s+`?option_name`?\s*,\s*`?option_value`?\s+FROM\s+(?`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*)\s+WHERE\s+`?autoload`?\s+IN\s*\(\s*\'yes\'\s*,\s*\'on\'\s*,\s*\'auto-on\'\s*,\s*\'auto\'\s*\)$/', + $normalized_query, + $matches + ) + ) { + return null; + } + + $table_name = $this->fast_path_mysql_identifier_value( $matches['table'] ); + if ( ! $this->is_wordpress_options_table_name( $table_name ) ) { + return null; + } + + $sql = 'SELECT ' + . $this->connection->quote_identifier( 'option_name' ) + . ', ' + . $this->connection->quote_identifier( 'option_value' ) + . ' FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' WHERE ' + . $this->connection->quote_identifier( 'autoload' ) + . ' IN (' + . implode( + ', ', + array_map( + array( $this->connection, 'quote' ), + array( 'yes', 'on', 'auto-on', 'auto' ) + ) + ) + . ')'; + + $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); + $this->found_rows = $sql; + + return $this->apply_result_column_metadata( + $result, + $this->wordpress_options_autoload_result_column_metadata( $table_name ) + ); + } + + /** + * Execute WordPress' high-frequency multi-option SELECT without parser/metadata fanout. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_options_multi_name_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $identifier_pattern = '`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*'; + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\])*\''; + if ( + ! preg_match( + '/^SELECT\s+(?' . $identifier_pattern . ')\s*,\s*(?' . $identifier_pattern . ')\s+FROM\s+(?
' . $identifier_pattern . ')\s+WHERE\s+(?' . $identifier_pattern . ')\s+IN\s*\(\s*(?' . $literal_pattern . '(?:\s*,\s*' . $literal_pattern . ')*)\s*\)(?:\s+ORDER\s+BY\s+(?' . $identifier_pattern . ')(?:\s+ASC)?)?$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $table_name = $this->fast_path_mysql_identifier_value( $matches['table'] ); + if ( ! $this->is_wordpress_options_table_name( $table_name ) ) { + return null; + } + + if ( + ! $this->fast_path_identifier_matches( 'option_name', $matches['name_column'] ) + || ! $this->fast_path_identifier_matches( 'option_value', $matches['value_column'] ) + || ! $this->fast_path_identifier_matches( 'option_name', $matches['where_column'] ) + || ( isset( $matches['order_column'] ) && '' !== $matches['order_column'] && ! $this->fast_path_identifier_matches( 'option_name', $matches['order_column'] ) ) + ) { + return null; + } + + preg_match_all( '/' . $literal_pattern . '/', $matches['option_names'], $option_name_matches ); + if ( empty( $option_name_matches[0] ) ) { + return null; + } + + $option_name_sql = array(); + foreach ( $option_name_matches[0] as $option_name_literal ) { + $option_name = $this->fast_path_mysql_single_quoted_literal_value( $option_name_literal ); + if ( preg_match( '/\s/', $option_name ) ) { + return null; + } + $option_name_sql[] = $this->connection->quote( $option_name ); + } + + $sql = 'SELECT ' + . $this->connection->quote_identifier( 'option_name' ) + . ', ' + . $this->connection->quote_identifier( 'option_value' ) + . ' FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' WHERE ' + . $this->connection->quote_identifier( 'option_name' ) + . ' COLLATE NOCASE IN (' + . implode( ', ', $option_name_sql ) + . ')'; + + if ( isset( $matches['order_column'] ) && '' !== $matches['order_column'] ) { + $sql .= ' ORDER BY ' . $this->connection->quote_identifier( 'option_name' ); + } + + $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); + $this->found_rows = $sql; + + return $this->apply_result_column_metadata( + $result, + $this->wordpress_options_autoload_result_column_metadata( $table_name ) + ); + } + + /** + * Execute WordPress' high-frequency single-option SELECT without parser/metadata fanout. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_options_single_option_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + if ( + ! preg_match( + '/^SELECT\s+(?`(?:autoload|option_value)`|autoload|option_value)\s+FROM\s+(?
`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*)\s+WHERE\s+(?`option_name`|option_name)\s*=\s*(?\'(?:\\\\.|\'\'|[^\'\\\\\s])*\')(?:\s+LIMIT\s+1)?$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $table_name = $this->fast_path_mysql_identifier_value( $matches['table'] ); + if ( ! $this->is_wordpress_options_table_name( $table_name ) ) { + return null; + } + + $column_name = strtolower( $this->fast_path_mysql_identifier_value( $matches['column'] ) ); + if ( 'autoload' !== $column_name && 'option_value' !== $column_name ) { + return null; + } + + $name_column = strtolower( $this->fast_path_mysql_identifier_value( $matches['name_column'] ) ); + if ( 'option_name' !== $name_column ) { + return null; + } + + $option_name = $this->fast_path_mysql_single_quoted_literal_value( $matches['option_name'] ); + if ( preg_match( '/\s/', $option_name ) ) { + return null; + } + + $option_name_sql = $this->connection->quote( $option_name ); + $sql = 'SELECT ' + . $this->connection->quote_identifier( $column_name ) + . ' FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' WHERE lower(' + . $this->connection->quote_identifier( 'option_name' ) + . ') IS NOT DISTINCT FROM lower(CAST(' + . $option_name_sql + . ' AS VARCHAR))'; + + if ( preg_match( '/\s+LIMIT\s+1$/i', $normalized_query ) ) { + $sql .= ' LIMIT 1'; + } + + $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); + $this->found_rows = $sql; + + return $this->apply_result_column_metadata( + $result, + $this->wordpress_options_single_column_result_metadata( $table_name, $column_name ) + ); + } + + /** + * Execute WordPress' high-frequency single-option UPDATE without parser/metadata fanout. + * + * This parses the raw query instead of the normalized fast-path string so + * option values containing whitespace are preserved exactly. + * + * @param string $query MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_options_update_fast_path_statement( string $query ): ?WP_DuckDB_Result_Statement { + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\])*\''; + if ( + ! preg_match( + '/^\s*UPDATE\s+(?
`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*)\s+SET\s+(?`option_value`|option_value)\s*=\s*(?' . $literal_pattern . ')\s+WHERE\s+(?`option_name`|option_name)\s*=\s*(?' . $literal_pattern . ')\s*;?\s*$/i', + $query, + $matches + ) + ) { + return null; + } + + $table_name = $this->fast_path_mysql_identifier_value( $matches['table'] ); + if ( ! $this->is_wordpress_options_table_name( $table_name ) ) { + return null; + } + + $value_column = strtolower( $this->fast_path_mysql_identifier_value( $matches['value_column'] ) ); + if ( 'option_value' !== $value_column ) { + return null; + } + + $name_column = strtolower( $this->fast_path_mysql_identifier_value( $matches['name_column'] ) ); + if ( 'option_name' !== $name_column ) { + return null; + } + + $option_value = $this->fast_path_mysql_single_quoted_literal_value( $matches['option_value'] ); + $option_name = $this->fast_path_mysql_single_quoted_literal_value( $matches['option_name'] ); + $option_value_sql = $this->connection->quote( $option_value ); + $option_name_sql = $this->connection->quote( $option_name ); + $value_column_sql = $this->connection->quote_identifier( 'option_value' ); + + $sql = 'UPDATE ' + . $this->connection->quote_identifier( $table_name ) + . ' SET ' + . $value_column_sql + . ' = ' + . $option_value_sql + . ' WHERE lower(' + . $this->connection->quote_identifier( 'option_name' ) + . ') IS NOT DISTINCT FROM lower(CAST(' + . $option_name_sql + . ' AS VARCHAR))' + . ' AND (' + . $this->byte_sensitive_update_value_sql( $value_column_sql ) + . ' IS DISTINCT FROM ' + . $this->byte_sensitive_update_value_sql( $option_value_sql ) + . ')'; + + $this->found_rows = 0; + return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB UPDATE' ); + } + + /** + * Execute WordPress' hot options INSERT ... ON DUPLICATE KEY UPDATE shape. + * + * This keeps the generic ODKU implementation responsible for arbitrary + * tables, expressions, and multi-row writes. The exact WordPress options + * shape can avoid unique-key discovery and broad write metadata fanout. + * + * @param string $query MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_options_on_duplicate_key_update_fast_path_statement( string $query ): ?WP_DuckDB_Result_Statement { + $identifier_pattern = '`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*'; + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\])*\''; + if ( + ! preg_match( + '/^\s*INSERT\s+INTO\s+(?
' . $identifier_pattern . ')\s*\(\s*(?' . $identifier_pattern . ')\s*,\s*(?' . $identifier_pattern . ')\s*,\s*(?' . $identifier_pattern . ')\s*\)\s+VALUES\s*\(\s*(?' . $literal_pattern . ')\s*,\s*(?' . $literal_pattern . ')\s*,\s*(?' . $literal_pattern . ')\s*\)\s+ON\s+DUPLICATE\s+KEY\s+UPDATE\s+(?.+?)\s*;?\s*$/is', + $query, + $matches + ) + ) { + return null; + } + + $requested_table_name = $this->fast_path_mysql_identifier_value( $matches['table'] ); + if ( ! $this->is_wordpress_options_table_name( $requested_table_name ) ) { + return null; + } + + if ( + ! $this->fast_path_identifier_matches( 'option_name', $matches['name_column'] ) + || ! $this->fast_path_identifier_matches( 'option_value', $matches['value_column'] ) + || ! $this->fast_path_identifier_matches( 'autoload', $matches['autoload_column'] ) + ) { + return null; + } + + $option_name_sql = $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $matches['option_name'] ) ); + $option_value_sql = $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $matches['option_value'] ) ); + $autoload_sql = $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $matches['autoload'] ) ); + $assignments = $this->wordpress_options_on_duplicate_key_update_assignments( + $matches['updates'], + $identifier_pattern, + array( + 'option_name' => $option_name_sql, + 'option_value' => $option_value_sql, + 'autoload' => $autoload_sql, + ) + ); + if ( null === $assignments ) { + return null; + } + + $table_reference = $this->resolve_write_table_reference( $requested_table_name ); + $table_name = $table_reference['table_name']; + $table_sql = $this->connection->quote_identifier( $table_name ); + $name_predicate = 'lower(' + . $this->connection->quote_identifier( 'option_name' ) + . ') IS NOT DISTINCT FROM lower(CAST(' + . $option_name_sql + . ' AS VARCHAR))'; + $matched = false !== $this->execute_duckdb_query( + 'SELECT 1 FROM ' + . $table_sql + . ' WHERE ' + . $name_predicate + . ' LIMIT 1', + 'Failed to inspect DuckDB duplicate key target' + )->fetch( PDO::FETCH_NUM ); + + $this->found_rows = 0; + if ( $matched ) { + return $this->execute_duckdb_query( + 'UPDATE ' + . $table_sql + . ' SET ' + . implode( ', ', $assignments ) + . ' WHERE ' + . $name_predicate, + 'Failed to execute DuckDB INSERT' + ); + } + + $sql = 'INSERT INTO ' + . $table_sql + . ' (' + . $this->connection->quote_identifier( 'option_name' ) + . ', ' + . $this->connection->quote_identifier( 'option_value' ) + . ', ' + . $this->connection->quote_identifier( 'autoload' ) + . ') VALUES (' + . $option_name_sql + . ', ' + . $option_value_sql + . ', ' + . $autoload_sql + . ')'; + + try { + return $this->execute_auto_increment_returning_write( $sql, 'Failed to execute DuckDB INSERT', 'option_id' ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + if ( false !== stripos( $e->getMessage(), 'option_id' ) ) { + return null; + } + + throw $e; + } + } + + /** + * Parse exact WordPress options ODKU assignment list. + * + * @param string $updates Raw update list. + * @param string $identifier_pattern Regex fragment for identifiers. + * @param array $values_by_column Insert values keyed by lowercase column. + * @return string[]|null DuckDB assignment SQL, or null for unsupported shapes. + */ + private function wordpress_options_on_duplicate_key_update_assignments( string $updates, string $identifier_pattern, array $values_by_column ): ?array { + $updates = trim( rtrim( trim( $updates ), ';' ) ); + $assignments = array(); + $seen = array(); + + foreach ( preg_split( '/\s*,\s*/', $updates ) as $assignment ) { + if ( + ! is_string( $assignment ) + || '' === $assignment + || ! preg_match( + '/^(?' . $identifier_pattern . ')\s*=\s*VALUES\s*\(\s*(?' . $identifier_pattern . ')\s*\)$/i', + $assignment, + $matches + ) + ) { + return null; + } + + $target = strtolower( $this->fast_path_mysql_identifier_value( $matches['target'] ) ); + $source = strtolower( $this->fast_path_mysql_identifier_value( $matches['source'] ) ); + if ( + $target !== $source + || isset( $seen[ $target ] ) + || ! in_array( $target, array( 'option_name', 'option_value', 'autoload' ), true ) + ) { + return null; + } + + $seen[ $target ] = true; + $target_sql = $this->connection->quote_identifier( $target ); + $assignments[] = $target_sql . ' = CAST((' . $values_by_column[ $target ] . ') AS VARCHAR)'; + } + + return count( $assignments ) > 0 ? $assignments : null; + } + + /** + * Execute WordPress' high-frequency usermeta INSERT without parser/metadata fanout. + * + * This handles the exact `wpdb::insert()` shape for persistent usermeta + * tables. Temporary shadows deliberately fall back to the generic path. + * + * @param string $query MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_usermeta_insert_fast_path_statement( string $query ): ?WP_DuckDB_Result_Statement { + $identifier_pattern = '`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*'; + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\])*\''; + if ( + ! preg_match( + '/^\s*INSERT\s+INTO\s+(?
' . $identifier_pattern . ')\s*\(\s*(?' . $identifier_pattern . ')\s*,\s*(?' . $identifier_pattern . ')\s*,\s*(?' . $identifier_pattern . ')\s*\)\s+VALUES\s*\(\s*(?[0-9]+|\'[0-9]+\')\s*,\s*(?' . $literal_pattern . ')\s*,\s*(?' . $literal_pattern . '|NULL)\s*\)\s*;?\s*$/i', + $query, + $matches + ) + ) { + return null; + } + + $table_name = $this->fast_path_mysql_identifier_value( $matches['table'] ); + if ( ! $this->is_wordpress_usermeta_table_name( $table_name ) ) { + return null; + } + + if ( + ! $this->fast_path_identifier_matches( 'user_id', $matches['user_id_column'] ) + || ! $this->fast_path_identifier_matches( 'meta_key', $matches['meta_key_column'] ) + || ! $this->fast_path_identifier_matches( 'meta_value', $matches['meta_value_column'] ) + ) { + return null; + } + + if ( WP_DuckDB_Connection::class !== get_class( $this->connection ) ) { + return null; + } + + $user_id = $this->fast_path_mysql_unsigned_integer_literal_value( $matches['user_id'] ); + if ( null === $user_id ) { + return null; + } + + if ( null !== $this->resolve_temporary_user_table_reference( $table_name ) ) { + return null; + } + + $meta_value_sql = 0 === strcasecmp( 'NULL', $matches['meta_value'] ) + ? 'NULL' + : $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $matches['meta_value'] ) ); + + $sql = 'INSERT INTO ' + . $this->connection->quote_identifier( $table_name ) + . ' (' + . $this->connection->quote_identifier( 'user_id' ) + . ', ' + . $this->connection->quote_identifier( 'meta_key' ) + . ', ' + . $this->connection->quote_identifier( 'meta_value' ) + . ') VALUES (' + . (string) $user_id + . ', ' + . $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $matches['meta_key'] ) ) + . ', ' + . $meta_value_sql + . ')'; + + $this->found_rows = 0; + return $this->execute_auto_increment_returning_write( $sql, 'Failed to execute DuckDB INSERT', 'umeta_id' ); + } + + /** + * Execute WordPress' high-frequency posts primary-key lookup without full parsing. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_posts_id_lookup_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + if ( + ! preg_match( + '/^SELECT\s+\*\s+FROM\s+(?
`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*)\s+WHERE\s+(?`ID`|ID)\s*=\s*(?[0-9]+|\'[0-9]+\')\s+LIMIT\s+(?[0-9]+|\'[0-9]+\')$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $requested_table_name = $this->fast_path_mysql_identifier_value( $matches['table'] ); + if ( ! $this->is_wordpress_posts_table_name( $requested_table_name ) ) { + return null; + } + + $id_column = $this->fast_path_mysql_identifier_value( $matches['id_column'] ); + if ( 0 !== strcasecmp( 'ID', $id_column ) ) { + return null; + } + + $id = $this->fast_path_mysql_unsigned_integer_literal_value( $matches['id'] ); + if ( null === $id ) { + return null; + } + + $limit = $this->fast_path_mysql_unsigned_integer_literal_value( $matches['limit'] ); + if ( 1 !== $limit ) { + return null; + } + + $table_reference = $this->resolve_visible_user_table_reference( $requested_table_name ); + if ( null === $table_reference ) { + return null; + } + + $column_meta = $this->wordpress_posts_wildcard_result_column_metadata( + $table_reference['table_name'], + $table_reference['temporary'] + ); + if ( null === $column_meta ) { + return null; + } + + $sql = 'SELECT * FROM ' + . $this->connection->quote_identifier( $table_reference['table_name'] ) + . ' WHERE ' + . $this->connection->quote_identifier( 'ID' ) + . ' = ' + . (string) $id + . ' LIMIT 1'; + + $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 ); + } + + /** + * Execute WordPress' slug/status/date posts lookup without full parser fanout. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_posts_slug_status_lookup_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $identifier_pattern = '`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*'; + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\\s])*\''; + if ( + ! preg_match( + '/^SELECT\s+(?' . $identifier_pattern . ')\s*\.\s*\*\s+FROM\s+(?' . $identifier_pattern . ')\s+WHERE\s+1\s*=\s*1\s+AND\s+(?' . $identifier_pattern . ')\s*\.\s*(?`post_name`|post_name)\s*=\s*(?' . $literal_pattern . ')\s+AND\s+(?' . $identifier_pattern . ')\s*\.\s*(?`ID`|ID)\s+NOT\s+IN\s*\(\s*(?[0-9]+(?:\s*,\s*[0-9]+)*)\s*\)\s+AND\s+(?' . $identifier_pattern . ')\s*\.\s*(?`post_type`|post_type)\s+IN\s*\(\s*(?' . $literal_pattern . '(?:\s*,\s*' . $literal_pattern . ')*)\s*\)\s+AND\s*\(\s*\(\s*(?' . $identifier_pattern . ')\s*\.\s*(?`post_status`|post_status)\s*=\s*(?' . $literal_pattern . ')\s*\)\s*\)\s+ORDER\s+BY\s+(?' . $identifier_pattern . ')\s*\.\s*(?`post_date`|post_date)\s+DESC$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $requested_table_name = $this->fast_path_mysql_identifier_value( $matches['from_table'] ); + if ( ! $this->is_wordpress_posts_table_name( $requested_table_name ) ) { + return null; + } + + foreach ( array( 'select_table', 'name_table', 'id_table', 'type_table', 'status_table', 'order_table' ) as $table_match ) { + if ( 0 !== strcasecmp( $requested_table_name, $this->fast_path_mysql_identifier_value( $matches[ $table_match ] ) ) ) { + return null; + } + } + + $expected_columns = array( + 'name_column' => 'post_name', + 'id_column' => 'ID', + 'type_column' => 'post_type', + 'status_column' => 'post_status', + 'order_column' => 'post_date', + ); + foreach ( $expected_columns as $match_name => $column_name ) { + if ( 0 !== strcasecmp( $column_name, $this->fast_path_mysql_identifier_value( $matches[ $match_name ] ) ) ) { + return null; + } + } + + $table_reference = $this->resolve_visible_user_table_reference( $requested_table_name ); + if ( null === $table_reference ) { + return null; + } + + $column_meta = $this->wordpress_posts_wildcard_result_column_metadata( + $table_reference['table_name'], + $table_reference['temporary'] + ); + if ( null === $column_meta ) { + return null; + } + + $excluded_ids = array(); + foreach ( preg_split( '/\s*,\s*/', trim( $matches['excluded_ids'] ) ) as $id_literal ) { + $excluded_id = $this->fast_path_mysql_unsigned_integer_literal_value( $id_literal ); + if ( null === $excluded_id ) { + return null; + } + $excluded_ids[] = $excluded_id; + } + if ( count( $excluded_ids ) === 0 ) { + return null; + } + + $post_types = array(); + if ( ! preg_match_all( '/' . $literal_pattern . '/', $matches['post_types'], $post_type_matches ) ) { + return null; + } + foreach ( $post_type_matches[0] as $post_type_literal ) { + $post_types[] = $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $post_type_literal ) ); + } + if ( count( $post_types ) === 0 ) { + return null; + } + + $table_sql = $this->connection->quote_identifier( $table_reference['table_name'] ); + $sql = 'SELECT ' + . $table_sql + . '.* FROM ' + . $table_sql + . ' WHERE 1 = 1 AND ' + . $table_sql + . '.' + . $this->connection->quote_identifier( 'post_name' ) + . ' = ' + . $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $matches['post_name'] ) ) + . ' AND ' + . $table_sql + . '.' + . $this->connection->quote_identifier( 'ID' ) + . ' NOT IN (' + . implode( ', ', array_map( 'strval', $excluded_ids ) ) + . ') AND ' + . $table_sql + . '.' + . $this->connection->quote_identifier( 'post_type' ) + . ' IN (' + . implode( ', ', $post_types ) + . ') AND ((' + . $table_sql + . '.' + . $this->connection->quote_identifier( 'post_status' ) + . ' = ' + . $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $matches['post_status'] ) ) + . ')) ORDER BY ' + . $table_sql + . '.' + . $this->connection->quote_identifier( 'post_date' ) + . ' DESC, ' + . $table_sql + . '.' + . $this->connection->quote_identifier( 'ID' ) + . ' ASC'; + + $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 ); + } + + /** + * Execute WordPress' hot term + term_taxonomy lookup without full parser fanout. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_term_taxonomy_lookup_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $identifier_pattern = '`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*'; + if ( + ! preg_match( + '/^SELECT\s+(?' . $identifier_pattern . ')\s*\.\s*\*\s*,\s*(?' . $identifier_pattern . ')\s*\.\s*\*\s+FROM\s+(?' . $identifier_pattern . ')\s+AS\s+(?' . $identifier_pattern . ')\s+INNER\s+JOIN\s+(?' . $identifier_pattern . ')\s+AS\s+(?' . $identifier_pattern . ')\s+ON\s+(?' . $identifier_pattern . ')\s*\.\s*(?`term_id`|term_id)\s*=\s*(?' . $identifier_pattern . ')\s*\.\s*(?`term_id`|term_id)\s+WHERE\s+(?' . $identifier_pattern . ')\s*\.\s*(?`term_id`|term_id)\s+IN\s*\(\s*(?[0-9]+(?:\s*,\s*[0-9]+)*)\s*\)$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $requested_terms_table = $this->fast_path_mysql_identifier_value( $matches['terms_table'] ); + $requested_taxonomy_table = $this->fast_path_mysql_identifier_value( $matches['taxonomy_table'] ); + if ( + ! $this->is_wordpress_terms_table_name( $requested_terms_table ) + || ! $this->is_wordpress_term_taxonomy_table_name( $requested_taxonomy_table ) + ) { + return null; + } + if ( ! $this->wordpress_taxonomy_table_prefixes_match( $requested_terms_table, $requested_taxonomy_table ) ) { + return null; + } + + $terms_alias = $this->fast_path_mysql_identifier_value( $matches['terms_alias'] ); + $taxonomy_alias = $this->fast_path_mysql_identifier_value( $matches['taxonomy_alias'] ); + if ( + 0 !== strcasecmp( 't', $terms_alias ) + || 0 !== strcasecmp( 'tt', $taxonomy_alias ) + || ! $this->fast_path_identifier_matches( $terms_alias, $matches['terms_select_alias'] ) + || ! $this->fast_path_identifier_matches( $terms_alias, $matches['terms_on_alias'] ) + || ! $this->fast_path_identifier_matches( $terms_alias, $matches['where_alias'] ) + || ! $this->fast_path_identifier_matches( $taxonomy_alias, $matches['taxonomy_select_alias'] ) + || ! $this->fast_path_identifier_matches( $taxonomy_alias, $matches['taxonomy_on_alias'] ) + ) { + return null; + } + + foreach ( array( 'terms_on_column', 'taxonomy_on_column', 'where_column' ) as $column_match ) { + if ( 0 !== strcasecmp( 'term_id', $this->fast_path_mysql_identifier_value( $matches[ $column_match ] ) ) ) { + return null; + } + } + + $terms_reference = $this->resolve_visible_user_table_reference( $requested_terms_table ); + if ( null === $terms_reference ) { + return null; + } + + $taxonomy_reference = $this->resolve_visible_user_table_reference( $requested_taxonomy_table ); + if ( null === $taxonomy_reference ) { + return null; + } + + $terms_column_meta = $this->wordpress_table_wildcard_result_column_metadata( + $terms_reference['table_name'], + $terms_reference['temporary'], + $terms_alias + ); + if ( null === $terms_column_meta ) { + return null; + } + + $taxonomy_column_meta = $this->wordpress_table_wildcard_result_column_metadata( + $taxonomy_reference['table_name'], + $taxonomy_reference['temporary'], + $taxonomy_alias + ); + if ( null === $taxonomy_column_meta ) { + return null; + } + + $column_meta = array_merge( $terms_column_meta, $taxonomy_column_meta ); + + $term_ids = array(); + foreach ( preg_split( '/\s*,\s*/', trim( $matches['term_ids'] ) ) as $id_literal ) { + $term_id = $this->fast_path_mysql_unsigned_integer_literal_value( $id_literal ); + if ( null === $term_id ) { + return null; + } + $term_ids[] = $term_id; + } + if ( count( $term_ids ) === 0 ) { + return null; + } + + $terms_alias_sql = $this->connection->quote_identifier( $terms_alias ); + $taxonomy_alias_sql = $this->connection->quote_identifier( $taxonomy_alias ); + $sql = 'SELECT ' + . $terms_alias_sql + . '.*, ' + . $taxonomy_alias_sql + . '.* FROM ' + . $this->connection->quote_identifier( $terms_reference['table_name'] ) + . ' AS ' + . $terms_alias_sql + . ' INNER JOIN ' + . $this->connection->quote_identifier( $taxonomy_reference['table_name'] ) + . ' AS ' + . $taxonomy_alias_sql + . ' ON ' + . $terms_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_id' ) + . ' = ' + . $taxonomy_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_id' ) + . ' WHERE ' + . $terms_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_id' ) + . ' IN (' + . implode( ', ', array_map( 'strval', $term_ids ) ) + . ')'; + + $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 ); + } + + /** + * Execute WordPress' shared-term split probe without parser GROUP BY overhead. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_shared_term_group_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $identifier_pattern = '`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*'; + if ( + ! preg_match( + '/^SELECT\s+(?' . $identifier_pattern . ')\s*\.\s*(?`term_id`|term_id)\s*,\s*(?' . $identifier_pattern . ')\s*\.\s*\*\s*,\s*COUNT\s*\(\s*\*\s*\)\s+(?:AS\s+)?(?`term_tt_count`|term_tt_count)\s+FROM\s+(?' . $identifier_pattern . ')\s+(?:AS\s+)?(?' . $identifier_pattern . ')\s+LEFT\s+JOIN\s+(?' . $identifier_pattern . ')\s+(?:AS\s+)?(?' . $identifier_pattern . ')\s+ON\s+(?' . $identifier_pattern . ')\s*\.\s*(?`term_id`|term_id)\s*=\s*(?' . $identifier_pattern . ')\s*\.\s*(?`term_id`|term_id)\s+GROUP\s+BY\s+(?' . $identifier_pattern . ')\s*\.\s*(?`term_id`|term_id)\s+HAVING\s+(?`term_tt_count`|term_tt_count)\s*>\s*1\s+LIMIT\s+(?[0-9]+)$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $taxonomy_table = $this->fast_path_mysql_identifier_value( $matches['taxonomy_table'] ); + $terms_table = $this->fast_path_mysql_identifier_value( $matches['terms_table'] ); + if ( + ! $this->is_wordpress_term_taxonomy_table_name( $taxonomy_table ) + || ! $this->is_wordpress_terms_table_name( $terms_table ) + || ! $this->wordpress_taxonomy_table_prefixes_match( $terms_table, $taxonomy_table ) + ) { + return null; + } + + $taxonomy_alias = $this->fast_path_mysql_identifier_value( $matches['taxonomy_alias'] ); + $terms_alias = $this->fast_path_mysql_identifier_value( $matches['terms_alias'] ); + if ( + 0 !== strcasecmp( 'tt', $taxonomy_alias ) + || 0 !== strcasecmp( 't', $terms_alias ) + || ! $this->fast_path_identifier_matches( $taxonomy_alias, $matches['taxonomy_select_alias'] ) + || ! $this->fast_path_identifier_matches( $terms_alias, $matches['terms_select_alias'] ) + || ! $this->fast_path_identifier_matches( $terms_alias, $matches['group_alias'] ) + || ! $this->fast_path_identifier_matches( $this->fast_path_mysql_identifier_value( $matches['count_alias'] ), $matches['having_alias'] ) + ) { + return null; + } + + if ( + 0 !== strcasecmp( 'term_id', $this->fast_path_mysql_identifier_value( $matches['select_term_column'] ) ) + || 0 !== strcasecmp( 'term_id', $this->fast_path_mysql_identifier_value( $matches['join_left_column'] ) ) + || 0 !== strcasecmp( 'term_id', $this->fast_path_mysql_identifier_value( $matches['join_right_column'] ) ) + || 0 !== strcasecmp( 'term_id', $this->fast_path_mysql_identifier_value( $matches['group_column'] ) ) + || ! $this->wordpress_shared_term_group_join_matches( $matches, $terms_alias, $taxonomy_alias ) + ) { + return null; + } + + $limit = $this->fast_path_mysql_unsigned_integer_literal_value( $matches['limit'] ); + if ( null === $limit ) { + return null; + } + + $terms_reference = $this->resolve_visible_user_table_reference( $terms_table ); + if ( null === $terms_reference ) { + return null; + } + + $taxonomy_reference = $this->resolve_visible_user_table_reference( $taxonomy_table ); + if ( null === $taxonomy_reference ) { + return null; + } + + $taxonomy_term_id_meta = $this->wordpress_single_column_result_metadata( + $taxonomy_reference['table_name'], + $taxonomy_reference['temporary'], + $taxonomy_alias, + 'term_id' + ); + $terms_column_meta = $this->wordpress_table_wildcard_result_column_metadata( + $terms_reference['table_name'], + $terms_reference['temporary'], + $terms_alias + ); + if ( null === $taxonomy_term_id_meta || null === $terms_column_meta ) { + return null; + } + + $column_meta = array_merge( + $taxonomy_term_id_meta, + $terms_column_meta, + array( + array( + 'table' => '', + 'name' => 'term_tt_count', + 'mysqli:orgname' => '', + 'mysqli:orgtable' => '', + 'mysqli:db' => $this->database, + ), + ) + ); + $terms_alias_sql = $this->connection->quote_identifier( $terms_alias ); + $terms_select_expressions = array(); + $has_terms_term_id = false; + foreach ( $terms_column_meta as $metadata ) { + if ( ! isset( $metadata['name'] ) || '' === (string) $metadata['name'] ) { + return null; + } + $column_name = (string) $metadata['name']; + if ( 0 === strcasecmp( 'term_id', $column_name ) ) { + $has_terms_term_id = true; + } + $terms_select_expressions[] = 'ANY_VALUE(' + . $terms_alias_sql + . '.' + . $this->connection->quote_identifier( $column_name ) + . ') AS ' + . $this->connection->quote_identifier( $column_name ); + } + if ( ! $has_terms_term_id ) { + return null; + } + + $taxonomy_alias_sql = $this->connection->quote_identifier( $taxonomy_alias ); + $sql = 'SELECT ANY_VALUE(' + . $taxonomy_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_id' ) + . ') AS ' + . $this->connection->quote_identifier( 'term_id' ) + . ', ' + . implode( ', ', $terms_select_expressions ) + . ', COUNT(*) AS ' + . $this->connection->quote_identifier( 'term_tt_count' ) + . ' FROM ' + . $this->connection->quote_identifier( $taxonomy_reference['table_name'] ) + . ' AS ' + . $taxonomy_alias_sql + . ' LEFT JOIN ' + . $this->connection->quote_identifier( $terms_reference['table_name'] ) + . ' AS ' + . $terms_alias_sql + . ' ON ' + . $terms_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_id' ) + . ' = ' + . $taxonomy_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_id' ) + . ' GROUP BY ' + . $terms_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_id' ) + . ' HAVING COUNT(*) > 1 LIMIT ' + . $limit; + + $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 ); + } + + /** + * Check whether the shared-term split join uses WordPress' canonical operands. + * + * @param array $matches Regex matches. + * @param string $terms_alias Terms table alias. + * @param string $taxonomy_alias Term taxonomy table alias. + * @return bool Whether the join matches. + */ + private function wordpress_shared_term_group_join_matches( array $matches, string $terms_alias, string $taxonomy_alias ): bool { + $left_alias = $this->fast_path_mysql_identifier_value( $matches['join_left_alias'] ); + $right_alias = $this->fast_path_mysql_identifier_value( $matches['join_right_alias'] ); + + return ( + 0 === strcasecmp( $terms_alias, $left_alias ) + && 0 === strcasecmp( $taxonomy_alias, $right_alias ) + ) || ( + 0 === strcasecmp( $taxonomy_alias, $left_alias ) + && 0 === strcasecmp( $terms_alias, $right_alias ) + ); + } + + /** + * Execute WordPress' hot term relationship DISTINCT lookup without full parser fanout. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_term_relationships_distinct_terms_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $identifier_pattern = '`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*'; + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\\s])*\''; + if ( + ! preg_match( + '/^SELECT\s+DISTINCT\s+(?' . $identifier_pattern . ')\s*\.\s*(?`term_id`|term_id)\s+FROM\s+(?' . $identifier_pattern . ')\s+AS\s+(?' . $identifier_pattern . ')\s+INNER\s+JOIN\s+(?' . $identifier_pattern . ')\s+AS\s+(?' . $identifier_pattern . ')\s+ON\s+(?' . $identifier_pattern . ')\s*\.\s*(?`term_id`|term_id)\s*=\s*(?' . $identifier_pattern . ')\s*\.\s*(?`term_id`|term_id)\s+INNER\s+JOIN\s+(?' . $identifier_pattern . ')\s+AS\s+(?' . $identifier_pattern . ')\s+ON\s+(?' . $identifier_pattern . ')\s*\.\s*(?`term_taxonomy_id`|term_taxonomy_id)\s*=\s*(?' . $identifier_pattern . ')\s*\.\s*(?`term_taxonomy_id`|term_taxonomy_id)\s+WHERE\s+(?' . $identifier_pattern . ')\s*\.\s*(?`taxonomy`|taxonomy)\s+IN\s*\(\s*(?' . $literal_pattern . '(?:\s*,\s*' . $literal_pattern . ')*)\s*\)\s+AND\s+(?' . $identifier_pattern . ')\s*\.\s*(?`object_id`|object_id)\s+IN\s*\(\s*(?[0-9]+(?:\s*,\s*[0-9]+)*)\s*\)\s+ORDER\s+BY\s+(?' . $identifier_pattern . ')\s*\.\s*(?`name`|name)\s+ASC$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $terms_table = $this->fast_path_mysql_identifier_value( $matches['terms_table'] ); + $taxonomy_table = $this->fast_path_mysql_identifier_value( $matches['taxonomy_table'] ); + $relationships_table = $this->fast_path_mysql_identifier_value( $matches['relationships_table'] ); + if ( + ! $this->is_wordpress_terms_table_name( $terms_table ) + || ! $this->is_wordpress_term_taxonomy_table_name( $taxonomy_table ) + || ! $this->is_wordpress_term_relationships_table_name( $relationships_table ) + ) { + return null; + } + + $terms_alias = $this->fast_path_mysql_identifier_value( $matches['terms_alias'] ); + $taxonomy_alias = $this->fast_path_mysql_identifier_value( $matches['taxonomy_alias'] ); + $relationships_alias = $this->fast_path_mysql_identifier_value( $matches['relationships_alias'] ); + if ( + ! $this->fast_path_identifier_matches( $terms_alias, $matches['terms_select_alias'] ) + || ! $this->fast_path_identifier_matches( $terms_alias, $matches['terms_on_alias'] ) + || ! $this->fast_path_identifier_matches( $terms_alias, $matches['order_alias'] ) + || ! $this->fast_path_identifier_matches( $taxonomy_alias, $matches['taxonomy_on_alias'] ) + || ! $this->fast_path_identifier_matches( $taxonomy_alias, $matches['taxonomy_relationship_alias'] ) + || ! $this->fast_path_identifier_matches( $taxonomy_alias, $matches['taxonomy_where_alias'] ) + || ! $this->fast_path_identifier_matches( $relationships_alias, $matches['relationships_on_alias'] ) + || ! $this->fast_path_identifier_matches( $relationships_alias, $matches['relationships_where_alias'] ) + ) { + return null; + } + + $expected_columns = array( + 'term_id_column' => 'term_id', + 'terms_on_column' => 'term_id', + 'taxonomy_on_column' => 'term_id', + 'relationships_on_column' => 'term_taxonomy_id', + 'taxonomy_relationship_column' => 'term_taxonomy_id', + 'taxonomy_where_column' => 'taxonomy', + 'relationships_where_column' => 'object_id', + 'order_column' => 'name', + ); + foreach ( $expected_columns as $match_name => $column_name ) { + if ( 0 !== strcasecmp( $column_name, $this->fast_path_mysql_identifier_value( $matches[ $match_name ] ) ) ) { + return null; + } + } + + if ( ! preg_match_all( '/' . $literal_pattern . '/', $matches['taxonomies'], $taxonomy_matches ) ) { + return null; + } + $taxonomy_values = array(); + foreach ( $taxonomy_matches[0] as $taxonomy_literal ) { + $taxonomy_values[] = $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $taxonomy_literal ) ); + } + if ( count( $taxonomy_values ) === 0 ) { + return null; + } + + $object_ids = array(); + foreach ( preg_split( '/\s*,\s*/', trim( $matches['object_ids'] ) ) as $id_literal ) { + $object_id = $this->fast_path_mysql_unsigned_integer_literal_value( $id_literal ); + if ( null === $object_id ) { + return null; + } + $object_ids[] = $object_id; + } + if ( count( $object_ids ) === 0 ) { + return null; + } + + $terms_alias_sql = $this->connection->quote_identifier( $terms_alias ); + $taxonomy_alias_sql = $this->connection->quote_identifier( $taxonomy_alias ); + $relationships_alias_sql = $this->connection->quote_identifier( $relationships_alias ); + $sql = 'SELECT DISTINCT ' + . $terms_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_id' ) + . ' FROM ' + . $this->connection->quote_identifier( $terms_table ) + . ' AS ' + . $terms_alias_sql + . ' INNER JOIN ' + . $this->connection->quote_identifier( $taxonomy_table ) + . ' AS ' + . $taxonomy_alias_sql + . ' ON ' + . $terms_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_id' ) + . ' = ' + . $taxonomy_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_id' ) + . ' INNER JOIN ' + . $this->connection->quote_identifier( $relationships_table ) + . ' AS ' + . $relationships_alias_sql + . ' ON ' + . $relationships_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_taxonomy_id' ) + . ' = ' + . $taxonomy_alias_sql + . '.' + . $this->connection->quote_identifier( 'term_taxonomy_id' ) + . ' WHERE ' + . $taxonomy_alias_sql + . '.' + . $this->connection->quote_identifier( 'taxonomy' ) + . ' IN (' + . implode( ', ', $taxonomy_values ) + . ') AND ' + . $relationships_alias_sql + . '.' + . $this->connection->quote_identifier( 'object_id' ) + . ' IN (' + . implode( ', ', array_map( 'strval', $object_ids ) ) + . ') ORDER BY ' + . $terms_alias_sql + . '.' + . $this->connection->quote_identifier( 'name' ) + . ' ASC'; + + $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); + $this->found_rows = $sql; + + return $result; + } + + /** + * Execute WordPress' hot term relationship post-count query without parser overhead. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_term_relationships_post_count_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $identifier_pattern = '`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*'; + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\\s])*\''; + if ( + ! preg_match( + '/^SELECT\s+COUNT\s*\(\s*\*\s*\)\s+FROM\s+(?' . $identifier_pattern . ')\s*,\s*(?' . $identifier_pattern . ')\s+WHERE\s+(?' . $identifier_pattern . ')\s*\.\s*(?`ID`|ID)\s*=\s*(?' . $identifier_pattern . ')\s*\.\s*(?`object_id`|object_id)\s+AND\s+(?`post_status`|post_status)\s+IN\s*\(\s*(?' . $literal_pattern . '(?:\s*,\s*' . $literal_pattern . ')*)\s*\)\s+AND\s+(?`post_type`|post_type)\s+IN\s*\(\s*(?' . $literal_pattern . '(?:\s*,\s*' . $literal_pattern . ')*)\s*\)\s+AND\s+(?`term_taxonomy_id`|term_taxonomy_id)\s*=\s*(?[0-9]+)$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $relationships_table = $this->fast_path_mysql_identifier_value( $matches['relationships_table'] ); + $posts_table = $this->fast_path_mysql_identifier_value( $matches['posts_table'] ); + if ( + ! $this->is_wordpress_term_relationships_table_name( $relationships_table ) + || ! $this->is_wordpress_posts_table_name( $posts_table ) + || ! $this->wordpress_posts_relationships_table_prefixes_match( $posts_table, $relationships_table ) + ) { + return null; + } + + if ( + ! $this->fast_path_identifier_matches( $posts_table, $matches['posts_join_table'] ) + || ! $this->fast_path_identifier_matches( $relationships_table, $matches['relationships_join_table'] ) + ) { + return null; + } + + $expected_columns = array( + 'posts_join_column' => 'ID', + 'relationships_join_column' => 'object_id', + 'status_column' => 'post_status', + 'type_column' => 'post_type', + 'taxonomy_column' => 'term_taxonomy_id', + ); + foreach ( $expected_columns as $match_name => $column_name ) { + if ( 0 !== strcasecmp( $column_name, $this->fast_path_mysql_identifier_value( $matches[ $match_name ] ) ) ) { + return null; + } + } + + $relationships_reference = $this->resolve_visible_user_table_reference( $relationships_table ); + if ( null === $relationships_reference ) { + return null; + } + + $posts_reference = $this->resolve_visible_user_table_reference( $posts_table ); + if ( null === $posts_reference ) { + return null; + } + + if ( ! preg_match_all( '/' . $literal_pattern . '/', $matches['statuses'], $status_matches ) ) { + return null; + } + $statuses = array(); + foreach ( $status_matches[0] as $status_literal ) { + $statuses[] = $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $status_literal ) ); + } + if ( count( $statuses ) === 0 ) { + return null; + } + + if ( ! preg_match_all( '/' . $literal_pattern . '/', $matches['post_types'], $post_type_matches ) ) { + return null; + } + $post_types = array(); + foreach ( $post_type_matches[0] as $post_type_literal ) { + $post_types[] = $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $post_type_literal ) ); + } + if ( count( $post_types ) === 0 ) { + return null; + } + + $taxonomy_id = $this->fast_path_mysql_unsigned_integer_literal_value( $matches['taxonomy_id'] ); + if ( null === $taxonomy_id ) { + return null; + } + + $relationships_table_sql = $this->connection->quote_identifier( $relationships_reference['table_name'] ); + $posts_table_sql = $this->connection->quote_identifier( $posts_reference['table_name'] ); + $sql = 'SELECT COUNT(*) AS "COUNT(*)" FROM ' + . $relationships_table_sql + . ', ' + . $posts_table_sql + . ' WHERE ' + . $posts_table_sql + . '.' + . $this->connection->quote_identifier( 'ID' ) + . ' = ' + . $relationships_table_sql + . '.' + . $this->connection->quote_identifier( 'object_id' ) + . ' AND ' + . $posts_table_sql + . '.' + . $this->connection->quote_identifier( 'post_status' ) + . ' IN (' + . implode( ', ', $statuses ) + . ') AND ' + . $posts_table_sql + . '.' + . $this->connection->quote_identifier( 'post_type' ) + . ' IN (' + . implode( ', ', $post_types ) + . ') AND ' + . $relationships_table_sql + . '.' + . $this->connection->quote_identifier( 'term_taxonomy_id' ) + . ' = ' + . $taxonomy_id; + + $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); + $this->found_rows = $sql; + + return $result; + } + + /** + * Execute WordPress' meta cache-load SELECT without parser/metadata fanout. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_meta_cache_load_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + if ( + ! preg_match( + '/^SELECT\s+(?`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*)\s*,\s*(?:`meta_key`|meta_key)\s*,\s*(?:`meta_value`|meta_value)\s+FROM\s+(?
`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*)\s+WHERE\s+(?`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*)\s+IN\s*\(\s*(?[0-9]+(?:\s*,\s*[0-9]+)*)\s*\)\s+ORDER\s+BY\s+(?`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*)\s+ASC$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $table_name = $this->fast_path_mysql_identifier_value( $matches['table'] ); + $object_column = $this->fast_path_mysql_identifier_value( $matches['object_column'] ); + $where_column = $this->fast_path_mysql_identifier_value( $matches['where_column'] ); + $order_column = $this->fast_path_mysql_identifier_value( $matches['order_column'] ); + + if ( 0 !== strcasecmp( $object_column, $where_column ) ) { + return null; + } + + $family = $this->wordpress_meta_cache_load_family( $table_name, $object_column, $order_column ); + if ( null === $family ) { + return null; + } + + $object_ids = array(); + foreach ( preg_split( '/\s*,\s*/', trim( $matches['ids'] ) ) as $id_literal ) { + $object_id = $this->fast_path_mysql_unsigned_integer_literal_value( $id_literal ); + if ( null === $object_id ) { + return null; + } + $object_ids[] = $object_id; + } + + if ( count( $object_ids ) === 0 ) { + return null; + } + + $sql = 'SELECT ' + . $this->connection->quote_identifier( $family['object_column'] ) + . ', ' + . $this->connection->quote_identifier( 'meta_key' ) + . ', ' + . $this->connection->quote_identifier( 'meta_value' ) + . ' FROM ' + . $this->connection->quote_identifier( $table_name ) + . ' WHERE ' + . $this->connection->quote_identifier( $family['object_column'] ) + . ' IN (' + . implode( ', ', array_map( 'strval', $object_ids ) ) + . ') ORDER BY ' + . $this->connection->quote_identifier( $family['order_column'] ) + . ' ASC'; + + $result = $this->execute_duckdb_query( $sql, 'Unsupported DuckDB MySQL-emulation SELECT statement' ); + $this->found_rows = $sql; + + return $this->apply_result_column_metadata( + $result, + $this->wordpress_meta_cache_load_result_column_metadata( $table_name, $family['object_column'] ) + ); + } + + /** + * Execute WordPress comment queries grouped by comment ID and ordered by meta value. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_comments_group_by_meta_order_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $identifier_pattern = '`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*'; + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\])*\''; + if ( + ! preg_match( + '/^SELECT\s+(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s+FROM\s+(?' . $identifier_pattern . ')\s+INNER\s+JOIN\s+(?' . $identifier_pattern . ')\s+ON\s*\(\s*(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s*=\s*(?' . $identifier_pattern . ')\s*\.\s*(?`comment_id`|comment_id)\s*\)\s+WHERE\s*\(\s*\(\s*(?`comment_approved`|comment_approved)\s*=\s*(?' . $literal_pattern . ')\s+OR\s*(?`comment_approved`|comment_approved)\s*=\s*(?' . $literal_pattern . ')\s*\)\s*\)\s+AND\s*\(\s*(?' . $identifier_pattern . ')\s*\.\s*(?`meta_key`|meta_key)\s*=\s*(?' . $literal_pattern . ')\s*\)\s+GROUP\s+BY\s+(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s+ORDER\s+BY\s+(?' . $identifier_pattern . ')\s*\.\s*(?`meta_value`|meta_value)\s+DESC\s*,\s*(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s+DESC$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $comments_table = $this->fast_path_mysql_identifier_value( $matches['comments_table'] ); + $meta_table = $this->fast_path_mysql_identifier_value( $matches['meta_table'] ); + if ( + ! $this->is_wordpress_comments_table_name( $comments_table ) + || ! $this->is_wordpress_commentmeta_table_name( $meta_table ) + || ! $this->wordpress_comments_commentmeta_table_prefixes_match( $comments_table, $meta_table ) + ) { + return null; + } + + foreach ( array( 'select_comments_table', 'on_comments_table', 'group_comments_table', 'order_comments_table' ) as $comments_match ) { + if ( 0 !== strcasecmp( $comments_table, $this->fast_path_mysql_identifier_value( $matches[ $comments_match ] ) ) ) { + return null; + } + } + + foreach ( array( 'on_meta_table', 'where_meta_table', 'order_meta_table' ) as $meta_match ) { + if ( 0 !== strcasecmp( $meta_table, $this->fast_path_mysql_identifier_value( $matches[ $meta_match ] ) ) ) { + return null; + } + } + + foreach ( array( 'select_column', 'on_comments_column', 'group_column', 'order_comments_column' ) as $column_match ) { + if ( ! $this->fast_path_identifier_matches( 'comment_ID', $matches[ $column_match ] ) ) { + return null; + } + } + + $expected_columns = array( + 'on_meta_column' => 'comment_id', + 'approved_column' => 'comment_approved', + 'meta_key_column' => 'meta_key', + 'order_meta_column' => 'meta_value', + ); + foreach ( $expected_columns as $match_name => $column_name ) { + if ( ! $this->fast_path_identifier_matches( $column_name, $matches[ $match_name ] ) ) { + return null; + } + } + if ( ! $this->fast_path_identifier_matches( 'comment_approved', $matches['approved_column_right'] ) ) { + return null; + } + + $approved_values = array( + $this->fast_path_mysql_single_quoted_literal_value( $matches['approved_left'] ), + $this->fast_path_mysql_single_quoted_literal_value( $matches['approved_right'] ), + ); + sort( $approved_values ); + if ( array( '0', '1' ) !== $approved_values ) { + return null; + } + + $comments_reference = $this->resolve_visible_user_table_reference( $comments_table ); + $meta_reference = $this->resolve_visible_user_table_reference( $meta_table ); + if ( null === $comments_reference || null === $meta_reference ) { + return null; + } + + $column_meta = $this->wordpress_single_column_result_metadata( + $comments_reference['table_name'], + $comments_reference['temporary'], + $comments_table, + 'comment_ID' + ); + if ( null === $column_meta ) { + return null; + } + + $comments_sql = $this->connection->quote_identifier( $comments_reference['table_name'] ); + $meta_sql = $this->connection->quote_identifier( $meta_reference['table_name'] ); + $sql = 'SELECT ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' FROM ' + . $comments_sql + . ' INNER JOIN ' + . $meta_sql + . ' ON (' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' = ' + . $meta_sql + . '.' + . $this->connection->quote_identifier( 'comment_id' ) + . ') WHERE ((' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_approved' ) + . ' = \'0\' OR ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_approved' ) + . ' = \'1\')) AND (' + . $meta_sql + . '.' + . $this->connection->quote_identifier( 'meta_key' ) + . ' = ' + . $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $matches['meta_key'] ) ) + . ') GROUP BY ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' ORDER BY ANY_VALUE(' + . $meta_sql + . '.' + . $this->connection->quote_identifier( 'meta_value' ) + . ') DESC, ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' DESC'; + + $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 ); + } + + /** + * Execute WordPress comment queries ordered by comment date and casted commentmeta value. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_comments_group_by_cast_meta_order_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $identifier_pattern = '`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*'; + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\])*\''; + if ( + ! preg_match( + '/^SELECT\s+(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s+FROM\s+(?' . $identifier_pattern . ')\s+INNER\s+JOIN\s+(?' . $identifier_pattern . ')\s+ON\s*\(\s*(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s*=\s*(?' . $identifier_pattern . ')\s*\.\s*(?`comment_id`|comment_id)\s*\)\s+WHERE\s*\(\s*\(\s*(?`comment_approved`|comment_approved)\s*=\s*(?' . $literal_pattern . ')\s+OR\s*(?`comment_approved`|comment_approved)\s*=\s*(?' . $literal_pattern . ')\s*\)\s*\)\s+AND\s*\(\s*(?' . $identifier_pattern . ')\s*\.\s*(?`meta_key`|meta_key)\s*=\s*(?' . $literal_pattern . ')\s*\)\s+GROUP\s+BY\s+(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s+ORDER\s+BY\s+(?' . $identifier_pattern . ')\s*\.\s*(?`comment_date`|comment_date)\s+ASC\s*,\s*CAST\s*\(\s*(?' . $identifier_pattern . ')\s*\.\s*(?`meta_value`|meta_value)\s+AS\s+CHAR\s*\)\s+ASC\s*,\s*(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s+ASC$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $comments_table = $this->fast_path_mysql_identifier_value( $matches['comments_table'] ); + $meta_table = $this->fast_path_mysql_identifier_value( $matches['meta_table'] ); + if ( + ! $this->is_wordpress_comments_table_name( $comments_table ) + || ! $this->is_wordpress_commentmeta_table_name( $meta_table ) + || ! $this->wordpress_comments_commentmeta_table_prefixes_match( $comments_table, $meta_table ) + ) { + return null; + } + + foreach ( array( 'select_comments_table', 'on_comments_table', 'group_comments_table', 'order_date_table', 'order_comments_table' ) as $comments_match ) { + if ( 0 !== strcasecmp( $comments_table, $this->fast_path_mysql_identifier_value( $matches[ $comments_match ] ) ) ) { + return null; + } + } + + foreach ( array( 'on_meta_table', 'where_meta_table', 'cast_meta_table' ) as $meta_match ) { + if ( 0 !== strcasecmp( $meta_table, $this->fast_path_mysql_identifier_value( $matches[ $meta_match ] ) ) ) { + return null; + } + } + + foreach ( array( 'select_column', 'on_comments_column', 'group_column', 'order_comments_column' ) as $column_match ) { + if ( ! $this->fast_path_identifier_matches( 'comment_ID', $matches[ $column_match ] ) ) { + return null; + } + } + + $expected_columns = array( + 'on_meta_column' => 'comment_id', + 'approved_column' => 'comment_approved', + 'meta_key_column' => 'meta_key', + 'order_date_column' => 'comment_date', + 'cast_meta_column' => 'meta_value', + ); + foreach ( $expected_columns as $match_name => $column_name ) { + if ( ! $this->fast_path_identifier_matches( $column_name, $matches[ $match_name ] ) ) { + return null; + } + } + if ( ! $this->fast_path_identifier_matches( 'comment_approved', $matches['approved_column_right'] ) ) { + return null; + } + + $approved_values = array( + $this->fast_path_mysql_single_quoted_literal_value( $matches['approved_left'] ), + $this->fast_path_mysql_single_quoted_literal_value( $matches['approved_right'] ), + ); + sort( $approved_values ); + if ( array( '0', '1' ) !== $approved_values ) { + return null; + } + + $comments_reference = $this->resolve_visible_user_table_reference( $comments_table ); + $meta_reference = $this->resolve_visible_user_table_reference( $meta_table ); + if ( null === $comments_reference || null === $meta_reference ) { + return null; + } + + $column_meta = $this->wordpress_single_column_result_metadata( + $comments_reference['table_name'], + $comments_reference['temporary'], + $comments_table, + 'comment_ID' + ); + if ( null === $column_meta ) { + return null; + } + + $comments_sql = $this->connection->quote_identifier( $comments_reference['table_name'] ); + $meta_sql = $this->connection->quote_identifier( $meta_reference['table_name'] ); + $sql = 'SELECT ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' FROM ' + . $comments_sql + . ' INNER JOIN ' + . $meta_sql + . ' ON (' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' = ' + . $meta_sql + . '.' + . $this->connection->quote_identifier( 'comment_id' ) + . ') WHERE ((' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_approved' ) + . ' = \'0\' OR ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_approved' ) + . ' = \'1\')) AND (' + . $meta_sql + . '.' + . $this->connection->quote_identifier( 'meta_key' ) + . ' = ' + . $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $matches['meta_key'] ) ) + . ') GROUP BY ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' ORDER BY ANY_VALUE(' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_date' ) + . ') ASC, ANY_VALUE(CAST(' + . $meta_sql + . '.' + . $this->connection->quote_identifier( 'meta_value' ) + . ' AS VARCHAR)) ASC, ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' ASC'; + + $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 ); + } + + /** + * Execute WordPress comment queries ordered by two casted commentmeta values. + * + * @param string $normalized_query Normalized MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_wordpress_comments_group_by_two_cast_meta_orders_fast_path_statement( string $normalized_query ): ?WP_DuckDB_Result_Statement { + $identifier_pattern = '`(?:``|[^`])+`|[A-Za-z_][A-Za-z0-9_]*'; + $literal_pattern = '\'(?:\\\\.|\'\'|[^\'\\\\])*\''; + if ( + ! preg_match( + '/^SELECT\s+(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s+FROM\s+(?' . $identifier_pattern . ')\s+INNER\s+JOIN\s+(?' . $identifier_pattern . ')\s+ON\s*\(\s*(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s*=\s*(?' . $identifier_pattern . ')\s*\.\s*(?`comment_id`|comment_id)\s*\)\s+INNER\s+JOIN\s+(?' . $identifier_pattern . ')\s+AS\s+(?' . $identifier_pattern . ')\s+ON\s*\(\s*(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s*=\s*(?' . $identifier_pattern . ')\s*\.\s*(?`comment_id`|comment_id)\s*\)\s+WHERE\s*\(\s*\(\s*(?`comment_approved`|comment_approved)\s*=\s*(?' . $literal_pattern . ')\s+OR\s*(?`comment_approved`|comment_approved)\s*=\s*(?' . $literal_pattern . ')\s*\)\s*\)\s+AND\s*\(\s*(?' . $identifier_pattern . ')\s*\.\s*(?`meta_key`|meta_key)\s*=\s*(?' . $literal_pattern . ')\s+AND\s+(?' . $identifier_pattern . ')\s*\.\s*(?`meta_key`|meta_key)\s*=\s*(?' . $literal_pattern . ')\s*\)\s+GROUP\s+BY\s+(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s+ORDER\s+BY\s+CAST\s*\(\s*(?' . $identifier_pattern . ')\s*\.\s*(?`meta_value`|meta_value)\s+AS\s+CHAR\s*\)\s+ASC\s*,\s*CAST\s*\(\s*(?' . $identifier_pattern . ')\s*\.\s*(?`meta_value`|meta_value)\s+AS\s+CHAR\s*\)\s+DESC\s*,\s*(?' . $identifier_pattern . ')\s*\.\s*(?`comment_ID`|comment_ID)\s+DESC$/i', + $normalized_query, + $matches + ) + ) { + return null; + } + + $comments_table = $this->fast_path_mysql_identifier_value( $matches['comments_table'] ); + $meta_table = $this->fast_path_mysql_identifier_value( $matches['meta_table'] ); + $second_meta_table = $this->fast_path_mysql_identifier_value( $matches['second_meta_table'] ); + $second_meta_alias = $this->fast_path_mysql_identifier_value( $matches['second_meta_alias'] ); + if ( + ! $this->is_wordpress_comments_table_name( $comments_table ) + || ! $this->is_wordpress_commentmeta_table_name( $meta_table ) + || ! $this->is_wordpress_commentmeta_table_name( $second_meta_table ) + || ! $this->wordpress_comments_commentmeta_table_prefixes_match( $comments_table, $meta_table ) + || ! $this->wordpress_comments_commentmeta_table_prefixes_match( $comments_table, $second_meta_table ) + || 0 !== strcasecmp( $meta_table, $second_meta_table ) + || 1 !== preg_match( '/^mt[0-9]+$/i', $second_meta_alias ) + ) { + return null; + } + + foreach ( array( 'select_comments_table', 'on_comments_table', 'on_second_comments_table', 'group_comments_table', 'order_comments_table' ) as $comments_match ) { + if ( 0 !== strcasecmp( $comments_table, $this->fast_path_mysql_identifier_value( $matches[ $comments_match ] ) ) ) { + return null; + } + } + + foreach ( array( 'on_meta_table', 'where_meta_table', 'cast_meta_table' ) as $meta_match ) { + if ( 0 !== strcasecmp( $meta_table, $this->fast_path_mysql_identifier_value( $matches[ $meta_match ] ) ) ) { + return null; + } + } + + foreach ( array( 'on_second_meta_alias', 'where_second_meta_alias', 'cast_second_meta_alias' ) as $second_meta_alias_match ) { + if ( 0 !== strcasecmp( $second_meta_alias, $this->fast_path_mysql_identifier_value( $matches[ $second_meta_alias_match ] ) ) ) { + return null; + } + } + + foreach ( array( 'select_column', 'on_comments_column', 'on_second_comments_column', 'group_column', 'order_comments_column' ) as $column_match ) { + if ( ! $this->fast_path_identifier_matches( 'comment_ID', $matches[ $column_match ] ) ) { + return null; + } + } + + $expected_columns = array( + 'on_meta_column' => 'comment_id', + 'on_second_meta_column' => 'comment_id', + 'approved_column' => 'comment_approved', + 'meta_key_column' => 'meta_key', + 'second_meta_key_column' => 'meta_key', + 'cast_meta_column' => 'meta_value', + 'cast_second_meta_column' => 'meta_value', + ); + foreach ( $expected_columns as $match_name => $column_name ) { + if ( ! $this->fast_path_identifier_matches( $column_name, $matches[ $match_name ] ) ) { + return null; + } + } + if ( ! $this->fast_path_identifier_matches( 'comment_approved', $matches['approved_column_right'] ) ) { + return null; + } + + $approved_values = array( + $this->fast_path_mysql_single_quoted_literal_value( $matches['approved_left'] ), + $this->fast_path_mysql_single_quoted_literal_value( $matches['approved_right'] ), + ); + sort( $approved_values ); + if ( array( '0', '1' ) !== $approved_values ) { + return null; + } + + $comments_reference = $this->resolve_visible_user_table_reference( $comments_table ); + $meta_reference = $this->resolve_visible_user_table_reference( $meta_table ); + if ( null === $comments_reference || null === $meta_reference ) { + return null; + } + + $column_meta = $this->wordpress_single_column_result_metadata( + $comments_reference['table_name'], + $comments_reference['temporary'], + $comments_table, + 'comment_ID' + ); + if ( null === $column_meta ) { + return null; + } + + $comments_sql = $this->connection->quote_identifier( $comments_reference['table_name'] ); + $meta_sql = $this->connection->quote_identifier( $meta_reference['table_name'] ); + $second_meta_alias_sql = $this->connection->quote_identifier( $second_meta_alias ); + $sql = 'SELECT ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' FROM ' + . $comments_sql + . ' INNER JOIN ' + . $meta_sql + . ' ON (' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' = ' + . $meta_sql + . '.' + . $this->connection->quote_identifier( 'comment_id' ) + . ') INNER JOIN ' + . $meta_sql + . ' AS ' + . $second_meta_alias_sql + . ' ON (' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' = ' + . $second_meta_alias_sql + . '.' + . $this->connection->quote_identifier( 'comment_id' ) + . ') WHERE ((' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_approved' ) + . ' = \'0\' OR ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_approved' ) + . ' = \'1\')) AND (' + . $meta_sql + . '.' + . $this->connection->quote_identifier( 'meta_key' ) + . ' = ' + . $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $matches['meta_key'] ) ) + . ' AND ' + . $second_meta_alias_sql + . '.' + . $this->connection->quote_identifier( 'meta_key' ) + . ' = ' + . $this->connection->quote( $this->fast_path_mysql_single_quoted_literal_value( $matches['second_meta_key'] ) ) + . ') GROUP BY ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' ORDER BY ANY_VALUE(CAST(' + . $meta_sql + . '.' + . $this->connection->quote_identifier( 'meta_value' ) + . ' AS VARCHAR)) ASC, ANY_VALUE(CAST(' + . $second_meta_alias_sql + . '.' + . $this->connection->quote_identifier( 'meta_value' ) + . ' AS VARCHAR)) DESC, ' + . $comments_sql + . '.' + . $this->connection->quote_identifier( 'comment_ID' ) + . ' DESC'; + + $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 ); + } + + /** + * Decode a simple MySQL identifier accepted by the fast-path regex. + * + * @param string $identifier MySQL identifier. + * @return string Identifier value. + */ + private function fast_path_mysql_identifier_value( string $identifier ): string { + if ( strlen( $identifier ) >= 2 && '`' === $identifier[0] && '`' === substr( $identifier, -1 ) ) { + return str_replace( '``', '`', substr( $identifier, 1, -1 ) ); + } + + return $identifier; + } + + /** + * Check whether a decoded identifier matches an accepted fast-path identifier token. + * + * @param string $expected Expected identifier value. + * @param string $identifier MySQL identifier token. + * @return bool Whether identifiers match case-insensitively. + */ + private function fast_path_identifier_matches( string $expected, string $identifier ): bool { + return 0 === strcasecmp( $expected, $this->fast_path_mysql_identifier_value( $identifier ) ); + } + + /** + * Decode an unsigned integer literal accepted by the fast-path regex. + * + * @param string $literal MySQL integer literal. + * @return int|null Integer value, or null when outside PHP's integer range. + */ + private function fast_path_mysql_unsigned_integer_literal_value( string $literal ): ?int { + if ( strlen( $literal ) >= 2 && "'" === $literal[0] && "'" === substr( $literal, -1 ) ) { + $literal = substr( $literal, 1, -1 ); + } + + $value = filter_var( + $literal, + FILTER_VALIDATE_INT, + array( + 'options' => array( + 'min_range' => 0, + ), + ) + ); + + return false === $value ? null : $value; + } + + /** + * Decode a simple MySQL single-quoted literal accepted by the fast-path regex. + * + * @param string $literal MySQL single-quoted literal. + * @return string Literal value. + */ + private function fast_path_mysql_single_quoted_literal_value( string $literal ): string { + $value = substr( $literal, 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, + "''" => "'", + ); + + $value = strtr( $value, $replacements ); + return preg_replace( '/' . preg_quote( $backslash, '/' ) . '(.)/s', '$1', $value ); + } + + /** + * Check whether a table name is the WordPress options table shape. + * + * @param string $table_name Table name. + * @return bool Whether the table name is options or a prefixed options table. + */ + private function is_wordpress_options_table_name( string $table_name ): bool { + return 1 === preg_match( '/^(?:options|[A-Za-z0-9_]+_options)$/i', $table_name ); + } + + /** + * Check whether a table name is the WordPress usermeta table shape. + * + * @param string $table_name Table name. + * @return bool Whether the table name is usermeta or a prefixed usermeta table. + */ + private function is_wordpress_usermeta_table_name( string $table_name ): bool { + return 1 === preg_match( '/^(?:usermeta|[A-Za-z0-9_]+_usermeta)$/i', $table_name ); + } + + /** + * Check whether a table name is the WordPress postmeta table shape. + * + * @param string $table_name Table name. + * @return bool Whether the table name is postmeta or a prefixed postmeta table. + */ + private function is_wordpress_postmeta_table_name( string $table_name ): bool { + return 1 === preg_match( '/^(?:postmeta|[A-Za-z0-9_]+_postmeta)$/i', $table_name ); + } + + /** + * Check whether a table name is the WordPress termmeta table shape. + * + * @param string $table_name Table name. + * @return bool Whether the table name is termmeta or a prefixed termmeta table. + */ + private function is_wordpress_termmeta_table_name( string $table_name ): bool { + return 1 === preg_match( '/^(?:termmeta|[A-Za-z0-9_]+_termmeta)$/i', $table_name ); + } + + /** + * Check whether a table name is the WordPress commentmeta table shape. + * + * @param string $table_name Table name. + * @return bool Whether the table name is commentmeta or a prefixed commentmeta table. + */ + private function is_wordpress_commentmeta_table_name( string $table_name ): bool { + return 1 === preg_match( '/^(?:commentmeta|[A-Za-z0-9_]+_commentmeta)$/i', $table_name ); + } + + /** + * Check whether a table name is the WordPress terms table shape. + * + * @param string $table_name Table name. + * @return bool Whether the table name is terms or a prefixed terms table. + */ + private function is_wordpress_terms_table_name( string $table_name ): bool { + return 1 === preg_match( '/^(?:terms|[A-Za-z0-9_]+_terms)$/i', $table_name ); + } + + /** + * Check whether a table name is the WordPress term_taxonomy table shape. + * + * @param string $table_name Table name. + * @return bool Whether the table name is term_taxonomy or a prefixed term_taxonomy table. + */ + private function is_wordpress_term_taxonomy_table_name( string $table_name ): bool { + return 1 === preg_match( '/^(?:term_taxonomy|[A-Za-z0-9_]+_term_taxonomy)$/i', $table_name ); + } + + /** + * Check whether WordPress terms and term_taxonomy tables use the same prefix. + * + * @param string $terms_table Terms table name. + * @param string $taxonomy_table Term taxonomy table name. + * @return bool Whether both table names share the same WordPress prefix. + */ + private function wordpress_taxonomy_table_prefixes_match( string $terms_table, string $taxonomy_table ): bool { + $terms_prefix = $this->wordpress_fast_path_table_prefix( $terms_table, 'terms' ); + $taxonomy_prefix = $this->wordpress_fast_path_table_prefix( $taxonomy_table, 'term_taxonomy' ); + + return null !== $terms_prefix && null !== $taxonomy_prefix && 0 === strcasecmp( $terms_prefix, $taxonomy_prefix ); + } + + /** + * Check whether WordPress posts and term_relationships tables use the same prefix. + * + * @param string $posts_table Posts table name. + * @param string $relationships_table Term relationships table name. + * @return bool Whether both table names share the same WordPress prefix. + */ + private function wordpress_posts_relationships_table_prefixes_match( string $posts_table, string $relationships_table ): bool { + $posts_prefix = $this->wordpress_fast_path_table_prefix( $posts_table, 'posts' ); + $relationships_prefix = $this->wordpress_fast_path_table_prefix( $relationships_table, 'term_relationships' ); + + return null !== $posts_prefix && null !== $relationships_prefix && 0 === strcasecmp( $posts_prefix, $relationships_prefix ); + } + + /** + * Check whether WordPress comments and commentmeta tables use the same prefix. + * + * @param string $comments_table Comments table name. + * @param string $meta_table Commentmeta table name. + * @return bool Whether both table names share the same WordPress prefix. + */ + private function wordpress_comments_commentmeta_table_prefixes_match( string $comments_table, string $meta_table ): bool { + $comments_prefix = $this->wordpress_fast_path_table_prefix( $comments_table, 'comments' ); + $meta_prefix = $this->wordpress_fast_path_table_prefix( $meta_table, 'commentmeta' ); + + return null !== $comments_prefix && null !== $meta_prefix && 0 === strcasecmp( $comments_prefix, $meta_prefix ); + } + + /** + * Return a WordPress table prefix from a table name and suffix. + * + * @param string $table_name Table name. + * @param string $suffix Unprefixed table suffix. + * @return string|null Prefix, or null when the suffix does not match. + */ + private function wordpress_fast_path_table_prefix( string $table_name, string $suffix ): ?string { + if ( 0 === strcasecmp( $table_name, $suffix ) ) { + return ''; + } + + $prefixed_suffix = '_' . $suffix; + if ( strlen( $table_name ) <= strlen( $prefixed_suffix ) ) { + return null; + } + if ( 0 !== strcasecmp( substr( $table_name, -strlen( $prefixed_suffix ) ), $prefixed_suffix ) ) { + return null; + } + + return substr( $table_name, 0, -strlen( $prefixed_suffix ) ); + } + + /** + * Check whether a table name is the WordPress term_relationships table shape. + * + * @param string $table_name Table name. + * @return bool Whether the table name is term_relationships or a prefixed term_relationships table. + */ + private function is_wordpress_term_relationships_table_name( string $table_name ): bool { + return 1 === preg_match( '/^(?:term_relationships|[A-Za-z0-9_]+_term_relationships)$/i', $table_name ); + } + + /** + * Build minimal MySQL-shaped metadata for WordPress' alloptions SELECT. + * + * @param string $table_name Options table name. + * @return array> Column metadata. + */ + private function wordpress_options_autoload_result_column_metadata( string $table_name ): array { + return array( + array( + 'table' => $table_name, + 'name' => 'option_name', + 'mysqli:orgname' => 'option_name', + 'mysqli:orgtable' => $table_name, + 'mysqli:db' => $this->database, + ), + array( + 'table' => $table_name, + 'name' => 'option_value', + 'mysqli:orgname' => 'option_value', + 'mysqli:orgtable' => $table_name, + 'mysqli:db' => $this->database, + ), + ); + } + + /** + * Build minimal MySQL-shaped metadata for a WordPress options single-column SELECT. + * + * @param string $table_name Options table name. + * @param string $column_name Selected column name. + * @return array> Column metadata. + */ + private function wordpress_options_single_column_result_metadata( string $table_name, string $column_name ): array { + return array( + array( + 'table' => $table_name, + 'name' => $column_name, + 'mysqli:orgname' => $column_name, + 'mysqli:orgtable' => $table_name, + 'mysqli:db' => $this->database, + ), + ); + } + + /** + * Resolve a WordPress meta cache-load table/column family. + * + * @param string $table_name Meta table name. + * @param string $object_column Object ID column name. + * @param string $order_column Meta ID order column name. + * @return array{object_column:string,order_column:string}|null Resolved family, or null. + */ + private function wordpress_meta_cache_load_family( string $table_name, string $object_column, string $order_column ): ?array { + $families = array( + array( + 'table_match' => 'is_wordpress_usermeta_table_name', + 'object_column' => 'user_id', + 'order_column' => 'umeta_id', + ), + array( + 'table_match' => 'is_wordpress_postmeta_table_name', + 'object_column' => 'post_id', + 'order_column' => 'meta_id', + ), + array( + 'table_match' => 'is_wordpress_termmeta_table_name', + 'object_column' => 'term_id', + 'order_column' => 'meta_id', + ), + array( + 'table_match' => 'is_wordpress_commentmeta_table_name', + 'object_column' => 'comment_id', + 'order_column' => 'meta_id', + ), + ); + + foreach ( $families as $family ) { + if ( + 0 === strcasecmp( $family['object_column'], $object_column ) + && 0 === strcasecmp( $family['order_column'], $order_column ) + && $this->{$family['table_match']}( $table_name ) + ) { + return array( + 'object_column' => $family['object_column'], + 'order_column' => $family['order_column'], + ); + } + } + + return null; + } + + /** + * Build minimal MySQL-shaped metadata for WordPress' meta cache-load SELECT. + * + * @param string $table_name Meta table name. + * @param string $object_column Object ID column name. + * @return array> Column metadata. + */ + private function wordpress_meta_cache_load_result_column_metadata( string $table_name, string $object_column ): array { + $column_meta = array(); + foreach ( array( $object_column, 'meta_key', 'meta_value' ) as $column_name ) { + $column_meta[] = array( + 'table' => $table_name, + 'name' => $column_name, + 'mysqli:orgname' => $column_name, + 'mysqli:orgtable' => $table_name, + 'mysqli:db' => $this->database, + ); + } + + return $column_meta; + } + + /** + * Build MySQL-shaped metadata for one direct table column. + * + * @param string $table_name Resolved table name. + * @param bool $temporary Whether the resolved table is temporary. + * @param string $table_alias Result table alias. + * @param string $column_name Column name. + * @return array>|null Column metadata, or null when unavailable. + */ + private function wordpress_single_column_result_metadata( string $table_name, bool $temporary, string $table_alias, string $column_name ): ?array { + foreach ( $this->table_column_metadata_rows( $table_name, $temporary ) as $metadata ) { + if ( 0 === strcasecmp( $column_name, (string) $metadata['column_name'] ) ) { + return array( + $this->mysql_result_column_metadata( + $table_name, + $table_alias, + $metadata, + (string) $metadata['column_name'] + ), + ); + } + } + + return null; + } + + /** + * Build MySQL-shaped metadata for a WordPress posts wildcard SELECT. + * + * @param string $table_name Resolved posts table name. + * @param bool $temporary Whether the resolved table is temporary. + * @return array>|null Column metadata, or null when unavailable. + */ + private function wordpress_posts_wildcard_result_column_metadata( string $table_name, bool $temporary ): ?array { + return $this->wordpress_table_wildcard_result_column_metadata( $table_name, $temporary, $table_name ); + } + + /** + * Build MySQL-shaped metadata for a wildcard table projection. + * + * @param string $table_name Resolved table name. + * @param bool $temporary Whether the resolved table is temporary. + * @param string $table_alias Result table alias. + * @return array>|null Column metadata, or null when unavailable. + */ + private function wordpress_table_wildcard_result_column_metadata( string $table_name, bool $temporary, string $table_alias ): ?array { + $cache_key = $this->metadata_table_cache_key( $table_name, $temporary ) . ':alias:' . $table_alias; + if ( isset( $this->wildcard_result_column_metadata_cache[ $cache_key ] ) ) { + return $this->wildcard_result_column_metadata_cache[ $cache_key ]; + } + + $metadata_rows = $this->table_column_metadata_rows( $table_name, $temporary ); + if ( count( $metadata_rows ) === 0 ) { + return null; + } + + $column_meta = array(); + foreach ( $metadata_rows as $metadata ) { + $column_meta[] = $this->mysql_result_column_metadata( + $table_name, + $table_alias, + $metadata, + (string) $metadata['column_name'] + ); + } + + $this->wildcard_result_column_metadata_cache[ $cache_key ] = $column_meta; + return $column_meta; + } + + /** + * Execute the narrow stored-procedure subset needed by WordPress core tests. + * + * @param string $query MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_stored_procedure_fast_path_statement( string $query ): ?WP_DuckDB_Result_Statement { + if ( ! preg_match( '/^\s*(CREATE|DROP|SHOW|CALL)\b/i', $query, $statement_match ) ) { + return null; + } + + switch ( strtoupper( $statement_match[1] ) ) { + case 'CREATE': + return $this->execute_create_procedure_fast_path_statement( $query ); + case 'DROP': + return $this->execute_drop_procedure_fast_path_statement( $query ); + case 'SHOW': + return $this->execute_show_create_procedure_fast_path_statement( $query ); + case 'CALL': + return $this->execute_call_procedure_fast_path_statement( $query ); + } + + return null; + } + + /** + * Store a single-SELECT MySQL procedure body for later CALL execution. + * + * @param string $query MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_create_procedure_fast_path_statement( string $query ): ?WP_DuckDB_Result_Statement { + if ( ! preg_match( '/^\s*CREATE\s+PROCEDURE\b/i', $query ) ) { + return null; + } + + if ( + ! preg_match( + '/^\s*CREATE\s+PROCEDURE\s+(.+?)\s*\(\s*\)\s+BEGIN\s+(SELECT\b[\s\S]*?)\s*;\s*END\s*;?\s*$/i', + $query, + $matches + ) + ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE PROCEDURE statement in DuckDB driver. Only no-argument single-SELECT procedures are supported.' ); + } + + $reference = $this->parse_stored_procedure_reference( $matches[1] ); + if ( isset( $this->stored_procedures[ $reference['key'] ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Procedure '{$reference['database']}.{$reference['name']}' already exists in DuckDB driver." ); + } + + $select_sql = trim( $matches[2] ); + $select_tokens = $this->tokenize_and_validate( $select_sql ); + if ( count( $select_tokens ) === 0 || WP_MySQL_Lexer::SELECT_SYMBOL !== $select_tokens[0]->id ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE PROCEDURE statement in DuckDB driver. Procedure body must be a SELECT statement.' ); + } + + $this->found_rows = 0; + $this->stored_procedures[ $reference['key'] ] = array( + 'database' => $reference['database'], + 'name' => $reference['name'], + 'create_sql' => $this->trim_statement_semicolon( $query ), + 'select_sql' => $select_sql, + ); + + return $this->empty_ddl_result(); + } + + /** + * Drop a stored procedure from the connection-local registry. + * + * @param string $query MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_drop_procedure_fast_path_statement( string $query ): ?WP_DuckDB_Result_Statement { + if ( ! preg_match( '/^\s*DROP\s+PROCEDURE\b/i', $query ) ) { + return null; + } + + if ( ! preg_match( '/^\s*DROP\s+PROCEDURE\s+(IF\s+EXISTS\s+)?(.+?)\s*;?\s*$/i', $query, $matches ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP PROCEDURE statement in DuckDB driver.' ); + } + + $if_exists = '' !== trim( $matches[1] ?? '' ); + $reference = $this->parse_stored_procedure_reference( $matches[2] ); + if ( ! isset( $this->stored_procedures[ $reference['key'] ] ) && ! $if_exists ) { + throw new WP_DuckDB_Driver_Exception( "Unknown procedure '{$reference['database']}.{$reference['name']}' in DuckDB driver." ); + } + + unset( $this->stored_procedures[ $reference['key'] ] ); + $this->found_rows = 0; + + return $this->empty_ddl_result(); + } + + /** + * Return MySQL-shaped SHOW CREATE PROCEDURE rows. + * + * @param string $query MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_show_create_procedure_fast_path_statement( string $query ): ?WP_DuckDB_Result_Statement { + if ( ! preg_match( '/^\s*SHOW\s+CREATE\s+PROCEDURE\b/i', $query ) ) { + return null; + } + + if ( ! preg_match( '/^\s*SHOW\s+CREATE\s+PROCEDURE\s+(.+?)\s*;?\s*$/i', $query, $matches ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported SHOW CREATE PROCEDURE statement in DuckDB driver.' ); + } + + $procedure = $this->stored_procedure( $this->parse_stored_procedure_reference( $matches[1] ) ); + $columns = array( 'Procedure', 'sql_mode', 'Create Procedure', 'character_set_client', 'collation_connection', 'Database Collation' ); + $rows = array( + array( + $procedure['name'], + implode( ',', $this->active_sql_modes ), + $procedure['create_sql'], + 'utf8mb4', + 'utf8mb4_unicode_ci', + 'utf8mb4_unicode_ci', + ), + ); + + return $this->record_found_rows_from_result( new WP_DuckDB_Result_Statement( $columns, $rows, 0 ) ); + } + + /** + * Execute a stored no-argument SELECT procedure. + * + * @param string $query MySQL query. + * @return WP_DuckDB_Result_Statement|null Fast-path result, or null. + */ + private function execute_call_procedure_fast_path_statement( string $query ): ?WP_DuckDB_Result_Statement { + if ( ! preg_match( '/^\s*CALL\b/i', $query ) ) { + return null; + } + + if ( ! preg_match( '/^\s*CALL\s+(.+?)(?:\s*\(\s*\))?\s*;?\s*$/i', $query, $matches ) ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CALL statement in DuckDB driver. Only no-argument stored procedures are supported.' ); + } + + $procedure = $this->stored_procedure( $this->parse_stored_procedure_reference( $matches[1] ) ); + return $this->execute_select( $this->tokenize_and_validate( $procedure['select_sql'] ) ); + } + + /** + * Parse a stored-procedure reference. + * + * @param string $reference_sql MySQL procedure reference. + * @return array{database:string,name:string,key:string} + */ + private function parse_stored_procedure_reference( string $reference_sql ): array { + $tokens = $this->tokenize_fragment( trim( $reference_sql ) ); + if ( 1 === count( $tokens ) ) { + $database = $this->current_database; + $name = $this->identifier_value( $tokens[0] ); + } elseif ( + 3 === count( $tokens ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[1]->id + ) { + $database = $this->identifier_value( $tokens[0] ); + $name = $this->identifier_value( $tokens[2] ); + } else { + throw new WP_DuckDB_Driver_Exception( 'Expected a stored procedure name in DuckDB driver statement.' ); + } + + return array( + 'database' => $database, + 'name' => $name, + 'key' => $this->stored_procedure_key( $database, $name ), + ); + } + + /** + * Read a stored procedure from the registry. + * + * @param array{database:string,name:string,key:string} $reference Procedure reference. + * @return array{database:string,name:string,create_sql:string,select_sql:string} + */ + private function stored_procedure( array $reference ): array { + if ( ! isset( $this->stored_procedures[ $reference['key'] ] ) ) { + throw new WP_DuckDB_Driver_Exception( "Unknown procedure '{$reference['database']}.{$reference['name']}' in DuckDB driver." ); + } + + return $this->stored_procedures[ $reference['key'] ]; + } + + /** + * Build a case-insensitive stored procedure key. + * + * @param string $database Database name. + * @param string $name Procedure name. + * @return string Procedure registry key. + */ + private function stored_procedure_key( string $database, string $name ): string { + return strtolower( $database ) . "\0" . strtolower( $name ); + } + + /** + * Trim one optional statement terminator. + * + * @param string $query MySQL query. + * @return string Trimmed query. + */ + private function trim_statement_semicolon( string $query ): string { + $query = trim( $query ); + if ( ';' === substr( $query, -1 ) ) { + return trim( substr( $query, 0, -1 ) ); + } + return $query; + } + + /** + * 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. + * + * @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; + } + + /** + * 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; + } + + /** + * 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->should_rollback_active_transaction_on_error( $error ) ) { + return; + } + + if ( ! $this->connection->inTransaction() ) { + 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(); + } + + /** + * 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; + } + + if ( $this->is_current_transaction_aborted_error( $error ) ) { + return true; + } + + 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; + } + + /** + * 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 { + $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 ); + } 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->rollbackNativeTransaction(); + $this->clear_schema_state_after_rollback(); + return; + } + + $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(); + $this->clear_schema_state_after_rollback(); + } + } + + /** + * 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 = 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; + } + + /** + * 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. + * + * @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 { + $profile_enabled = self::query_profile_enabled(); + $profile_started_at = $profile_enabled ? microtime( true ) : 0.0; + $runtime_enabled = self::runtime_counters_enabled(); + $runtime_started_at = $runtime_enabled ? microtime( true ) : 0.0; + + try { + $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 ); + + $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 ); + } finally { + if ( $profile_enabled ) { + $this->profile_current_parse_seconds += microtime( true ) - $profile_started_at; + } + if ( $runtime_enabled ) { + self::record_runtime_counter( 'tokenize_and_validate', microtime( true ) - $runtime_started_at ); + } + } + } + + /** + * 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 ); + $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. + * + * @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 { + $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 ); + } + + $variable_select = $this->execute_variable_select( $tokens ); + if ( null !== $variable_select ) { + 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 ); + } + + $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 ); + $group_by_expansion = $this->primary_key_group_by_expansion( $tokens ); + $seeded_rand_expressions = $this->parse_seeded_rand_select_expressions( $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 ); + $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 ); + $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 ); + $information_schema_tables_refresh_names = null; + $information_schema_columns_refresh_names = null; + $information_schema_statistics_refresh_names = null; + if ( $rewrite_information_schema_tables ) { + $information_schema_tables_refresh_names = $this->bounded_information_schema_tables_select_table_names( $sql_tokens ); + } + if ( $rewrite_information_schema_columns ) { + $information_schema_columns_refresh_names = $this->bounded_information_schema_columns_select_table_names( $sql_tokens ); + } + if ( $rewrite_information_schema_statistics ) { + $information_schema_statistics_refresh_names = $this->bounded_information_schema_statistics_select_table_names( $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( $information_schema_tables_refresh_names ); + } + if ( $rewrite_information_schema_columns ) { + $this->refresh_information_schema_columns_table( $information_schema_columns_refresh_names ); + } + if ( $rewrite_information_schema_statistics ) { + $this->refresh_information_schema_statistics_table( $information_schema_statistics_refresh_names ); + } + 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(); + } + 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(); + } + + $primary_key_select_rewrites = $this->primary_key_group_by_select_item_rewrites( + $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 + ); + $order_by_item_rewrites = $grouped_date_select_rewrites + $primary_key_select_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 + ); + $order_by_item_rewrites += $this->grouped_posts_date_order_by_tiebreak_rewrites( $sql_tokens, $group_by_expansion, $order_by_item_rewrites ); + $order_by_item_rewrites += $this->posts_date_order_by_tiebreak_rewrites( $sql_tokens, $order_by_item_rewrites ); + $order_by_item_rewrites += $this->posts_page_hierarchy_order_by_tiebreak_rewrites( $sql_tokens, $order_by_item_rewrites ); + $order_by_item_rewrites += $this->text_value_numeric_order_by_null_rewrites( $sql_tokens, $order_by_item_rewrites ); + + $sql = $this->translate_tokens_to_duckdb_sql( + $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, + $seeded_rand_rewrites, + true, + $group_by_expansion, + $order_by_item_rewrites + ); + $attachment_mime_distinct_sql = $this->attachment_mime_distinct_first_seen_order_sql( $sql_tokens ); + if ( null !== $attachment_mime_distinct_sql ) { + $sql = $attachment_mime_distinct_sql; + } + + if ( $has_sql_calc_found_rows ) { + try { + $this->found_rows = $this->count_select_rows( + $this->strip_top_level_limit_clause( $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, + $seeded_rand_rewrites, + $group_by_expansion, + $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 ); + $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; + } + } + + $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 ); + $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 ); + } + + /** + * 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; + } + + /** + * Rewrite WordPress' attachment MIME DISTINCT query to deterministic first-seen order. + * + * WordPress' get_available_post_mime_types() relies on the no-order + * SELECT DISTINCT post_mime_type query returning MIME types in first + * encountered row order. SQLite/MySQL expose that order for this query, + * while DuckDB returns hash/sorted DISTINCT output. + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @return string|null DuckDB SQL, or null when the query is outside this narrow WordPress shape. + */ + private function attachment_mime_distinct_first_seen_order_sql( array $tokens ): ?string { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $has_distinct = false; + $select_start = 1; + while ( isset( $tokens[ $select_start ] ) && $this->is_select_option_token( $tokens[ $select_start ] ) ) { + if ( + WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $select_start ]->id + || WP_MySQL_Lexer::DISTINCTROW_SYMBOL === $tokens[ $select_start ]->id + ) { + $has_distinct = true; + ++$select_start; + continue; + } + + return null; + } + if ( ! $has_distinct ) { + return null; + } + + foreach ( + array( + 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, + ) as $clause_token + ) { + if ( null !== $this->find_top_level_token_index( $tokens, 1, $clause_token ) ) { + return null; + } + } + + $from_index = $this->find_top_level_token_index( $tokens, $select_start, WP_MySQL_Lexer::FROM_SYMBOL ); + if ( null === $from_index || $from_index !== $this->top_level_select_list_end( $tokens ) ) { + return null; + } + + $select_items = $this->split_top_level_comma_items( array_slice( $tokens, $select_start, $from_index - $select_start ) ); + if ( 1 !== count( $select_items ) ) { + return null; + } + + $select_column = $this->parse_simple_select_column_reference( $select_items[0] ); + if ( + null === $select_column + || $select_column['wildcard'] + || 0 !== strcasecmp( $select_column['column_name'], 'post_mime_type' ) + ) { + return null; + } + + $where_index = $this->find_top_level_token_index( $tokens, $from_index + 1, WP_MySQL_Lexer::WHERE_SYMBOL ); + if ( null === $where_index ) { + return null; + } + + $table = $this->parse_unresolved_simple_select_table_reference( array_slice( $tokens, $from_index + 1, $where_index - $from_index - 1 ) ); + if ( + null === $table + || ! $this->is_wordpress_posts_table_name( $table['table_name'] ) + || ! $this->simple_select_column_qualifier_matches_table( $select_column['qualifier'], $table ) + ) { + return null; + } + + $mime_sql = $this->connection->quote_identifier( 'post_mime_type' ); + $where_sql = $this->attachment_mime_distinct_where_sql( array_slice( $tokens, $where_index + 1 ), $table, $mime_sql ); + if ( null === $where_sql ) { + return null; + } + + $select_sql = $mime_sql; + if ( 0 !== strcasecmp( $select_column['name'], 'post_mime_type' ) ) { + $select_sql .= ' AS ' . $this->connection->quote_identifier( $select_column['name'] ); + } + + return 'SELECT ' + . $select_sql + . ' FROM ' + . $this->connection->quote_identifier( $table['table_name'] ) + . ' WHERE ' + . $where_sql + . ' GROUP BY ' + . $mime_sql + . ' ORDER BY MIN(rowid)'; + } + + /** + * Parse a single-table FROM clause without checking whether the table exists. + * + * @param WP_Parser_Token[] $tokens Table reference tokens. + * @return array{table_name:string,alias:string,temporary:bool}|null Parsed table, or null when unsupported. + */ + private function parse_unresolved_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; + $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; + if ( 0 === strcasecmp( $database, 'information_schema' ) || 0 !== strcasecmp( $database, $this->database ) ) { + return null; + } + + ++$index; + $table_name = $this->metadata_identifier_value( $tokens[ $index ] ?? null ); + if ( null === $table_name ) { + return null; + } + ++$index; + } + + $alias = $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_name, + 'alias' => $alias, + 'temporary' => false, + ); + } + + /** + * Append a deterministic ID tie-breaker for WordPress posts date ordering. + * + * WordPress core relies on MySQL/SQLite's posts index order for equal + * post_date/post_modified values. DuckDB does not preserve that secondary + * index order, so add ID with the same direction as the date sort only for a + * simple top-level posts-table SELECT where ID is not already ordered. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @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 posts_date_order_by_tiebreak_rewrites( array $tokens, array $existing_rewrites = array() ): 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::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 array(); + } + + $select_start = 1; + while ( isset( $tokens[ $select_start ] ) && $this->is_select_option_token( $tokens[ $select_start ] ) ) { + if ( + WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $select_start ]->id + || WP_MySQL_Lexer::DISTINCTROW_SYMBOL === $tokens[ $select_start ]->id + ) { + return array(); + } + ++$select_start; + } + + $from_index = $this->find_top_level_token_index( $tokens, $select_start, WP_MySQL_Lexer::FROM_SYMBOL ); + $where_index = $this->find_top_level_token_index( $tokens, $select_start, WP_MySQL_Lexer::WHERE_SYMBOL ); + $order_index = $this->find_top_level_token_index( $tokens, $select_start, WP_MySQL_Lexer::ORDER_SYMBOL ); + if ( + null === $from_index + || null === $order_index + || $from_index >= $order_index + || ! isset( $tokens[ $order_index + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_index + 1 ]->id + ) { + return array(); + } + + $table = $this->parse_unresolved_simple_select_table_reference( + array_slice( + $tokens, + $from_index + 1, + $this->simple_select_from_clause_end( $tokens, $from_index + 1 ) - $from_index - 1 + ) + ); + if ( null === $table || ! $this->is_wordpress_posts_table_name( $table['table_name'] ) ) { + return array(); + } + + $has_post_name_lookup = null !== $where_index + && $from_index < $where_index + && $where_index < $order_index + && $this->posts_where_has_post_name_equality( + array_slice( $tokens, $where_index + 1, $order_index - $where_index - 1 ), + $table + ); + + $order_end = $this->primary_key_order_by_clause_end( $tokens, $order_index + 2 ); + $order_items = $this->split_top_level_select_item_ranges( $tokens, $order_index + 2, $order_end ); + if ( count( $order_items ) === 0 ) { + return array(); + } + + $tiebreak_direction = null; + foreach ( $order_items as $item ) { + $column = $this->parse_order_by_column_reference_with_direction( $item['tokens'] ); + if ( null === $column || ! $this->simple_select_column_qualifier_matches_table( $column['qualifier'], $table ) ) { + continue; + } + + if ( 0 === strcasecmp( $column['column_name'], 'ID' ) ) { + return array(); + } + + if ( null === $tiebreak_direction && $this->is_wordpress_posts_date_order_column( $column['column_name'] ) ) { + $tiebreak_direction = $this->posts_date_order_by_tiebreak_direction( $column['column_name'], $column['direction'] ); + if ( $has_post_name_lookup && 0 === strcasecmp( $column['column_name'], 'post_date' ) ) { + $tiebreak_direction = 'ASC'; + } + } + } + + if ( null === $tiebreak_direction ) { + return array(); + } + + $last_item = $order_items[ count( $order_items ) - 1 ]; + if ( isset( $existing_rewrites[ $last_item['start'] ] ) ) { + return array(); + } + + return array( + $last_item['start'] => array( + 'end' => $last_item['end'], + 'sql' => $this->translate_tokens_to_duckdb_sql( $last_item['tokens'] ) + . ', ' + . $this->posts_order_by_tiebreak_id_sql( $table ) + . ' ' + . $tiebreak_direction, + ), + ); + } + + /** + * Append a deterministic ID tie-breaker for WordPress hierarchical page ordering. + * + * WordPress' admin page list orders pages by menu_order and title before + * building the hierarchical display. SQLite/MySQL expose primary-key order + * for equal menu/title groups, while DuckDB may return equal groups in a + * different order and make the hierarchy start from the wrong parent. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @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 posts_page_hierarchy_order_by_tiebreak_rewrites( array $tokens, array $existing_rewrites = array() ): 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::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 array(); + } + + $select_start = 1; + while ( isset( $tokens[ $select_start ] ) && $this->is_select_option_token( $tokens[ $select_start ] ) ) { + if ( + WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $select_start ]->id + || WP_MySQL_Lexer::DISTINCTROW_SYMBOL === $tokens[ $select_start ]->id + ) { + return array(); + } + ++$select_start; + } + + $from_index = $this->find_top_level_token_index( $tokens, $select_start, WP_MySQL_Lexer::FROM_SYMBOL ); + $where_index = $this->find_top_level_token_index( $tokens, $select_start, WP_MySQL_Lexer::WHERE_SYMBOL ); + $order_index = $this->find_top_level_token_index( $tokens, $select_start, WP_MySQL_Lexer::ORDER_SYMBOL ); + if ( + null === $from_index + || null === $where_index + || null === $order_index + || $from_index >= $where_index + || $where_index >= $order_index + || ! isset( $tokens[ $order_index + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_index + 1 ]->id + ) { + return array(); + } + + $table = $this->parse_unresolved_simple_select_table_reference( + array_slice( + $tokens, + $from_index + 1, + $this->simple_select_from_clause_end( $tokens, $from_index + 1 ) - $from_index - 1 + ) + ); + if ( null === $table || ! $this->is_wordpress_posts_table_name( $table['table_name'] ) ) { + return array(); + } + + if ( + ! $this->posts_page_hierarchy_where_has_post_type_page( + array_slice( $tokens, $where_index + 1, $order_index - $where_index - 1 ), + $table + ) + ) { + return array(); + } + + $order_end = $this->primary_key_order_by_clause_end( $tokens, $order_index + 2 ); + $order_items = $this->split_top_level_select_item_ranges( $tokens, $order_index + 2, $order_end ); + if ( count( $order_items ) === 0 ) { + return array(); + } + + $has_menu_order = false; + $has_post_title = false; + foreach ( $order_items as $item ) { + $column = $this->parse_order_by_column_reference_with_direction( $item['tokens'] ); + if ( null === $column || ! $this->simple_select_column_qualifier_matches_table( $column['qualifier'], $table ) ) { + continue; + } + + if ( 0 === strcasecmp( $column['column_name'], 'ID' ) ) { + return array(); + } + + if ( 'ASC' !== $column['direction'] ) { + continue; + } + + if ( 0 === strcasecmp( $column['column_name'], 'menu_order' ) ) { + $has_menu_order = true; + } elseif ( 0 === strcasecmp( $column['column_name'], 'post_title' ) ) { + $has_post_title = true; + } + } + + if ( ! $has_menu_order || ! $has_post_title ) { + return array(); + } + + $last_item = $order_items[ count( $order_items ) - 1 ]; + if ( isset( $existing_rewrites[ $last_item['start'] ] ) ) { + return array(); + } + + return array( + $last_item['start'] => array( + 'end' => $last_item['end'], + 'sql' => $this->translate_tokens_to_duckdb_sql( $last_item['tokens'] ) + . ', ' + . $this->posts_order_by_tiebreak_id_sql( $table ) + . ' ASC', + ), + ); + } + + /** + * Check whether a WHERE clause contains the page post_type predicate. + * + * @param WP_Parser_Token[] $tokens WHERE clause tokens before ORDER BY. + * @param array{table_name:string,alias:string,temporary:bool} $table Parsed table reference. + * @return bool Whether the WHERE clause constrains the posts table to pages. + */ + private function posts_page_hierarchy_where_has_post_type_page( array $tokens, array $table ): bool { + foreach ( $tokens as $index => $token ) { + if ( + WP_MySQL_Lexer::EQUAL_OPERATOR !== $token->id + || ! isset( $tokens[ $index + 1 ] ) + || ! $this->is_string_literal_token( $tokens[ $index + 1 ] ) + || 'page' !== $this->token_value( $tokens[ $index + 1 ] ) + ) { + continue; + } + + $start = $index; + while ( $start > 0 && $this->is_identifier_or_dot_token( $tokens[ $start - 1 ] ) ) { + --$start; + } + + $column = $this->parse_simple_select_column_reference( array_slice( $tokens, $start, $index - $start ) ); + if ( + null !== $column + && ! $column['wildcard'] + && 0 === strcasecmp( $column['column_name'], 'post_type' ) + && $this->simple_select_column_qualifier_matches_table( $column['qualifier'], $table ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a WHERE clause contains a posts post_name equality predicate. + * + * SQLite uses the post_name index for the common slug lookup shape, so rows + * with equal post_date values follow ascending primary-key order instead of + * the type/status/date index's descending ID tie order. + * + * @param WP_Parser_Token[] $tokens WHERE clause tokens before ORDER BY. + * @param array{table_name:string,alias:string,temporary:bool} $table Parsed table reference. + * @return bool Whether the WHERE clause constrains post_name to a literal. + */ + private function posts_where_has_post_name_equality( array $tokens, array $table ): bool { + foreach ( $tokens as $index => $token ) { + if ( + WP_MySQL_Lexer::EQUAL_OPERATOR !== $token->id + || ! isset( $tokens[ $index + 1 ] ) + || ! $this->is_string_literal_token( $tokens[ $index + 1 ] ) + ) { + continue; + } + + if ( ! isset( $tokens[ $index - 1 ] ) ) { + continue; + } + + $column_name = $this->metadata_identifier_value( $tokens[ $index - 1 ] ); + $qualifier = null; + if ( + $index >= 3 + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $index - 2 ]->id + ) { + $qualifier = $this->metadata_identifier_value( $tokens[ $index - 3 ] ); + } + + if ( + null !== $column_name + && 0 === strcasecmp( $column_name, 'post_name' ) + && $this->simple_select_column_qualifier_matches_table( $qualifier, $table ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a token can be part of a simple identifier reference. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token is an identifier or dot. + */ + private function is_identifier_or_dot_token( WP_Parser_Token $token ): bool { + return WP_MySQL_Lexer::DOT_SYMBOL === $token->id || null !== $this->metadata_identifier_value( $token ); + } + + /** + * Append a deterministic ID tie-breaker for grouped WordPress posts date ordering. + * + * WordPress taxonomy/meta queries commonly group by the posts primary key + * while ordering by a posts datetime column. Once DuckDB expands the GROUP + * BY to satisfy strict SQL, equal datetime values need the same posts ID + * secondary order as the non-grouped WordPress query 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 grouped_posts_date_order_by_tiebreak_rewrites( array $tokens, ?array $group_by_expansion, array $existing_rewrites = array() ): array { + if ( + null === $group_by_expansion + || ! isset( $group_by_expansion['table_name'] ) + || ! $this->is_wordpress_posts_table_name( $group_by_expansion['table_name'] ) + ) { + 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(); + } + + $order_end = $this->primary_key_order_by_clause_end( $tokens, $group_end + 2 ); + $order_items = $this->split_top_level_select_item_ranges( $tokens, $group_end + 2, $order_end ); + if ( count( $order_items ) === 0 ) { + return array(); + } + + $tiebreak_direction = null; + foreach ( $order_items as $item ) { + $column = $this->parse_order_by_column_reference_with_direction( $item['tokens'] ); + if ( + null === $column + || ! $this->grouped_posts_order_column_matches_primary_table( $column, $group_by_expansion ) + ) { + continue; + } + + if ( 0 === strcasecmp( $column['column_name'], 'ID' ) ) { + return array(); + } + + if ( null === $tiebreak_direction && $this->is_wordpress_posts_date_order_column( $column['column_name'] ) ) { + $tiebreak_direction = $this->posts_date_order_by_tiebreak_direction( $column['column_name'], $column['direction'] ); + } + } + + if ( null === $tiebreak_direction ) { + return array(); + } + + $last_item = $order_items[ count( $order_items ) - 1 ]; + if ( isset( $existing_rewrites[ $last_item['start'] ] ) ) { + return array(); + } + + return array( + $last_item['start'] => array( + 'end' => $last_item['end'], + 'sql' => $this->translate_tokens_to_duckdb_sql( $last_item['tokens'] ) + . ', ' + . $this->grouped_posts_order_by_tiebreak_id_sql( $group_by_expansion ) + . ' ' + . $tiebreak_direction, + ), + ); + } + + /** + * Check whether a grouped posts ORDER BY column belongs to the primary posts 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 posts table. + */ + private function grouped_posts_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'] ); + } + + /** + * Build the grouped posts ID tie-breaker expression. + * + * @param array{table_alias:string} $group_by_expansion Primary-key grouping metadata. + * @return string Quoted ID expression. + */ + private function grouped_posts_order_by_tiebreak_id_sql( array $group_by_expansion ): string { + return $this->connection->quote_identifier( $group_by_expansion['table_alias'] ) . '.' . $this->connection->quote_identifier( 'ID' ); + } + + /** + * Check whether an ORDER BY column is a WordPress posts date column. + * + * @param string $column_name Column name. + * @return bool Whether the column is a posts date ordering column. + */ + private function is_wordpress_posts_date_order_column( string $column_name ): bool { + return in_array( + strtolower( $column_name ), + array( + 'post_date', + 'post_date_gmt', + 'post_modified', + 'post_modified_gmt', + ), + true + ); + } + + /** + * Choose the posts ID tie-break direction for a datetime ORDER BY column. + * + * WordPress' stock schema has an index on post_date, ID, so descending + * post_date scans expose descending ID ties. The modified/GMT columns do not + * have that secondary index in core and SQLite preserves rowid order there. + * + * @param string $column_name Column name. + * @param string $direction Date ORDER BY direction. + * @return string ID ORDER BY direction. + */ + private function posts_date_order_by_tiebreak_direction( string $column_name, string $direction ): string { + if ( 0 === strcasecmp( $column_name, 'post_date' ) ) { + return $direction; + } + + return 'ASC'; + } + + /** + * Build the ID expression used as the posts date-order tie-breaker. + * + * @param array{table_name:string,alias:string,temporary:bool} $table Parsed table reference. + * @return string Quoted ID expression. + */ + private function posts_order_by_tiebreak_id_sql( array $table ): string { + if ( 0 !== strcasecmp( $table['alias'], $table['table_name'] ) ) { + return $this->connection->quote_identifier( $table['alias'] ) . '.' . $this->connection->quote_identifier( 'ID' ); + } + + return $this->connection->quote_identifier( 'ID' ); + } + + /** + * Check whether a table name matches WordPress' posts-table naming convention. + * + * @param string $table_name Table name. + * @return bool Whether the table is the WordPress posts table. + */ + private function is_wordpress_posts_table_name( string $table_name ): bool { + return 1 === preg_match( '/(?:^|_)posts$/i', $table_name ); + } + + /** + * Check whether a table name matches WordPress' comments-table naming convention. + * + * @param string $table_name Table name. + * @return bool Whether the table is the WordPress comments table. + */ + private function is_wordpress_comments_table_name( string $table_name ): bool { + return 1 === preg_match( '/(?:^|_)comments$/i', $table_name ); + } + + /** + * Build the bounded WHERE predicate for WordPress' attachment MIME DISTINCT query. + * + * @param WP_Parser_Token[] $tokens WHERE expression tokens. + * @param array{table_name:string,alias:string,temporary:bool} $table Parsed table reference. + * @param string $mime_sql Quoted MIME column SQL. + * @return string|null Predicate SQL, or null when unsupported. + */ + private function attachment_mime_distinct_where_sql( array $tokens, array $table, string $mime_sql ): ?string { + $predicates = $this->split_top_level_and_predicates( $tokens ); + if ( count( $predicates ) < 1 || count( $predicates ) > 2 ) { + return null; + } + + $matched = array(); + foreach ( $predicates as $predicate ) { + $parsed = $this->parse_attachment_mime_distinct_where_predicate( $predicate, $table, $mime_sql ); + if ( null === $parsed || isset( $matched[ $parsed['type'] ] ) ) { + return null; + } + + $matched[ $parsed['type'] ] = $parsed['sql']; + } + + if ( ! isset( $matched['post_type'] ) ) { + return null; + } + + $where_parts = array( $matched['post_type'] ); + if ( isset( $matched['mime_not_empty'] ) ) { + $where_parts[] = $matched['mime_not_empty']; + } + + return implode( ' AND ', $where_parts ); + } + + /** + * Split a WHERE expression into top-level AND predicates. + * + * @param WP_Parser_Token[] $tokens WHERE expression tokens. + * @return array Predicate token lists. + */ + private function split_top_level_and_predicates( array $tokens ): array { + $predicates = array(); + $depth = 0; + $start = 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 + && in_array( $token->id, array( WP_MySQL_Lexer::AND_SYMBOL, WP_MySQL_Lexer::LOGICAL_AND_OPERATOR ), true ) + ) { + $predicates[] = array_slice( $tokens, $start, $index - $start ); + $start = $index + 1; + } + } + $predicates[] = array_slice( $tokens, $start ); + + return $predicates; + } + + /** + * Parse one supported attachment MIME DISTINCT WHERE predicate. + * + * @param WP_Parser_Token[] $tokens Predicate tokens. + * @param array{table_name:string,alias:string,temporary:bool} $table Parsed table reference. + * @param string $mime_sql Quoted MIME column SQL. + * @return array{type:string,sql:string}|null Parsed predicate. + */ + private function parse_attachment_mime_distinct_where_predicate( array $tokens, array $table, string $mime_sql ): ?array { + $operator_index = null; + foreach ( array( WP_MySQL_Lexer::EQUAL_OPERATOR, WP_MySQL_Lexer::NOT_EQUAL_OPERATOR ) as $operator ) { + $found = $this->find_top_level_token_index( $tokens, 0, $operator ); + if ( null !== $found ) { + $operator_index = $found; + break; + } + } + if ( + null === $operator_index + || ! isset( $tokens[ $operator_index + 1 ] ) + || count( $tokens ) !== $operator_index + 2 + || ! $this->is_string_literal_token( $tokens[ $operator_index + 1 ] ) + ) { + return null; + } + + $column = $this->parse_simple_select_column_reference( array_slice( $tokens, 0, $operator_index ) ); + if ( + null === $column + || $column['wildcard'] + || ! $this->simple_select_column_qualifier_matches_table( $column['qualifier'], $table ) + ) { + return null; + } + + if ( + 0 === strcasecmp( $column['column_name'], 'post_type' ) + && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $operator_index ]->id + && 'attachment' === $this->token_value( $tokens[ $operator_index + 1 ] ) + ) { + return array( + 'type' => 'post_type', + 'sql' => $this->connection->quote_identifier( 'post_type' ) . ' = ' . $this->connection->quote( 'attachment' ), + ); + } + + if ( + 0 === strcasecmp( $column['column_name'], 'post_mime_type' ) + && WP_MySQL_Lexer::NOT_EQUAL_OPERATOR === $tokens[ $operator_index ]->id + && '' === $this->token_value( $tokens[ $operator_index + 1 ] ) + ) { + return array( + 'type' => 'mime_not_empty', + 'sql' => $mime_sql . ' != ' . $this->connection->quote( '' ), + ); + } + + return null; + } + + /** + * 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; + $last_wildcard_index = null; + $seeded_columns = array(); + foreach ( $ranges as $column_index => $range ) { + if ( $this->select_item_is_wildcard( $range['tokens'] ) ) { + $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; + $seeded_columns[] = $column_index; + } + } + + if ( $has_wildcard && count( $expressions ) > 0 ) { + 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; + } + + /** + * 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 ); + } + + /** + * Find the token offset where top-level SELECT result items start. + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @return int Start offset, inclusive. + */ + private function top_level_select_list_start( array $tokens ): int { + $index = 1; + while ( isset( $tokens[ $index ] ) && $this->is_select_option_token( $tokens[ $index ] ) ) { + ++$index; + } + + return $index; + } + + /** + * Check whether a token is a SELECT option before the result list. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether this token is a SELECT option. + */ + private function is_select_option_token( 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 + ); + } + + /** + * 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|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 ( + ! 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 ); + $has_alias = $this->validate_seeded_rand_select_item_tail( $tokens, $close_index + 1 ); + $replacement = null === $seed + ? $this->translate_tokens_to_duckdb_sql( $seed_tokens ) + : '0.0'; + if ( ! $has_alias ) { + $replacement .= ' AS ' . $this->connection->quote_identifier( + $this->seeded_rand_select_expression_label( 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; + } + + /** + * Parse a bounded SELECT ORDER BY RAND(seed) clause. + * + * @param WP_Parser_Token[] $tokens SELECT tokens. + * @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 ); + if ( + null === $order_index + || ! isset( $tokens[ $order_index + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_index + 1 ]->id + ) { + return null; + } + + $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; + } + + $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; + } + + $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, + '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, + ); + } + + /** + * 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,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 { + 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']; + } + 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 ); + } + + /** + * 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. + * @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 ) { + $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.' ); + } + $seed = null === $expression['seed'] + ? $this->normalize_seeded_rand_seed( $row[ $column ] ) + : $expression['seed']; + $row[ $column ] = $this->next_seeded_rand_value( $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; + } + + /** + * 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. + * + * @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 ( ! $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 ( $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; + } + + $column_meta[] = $this->mysql_result_column_metadata( + $table['table_name'], + $table['alias'], + $metadata_by_column[ $key ], + $column['name'] + ); + } + + return $column_meta; + } + + /** + * 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,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 ) { + return null; + } + if ( + null !== $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::UNION_SYMBOL ) + ) { + return null; + } + $has_aggregate = $this->contains_top_level_aggregate_function_call( $tokens ); + + $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_start = $this->top_level_select_list_start( $tokens ); + $select_items = $this->split_top_level_comma_items( array_slice( $tokens, $select_start, $from_index - $select_start ) ); + if ( count( $select_items ) === 0 ) { + 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_primary_key_group_by_from_clause( $table_tokens ); + if ( null === $table ) { + 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; + } + if ( isset( $table['joined'] ) && $table['joined'] ) { + $grouped_table = $this->primary_key_group_by_grouped_table_reference( $table_tokens, $group_tokens ); + if ( null === $grouped_table ) { + return null; + } + $table = $grouped_table; + } + $allowed_nonaggregate_joined_projection_columns = ! $has_aggregate + ? $this->wordpress_taxonomy_group_by_nonaggregate_joined_projection_columns( + $tokens, + $table_tokens, + $group_tokens, + $table, + $select_items, + $group_end + ) + : array(); + + $selected_columns = array(); + $selected_aliases = array(); + $select_has_wildcard = false; + foreach ( $select_items as $item ) { + if ( $this->contains_top_level_aggregate_function_call( $item ) ) { + continue; + } + + $column = $this->parse_simple_select_column_reference( $item ); + if ( + null === $column + || ( + ! $this->primary_key_group_by_column_qualifier_matches_table( $column['qualifier'], $table ) + && ( ! $has_aggregate || ! isset( $table['joined'] ) || ! $table['joined'] || $column['wildcard'] ) + && ! $this->primary_key_group_by_joined_projection_column_is_allowed( $column, $allowed_nonaggregate_joined_projection_columns ) + ) + ) { + return null; + } + if ( ! $this->primary_key_group_by_column_qualifier_matches_table( $column['qualifier'], $table ) ) { + continue; + } + + if ( $column['wildcard'] ) { + $select_has_wildcard = true; + continue; + } + + $selected_aliases[ strtolower( $column['name'] ) ] = true; + $selected_columns[ strtolower( $column['column_name'] ) ] = true; + } + + $primary_key_columns = $this->primary_key_columns_for_table( $table['table_name'] ); + if ( count( $primary_key_columns ) === 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->primary_key_group_by_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; + } + } + + $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; + } + $metadata_columns = array_fill_keys( array_keys( $metadata_by_column ), true ); + + $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; + } + + return array( + '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, + ); + } + + /** + * Build a narrow allow-list for WordPress' non-aggregate taxonomy grouped projection. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param WP_Parser_Token[] $table_tokens FROM-clause tokens. + * @param WP_Parser_Token[] $group_tokens GROUP BY tokens. + * @param array{table_name:string,alias:string,temporary:bool,joined?:bool} $table Grouped table reference. + * @param array $select_items SELECT-list items. + * @param int $group_end End of the GROUP BY clause. + * @return array Allowed joined projection columns keyed by lower-case qualifier.column. + */ + private function wordpress_taxonomy_group_by_nonaggregate_joined_projection_columns( + array $tokens, + array $table_tokens, + array $group_tokens, + array $table, + array $select_items, + int $group_end + ): array { + if ( + empty( $table['joined'] ) + || ! $this->is_wordpress_terms_table_name( $table['table_name'] ) + || isset( $tokens[ $group_end ] ) && WP_MySQL_Lexer::HAVING_SYMBOL === $tokens[ $group_end ]->id + ) { + return array(); + } + + $table_references = $this->primary_key_group_by_table_references( $table_tokens ); + if ( 3 !== count( $table_references ) ) { + return array(); + } + + $terms = null; + $taxonomy = null; + $relationships = null; + foreach ( $table_references as $reference ) { + if ( $this->is_wordpress_terms_table_name( $reference['table_name'] ) ) { + if ( null !== $terms ) { + return array(); + } + $terms = $reference; + } elseif ( $this->is_wordpress_term_taxonomy_table_name( $reference['table_name'] ) ) { + if ( null !== $taxonomy ) { + return array(); + } + $taxonomy = $reference; + } elseif ( $this->is_wordpress_term_relationships_table_name( $reference['table_name'] ) ) { + if ( null !== $relationships ) { + return array(); + } + $relationships = $reference; + } else { + return array(); + } + } + + if ( + null === $terms + || null === $taxonomy + || null === $relationships + || 0 !== strcasecmp( $terms['alias'], $table['alias'] ) + || 0 !== strcasecmp( 't', $terms['alias'] ) + || 0 !== strcasecmp( 'tt', $taxonomy['alias'] ) + || 0 !== strcasecmp( 'tr', $relationships['alias'] ) + || ! $this->wordpress_terms_taxonomy_relationships_table_prefixes_match( + $terms['table_name'], + $taxonomy['table_name'], + $relationships['table_name'] + ) + || ! $this->wordpress_taxonomy_group_by_nonaggregate_joins_match( $table_tokens, $terms, $taxonomy, $relationships ) + || ! $this->wordpress_taxonomy_group_by_nonaggregate_group_matches_terms( $group_tokens, $terms ) + || ! $this->wordpress_taxonomy_group_by_nonaggregate_where_matches( $tokens, $group_end, $taxonomy, $relationships ) + ) { + return array(); + } + + $seen_terms_id = false; + $seen_term_taxonomy_id = false; + foreach ( $select_items as $item ) { + $column = $this->parse_simple_select_column_reference( $item ); + if ( null === $column || $column['wildcard'] || null === $column['qualifier'] ) { + return array(); + } + + if ( + 0 === strcasecmp( $terms['alias'], $column['qualifier'] ) + && 0 === strcasecmp( 'term_id', $column['column_name'] ) + ) { + $seen_terms_id = true; + continue; + } + + if ( + 0 === strcasecmp( $taxonomy['alias'], $column['qualifier'] ) + && 0 === strcasecmp( 'term_taxonomy_id', $column['column_name'] ) + ) { + $seen_term_taxonomy_id = true; + continue; + } + + return array(); + } + + if ( ! $seen_terms_id || ! $seen_term_taxonomy_id ) { + return array(); + } + + return array( + strtolower( $taxonomy['alias'] ) . '.term_taxonomy_id' => true, + ); + } + + /** + * Check whether WordPress terms, term_taxonomy, and term_relationships tables share a prefix. + * + * @param string $terms_table Terms table name. + * @param string $taxonomy_table Term taxonomy table name. + * @param string $relationships_table Term relationships table name. + * @return bool Whether the tables share a WordPress prefix. + */ + private function wordpress_terms_taxonomy_relationships_table_prefixes_match( + string $terms_table, + string $taxonomy_table, + string $relationships_table + ): bool { + $terms_prefix = $this->wordpress_fast_path_table_prefix( $terms_table, 'terms' ); + $taxonomy_prefix = $this->wordpress_fast_path_table_prefix( $taxonomy_table, 'term_taxonomy' ); + $relationships_prefix = $this->wordpress_fast_path_table_prefix( $relationships_table, 'term_relationships' ); + + return null !== $terms_prefix + && null !== $taxonomy_prefix + && null !== $relationships_prefix + && 0 === strcasecmp( $terms_prefix, $taxonomy_prefix ) + && 0 === strcasecmp( $terms_prefix, $relationships_prefix ); + } + + /** + * Check whether the taxonomy non-aggregate GROUP BY uses the canonical WordPress joins. + * + * @param WP_Parser_Token[] $tokens FROM-clause tokens. + * @param array{table_name:string,alias:string,temporary:bool,joined?:bool} $terms Terms table reference. + * @param array{table_name:string,alias:string,temporary:bool,joined?:bool} $taxonomy Term taxonomy table reference. + * @param array{table_name:string,alias:string,temporary:bool,joined?:bool} $relationships Term relationships table reference. + * @return bool Whether the joins match the supported shape. + */ + private function wordpress_taxonomy_group_by_nonaggregate_joins_match( + array $tokens, + array $terms, + array $taxonomy, + array $relationships + ): bool { + $join_index = $this->find_primary_key_group_by_join_index( $tokens, 0 ); + if ( null === $join_index ) { + return false; + } + + $first_table = $this->parse_simple_select_table_reference( array_slice( $tokens, 0, $join_index ) ); + if ( + null === $first_table + || 0 !== strcasecmp( $terms['alias'], $first_table['alias'] ) + || 0 !== strcasecmp( $terms['table_name'], $first_table['table_name'] ) + ) { + return false; + } + + $expected_joins = array( + array( + 'reference' => $taxonomy, + 'left_alias' => $terms['alias'], + 'left_column' => 'term_id', + 'right_alias' => $taxonomy['alias'], + 'right_column' => 'term_id', + ), + array( + 'reference' => $relationships, + 'left_alias' => $relationships['alias'], + 'left_column' => 'term_taxonomy_id', + 'right_alias' => $taxonomy['alias'], + 'right_column' => 'term_taxonomy_id', + ), + ); + + $index = $join_index; + foreach ( $expected_joins as $expected_join ) { + if ( ! isset( $tokens[ $index ] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { + return false; + } + + ++$index; + $table_end = $this->primary_key_group_by_join_table_factor_end( $tokens, $index ); + $table = $this->parse_simple_select_table_reference( array_slice( $tokens, $index, $table_end - $index ) ); + if ( + null === $table + || 0 !== strcasecmp( $expected_join['reference']['alias'], $table['alias'] ) + || 0 !== strcasecmp( $expected_join['reference']['table_name'], $table['table_name'] ) + ) { + 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 + || ! $this->wordpress_taxonomy_group_by_join_predicate_matches( + array_slice( $tokens, $index, $predicate_end - $index ), + $expected_join['left_alias'], + $expected_join['left_column'], + $expected_join['right_alias'], + $expected_join['right_column'] + ) + ) { + return false; + } + + $index = $predicate_end; + } + + return count( $tokens ) === $index; + } + + /** + * Check a simple equality join predicate, allowing either operand order. + * + * @param WP_Parser_Token[] $tokens Predicate tokens. + * @param string $left_alias First expected alias. + * @param string $left_column First expected column. + * @param string $right_alias Second expected alias. + * @param string $right_column Second expected column. + * @return bool Whether the predicate matches. + */ + private function wordpress_taxonomy_group_by_join_predicate_matches( + array $tokens, + string $left_alias, + string $left_column, + string $right_alias, + string $right_column + ): bool { + $equals_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); + if ( null === $equals_index ) { + return false; + } + + $left = $this->parse_group_by_column_reference( array_slice( $tokens, 0, $equals_index ) ); + $right = $this->parse_group_by_column_reference( array_slice( $tokens, $equals_index + 1 ) ); + return $this->wordpress_taxonomy_group_by_join_column_pair_matches( $left, $right, $left_alias, $left_column, $right_alias, $right_column ) + || $this->wordpress_taxonomy_group_by_join_column_pair_matches( $right, $left, $left_alias, $left_column, $right_alias, $right_column ); + } + + /** + * Check one ordered join predicate column pair. + * + * @param array{column_name:string,qualifier:string|null}|null $left First parsed column. + * @param array{column_name:string,qualifier:string|null}|null $right Second parsed column. + * @param string $left_alias First expected alias. + * @param string $left_column First expected column. + * @param string $right_alias Second expected alias. + * @param string $right_column Second expected column. + * @return bool Whether the ordered column pair matches. + */ + private function wordpress_taxonomy_group_by_join_column_pair_matches( + ?array $left, + ?array $right, + string $left_alias, + string $left_column, + string $right_alias, + string $right_column + ): bool { + return null !== $left + && null !== $right + && null !== $left['qualifier'] + && null !== $right['qualifier'] + && 0 === strcasecmp( $left_alias, $left['qualifier'] ) + && 0 === strcasecmp( $left_column, $left['column_name'] ) + && 0 === strcasecmp( $right_alias, $right['qualifier'] ) + && 0 === strcasecmp( $right_column, $right['column_name'] ); + } + + /** + * Check whether the non-aggregate taxonomy GROUP BY is exactly t.term_id. + * + * @param WP_Parser_Token[] $group_tokens GROUP BY tokens. + * @param array{table_name:string,alias:string,temporary:bool,joined?:bool} $terms Terms table reference. + * @return bool Whether the GROUP BY matches the supported shape. + */ + private function wordpress_taxonomy_group_by_nonaggregate_group_matches_terms( array $group_tokens, array $terms ): bool { + $group_items = $this->split_top_level_comma_items( $group_tokens ); + if ( 1 !== count( $group_items ) ) { + return false; + } + + $column = $this->parse_group_by_column_reference( $group_items[0] ); + return null !== $column + && null !== $column['qualifier'] + && 0 === strcasecmp( $terms['alias'], $column['qualifier'] ) + && 0 === strcasecmp( 'term_id', $column['column_name'] ); + } + + /** + * Check whether taxonomy filters bound the non-aggregate grouped projection. + * + * @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,joined?:bool} $taxonomy Term taxonomy table reference. + * @param array{table_name:string,alias:string,temporary:bool,joined?:bool} $relationships Term relationships table reference. + * @return bool Whether the WHERE clause matches the supported WordPress shape. + */ + private function wordpress_taxonomy_group_by_nonaggregate_where_matches( + array $tokens, + int $group_end, + array $taxonomy, + array $relationships + ): bool { + $where_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::WHERE_SYMBOL ); + $group_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::GROUP_SYMBOL ); + if ( null === $where_index || null === $group_index || $group_index <= $where_index || $group_end <= $group_index ) { + return false; + } + + $where_end = $this->simple_select_where_clause_end( $tokens, $where_index + 1 ); + if ( $where_end !== $group_index ) { + return false; + } + + $where_tokens = array_slice( $tokens, $where_index + 1, $where_end - $where_index - 1 ); + $predicates = $this->split_top_level_and_predicates_without_or( $where_tokens ); + if ( null === $predicates || 2 !== count( $predicates ) ) { + return false; + } + + $seen_taxonomy_filter = false; + $seen_object_filter = false; + foreach ( $predicates as $predicate ) { + if ( $this->wordpress_taxonomy_group_by_string_filter_predicate( $predicate, $taxonomy['alias'], 'taxonomy' ) ) { + $seen_taxonomy_filter = true; + continue; + } + + if ( $this->wordpress_taxonomy_group_by_integer_filter_predicate( $predicate, $relationships['alias'], 'object_id' ) ) { + $seen_object_filter = true; + continue; + } + + return false; + } + + return $seen_taxonomy_filter && $seen_object_filter; + } + + /** + * Check a simple string equality or IN predicate against a qualified column. + * + * @param WP_Parser_Token[] $tokens Predicate tokens. + * @param string $alias Expected table alias. + * @param string $column_name Expected column name. + * @return bool Whether the predicate matches. + */ + private function wordpress_taxonomy_group_by_string_filter_predicate( array $tokens, string $alias, string $column_name ): bool { + return $this->wordpress_taxonomy_group_by_literal_filter_predicate( + $tokens, + $alias, + $column_name, + function ( array $literal_tokens ): bool { + return 1 === count( $literal_tokens ) && $this->is_string_literal_token( $literal_tokens[0] ); + } + ); + } + + /** + * Check a simple integer equality or IN predicate against a qualified column. + * + * @param WP_Parser_Token[] $tokens Predicate tokens. + * @param string $alias Expected table alias. + * @param string $column_name Expected column name. + * @return bool Whether the predicate matches. + */ + private function wordpress_taxonomy_group_by_integer_filter_predicate( array $tokens, string $alias, string $column_name ): bool { + return $this->wordpress_taxonomy_group_by_literal_filter_predicate( + $tokens, + $alias, + $column_name, + function ( array $literal_tokens ): bool { + return 1 === count( $literal_tokens ) + && null !== $this->fast_path_mysql_unsigned_integer_literal_value( $literal_tokens[0]->get_bytes() ); + } + ); + } + + /** + * Check a simple equality or IN predicate against a qualified column. + * + * @param WP_Parser_Token[] $tokens Predicate tokens. + * @param string $alias Expected table alias. + * @param string $column_name Expected column name. + * @param callable $is_literal Literal token validator. + * @return bool Whether the predicate matches. + */ + private function wordpress_taxonomy_group_by_literal_filter_predicate( + array $tokens, + string $alias, + string $column_name, + callable $is_literal + ): bool { + $equals_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); + if ( null !== $equals_index ) { + return ( + $this->wordpress_taxonomy_group_by_filter_column_matches( array_slice( $tokens, 0, $equals_index ), $alias, $column_name ) + && $is_literal( array_slice( $tokens, $equals_index + 1 ) ) + ) || ( + $this->wordpress_taxonomy_group_by_filter_column_matches( array_slice( $tokens, $equals_index + 1 ), $alias, $column_name ) + && $is_literal( array_slice( $tokens, 0, $equals_index ) ) + ); + } + + $in_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::IN_SYMBOL ); + if ( + null === $in_index + || ! $this->wordpress_taxonomy_group_by_filter_column_matches( array_slice( $tokens, 0, $in_index ), $alias, $column_name ) + || ! isset( $tokens[ $in_index + 2 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $in_index + 1 ]->id + ) { + return false; + } + + $close_index = $this->matching_parenthesis_index( $tokens, $in_index + 1 ); + if ( null === $close_index || count( $tokens ) !== $close_index + 1 ) { + return false; + } + + $literal_items = $this->split_top_level_comma_items( array_slice( $tokens, $in_index + 2, $close_index - $in_index - 2 ) ); + if ( count( $literal_items ) === 0 ) { + return false; + } + + foreach ( $literal_items as $literal_tokens ) { + if ( ! $is_literal( $literal_tokens ) ) { + return false; + } + } + + return true; + } + + /** + * Check a qualified filter column reference. + * + * @param WP_Parser_Token[] $tokens Column tokens. + * @param string $alias Expected table alias. + * @param string $column_name Expected column name. + * @return bool Whether the column matches. + */ + private function wordpress_taxonomy_group_by_filter_column_matches( array $tokens, string $alias, string $column_name ): bool { + $column = $this->parse_simple_select_column_reference( $tokens ); + return null !== $column + && ! $column['wildcard'] + && null !== $column['qualifier'] + && 0 === strcasecmp( $alias, $column['qualifier'] ) + && 0 === strcasecmp( $column_name, $column['column_name'] ); + } + + /** + * Check whether a joined projection column is on a pre-approved non-aggregate list. + * + * @param array{name:string,column_name:string,qualifier:string|null,wildcard:bool} $column SELECT column. + * @param array $allowed_columns Allowed lower-case qualifier.column keys. + * @return bool Whether the joined projection is allowed. + */ + private function primary_key_group_by_joined_projection_column_is_allowed( array $column, array $allowed_columns ): bool { + if ( $column['wildcard'] || null === $column['qualifier'] ) { + return false; + } + + $key = strtolower( $column['qualifier'] ) . '.' . strtolower( $column['column_name'] ); + return isset( $allowed_columns[ $key ] ); + } + + /** + * 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 ); + } + + /** + * Return a bounded information_schema.tables refresh set when a SELECT proves it. + * + * The returned names only limit compatibility-table materialization. The + * original translated SELECT still applies projection, grouping, ordering, and + * all predicates. + * + * @param WP_Parser_Token[] $tokens SELECT token stream. + * @return string[]|null Requested TABLE_NAME literals, empty for proven no rows, or null for full refresh. + */ + private function bounded_information_schema_tables_select_table_names( 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::UNION_SYMBOL ) ) { + return null; + } + if ( 1 !== $this->information_schema_tables_reference_count( $tokens ) ) { + return null; + } + + $from_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::FROM_SYMBOL ); + if ( null === $from_index ) { + 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; + } + + $where_index = $this->find_top_level_token_index( $tokens, $from_index + 1, WP_MySQL_Lexer::WHERE_SYMBOL ); + if ( null === $where_index ) { + return null; + } + + $where_end = $this->simple_select_where_clause_end( $tokens, $where_index + 1 ); + $where_tokens = array_slice( $tokens, $where_index + 1, $where_end - $where_index - 1 ); + $predicates = $this->split_top_level_and_predicates_without_or( $where_tokens ); + if ( null === $predicates ) { + return null; + } + + $table_names = array(); + $seen = array(); + foreach ( $predicates as $predicate ) { + $filter = $this->parse_information_schema_tables_filter_predicate( $predicate, $table ); + if ( null === $filter ) { + continue; + } + + if ( 'TABLE_SCHEMA' === $filter['column'] ) { + $matches_current = false; + foreach ( $filter['values'] as $schema ) { + if ( 0 === strcasecmp( $schema, $this->database ) ) { + $matches_current = true; + break; + } + } + if ( ! $matches_current ) { + return array(); + } + continue; + } + + foreach ( $filter['values'] as $table_name ) { + $key = strtolower( $table_name ); + if ( isset( $seen[ $key ] ) ) { + continue; + } + $seen[ $key ] = true; + $table_names[] = $table_name; + } + } + + return count( $table_names ) > 0 ? $table_names : null; + } + + /** + * Count information_schema.tables table references in a SELECT. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return int Reference count. + */ + private function information_schema_tables_reference_count( array $tokens ): int { + $count = 0; + for ( $index = 0; $index < count( $tokens ); ++$index ) { + $reference_length = $this->information_schema_tables_reference_length( $tokens, $index ); + if ( null === $reference_length ) { + continue; + } + + ++$count; + $index += $reference_length - 1; + } + + return $count; + } + + /** + * Find the end of a simple SELECT WHERE clause. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $start First token after WHERE. + * @return int End index, exclusive. + */ + private function simple_select_where_clause_end( array $tokens, int $start ): int { + $clause_tokens = array( + 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 ); + } + + /** + * Split top-level AND predicates, rejecting top-level OR/XOR. + * + * @param WP_Parser_Token[] $tokens WHERE expression tokens. + * @return array|null Predicate token lists, or null when unsupported. + */ + private function split_top_level_and_predicates_without_or( array $tokens ): ?array { + $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, + array( + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::LOGICAL_OR_OPERATOR, + WP_MySQL_Lexer::XOR_SYMBOL, + ), + true + ) + ) { + return null; + } + } + + return $this->split_top_level_and_predicates( $tokens ); + } + + /** + * Parse one safe information_schema.tables TABLE_NAME/TABLE_SCHEMA predicate. + * + * @param WP_Parser_Token[] $tokens Predicate tokens. + * @param array{alias:string} $table Parsed information_schema.tables source. + * @return array{column:string,values:string[]}|null Parsed filter, or null when unsupported. + */ + private function parse_information_schema_tables_filter_predicate( array $tokens, array $table ): ?array { + $equals_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); + if ( null !== $equals_index ) { + $left_tokens = array_slice( $tokens, 0, $equals_index ); + $right_tokens = array_slice( $tokens, $equals_index + 1 ); + $column = $this->parse_information_schema_tables_filter_column( $left_tokens, $table ); + $value = $this->information_schema_tables_filter_string_literal( $right_tokens ); + if ( null !== $column && null !== $value ) { + return array( + 'column' => $column, + 'values' => array( $value ), + ); + } + + $column = $this->parse_information_schema_tables_filter_column( $right_tokens, $table ); + $value = $this->information_schema_tables_filter_string_literal( $left_tokens ); + if ( null !== $column && null !== $value ) { + return array( + 'column' => $column, + 'values' => array( $value ), + ); + } + + return null; + } + + $in_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::IN_SYMBOL ); + if ( + null === $in_index + || ! isset( $tokens[ $in_index + 2 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $in_index + 1 ]->id + ) { + return null; + } + + $column = $this->parse_information_schema_tables_filter_column( array_slice( $tokens, 0, $in_index ), $table ); + if ( null === $column ) { + return null; + } + + $close_index = $this->matching_parenthesis_index( $tokens, $in_index + 1 ); + if ( null === $close_index || count( $tokens ) !== $close_index + 1 ) { + return null; + } + + $values = array(); + foreach ( $this->split_top_level_comma_items( array_slice( $tokens, $in_index + 2, $close_index - $in_index - 2 ) ) as $item ) { + $value = $this->information_schema_tables_filter_string_literal( $item ); + if ( null === $value ) { + return null; + } + + $values[] = $value; + } + + return count( $values ) > 0 + ? array( + 'column' => $column, + 'values' => $values, + ) + : null; + } + + /** + * Parse a supported information_schema.tables filter column. + * + * @param WP_Parser_Token[] $tokens Column tokens. + * @param array{alias:string} $table Parsed information_schema.tables source. + * @return string|null Canonical column name, or null when unsupported. + */ + private function parse_information_schema_tables_filter_column( array $tokens, array $table ): ?string { + $column = $this->parse_simple_select_column_reference( $tokens ); + if ( + null === $column + || $column['wildcard'] + || ! $this->information_schema_tables_group_by_column_qualifier_matches_table( $column['qualifier'], $table ) + ) { + return null; + } + + if ( 0 === strcasecmp( $column['column_name'], 'TABLE_NAME' ) ) { + return 'TABLE_NAME'; + } + if ( 0 === strcasecmp( $column['column_name'], 'TABLE_SCHEMA' ) ) { + return 'TABLE_SCHEMA'; + } + + return null; + } + + /** + * Parse an exact quoted string literal. + * + * @param WP_Parser_Token[] $tokens Literal tokens. + * @return string|null Literal value, or null when unsupported. + */ + private function information_schema_tables_filter_string_literal( array $tokens ): ?string { + if ( 1 !== count( $tokens ) || ! $this->is_string_literal_token( $tokens[0] ) ) { + return null; + } + + return $this->token_value( $tokens[0] ); + } + + /** + * Return a bounded information_schema.columns refresh set when a SELECT proves it. + * + * @param WP_Parser_Token[] $tokens SELECT token stream. + * @return string[]|null Requested TABLE_NAME literals, empty for proven no rows, or null for full refresh. + */ + private function bounded_information_schema_columns_select_table_names( 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::UNION_SYMBOL ) ) { + return null; + } + if ( 1 !== $this->information_schema_columns_reference_count( $tokens ) ) { + return null; + } + + $from_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::FROM_SYMBOL ); + if ( null === $from_index ) { + 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_columns_from_clause( $table_tokens ); + if ( null === $table ) { + return null; + } + + $where_index = $this->find_top_level_token_index( $tokens, $from_index + 1, WP_MySQL_Lexer::WHERE_SYMBOL ); + if ( null === $where_index ) { + return null; + } + + $where_end = $this->simple_select_where_clause_end( $tokens, $where_index + 1 ); + $where_tokens = array_slice( $tokens, $where_index + 1, $where_end - $where_index - 1 ); + $predicates = $this->split_top_level_and_predicates_without_or( $where_tokens ); + if ( null === $predicates ) { + return null; + } + + $table_names = array(); + $seen = array(); + foreach ( $predicates as $predicate ) { + $filter = $this->parse_information_schema_columns_filter_predicate( $predicate, $table ); + if ( null === $filter ) { + continue; + } + + if ( 'TABLE_SCHEMA' === $filter['column'] ) { + $matches_current = false; + foreach ( $filter['values'] as $schema ) { + if ( 0 === strcasecmp( $schema, $this->database ) ) { + $matches_current = true; + break; + } + } + if ( ! $matches_current ) { + return array(); + } + continue; + } + + foreach ( $filter['values'] as $table_name ) { + $key = strtolower( $table_name ); + if ( isset( $seen[ $key ] ) ) { + continue; + } + $seen[ $key ] = true; + $table_names[] = $table_name; + } + } + + return count( $table_names ) > 0 ? $table_names : null; + } + + /** + * Count information_schema.columns table references in a SELECT. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return int Reference count. + */ + private function information_schema_columns_reference_count( array $tokens ): int { + $count = 0; + for ( $index = 0; $index < count( $tokens ); ++$index ) { + $reference_length = $this->information_schema_columns_reference_length( $tokens, $index ); + if ( null === $reference_length ) { + continue; + } + + ++$count; + $index += $reference_length - 1; + } + + return $count; + } + + /** + * Parse the FROM clause supported by bounded information_schema.columns refresh. + * + * @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_columns_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_columns_reference_length( $tokens, 0 ); + if ( null === $reference_length ) { + return null; + } + + $index = $reference_length; + $alias = self::INFO_SCHEMA_COLUMNS_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, + ); + } + + /** + * Parse one safe information_schema.columns TABLE_NAME/TABLE_SCHEMA predicate. + * + * @param WP_Parser_Token[] $tokens Predicate tokens. + * @param array{alias:string} $table Parsed information_schema.columns source. + * @return array{column:string,values:string[]}|null Parsed filter, or null when unsupported. + */ + private function parse_information_schema_columns_filter_predicate( array $tokens, array $table ): ?array { + $equals_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); + if ( null !== $equals_index ) { + $left_tokens = array_slice( $tokens, 0, $equals_index ); + $right_tokens = array_slice( $tokens, $equals_index + 1 ); + $column = $this->parse_information_schema_columns_filter_column( $left_tokens, $table ); + $value = $this->information_schema_tables_filter_string_literal( $right_tokens ); + if ( null !== $column && null !== $value ) { + return array( + 'column' => $column, + 'values' => array( $value ), + ); + } + + $column = $this->parse_information_schema_columns_filter_column( $right_tokens, $table ); + $value = $this->information_schema_tables_filter_string_literal( $left_tokens ); + if ( null !== $column && null !== $value ) { + return array( + 'column' => $column, + 'values' => array( $value ), + ); + } + + return null; + } + + $in_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::IN_SYMBOL ); + if ( + null === $in_index + || ! isset( $tokens[ $in_index + 2 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $in_index + 1 ]->id + ) { + return null; + } + + $column = $this->parse_information_schema_columns_filter_column( array_slice( $tokens, 0, $in_index ), $table ); + if ( null === $column ) { + return null; + } + + $close_index = $this->matching_parenthesis_index( $tokens, $in_index + 1 ); + if ( null === $close_index || count( $tokens ) !== $close_index + 1 ) { + return null; + } + + $values = array(); + foreach ( $this->split_top_level_comma_items( array_slice( $tokens, $in_index + 2, $close_index - $in_index - 2 ) ) as $item ) { + $value = $this->information_schema_tables_filter_string_literal( $item ); + if ( null === $value ) { + return null; + } + + $values[] = $value; + } + + return count( $values ) > 0 + ? array( + 'column' => $column, + 'values' => $values, + ) + : null; + } + + /** + * Parse a supported information_schema.columns filter column. + * + * @param WP_Parser_Token[] $tokens Column tokens. + * @param array{alias:string} $table Parsed information_schema.columns source. + * @return string|null Canonical column name, or null when unsupported. + */ + private function parse_information_schema_columns_filter_column( array $tokens, array $table ): ?string { + $column = $this->parse_simple_select_column_reference( $tokens ); + if ( + null === $column + || $column['wildcard'] + || ! $this->information_schema_columns_column_qualifier_matches_table( $column['qualifier'], $table ) + ) { + return null; + } + + if ( 0 === strcasecmp( $column['column_name'], 'TABLE_NAME' ) ) { + return 'TABLE_NAME'; + } + if ( 0 === strcasecmp( $column['column_name'], 'TABLE_SCHEMA' ) ) { + return 'TABLE_SCHEMA'; + } + + return null; + } + + /** + * Check whether a bounded-refresh column belongs to information_schema.columns. + * + * @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_columns_column_qualifier_matches_table( ?string $qualifier, array $table ): bool { + return null === $qualifier + || 0 === strcasecmp( $qualifier, $table['alias'] ) + || 0 === strcasecmp( $qualifier, 'columns' ) + || 0 === strcasecmp( $qualifier, self::INFO_SCHEMA_COLUMNS_TABLE ); + } + + /** + * Return a bounded information_schema.statistics refresh set when a SELECT proves it. + * + * @param WP_Parser_Token[] $tokens SELECT token stream. + * @return string[]|null Requested TABLE_NAME literals, empty for proven no rows, or null for full refresh. + */ + private function bounded_information_schema_statistics_select_table_names( 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::UNION_SYMBOL ) ) { + return null; + } + if ( 1 !== $this->information_schema_statistics_reference_count( $tokens ) ) { + return null; + } + + $from_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::FROM_SYMBOL ); + if ( null === $from_index ) { + 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_statistics_from_clause( $table_tokens ); + if ( null === $table ) { + return null; + } + + $where_index = $this->find_top_level_token_index( $tokens, $from_index + 1, WP_MySQL_Lexer::WHERE_SYMBOL ); + if ( null === $where_index ) { + return null; + } + + $where_end = $this->simple_select_where_clause_end( $tokens, $where_index + 1 ); + $where_tokens = array_slice( $tokens, $where_index + 1, $where_end - $where_index - 1 ); + $predicates = $this->split_top_level_and_predicates_without_or( $where_tokens ); + if ( null === $predicates ) { + return null; + } + + $table_names = array(); + $seen = array(); + foreach ( $predicates as $predicate ) { + $filter = $this->parse_information_schema_statistics_filter_predicate( $predicate, $table ); + if ( null === $filter ) { + continue; + } + + if ( 'TABLE_SCHEMA' === $filter['column'] ) { + $matches_current = false; + foreach ( $filter['values'] as $schema ) { + if ( 0 === strcasecmp( $schema, $this->database ) ) { + $matches_current = true; + break; + } + } + if ( ! $matches_current ) { + return array(); + } + continue; + } + + foreach ( $filter['values'] as $table_name ) { + $key = strtolower( $table_name ); + if ( isset( $seen[ $key ] ) ) { + continue; + } + $seen[ $key ] = true; + $table_names[] = $table_name; + } + } + + return count( $table_names ) > 0 ? $table_names : null; + } + + /** + * Count information_schema.statistics table references in a SELECT. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return int Reference count. + */ + private function information_schema_statistics_reference_count( array $tokens ): int { + $count = 0; + for ( $index = 0; $index < count( $tokens ); ++$index ) { + $reference_length = $this->information_schema_statistics_reference_length( $tokens, $index ); + if ( null === $reference_length ) { + continue; + } + + ++$count; + $index += $reference_length - 1; + } + + return $count; + } + + /** + * Parse the FROM clause supported by bounded information_schema.statistics refresh. + * + * @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_statistics_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_statistics_reference_length( $tokens, 0 ); + if ( null === $reference_length ) { + return null; + } + + $index = $reference_length; + $alias = self::INFO_SCHEMA_STATISTICS_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, + ); + } + + /** + * Parse one safe information_schema.statistics TABLE_NAME/TABLE_SCHEMA predicate. + * + * @param WP_Parser_Token[] $tokens Predicate tokens. + * @param array{alias:string} $table Parsed information_schema.statistics source. + * @return array{column:string,values:string[]}|null Parsed filter, or null when unsupported. + */ + private function parse_information_schema_statistics_filter_predicate( array $tokens, array $table ): ?array { + $equals_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); + if ( null !== $equals_index ) { + $left_tokens = array_slice( $tokens, 0, $equals_index ); + $right_tokens = array_slice( $tokens, $equals_index + 1 ); + $column = $this->parse_information_schema_statistics_filter_column( $left_tokens, $table ); + $value = $this->information_schema_tables_filter_string_literal( $right_tokens ); + if ( null !== $column && null !== $value ) { + return array( + 'column' => $column, + 'values' => array( $value ), + ); + } + + $column = $this->parse_information_schema_statistics_filter_column( $right_tokens, $table ); + $value = $this->information_schema_tables_filter_string_literal( $left_tokens ); + if ( null !== $column && null !== $value ) { + return array( + 'column' => $column, + 'values' => array( $value ), + ); + } + + return null; + } + + $in_index = $this->find_top_level_token_index( $tokens, 0, WP_MySQL_Lexer::IN_SYMBOL ); + if ( + null === $in_index + || ! isset( $tokens[ $in_index + 2 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $in_index + 1 ]->id + ) { + return null; + } + + $column = $this->parse_information_schema_statistics_filter_column( array_slice( $tokens, 0, $in_index ), $table ); + if ( null === $column ) { + return null; + } + + $close_index = $this->matching_parenthesis_index( $tokens, $in_index + 1 ); + if ( null === $close_index || count( $tokens ) !== $close_index + 1 ) { + return null; + } + + $values = array(); + foreach ( $this->split_top_level_comma_items( array_slice( $tokens, $in_index + 2, $close_index - $in_index - 2 ) ) as $item ) { + $value = $this->information_schema_tables_filter_string_literal( $item ); + if ( null === $value ) { + return null; + } + + $values[] = $value; + } + + return count( $values ) > 0 + ? array( + 'column' => $column, + 'values' => $values, + ) + : null; + } + + /** + * Parse a supported information_schema.statistics filter column. + * + * @param WP_Parser_Token[] $tokens Column tokens. + * @param array{alias:string} $table Parsed information_schema.statistics source. + * @return string|null Canonical column name, or null when unsupported. + */ + private function parse_information_schema_statistics_filter_column( array $tokens, array $table ): ?string { + $column = $this->parse_simple_select_column_reference( $tokens ); + if ( + null === $column + || $column['wildcard'] + || ! $this->information_schema_statistics_column_qualifier_matches_table( $column['qualifier'], $table ) + ) { + return null; + } + + if ( 0 === strcasecmp( $column['column_name'], 'TABLE_NAME' ) ) { + return 'TABLE_NAME'; + } + if ( 0 === strcasecmp( $column['column_name'], 'TABLE_SCHEMA' ) ) { + return 'TABLE_SCHEMA'; + } + + return null; + } + + /** + * Check whether a bounded-refresh column belongs to information_schema.statistics. + * + * @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_statistics_column_qualifier_matches_table( ?string $qualifier, array $table ): bool { + return null === $qualifier + || 0 === strcasecmp( $qualifier, $table['alias'] ) + || 0 === strcasecmp( $qualifier, 'statistics' ) + || 0 === strcasecmp( $qualifier, self::INFO_SCHEMA_STATISTICS_TABLE ); + } + + /** + * Parse the FROM clause supported by primary-key GROUP BY expansion. + * + * 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. + * @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; + } + + /** + * Resolve the table whose primary key is used by a joined GROUP BY clause. + * + * @param WP_Parser_Token[] $table_tokens FROM-clause tokens. + * @param WP_Parser_Token[] $group_tokens GROUP BY item tokens. + * @return array{table_name:string,alias:string,temporary:bool,joined:bool}|null Grouped table reference, or null when unsupported. + */ + private function primary_key_group_by_grouped_table_reference( array $table_tokens, array $group_tokens ): ?array { + $table_references = $this->primary_key_group_by_table_references( $table_tokens ); + if ( count( $table_references ) === 0 ) { + return null; + } + + $group_qualifier = null; + foreach ( $this->split_top_level_comma_items( $group_tokens ) as $item ) { + $column = $this->parse_group_by_column_reference( $item ); + if ( null === $column || null === $column['qualifier'] ) { + return null; + } + if ( null === $group_qualifier ) { + $group_qualifier = $column['qualifier']; + } elseif ( 0 !== strcasecmp( $group_qualifier, $column['qualifier'] ) ) { + return null; + } + } + + if ( null === $group_qualifier ) { + return null; + } + + $reference_key = strtolower( $group_qualifier ); + if ( ! isset( $table_references[ $reference_key ] ) ) { + return null; + } + + $table_references[ $reference_key ]['joined'] = count( $table_references ) > 1; + return $table_references[ $reference_key ]; + } + + /** + * Parse table references from a supported primary-key GROUP BY FROM clause. + * + * @param WP_Parser_Token[] $tokens FROM-clause tokens. + * @return array References keyed by lower-case alias. + */ + private function primary_key_group_by_table_references( array $tokens ): array { + $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 array(); + } + $table['joined'] = false; + return array( strtolower( $table['alias'] ) => $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 array(); + } + + $table['joined'] = true; + $references = array( strtolower( $table['alias'] ) => $table ); + $index = $join_index; + 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 array(); + } + } 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 array(); + } + } elseif ( WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $index ]->id ) { + return array(); + } + + ++$index; + $table_end = $this->primary_key_group_by_join_table_factor_end( $tokens, $index ); + $joined_table = $this->parse_simple_select_table_reference( array_slice( $tokens, $index, $table_end - $index ) ); + if ( null === $joined_table ) { + return array(); + } + + $joined_table['joined'] = true; + $references[ strtolower( $joined_table['alias'] ) ] = $joined_table; + $index = $table_end; + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $index ]->id ) { + return array(); + } + + ++$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 array(); + } + + $index = $predicate_end; + } + + return $references; + } + + /** + * 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::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; + } + + ++$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. + * + * @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 ); + } + + /** + * Force SQLite-compatible NULL placement for text-value numeric ORDER BY items. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @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 text_value_numeric_order_by_null_rewrites( array $tokens, array $existing_rewrites = array() ): array { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return 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 + ) { + return array(); + } + + $rewrites = array(); + $order_end = $this->primary_key_order_by_clause_end( $tokens, $order_index + 2 ); + foreach ( $this->split_top_level_select_item_ranges( $tokens, $order_index + 2, $order_end ) as $item ) { + if ( isset( $existing_rewrites[ $item['start'] ] ) ) { + continue; + } + + $order_item = $this->order_by_item_expression_and_direction( $item['tokens'] ); + if ( ' DESC' === $order_item['direction_sql'] || count( $order_item['tokens'] ) === 0 ) { + continue; + } + + $expression_index = 0; + $expression_sql = $this->translate_text_value_numeric_arithmetic_expression( $order_item['tokens'], $expression_index ); + if ( null === $expression_sql || count( $order_item['tokens'] ) - 1 !== $expression_index ) { + continue; + } + + $rewrites[ $item['start'] ] = array( + 'end' => $item['end'], + 'sql' => $expression_sql . $order_item['direction_sql'] . ' NULLS FIRST', + ); + } + + return $rewrites; + } + + /** + * 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,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. + */ + 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->primary_key_group_by_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 ); + } + + /** + * 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; + } + + /** + * 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 first + * grouped value to match SQLite's representative-row behavior. ORDER BY keeps + * its separate MIN/MAX bucket-boundary rewrite. + * + * @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 = 'first(' . $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 SELECT item rewrites for joined columns in primary-key GROUP BY queries. + * + * MySQL and SQLite allow WordPress taxonomy queries to group by the primary + * table key while selecting joined-table columns. DuckDB requires those joined + * projections to be aggregated, so wrap simple joined columns in ANY_VALUE() + * while preserving their result names. + * + * @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. + * @return array Rewrites keyed by SELECT item start offset. + */ + private function primary_key_group_by_select_item_rewrites( + 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 + ): array { + if ( null === $group_by_expansion || empty( $group_by_expansion['joined'] ) ) { + return array(); + } + + $from_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::FROM_SYMBOL ); + if ( null === $from_index ) { + return array(); + } + + $rewrites = array(); + $select_start = $this->top_level_select_list_start( $tokens ); + foreach ( $this->split_top_level_select_item_ranges( $tokens, $select_start, $from_index ) as $item ) { + if ( $this->contains_top_level_aggregate_function_call( $item['tokens'] ) ) { + continue; + } + + $column = $this->parse_simple_select_column_reference( $item['tokens'] ); + if ( + null === $column + || $column['wildcard'] + || $this->primary_key_group_by_select_column_matches_primary_table( $column, $group_by_expansion ) + ) { + continue; + } + + $expression_sql = $this->translate_tokens_to_duckdb_sql( + $this->select_item_expression_tokens( $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 . ') AS ' . $this->connection->quote_identifier( $column['name'] ), + ); + } + + return $rewrites; + } + + /** + * Check whether a SELECT column belongs to the grouped primary table. + * + * @param array{name:string,column_name:string,qualifier:string|null,wildcard:bool} $column SELECT 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_select_column_matches_primary_table( array $column, array $group_by_expansion ): bool { + $column_key = strtolower( $column['column_name'] ); + return isset( $group_by_expansion['metadata_columns'][ $column_key ] ) + && null !== $column['qualifier'] + && 0 === strcasecmp( $column['qualifier'], $group_by_expansion['table_alias'] ); + } + + /** + * 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/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. + */ + 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', 'WEEK', '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 ( '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; + } + + 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. + * + * @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. + * + * @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. + * + * @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. + * + * @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; + } + } elseif ( $this->is_information_schema_current_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,wildcard:bool}|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; + } + + 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 ); + 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, + 'wildcard' => false, + ); + } + + /** + * 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 ), + '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 ); + 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 { + if ( 'json' === $data_type ) { + return 255; + } + + $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. + * + * @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, + bool $rewrite_information_schema_referential_constraints, + bool $rewrite_information_schema_check_constraints, + array $seeded_rand_rewrites = array(), + ?array $group_by_expansion = null, + array $order_by_item_rewrites = array() + ): 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, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints, + $seeded_rand_rewrites, + true, + $group_by_expansion, + $order_by_item_rewrites + ); + + 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(); + $column_meta = array(); + for ( $index = 0; $index < $statement->columnCount(); ++$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(), $column_meta ); + } + + /** + * 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_variable_select( array $tokens ): ?WP_DuckDB_Result_Statement { + $variables = $this->parse_variable_select( $tokens ); + if ( null === $variables ) { + return null; + } + + $columns = array(); + $row = array(); + foreach ( $variables as $variable ) { + $columns[] = $variable['alias']; + $row[] = 'user' === $variable['type'] + ? $this->get_user_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' + ); + } + + /** + * 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. + * + * @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. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return array|null Variables, or null for generic SELECT handling. + */ + private function parse_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 ) { + $variable['type'] = 'system'; + $variables[] = $variable; + continue; + } + + $variable = $this->parse_user_variable_select_reference( $item ); + if ( null === $variable ) { + return null; + } + $variable['type'] = 'user'; + $variables[] = $variable; + } + + return $variables; + } + + /** + * Parse one supported @@session_variable reference. + * + * @param WP_Parser_Token[] $tokens Reference tokens. + * @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 { + $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 ) { + 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' => $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, + ); + } + + /** + * 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. + * + * @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() ); + } + + /** + * 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. + * + * @param string $name Lowercase variable name. + * @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_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; + } + + if ( isset( self::READ_ONLY_SESSION_SYSTEM_VARIABLES[ $name ] ) ) { + return true; + } + + return ! $explicit_scope && isset( self::READ_ONLY_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. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_create( array $tokens ): WP_DuckDB_Result_Statement { + if ( $this->is_create_view_statement( $tokens ) ) { + return $this->execute_create_view( $tokens ); + } + + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[1]->id ) { + 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 ); + } + + throw new WP_DuckDB_Driver_Exception( 'Unsupported CREATE statement in DuckDB driver. Only CREATE TABLE, CREATE INDEX, and CREATE VIEW 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 ); + } + + /** + * Check whether an ALTER statement targets a view definition. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return bool Whether this is an ALTER VIEW statement. + */ + private function is_alter_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 VIEW statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_create_view( array $tokens ): WP_DuckDB_Result_Statement { + return $this->execute_create_or_alter_view( $tokens, 'CREATE VIEW' ); + } + + /** + * Execute a supported ALTER VIEW statement. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_alter_view( array $tokens ): WP_DuckDB_Result_Statement { + return $this->execute_create_or_alter_view( $tokens, 'ALTER VIEW' ); + } + + /** + * Execute a supported CREATE/ALTER VIEW definition. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param string $statement Statement label for errors. + * @return WP_DuckDB_Result_Statement + */ + private function execute_create_or_alter_view( array $tokens, string $statement ): WP_DuckDB_Result_Statement { + $index = 0; + $or_replace = 'ALTER VIEW' === $statement; + + if ( 'CREATE VIEW' === $statement ) { + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::CREATE_SYMBOL, 'Expected CREATE.' ); + ++$index; + if ( + isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::OR_SYMBOL === $tokens[ $index ]->id + && WP_MySQL_Lexer::REPLACE_SYMBOL === $tokens[ $index + 1 ]->id + ) { + $or_replace = true; + $index += 2; + } + } else { + $this->expect_token( $tokens, $index, WP_MySQL_Lexer::ALTER_SYMBOL, 'Expected ALTER.' ); + ++$index; + } + + if ( $this->contains_unsupported_view_prefix_clause( $tokens, $index ) ) { + $this->throw_unsupported_view_statement( $statement ); + } + + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::VIEW_SYMBOL !== $tokens[ $index ]->id ) { + $this->throw_unsupported_view_statement( $statement ); + } + ++$index; + + $reference = $this->parse_schema_lifecycle_table_reference( $tokens, $index, $statement ); + $index = $reference['next_index']; + + $columns_sql = $this->parse_view_column_list_sql( $tokens, $index, $statement ); + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::AS_SYMBOL !== $tokens[ $index ]->id ) { + $this->throw_unsupported_view_statement( $statement ); + } + ++$index; + + if ( + ! isset( $tokens[ $index ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $index ]->id + || $this->contains_unsupported_view_select_suffix_clause( $tokens, $index ) + ) { + $this->throw_unsupported_view_statement( $statement ); + } + + $select_tokens = array_slice( $tokens, $index ); + $select_sql = $this->translate_tokens_to_duckdb_sql( $select_tokens ); + + return $this->execute_duckdb_query( + 'CREATE ' + . ( $or_replace ? 'OR REPLACE ' : '' ) + . 'VIEW ' + . $this->connection->quote_identifier( $reference['requested_table_name'] ) + . $columns_sql + . ' AS ' + . $select_sql, + 'Failed to create DuckDB view' + ); + } + + /** + * Parse an optional MySQL view column list. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current token index. + * @param string $statement Statement label for errors. + * @return string DuckDB column-list SQL. + */ + private function parse_view_column_list_sql( array $tokens, int &$index, string $statement ): string { + if ( ! isset( $tokens[ $index ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index ]->id ) { + return ''; + } + + ++$index; + try { + list( $items, $index ) = $this->collect_parenthesized_items( $tokens, $index ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $this->throw_unsupported_view_statement( $statement ); + } + + $columns = array(); + foreach ( $items as $item ) { + if ( 1 !== count( $item ) || $this->is_non_identifier_token( $item[0] ) ) { + $this->throw_unsupported_view_statement( $statement ); + } + $columns[] = $this->connection->quote_identifier( $this->identifier_value( $item[0] ) ); + } + + if ( count( $columns ) === 0 ) { + $this->throw_unsupported_view_statement( $statement ); + } + + return ' (' . implode( ', ', $columns ) . ')'; + } + + /** + * Check for unsupported MySQL-only view definition prefixes. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index Current token index. + * @return bool Whether unsupported syntax was found before VIEW. + */ + private function contains_unsupported_view_prefix_clause( array $tokens, int $index ): bool { + for ( ; $index < count( $tokens ); ++$index ) { + if ( WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[ $index ]->id ) { + return false; + } + + if ( + in_array( $tokens[ $index ]->id, array( WP_MySQL_Lexer::ALGORITHM_SYMBOL, WP_MySQL_Lexer::DEFINER_SYMBOL, WP_MySQL_Lexer::SECURITY_SYMBOL ), true ) + || ( + WP_MySQL_Lexer::SQL_SYMBOL === $tokens[ $index ]->id + && isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::SECURITY_SYMBOL === $tokens[ $index + 1 ]->id + ) + ) { + return true; + } + } + + return false; + } + + /** + * Check for unsupported MySQL-only view select suffix clauses. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @param int $index First token of the SELECT statement. + * @return bool Whether unsupported suffix syntax was found. + */ + private function contains_unsupported_view_select_suffix_clause( array $tokens, int $index ): bool { + $depth = 0; + for ( ; $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 ( $depth < 0 ) { + return true; + } + continue; + } + if ( + 0 === $depth + && WP_MySQL_Lexer::WITH_SYMBOL === $tokens[ $index ]->id + && isset( $tokens[ $index + 1 ] ) + && in_array( $tokens[ $index + 1 ]->id, array( WP_MySQL_Lexer::CASCADED_SYMBOL, WP_MySQL_Lexer::CHECK_SYMBOL, WP_MySQL_Lexer::LOCAL_SYMBOL ), true ) + ) { + return true; + } + } + + return 0 !== $depth; + } + + /** + * Throw a stable unsupported view statement exception. + * + * @param string $statement Statement label. + * @return void + */ + private function throw_unsupported_view_statement( string $statement ): void { + throw new WP_DuckDB_Driver_Exception( 'Unsupported ' . $statement . ' statement in DuckDB driver.' ); + } + + /** + * 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; + + $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; + + $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; + } + + $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; + + list( $items, $index ) = $this->collect_parenthesized_items( $tokens, $index ); + $table_metadata = $this->parse_create_table_options( array_slice( $tokens, $index ) ); + + $columns = array(); + $constraints = array(); + $check_constraints = array(); + $check_names = array(); + $foreign_keys = array(); + $foreign_key_names = 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 ) { + continue; + } + + 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; + } + + if ( $this->is_create_table_check_constraint( $item ) ) { + $check_constraint = $this->translate_table_check_constraint( $table_name, $item, $check_names ); + if ( null !== $check_constraint['sql'] ) { + $constraints[] = $check_constraint['sql']; + } + $check_constraints[] = $check_constraint['metadata']; + 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; + } + + 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, $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['column_default_collation'], + $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, + 'column_name' => $column_metadata['column_name'], + ); + } + foreach ( $column_indexes as $index_definition ) { + $indexes[] = $index_definition; + } + } + + if ( count( $columns ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'CREATE TABLE requires at least one column.' ); + } + + 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 '; + 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, $this->native_create_table_constraints( $constraints, $foreign_keys, $temporary ) ) ); + $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 ); + } + $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; + } + + /** + * 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; + } + + $index_type = 'BTREE'; + if ( + isset( $tokens[ $index ] ) + && ( WP_MySQL_Lexer::FULLTEXT_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::SPATIAL_SYMBOL === $tokens[ $index ]->id ) + ) { + $index_type = strtoupper( $tokens[ $index ]->get_bytes() ); + ++$index; + } + + $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; + + $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}.{$reference['requested_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, $table_reference['temporary'], $index_type ); + $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. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + private function execute_insert( array $tokens ): WP_DuckDB_Result_Statement { + $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; + } + + $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_insert_select_with_write_coercion( $tokens, $index, $select_index, $ignore ); + } + + $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_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', + $tokens, + $index + ); + } + + $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_insert_on_duplicate_key_update( $tokens, $index, $on_duplicate_index ); + } + + 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 ), + $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $index, $ignore, 'INSERT', null, true ), + 'Failed to execute DuckDB INSERT', + $tokens, + $index + ); + } + + /** + * 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, + true + ); + } + + /** + * 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; + } + + $select_index = $this->find_insert_select_index( $tokens, $index ); + if ( null !== $select_index ) { + return $this->execute_replace_select_with_write_coercion( $tokens, $index, $select_index ); + } + + $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; + } + + $replace_shape = $this->parse_insert_values_write_shape( $tokens, $index ); + $deleted_before_write = $this->replace_values_existing_conflict_count( $replace_shape ); + $result = $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 + ); + + if ( 0 === $deleted_before_write ) { + return $result; + } + + return new WP_DuckDB_Result_Statement( array(), array(), $result->rowCount() + $deleted_before_write ); + } + + /** + * 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 { + $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']; + + 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; + + $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 ); + + $changed_condition_sql = null === $clauses['order'] && null === $clauses['limit'] + ? $this->simple_literal_update_changed_condition_sql( $reference, $update_tokens ) + : null; + 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 ); + $where_sql = $this->translate_tokens_to_duckdb_sql( + array_slice( $tokens, $clauses['where'] + 1, $where_end - $clauses['where'] - 1 ) + ); + $sql .= ' WHERE (' . $where_sql . ')' + . ( null === $changed_condition_sql ? '' : ' AND (' . $changed_condition_sql . ')' ); + } elseif ( null !== $changed_condition_sql ) { + $sql .= ' WHERE ' . $changed_condition_sql; + } + + return $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB UPDATE' ); + } + + /** + * Build a changed-row condition for simple direct literal UPDATE assignments. + * + * @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[] $tokens Update-list tokens. + * @return string|null SQL condition, or null when unsupported. + */ + private function simple_literal_update_changed_condition_sql( array $reference, array $tokens ): ?string { + $qualifiers = array_filter( + array( + $reference['alias'], + $reference['requested_table_name'], + $reference['table_name'], + ), + 'is_string' + ); + + $metadata_map = $this->write_column_metadata_map( $reference['table_name'], $reference['temporary'] ?? false ); + $case_insensitive_columns = $this->case_insensitive_column_names( $reference['table_name'], $reference['temporary'] ?? false ); + $conditions = array(); + foreach ( $this->split_top_level_comma_items( $tokens ) as $item ) { + $qualifier = isset( $item[0] ) ? $this->identifier_value( $item[0] ) : null; + if ( + isset( $item[0], $item[1] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $item[1]->id + && null !== $qualifier + && in_array( strtolower( $qualifier ), array_map( 'strtolower', $qualifiers ), true ) + ) { + $item = array_slice( $item, 2 ); + } + + $equals_index = $this->find_top_level_token_index( $item, 0, WP_MySQL_Lexer::EQUAL_OPERATOR ); + if ( null === $equals_index ) { + return null; + } + + $left_tokens = array_slice( $item, 0, $equals_index ); + $right_tokens = array_slice( $item, $equals_index + 1 ); + if ( 1 !== count( $left_tokens ) || count( $right_tokens ) === 0 || ! $this->is_simple_update_changed_row_literal_tokens( $right_tokens ) ) { + return null; + } + + $column_name = $this->identifier_value( $left_tokens[0] ); + if ( null === $column_name ) { + return null; + } + + $column_key = strtolower( $column_name ); + $metadata = $metadata_map[ $column_key ] ?? null; + $seeded_rand_state = array(); + $value_sql = $this->translate_update_assignment_value_tokens_to_duckdb_sql( + $right_tokens, + false, + $seeded_rand_state + ); + if ( is_array( $metadata ) ) { + $value_sql = $this->coerce_write_value_for_column_sql( + $metadata, + $right_tokens, + $value_sql, + true + ); + } + + $left_sql = $this->translate_tokens_to_duckdb_sql( $left_tokens ); + $use_byte_sensitive_guard = isset( $case_insensitive_columns[ $column_key ] ) + && is_array( $metadata ) + && $this->is_character_write_data_type( $this->mysql_column_data_type( $metadata ) ); + $conditions[] = $use_byte_sensitive_guard + ? $this->byte_sensitive_update_value_sql( $left_sql ) . ' IS DISTINCT FROM ' . $this->byte_sensitive_update_value_sql( $value_sql ) + : $left_sql . ' IS DISTINCT FROM (' . $value_sql . ')'; + } + + if ( count( $conditions ) === 0 ) { + return null; + } + + return implode( ' OR ', $conditions ); + } + + /** + * Build a NULL-preserving, byte-sensitive text expression for changed-row guards. + * + * @param string $sql DuckDB SQL expression. + * @return string DuckDB SQL expression. + */ + private function byte_sensitive_update_value_sql( string $sql ): string { + return 'CASE WHEN (' . $sql . ') IS NULL THEN NULL ELSE hex(encode(CAST((' + . $sql + . ') AS VARCHAR))) END'; + } + + /** + * Check whether UPDATE changed-row pre-counting can safely evaluate tokens once. + * + * @param WP_Parser_Token[] $tokens Value tokens. + * @return bool Whether tokens are a stable literal shape. + */ + private function is_simple_update_changed_row_literal_tokens( array $tokens ): bool { + return $this->is_string_literal_tokens( $tokens ) + || $this->is_signed_or_unsigned_number_literal_tokens( $tokens ) + || $this->is_boolean_literal_tokens( $tokens ) + || $this->is_null_literal_tokens( $tokens ) + || null !== $this->binary_literal_write_hex_sql( $tokens ); + } + + /** + * Check whether tokens are NULL. + * + * @param WP_Parser_Token[] $tokens Value tokens. + * @return bool Whether tokens are a NULL literal. + */ + private function is_null_literal_tokens( array $tokens ): bool { + return 1 === count( $tokens ) + && ( WP_MySQL_Lexer::NULL_SYMBOL === $tokens[0]->id || WP_MySQL_Lexer::NULL2_SYMBOL === $tokens[0]->id ); + } + + /** + * 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 ); + } + + $started_transaction = ! $this->connection->inTransaction(); + if ( $started_transaction ) { + $this->connection->beginTransaction(); + } + + $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 ( $started_transaction ) { + $this->connection->commit(); + } + } catch ( Throwable $e ) { + if ( $started_transaction && $this->connection->inTransaction() ) { + $this->connection->rollback(); + $this->clear_schema_state_after_rollback(); + } + throw $e; + } + + return new WP_DuckDB_Result_Statement( array(), array(), $affected_rows ); + } + + /** + * 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|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] ) ) { + 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,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 { + $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['include_target_reference_in_from'] || $update['target_index'] !== $index ) { + $sources[] = $reference; + } + } + + $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 ) . ')'; + } + + $this->execute_duckdb_query( $sql, 'Failed to execute DuckDB joined UPDATE' ); + return new WP_DuckDB_Result_Statement( array(), array(), $row_count ); + } + + /** + * 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 { + $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.' ); + } + + $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'] ) + . ' 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( $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. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @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] ) ) { + 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; + + $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; + } + $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 ]; + 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'], + 'column' => '__target_' . $offset . '_rowid', + 'table_name' => $reference['table_name'], + 'temporary' => $reference['temporary'], + ); + } + + $where_tokens = array(); + if ( null !== $clauses['where'] ) { + $where_tokens = array_slice( $tokens, $clauses['where'] + 1 ); + } + + return array( + '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,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 { + 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']; + $where_clauses = array(); + if ( count( $shape['where_tokens'] ) > 0 ) { + $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 ); + } + if ( count( $where_clauses ) > 0 ) { + $sql .= ' WHERE (' . implode( ') AND (', $where_clauses ) . ')'; + } + + $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 ); + } + ); + } + + /** + * 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 ); + } + + /** + * 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. + * + * @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 ( + 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.' + ); + } + + $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,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 ) + || $this->contains_top_level_derived_table_factor( $tokens ) + || $this->contains_information_schema_reference( $tokens ) + ) { + return $this->parse_joined_multi_delete_table_references( $tokens ); + } + + $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'], + 'temporary' => $reference['temporary'], + ); + $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, + 'join_predicates' => 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. + */ + 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(); + $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 ) { + $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'], + ); + } + + /** + * Parse one base table reference for multi-table DELETE. + * + * @param WP_Parser_Token[] $tokens Table reference tokens. + * @return array{alias:string,table_name:string,temporary:bool} + */ + 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.' ); + } + } else { + $this->assert_unqualified_write_allowed_in_current_database(); + } + + 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.' ); + } + + $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." ); + } + + $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' => $table_reference['table_name'], + 'temporary' => $table_reference['temporary'], + ); + } + + /** + * Parse joined UPDATE table references. + * + * @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|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 ); + $target_item = array_shift( $items ); + $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 ( $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'], $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'], $source['reference'], $sources, $join_predicates, $statement ); + } + + return array( + 'target' => array( + 'alias' => $target['alias'], + 'explicit_alias' => $target['explicit_alias'], + 'sql' => $target['sql'], + 'table_name' => $target['table_name'], + 'temporary' => $target['temporary'], + '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. + * @param string $statement Statement name for diagnostics. + * @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 ] ) ) { + 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 ' . $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 ' . $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'" ); + } + + $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, + 'explicit_alias' => true, + 'sql' => '( ' + . $this->translate_tokens_to_duckdb_sql( $this->strip_for_update_locking_clause( $inner ) ) + . ' ) AS ' + . $this->connection->quote_identifier( $alias ), + 'table_name' => null, + 'temporary' => false, + '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' ) ) { + 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'" ); + } + + 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 ) ) { + 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 {$statement} statement." ); + } + + $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( $table_reference['table_name'] ) + . ' AS ' + . $this->connection->quote_identifier( $alias ), + 'table_name' => $table_reference['table_name'], + 'temporary' => $table_reference['temporary'], + 'requested_table_name' => $table_name, + ), + 'next_index' => $index, + ); + } + + /** + * 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. + * + * @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 or generated SQL strings. + * @param string $statement Statement name for diagnostics. + */ + 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::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.' ); + } + $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.' ); + } + 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, $statement ); + $sources[] = $source['reference']; + $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 ( '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 ) { + $join_predicates[] = $predicate; + } + $index = $using['next_index']; + $left_reference = $source['reference']; + continue; + } + + if ( 'CROSS' === $join_type ) { + 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; + } + + $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 ' . $statement . ' statement in DuckDB driver. JOIN predicate is required.' ); + } + $join_predicates[] = array_slice( $tokens, $index, $predicate_end - $index ); + $index = $predicate_end; + $left_reference = $source['reference']; + } + } + + /** + * 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_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 ' . $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 ' . $statement . ' statement.' ); + ++$index; + $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 ' . $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 ' . $statement . ' 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." ); + } + + // 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( + 'predicates' => $predicates, + 'next_index' => $close_index + 1, + ); + } + + /** + * 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; + } + + /** + * 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. + * + * @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::USING_SYMBOL, + ), + true + ); + } + + /** + * 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 + || WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === $token->id + || $this->is_unsupported_joined_update_join_token( $token ); + } + + /** + * 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 + && $this->is_joined_update_join_start_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; + } + + 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; + } + + /** + * 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::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 + && 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 ) { + 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 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 ) { + $last_token = $view_tokens[ count( $view_tokens ) - 1 ]; + if ( WP_MySQL_Lexer::RESTRICT_SYMBOL === $last_token->id || WP_MySQL_Lexer::CASCADE_SYMBOL === $last_token->id ) { + array_pop( $view_tokens ); + } + } + if ( count( $view_tokens ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'DROP VIEW requires at least one view name.' ); + } + + $targets = array(); + foreach ( $this->split_top_level_comma_items( $view_tokens ) as $view_item ) { + $reference = $this->parse_schema_lifecycle_table_reference( $view_item, 0, 'DROP VIEW' ); + if ( count( $view_item ) !== $reference['next_index'] ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported DROP VIEW statement in DuckDB driver. View aliases and extra options are not supported.' ); + } + $targets[] = $reference['requested_table_name']; + } + + $result = null; + foreach ( $targets as $view_name ) { + $result = $this->execute_duckdb_query( + 'DROP VIEW ' + . ( $if_exists ? 'IF EXISTS ' : '' ) + . $this->connection->quote_identifier( $view_name ), + 'Failed to drop DuckDB view' + ); + } + + if ( null === $result ) { + throw new WP_DuckDB_Driver_Exception( 'DROP VIEW requires at least one view name.' ); + } + + return $result; + } + + /** + * Execute DROP TABLE. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return 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; + + $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, $temporary_only ): WP_DuckDB_Result_Statement { + foreach ( $targets as $requested_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, $temporary ); + $index_names = array_map( + function ( array $index_definition ): string { + return $index_definition['index_name']; + }, + $this->secondary_index_definitions_for_table( $table_name, $temporary ) + ); + + $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, $temporary ); + } + $this->drop_auto_increment_sequences( $sequence_names ); + $this->delete_table_lifecycle_metadata( $table_name, $temporary ); + } + + $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_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, $temporary ); + } + + $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_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(); + } + ); + } + + /** + * 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.' ); + } + } else { + $this->assert_unqualified_write_allowed_in_current_database(); + } + + $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; + } + + $table_reference = $this->resolve_visible_user_table_reference( $table_name ); + + return array( + '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, + ); + } + + /** + * Build a DuckDB SQL table reference for a DML target. + * + * @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 { + $sql = $this->connection->quote_identifier( $reference['table_name'] ); + if ( null !== $reference['alias'] ) { + $sql .= ' AS ' . $this->connection->quote_identifier( $reference['alias'] ); + } + + 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.' ); + } + } else { + $this->assert_unqualified_write_allowed_in_current_database(); + } + + 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_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_reference; + } + + /** + * 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(); + $this->clear_schema_state_after_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 ); + } + + /** + * 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. + * + * @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 ) { + if ( $this->is_set_charset_bootstrap_assignment( $assignment ) ) { + 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 ); + } + + 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. + * + * @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 = $this->normalize_set_session_system_variable_value_tokens( $name, array_slice( $tokens, $index ) ); + if ( 'sql_mode' === $name ) { + $this->active_sql_modes = $this->normalize_sql_modes( (string) $value ); + } else { + $this->session_system_variables[ $name ] = $value; + } + + 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. + * + * @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 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] ) ) { + return $this->normalize_set_session_system_variable_stored_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. + * + * @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 ) { + 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 ); + } + 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 ); + + 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 ); + } + + /** + * 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. + * + * @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 { + 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, + ); + } + + $right = $this->user_variable_arithmetic_operand_value( $tokens[2] ); + if ( null === $right['matched'] ) { + return array( + 'matched' => null, + 'value' => null, + ); + } + + $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 array( + 'matched' => true, + 'value' => WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[1]->id ? $left + $right['value'] : $left - $right['value'], + ); + } + + /** + * Normalize a bounded arithmetic operand for user-variable assignments. + * + * @param WP_Parser_Token $token Operand token. + * @return array{matched:bool|null,value:int|float|null} Match state and numeric 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 ( $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 ) ) ), + ); + } + + return array( + 'matched' => null, + 'value' => null, + ); + } + + /** + * Convert a stored user variable to a bounded numeric operand. + * + * @param mixed $value Stored user-variable value. + * @return int|float|null Numeric value, or null when the variable is NULL. + */ + private function user_variable_numeric_value( $value ) { + if ( null === $value ) { + return null; + } + 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; + } + + throw $this->new_unsupported_set_user_variable_value_exception(); + } + + /** + * 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. + * + * @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 implode( ',', $this->normalize_sql_modes( $token->get_value() ) ); + } + + if ( 'default' === strtolower( $token->get_value() ) ) { + return 'DEFAULT'; + } + + if ( ! $this->is_non_identifier_token( $token ) ) { + 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. + * + * @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 { + 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.' + ); + } + 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.' + ); + } + 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 ' + . $name + . ' 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.' + ); + } + + /** + * 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 + . '. Supported variables: ' + . implode( ', ', array_keys( self::SUPPORTED_SESSION_SYSTEM_VARIABLES ) ) + . '.' + ); + } + + 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 [LOCAL]|[LOW_PRIORITY] 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 [LOCAL] or [LOW_PRIORITY] 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']; + + $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 ] ) + || $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.' ); + } + + 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 + ); + } + + /** + * 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 table administration statements as MySQL-shaped status reports. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + 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 ' . $statement . ' statement.' ); + } + ++$index; + + if ( ! isset( $tokens[ $index ] ) ) { + 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, $statement ); + $requested_table_names[] = $reference['requested_table_name']; + $index = $reference['next_index']; + + if ( $index >= count( $tokens ) ) { + break; + } + + 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( + $this->unsupported_table_administration_message( $statement, $operation ) + ); + } + ++$index; + + if ( $index >= count( $tokens ) ) { + 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->table_administration_status_rows( $requested_table_name, $operation ) + ); + } + + return new WP_DuckDB_Result_Statement( + array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + $rows + ); + } + + /** + * 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. + * + * @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 + ); + } + + /** + * 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 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 === $table_reference ) { + return array( + array( + $table_label, + $operation, + 'Error', + "Table '{$requested_table_name}' doesn't exist", + ), + array( + $table_label, + $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, + $operation, + 'status', + 'OK', + ), + ); + } + + /** + * 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(); + } + $this->table_lock_active = false; + 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(); + $this->clear_schema_state_after_rollback(); + } + $this->table_lock_active = false; + 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->table_lock_active = false; + } + + $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. + * + * @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 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. + * + * @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 { + $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'], + $reference['requested_table_name'], + $reference['table_name'], + ), + 'is_string' + ); + + $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] ) + && 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 ); + } + + $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_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 ) ], + $right_tokens, + $value_sql, + true + ); + } + + $items[] = $this->translate_tokens_to_duckdb_sql( $left_tokens ) . ' = ' . $value_sql; + } + + 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,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(); + foreach ( $references as $index => $reference ) { + $qualifiers = array_filter( + array( + $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(); + $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 ) { + 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] ); + $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'] ); + 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." ); + } + if ( $this->is_unsafe_unqualified_joined_update_target( $references, $assignment_target_index ) ) { + $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 ) + && WP_MySQL_Lexer::DOT_SYMBOL === $left_tokens[1]->id + ) { + $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." ); + } + $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." ); + } + $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 ] ) ) { + $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 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported UPDATE statement in DuckDB driver. UPDATE list is required.' ); + } + + return array( + 'target_index' => $target_index, + 'include_target_reference_in_from' => $include_target_reference_in_from, + 'sql' => implode( ', ', $items ), + ); + } + + /** + * 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. + * + * @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 $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 ); + } + + 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. + * + * @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, 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 ); + } + + 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.' ); + } + } + } + + /** + * 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.' ); + } + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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. + * + * @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; + + $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 ) { + 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 ); + + $result = null; + 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 ) ) { + $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 ); + 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; + } + + $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; + } + + 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; + } + + 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 ); + + 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; + } + + 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; + } + + 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; + } + + 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 ); + } + + 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 + ) { + 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. + * + * @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_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.' ); + } + 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 ( 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.' ); + } + 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_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; + } + + $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; + } + + 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 ); + $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 ), + $temporary + ); + } + + /** + * 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; + } + + /** + * 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. + * + * @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_constraint_actions( string $table_name, array $actions, bool $temporary = false ): void { + $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, $foreign_key_names, $foreign_key_names_loaded, $temporary ); + } + } + + /** + * Return recorded CHECK constraint names keyed lowercase. + * + * @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 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; + } + + /** + * 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 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, 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, $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, $foreign_key_names, $foreign_key_names_loaded, $temporary ); + 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 ); + } + } + + /** + * Validate ALTER TABLE ... ADD constraint actions before ADD COLUMN fallback. + * + * @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 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, array &$foreign_key_names, bool &$foreign_key_names_loaded, bool $temporary = false ): 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; + } + + $items = $this->alter_table_add_items_for_constraint_detection( $tokens ); + $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; + } + 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 ( $this->is_alter_table_add_unique_constraint_item( $item ) ) { + $contains_unique = true; + } + } + + 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 ); + } + + if ( + 1 !== count( $items ) + || 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.' ); + } + 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.' ); + } + } + + foreach ( $items as $item ) { + if ( $this->is_create_table_check_constraint( $item ) ) { + $this->ensure_alter_table_check_constraint_name_map( $table_name, $check_names, $check_names_loaded, $temporary ); + $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; + } + + if ( $this->is_create_table_foreign_key_constraint( $item ) ) { + $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 ( $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 ( $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.' ); + } + + 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; + } + + /** + * 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. + * + * @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 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, 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; + } + + 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; + } + 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; + } + + 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 ) { + $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; + } + 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 ( '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.' ); + } + } + + if ( + WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[1]->id + && isset( $tokens[2] ) + && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[2]->id + ) { + $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'] ) ] ); + } + } + + /** + * Reject table rebuilds inside an active transaction. + * + * @param string $operation Operation label for the error message. + */ + 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. {$operation} cannot run inside an active DuckDB transaction." ); + } + } + + /** + * 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_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. {$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']}'." + ); + } + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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. + * + * @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']; + + 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 ); + $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]; + } + + /** + * 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(); + } + ); + } + + /** + * 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, $temporary ) ) > 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 ); + } + + /** + * 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. + * + * @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. + * + * @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. + * + * @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 + ); + } + + /** + * 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 ); + } + + /** + * 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. + * + * @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'] ); + } + ) + ); + + 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 ); + $this->invalidate_information_schema_compatibility_tables(); + return $this->empty_ddl_result(); + } + ); + } + + /** + * 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(); + } + ); + } + + /** + * 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. + * + * @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; + } + + /** + * 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. + * + * @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. + * + * @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, $temporary ) ) > 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 { + $this->rebuild_table_from_metadata_plan( + $table_name, + $this->table_column_metadata_rows( $table_name, $temporary ), + $this->primary_key_columns_for_table( $table_name, $temporary ), + $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 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, $temporary ), + $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 ); + $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; + } + + /** + * 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. + * + * @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 ); + $column_names = array_map( + function ( array $column ): string { + return (string) $column['column_name']; + }, + $column_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_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 ' . $context . ' 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 ' . $context . ' rebuild' + ); + $this->execute_duckdb_query( + 'DROP TABLE ' . $this->connection->quote_identifier( $table_name ), + '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 ' + . $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 ' . $context . ' rebuild' + ); + $this->execute_duckdb_query( + 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $backup_table ), + 'Failed to drop DuckDB ' . $context . ' rebuild backup table' + ); + $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. + * + * @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' => $index_definition['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, bool $temporary = false ): array { + return array_map( + function ( array $row ): string { + return (string) $row[4]; + }, + $this->primary_key_index_rows( $table_name, $temporary ) + ); + } + + /** + * Return primary key columns from stored MySQL column metadata. + * + * @param array> $metadata_rows Column metadata rows. + * @return string[] Primary key columns in table order. + */ + private function primary_key_columns_from_column_metadata_rows( array $metadata_rows ): array { + $columns = array(); + foreach ( $metadata_rows as $row ) { + if ( + isset( $row['column_name'] ) + && isset( $row['column_key'] ) + && 'PRI' === strtoupper( (string) $row['column_key'] ) + ) { + $columns[] = (string) $row['column_name']; + } + } + + return $columns; + } + + /** + * 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_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(); + } + + $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(); + } + ); + } + + /** + * 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. + * + * @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, 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::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; + + 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 ) { + 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, $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(); + } + ); + } + + /** + * 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, $temporary ) ) === 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. + * + * @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 ); + 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 ); + }; + + 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.' ); + } + + 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, $temporary ) as $index_row ) { + 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'], $column_name ) ) { + return true; + } + + 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, $temporary ); + $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, bool $temporary = false ): array { + return array_values( + array_filter( + $this->primary_key_columns_for_table( $table_name, $temporary ), + 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; + } + + /** + * 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 { + $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; + } + + /** + * 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, + $index_definition['index_type'] ?? 'BTREE' + ); + } + + 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. + * @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, bool $allow_auto_increment_rebuild = 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, $temporary ) as $index_row ) { + if ( 0 === strcasecmp( (string) $index_row[4], $current_column_name ) ) { + 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; + } + } + + 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.' ); + } + } + + /** + * 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, + $index_definition['index_type'] ?? 'BTREE' + ); + } + + 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 $temporary = false ): bool { + foreach ( $this->primary_key_index_rows( $table_name, $temporary ) 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; + } + + /** + * 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. + * + * @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. + * + * @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_column_metadata_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', + $temporary + ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); + } + + /** + * 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_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', + $temporary + ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); + } + + /** + * 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, 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 ); + + 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, bool $temporary = false ): 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]; + } + + 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.' ); + } + 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' + ); + + if ( 'NO' === $metadata['is_nullable'] ) { + $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' + ); + }, + $temporary + ); + } + + 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, $temporary ); + + 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, + 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 ( ! $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']; + $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, $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 ); + $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; + $targets_key_column = $this->alter_table_change_modify_column_targets_key( $table_name, $current_column_name, $index_definitions, $temporary ); + + 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, + $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.' ); + } + 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 ); + } + + /** + * 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, $temporary ); + $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. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @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] ) + && ( + 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 ( + 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 definition metadata is not supported.' ); + } + + 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 + && WP_MySQL_Lexer::STATUS_SYMBOL === $tokens[2]->id + ) { + return $this->execute_show_table_status( $tokens ); + } + + 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 ) + ) { + return $this->execute_show_index( $tokens ); + } + + 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; + } + + 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; + 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. + * + * @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.' ); + } + + $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( $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 ); + if ( null === $table_reference ) { + 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( $table_reference['table_name'], $table_name, $table_reference['temporary'] ), + ), + ) + ); + } + + /** + * 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 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, ?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'], + ); + $column_rows = $this->show_create_table_column_rows( $table_name, $temporary ); + $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, $temporary ) as $index_group ) { + $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 ); + } + + $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 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. + * + * @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'] . ')'; + if ( ! $this->is_check_constraint_enforced( $check_constraint ) ) { + $sql .= ' /*!80016 NOT ENFORCED */'; + } + + return $sql; + } + + /** + * 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->current_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 = ''; + $bounded_like_table_status_names = 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 TABLE STATUS LIKE requires a string pattern in the DuckDB driver.' ); + } + $like_pattern = $tokens[ $index + 1 ]->get_value(); + $condition = ' AND ' . $this->connection->quote_identifier( 'Name' ) + . ' LIKE ' + . $this->connection->quote( $like_pattern ) + . ' ESCAPE ' + . $this->connection->quote( '\\' ); + if ( 0 === strcasecmp( $database, $this->database ) ) { + $bounded_like_table_status_names = $this->bounded_show_table_status_like_table_names( $like_pattern ); + } + $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( + $bounded_like_table_status_names + ); + + $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. + * + * @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; + + $requested_reference = $this->parse_metadata_table_reference( $tokens, $index, true ); + $index = $requested_reference['next_index']; + + 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" ); + } + + $table_reference = $this->resolve_visible_user_table_reference( $requested_reference['table_name'] ); + if ( null === $table_reference ) { + 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 ) { + return $this->execute_static_show_metadata_statement( + array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ), + $this->describe_column_rows( $table_reference['table_name'], $table_reference['temporary'] ), + 'Field', + $tokens, + $index, + 'SHOW COLUMNS' + ); + } + + 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->current_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 array( + 'database' => $database, + 'table_name' => $table_name, + 'database_explicit' => $database_explicit, + 'next_index' => $index, + ); + } + + /** + * Execute SHOW [FULL] TABLES. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return WP_DuckDB_Result_Statement + */ + 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->current_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 ); + } + + /** + * 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 ( + ! 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.' ); + } + + $requested_reference = $this->parse_metadata_table_reference( $tokens, 3, true ); + $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 ) ) { + $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'] ); + } + + 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( + $columns, + $rows + ); + } + + /** + * Build SHOW INDEX-compatible rows for all indexes on a table. + * + * @param string $table_name Table name. + * @param bool $temporary Whether to inspect the temporary metadata table. + * @return array> + */ + private function index_rows_for_table( string $table_name, bool $temporary = false ): array { + $rows = array_merge( + $this->primary_key_index_rows( $table_name, $temporary ), + $this->secondary_index_rows( $table_name, $temporary ) + ); + + return $this->show_index_rows_with_column_metadata( $table_name, $temporary, $rows ); + } + + /** + * Fill SHOW INDEX fields that depend on column metadata. + * + * @param string $table_name Table name. + * @param bool $temporary Whether to inspect the temporary metadata table. + * @param array> $rows SHOW INDEX rows. + * @return array> + */ + private function show_index_rows_with_column_metadata( string $table_name, bool $temporary, array $rows ): array { + if ( count( $rows ) === 0 ) { + return $rows; + } + + $nullable_by_column = $this->statistics_nullable_by_column( $table_name, $temporary ); + foreach ( $rows as &$row ) { + $key_name = (string) $row[2]; + $column_name = null === $row[4] ? null : (string) $row[4]; + + $row[6] = 0; + $row[9] = ''; + if ( 'PRIMARY' !== $key_name && null !== $column_name ) { + $row[9] = $nullable_by_column[ strtolower( $column_name ) ] ?? ''; + } + } + unset( $row ); + + return $rows; + } + + /** + * 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, 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 ); + } + + 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, bool $temporary = false ): array { + $groups = array(); + + 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( + '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 { + $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 ) . ')'; + } + + 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. + * + * @param string $table_name Table name. + * @return array> + */ + private function primary_key_index_rows( string $table_name, bool $temporary = false ): array { + $cache_key = $this->metadata_table_cache_key( $table_name, $temporary ); + if ( isset( $this->primary_key_index_rows_cache[ $cache_key ] ) ) { + return $this->primary_key_index_rows_cache[ $cache_key ]; + } + + $rows = $this->primary_key_index_rows_from_constraints( $table_name, $temporary ); + if ( null !== $rows ) { + $this->primary_key_index_rows_cache[ $cache_key ] = $rows; + return $rows; + } + + $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' + ); + + $rows = array(); + foreach ( $pragma->fetchAll( PDO::FETCH_ASSOC ) as $offset => $row ) { + $rows[] = $this->show_index_row( + $table_name, + 0, + 'PRIMARY', + $offset + 1, + (string) $row['name'], + null + ); + } + + $this->primary_key_index_rows_cache[ $cache_key ] = $rows; + return $rows; + } + + /** + * Build SHOW INDEX rows for the primary key from DuckDB constraints metadata. + * + * @param string $table_name Table name. + * @return array>|null Rows, or null when the catalog source is unavailable. + */ + private function primary_key_index_rows_from_constraints( string $table_name, bool $temporary = false ): ?array { + try { + $stmt = $this->execute_duckdb_query( + 'SELECT constraint_column_names FROM duckdb_constraints() WHERE table_name = ' + . $this->connection->quote( $table_name ) + . " AND constraint_type = 'PRIMARY KEY' AND database_name " + . ( $temporary ? '= ' : '<> ' ) + . $this->connection->quote( 'temp' ) + . ' ORDER BY constraint_index LIMIT 1', + 'Failed to inspect DuckDB primary key constraints' + ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + return null; + } + + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( false === $row ) { + return array(); + } + + $column_names = $row['constraint_column_names'] ?? null; + if ( ! is_array( $column_names ) ) { + return null; + } + + $rows = array(); + foreach ( $column_names as $offset => $column_name ) { + $rows[] = $this->show_index_row( + $table_name, + 0, + 'PRIMARY', + $offset + 1, + (string) $column_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, 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, index_type FROM ' + . $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', + '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'], + null === $row['index_type'] ? 'BTREE' : (string) $row['index_type'] + ); + } + + 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. + * @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, string $index_type = 'BTREE' ): array { + return array( + $table_name, + $non_unique, + $key_name, + $seq_in_index, + $column_name, + 'A', + null, + $sub_part, + null, + '', + $index_type, + '', + '', + '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 { + $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( $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|null $database Explicit requested database, or null when unqualified. + * @return 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 . '.' ); + } + + 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, 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 { + 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' + ); + $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 $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, 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 { + 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, $temporary ) 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. + * + * @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 ); + } + + /** + * Return a bounded SHOW TABLE STATUS LIKE table-name set when safe. + * + * @param string $pattern LIKE pattern. + * @return string[]|null Zero or one persistent table names, or null when ambiguous. + */ + private function bounded_show_table_status_like_table_names( string $pattern ): ?array { + if ( false !== strpbrk( $pattern, '%\\' ) ) { + return null; + } + + $table_names = array(); + foreach ( $this->user_table_names() as $table_name ) { + if ( $this->mysql_like_matches( $table_name, $pattern ) ) { + $table_names[] = $table_name; + if ( count( $table_names ) > 1 ) { + return null; + } + } + } + + return $table_names; + } + + /** + * 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.' ); + } + + /** + * 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 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. + * @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, ?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; + + $type_index = $index; + $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, $type_index ); + + $not_null = false; + $primary_key = false; + $unique_key = false; + $auto_increment = false; + $default_sql = null; + $default_mysql = 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 ]; + 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; + 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.' ); + $primary_key = true; + $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; + ++$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::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 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported CHARACTER column option in DuckDB driver.' ); + } + $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 ); + if ( null !== $check['sql'] ) { + $constraints[] = $check['sql']; + } + $checks[] = $check['metadata']; + break; + case WP_MySQL_Lexer::REFERENCES_SYMBOL: + 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() . '.' ); + } + } + + 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.' ); + } + + 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'; + } + + $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; + + 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 ); + $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; + } + + if ( $not_null && $include_inline_constraints ) { + $column_sql .= ' NOT NULL'; + } + if ( $primary_key && $include_inline_constraints ) { + $column_sql .= ' PRIMARY KEY'; + } + + $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, + ), + ), + $temporary + ); + } + + $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 || 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, + '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, $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|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.' ); + ++$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; + $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 + ) { + $enforced = 'NO'; + $index += 2; + } + + if ( isset( $tokens[ $index ] ) && 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 ); + + $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' => $enforced, + ), + ); + } + + /** + * 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, + ), + ); + } + + /** + * 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 int $type_index Index of the type token. + * @return int New index. + */ + 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 ) { + $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; + } + + /** + * 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 { + $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 ) { + $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 $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::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' ); + + 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' ); + + 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)' + ); + + 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; + + 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; + } + + /** + * 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. + * + * @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; + } + + /** + * 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 $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->normalize_mysql_metadata_identifier( + $this->option_value( $tokens, $index + 1 ), + 'column COLLATE' + ); + } + } + + 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. + * + * @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 ); + } + + /** + * 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, + 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, + ), + 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 + ); + } + + /** + * 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; + } + + $has_non_unique_index = false; + foreach ( $indexes as $index_definition ) { + if ( count( $index_definition['columns'] ) === 0 ) { + continue; + } + if ( strtolower( $index_definition['columns'][0]['name'] ) !== $column_name ) { + continue; + } + + 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 ); + + 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|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; + $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; + $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 + ) { + $enforced = 'NO'; + $index += 2; + } + + if ( isset( $tokens[ $index ] ) && 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 ); + + $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' => $enforced, + ), + ); + } + + /** + * 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; + } + + /** + * 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. + * + * @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 + ) + ); + } + + /** + * 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. + * + * @param WP_Parser_Token[] $tokens Constraint tokens. + * @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.' ); + $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->identifier_value( $tokens[ $index ] ); + ++$index; + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + } + } + + if ( count( $tokens ) !== $index ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported PRIMARY KEY constraint in DuckDB driver.' ); + } + + return $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 + ); + } + + /** + * 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. + * + * @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,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_type = 'BTREE'; + + if ( WP_MySQL_Lexer::FULLTEXT_SYMBOL === $tokens[0]->id || WP_MySQL_Lexer::SPATIAL_SYMBOL === $tokens[0]->id ) { + $index_type = strtoupper( $tokens[0]->get_bytes() ); + ++$index; + } + + 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 $this->build_secondary_index_definition( $table_name, $mysql_index_name, $unique, $columns, $column_metadata, $temporary, $index_type ); + } + + /** + * 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. + * @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, string $index_type = 'BTREE' ): array { + $index_type = strtoupper( $index_type ); + + return array( + 'sql' => 'CREATE ' + . ( $unique ? 'UNIQUE ' : '' ) + . 'INDEX IF NOT EXISTS ' + . $this->connection->quote_identifier( $this->index_name( $table_name, $mysql_index_name, $temporary ) ) + . ' ON ' + . $this->connection->quote_identifier( $table_name ) + . ' (' + . implode( ', ', $columns ) + . ')', + 'table_name' => $table_name, + 'index_name' => $mysql_index_name, + 'unique' => $unique, + 'temporary' => $temporary, + 'index_type' => $index_type, + '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() . '.' ); + } + } + + /** + * 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,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'; + $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 ) { + ++$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 = $this->normalize_mysql_metadata_identifier( + $this->option_value( $tokens, $index + 1 ), + 'CREATE TABLE COLLATE' + ); + $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::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; + } + + 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() . '.' ); + } + $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, + 'column_default_collation' => $column_default_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. + * + * @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; + } + + /** + * 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( $this->token_value( $token ) ); + } + 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.' ); + } + + /** + * 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; + } + + /** + * 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. + * @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. + * + * @param WP_Parser_Token[] $tokens MySQL tokens. + * @return string DuckDB SQL. + */ + 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_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, + array $seeded_rand_rewrites = array(), + bool $rewrite_text_value_numeric_literal_comparisons = false, + ?array $group_by_expansion = null, + array $order_by_item_rewrites = array() + ): 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 ( 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; + } + + $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 += $information_schema_reference_length - 1; + continue; + } + $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 += $information_schema_reference_length - 1; + continue; + } + $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 += $information_schema_reference_length - 1; + continue; + } + $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 += $information_schema_reference_length - 1; + continue; + } + $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 += $information_schema_reference_length - 1; + continue; + } + $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 += $information_schema_reference_length - 1; + continue; + } + $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 += $information_schema_reference_length - 1; + continue; + } + + if ( + WP_MySQL_Lexer::FROM_SYMBOL === $token->id + && isset( $tokens[ $index + 1 ] ) + && WP_MySQL_Lexer::DUAL_SYMBOL === $tokens[ $index + 1 ]->id + ) { + ++$index; + 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, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + if ( null !== $field_function ) { + $pieces[] = $field_function; + 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, + $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; + } + + $sum_function = $this->translate_fetchable_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 !== $sum_function ) { + $pieces[] = $sum_function; + continue; + } + + if ( + WP_MySQL_Lexer::NOT_SYMBOL === $token->id + && isset( $tokens[ $index + 1 ], $tokens[ $index + 2 ] ) + && $this->is_regexp_operator( $tokens[ $index + 1 ] ) + ) { + $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; + } + + if ( + $this->is_regexp_operator( $token ) + && isset( $tokens[ $index + 1 ] ) + ) { + $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; + } + + if ( $this->is_empty_function_call( $tokens, $index, 'DATABASE' ) ) { + $pieces[] = $this->connection->quote( $this->current_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, '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; + 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; + continue; + } + + $coalesce_date_text_numeric_comparison = $this->translate_coalesce_date_text_numeric_literal_comparison( + $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 !== $coalesce_date_text_numeric_comparison ) { + $pieces[] = $coalesce_date_text_numeric_comparison; + continue; + } + + $date_text_numeric_comparison = $this->translate_date_text_numeric_literal_comparison( + $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_text_numeric_comparison ) { + $pieces[] = $date_text_numeric_comparison; + continue; + } + + $date_part_string_comparison = $this->translate_date_part_string_literal_comparison( + $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_part_string_comparison ) { + $pieces[] = $date_part_string_comparison; + 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, + $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_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, + $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; + } + + $text_value_arithmetic = $this->translate_text_value_numeric_arithmetic_expression( $tokens, $index ); + if ( null !== $text_value_arithmetic ) { + $pieces[] = $text_value_arithmetic; + continue; + } + + $numeric_string_comparison = $this->translate_numeric_identifier_string_literal_comparison( $tokens, $index ); + if ( null !== $numeric_string_comparison ) { + $pieces[] = $numeric_string_comparison; + continue; + } + + $numeric_string_between_predicate = $this->translate_numeric_identifier_string_literal_between_predicate( $tokens, $index ); + if ( null !== $numeric_string_between_predicate ) { + $pieces[] = $numeric_string_between_predicate; + continue; + } + + $numeric_string_in_predicate = $this->translate_numeric_identifier_string_literal_in_predicate( $tokens, $index ); + if ( null !== $numeric_string_in_predicate ) { + $pieces[] = $numeric_string_in_predicate; + continue; + } + + $numeric_identifier_like_predicate = $this->translate_numeric_identifier_like_predicate( $tokens, $index ); + if ( null !== $numeric_identifier_like_predicate ) { + $pieces[] = $numeric_identifier_like_predicate; + continue; + } + + $unix_timestamp_comparison = $this->translate_unix_timestamp_comparison( $tokens, $index ); + if ( null !== $unix_timestamp_comparison ) { + $pieces[] = $unix_timestamp_comparison; + continue; + } + + $datetime_iso_comparison = $this->translate_wordpress_datetime_iso_literal_comparison( $tokens, $index ); + if ( null !== $datetime_iso_comparison ) { + $pieces[] = $datetime_iso_comparison; + continue; + } + + 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; + } + } + + $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 ) { + $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( $this->token_value( $token ) ); + } + if ( null === $identifier && $rewrite_information_schema_table_constraints ) { + $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( $this->token_value( $token ) ); + } + if ( null === $identifier && $rewrite_information_schema_referential_constraints ) { + $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( $this->token_value( $token ) ); + } + $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( $this->token_value( $token ) ); + 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 ) { + $pieces[] = $this->connection->quote_identifier( $identifier ); + continue; + } + } + + 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; + } + } + + 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(); + } + + return $this->join_sql_pieces( $pieces ); + } + + /** + * 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 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. + * + * @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 locking clauses. + */ + private function strip_select_locking_clauses( 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 ( + WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $index ]->id + && isset( $tokens[ $index + 1 ] ) + && ( WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $index + 1 ]->id || WP_MySQL_Lexer::SHARE_SYMBOL === $tokens[ $index + 1 ]->id ) + ) { + $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 ]; + } + + 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, + 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(), '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, + $rewrite_information_schema_referential_constraints, + $rewrite_information_schema_check_constraints + ); + + $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, + $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; + } + $cases[] = 'ELSE 0 END'; + + $index = $next_index - 1; + 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. + * + * 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 SUM() to a PHP-client-fetchable result type. + * + * DuckDB promotes some integer SUM() results to HUGEINT, which the PHP + * client refuses without bcmath. MySQL/wpdb exposes ordinary plugin report + * sums as fetchable scalars, so keep the SQL numeric while avoiding HUGEINT. + * + * @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(). + */ + private function translate_fetchable_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 ( + ! 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 ) ) { + 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 DOUBLE)'; + } + + /** + * 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; + } + + 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 ) ); + } + + /** + * 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; + } + + 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 ) ); + } + + /** + * 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 $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $table_index, true, 'INSERT', null, true ); + } + + /** + * 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 ) ); + } + + /** + * 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. + * @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_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 ) + || $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'], + $this->translate_insert_select_tokens_to_duckdb_sql( $tokens, $table_index, $ignore ), + 'Failed to execute DuckDB INSERT', + $tokens, + $table_index + ); + } + + $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', + null === $text_blob_write_plan ? array() : $text_blob_write_plan['precoerced_offsets'] + ); + $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'], + '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 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_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'] ); + $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 ) + || $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'], + $this->translate_replace_tokens_to_duckdb_sql( $tokens ), + 'Failed to execute DuckDB REPLACE', + $tokens, + $table_index + ); + } + + $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', + null === $text_blob_write_plan ? array() : $text_blob_write_plan['precoerced_offsets'] + ); + $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'] ); + $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 ' + . $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 { + if ( null !== $write_stage ) { + $this->drop_select_write_stage( $write_stage['table_name'] ); + } + $this->drop_select_write_stage( $stage['table_name'] ); + } + } + + /** + * 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 ( '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 ); + } + + 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. + * + * @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_implicit_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 ), + ); + } + + /** + * Check whether REPLACE ... SELECT needs manual MySQL conflict handling. + * + * @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 $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 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 { + $deleted_rows = 0; + $delete_predicate = $this->replace_select_manual_delete_predicate( + $shape['table_name'], + $write_stage_table, + $unique_sets, + $case_insensitive_columns + ); + + if ( '' !== $delete_predicate ) { + $delete_result = $this->execute_duckdb_query( + 'DELETE FROM ' + . $this->connection->quote_identifier( $shape['table_name'] ) + . ' WHERE ' + . $delete_predicate, + 'Failed to delete DuckDB REPLACE SELECT conflicts' + ); + $deleted_rows = $delete_result->rowCount(); + } + + $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(); + } + + if ( 0 === $deleted_rows ) { + return $result; + } + + return new WP_DuckDB_Result_Statement( array(), array(), $result->rowCount() + $deleted_rows ); + } catch ( Throwable $e ) { + if ( $started_transaction && $this->connection->inTransaction() ) { + $this->connection->rollback(); + $this->clear_schema_state_after_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 ); + } + + /** + * 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; + } + + if ( null === $select_items ) { + return true; + } + + return true; + } + + return false; + } + + /** + * Check whether a simple SELECT item reads an integer-backed source column. + * + * @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_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; + } + + 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; + } + + 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 + ); + } + + /** + * 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. + * @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 $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.' ); + } + + $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->write_validation_for_column( $metadata, array(), $source_sql ); + if ( null !== $validation ) { + $validations[] = $validation; + } + + $columns[] = $column_name; + $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 ) { + $columns[] = $default_write['column_name']; + $expressions[] = $default_write['value_sql']; + } + + return array( + 'columns' => $columns, + 'expressions' => $expressions, + 'validations' => $validations, + ); + } + + /** + * Validate 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_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'] ) { + $error_message = $validation['error_message'] ?? ( 'Incorrect ' . $validation['data_type'] . ' value' ); + throw new WP_DuckDB_Driver_Exception( + $error_message . ": '" . (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 ); + } + + /** + * 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. + * + * @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. + * @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, 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 ' + . $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. + * + * @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 { + $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']; + $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( + $metadata_map[ strtolower( $assignment['column_name'] ) ], + $assignment['value_tokens'], + $value_sql, + false + ); + } + $values[] = $value_sql; + } + + 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']; + } + + return 'INSERT ' + . ( $ignore ? 'OR IGNORE ' : '' ) + . 'INTO ' + . $this->connection->quote_identifier( $reference['table_name'] ) + . ' (' + . implode( ', ', $columns ) + . ') VALUES (' + . implode( ', ', $values ) + . ')'; + } + + /** + * Parse INSERT ... SET assignments. + * + * @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 &$seeded_rand_state ): array { + $assignments = array(); + $index = 0; + + while ( $index < count( $tokens ) ) { + $column_name = $this->identifier_value( $tokens[ $index ] ?? null ); + $column_sql = $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 . '.' ); + } + + $assignments[] = array( + 'column_name' => $column_name, + 'column_sql' => $column_sql, + 'value_tokens' => $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; + } + } + + if ( count( $assignments ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... SET statement in DuckDB driver. At least one assignment is required.' ); + } + + 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. + * + * @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{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 ): array { + $insert_shape = $this->parse_on_duplicate_insert_shape( $tokens, $table_index, $on_duplicate_index ); + $target = $this->select_on_duplicate_conflict_target_for_rows( $insert_shape['table_name'], $insert_shape['temporary'], $insert_shape['rows'] ); + $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'] ), + $insert_shape['table_name'], + $insert_shape['requested_table_name'] + ); + + if ( '' === $update_sql ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. UPDATE list is required.' ); + } + + $sql = $this->translate_insert_values_tokens_to_duckdb_sql( $tokens, $table_index, false, 'INSERT', $on_duplicate_index ) + . ' ON CONFLICT (' + . implode( + ', ', + array_map( + function ( string $column_name ): string { + return $this->connection->quote_identifier( $column_name ); + }, + $target['columns'] + ) + ) + . ') DO UPDATE SET ' + . $update_sql; + + return array( + 'sql' => $sql, + 'matched' => $target['matched'], + ); + } + + /** + * 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,requested_table_name:string,temporary:bool,rows: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 ); + $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.' ); + + $shape = $this->parse_insert_values_write_shape( $tokens, $table_index, $on_duplicate_index, true ); + if ( count( $shape['columns'] ) === 0 || count( $shape['rows'] ) === 0 ) { + throw new WP_DuckDB_Driver_Exception( 'Unsupported INSERT ... ON DUPLICATE KEY UPDATE statement in DuckDB driver. Only INSERT ... VALUES is supported.' ); + } + + return array( + 'table_name' => $shape['table_name'], + 'requested_table_name' => $requested_table_name, + 'temporary' => $shape['temporary'], + 'rows' => $shape['rows'], + ); + } + + /** + * 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, true, true ); + $unique_column_sets = $this->unique_key_column_sets( $insert_shape['table_name'], $insert_shape['temporary'] ); + $candidate_row_sets = array(); + + foreach ( $insert_shape['rows'] as $row_index => $values_by_column ) { + foreach ( $unique_column_sets 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 ) { + $candidate_row_sets[ $row_index ][] = $column_set; + } + } + } + + if ( count( $candidate_row_sets ) === 0 ) { + return; + } + + $case_insensitive_columns = $this->case_insensitive_column_names( $insert_shape['table_name'], $insert_shape['temporary'] ); + if ( count( $case_insensitive_columns ) === 0 ) { + return; + } + + foreach ( $candidate_row_sets as $row_index => $column_sets ) { + $values_by_column = $insert_shape['rows'][ $row_index ]; + foreach ( $column_sets as $column_set ) { + $has_case_insensitive_key = false; + foreach ( $column_set as $column_name ) { + $column_key = strtolower( $column_name ); + if ( isset( $case_insensitive_columns[ $column_key ] ) ) { + $has_case_insensitive_key = true; + break; + } + } + + if ( ! $has_case_insensitive_key ) { + continue; + } + + 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'] + . '.' + . 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. + * @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, 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'], + 'temporary' => $shape['temporary'], + '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. + * @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,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 ) { + $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 $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 $this->empty_insert_values_write_shape( $table_name, $temporary ); + } + $columns[] = (string) $row['column_name']; + } + } + + $omitted_defaults = $coerce_for_storage + ? $this->omitted_non_strict_implicit_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 $this->empty_insert_values_write_shape( $table_name, $temporary ); + } + ++$index; + + $rows = array(); + $ordered_rows = array(); + $coerced_value_rows = array(); + $write_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 $this->empty_insert_values_write_shape( $table_name, $temporary ); + } + + $values_by_column = array(); + $ordered_row = array(); + $coerced_values = array(); + $validation_values = array(); + foreach ( $columns as $offset => $column_name ) { + $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->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; + } + 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; + $write_validation_rows[] = $validation_values; + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + continue; + } + break; + } + + return array( + 'table_name' => $table_name, + 'temporary' => $temporary, + 'columns' => $storage_columns, + 'rows' => $rows, + 'ordered_rows' => $ordered_rows, + 'coerced_value_rows' => $coerced_value_rows, + 'write_validation_rows' => $write_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,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( + 'table_name' => $table_name, + 'temporary' => $temporary, + 'columns' => array(), + 'rows' => array(), + 'ordered_rows' => array(), + 'coerced_value_rows' => array(), + 'write_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 { + 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 ( + ! 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 ' . $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 ' . $context . ' 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. + * + * @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 { + $this->assert_unqualified_write_allowed_in_current_database(); + + $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; + } + + /** + * 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_implicit_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 ); + $implicit_default = $this->non_strict_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 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_default_value_tokens( $value_tokens ) ) { + return $value_sql; + } + + if ( $this->is_character_write_data_type( $data_type ) ) { + $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 ) ) { + 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 ) ) { + 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 + && 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; + } + + $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; + } + + /** + * 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', 'json', 'enum', 'set' ), 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( 'binary', 'varbinary', '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 + ); + } + + /** + * 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. + * + * @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)'; + } + + /** + * 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'; + } + + /** + * 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. + * + * @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 'bit': + return 'BIGINT'; + 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 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( $this->token_value( $token ) ); + } + + 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. + * + * @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 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. + * + * @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 ), + ); + } + + /** + * 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. + * + * @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; + } + + /** + * 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. + * + * @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( $this->token_value( $token ) ); + } + 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 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. + * + * @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]{1,2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?)|(T[0-9]{2}:[0-9]{2}:[0-9]{2}Z))?$'; + } + + 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' ); + } + + /** + * Count existing target rows that a REPLACE ... VALUES statement will delete. + * + * @param array{table_name:string,temporary:bool,rows:array>} $replace_shape Parsed VALUES shape. + * @return int Existing target rows matched by supplied unique keys. + */ + private function replace_values_existing_conflict_count( array $replace_shape ): int { + if ( count( $replace_shape['rows'] ) === 0 ) { + return 0; + } + + $unique_sets = $this->unique_key_column_sets( $replace_shape['table_name'], $replace_shape['temporary'] ); + if ( count( $unique_sets ) === 0 ) { + return 0; + } + + $case_insensitive_columns = $this->case_insensitive_column_names( $replace_shape['table_name'], $replace_shape['temporary'] ); + $delete_predicates = array(); + foreach ( $replace_shape['rows'] as $values_by_column ) { + 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 ) { + return 0; + } + + $stmt = $this->execute_duckdb_query( + 'SELECT COUNT(DISTINCT rowid) AS deleted_rows FROM ' + . $this->connection->quote_identifier( $replace_shape['table_name'] ) + . ' WHERE ' + . implode( ' OR ', $delete_predicates ), + 'Failed to inspect DuckDB REPLACE conflicts' + ); + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + + return false === $row ? 0 : (int) $row['deleted_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_write_shape( $tokens, $table_index, null, true ); + $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'], $replace_shape['temporary'] ); + if ( count( $unique_sets ) < 2 && ! $this->has_case_insensitive_unique_key( $unique_sets, $case_insensitive_columns ) ) { + 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(); + $started_transaction = true; + } + + try { + $deleted_rows = 0; + 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 ) { + $delete_result = $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' + ); + $deleted_rows += $delete_result->rowCount(); + } + } + + $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(); + } + + if ( 0 === $deleted_rows ) { + return $result; + } + + return new WP_DuckDB_Result_Statement( array(), array(), $result->rowCount() + $deleted_rows ); + } catch ( Throwable $e ) { + if ( $started_transaction && $this->connection->inTransaction() ) { + $this->connection->rollback(); + $this->clear_schema_state_after_rollback(); + } + throw $e; + } + } + + /** + * Evaluate INSERT/REPLACE VALUES validations before side-effecting emulation. + * + * @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['write_validation_rows'] ) ) { + return; + } + + foreach ( $shape['write_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'] ) { + $error_message = $validation['error_message'] ?? ( 'Incorrect ' . $validation['data_type'] . ' value' ); + throw new WP_DuckDB_Driver_Exception( + $error_message . ": '" . (string) $result['invalid_display'] . "'" + ); + } + } + } + } + + /** + * 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 can emulate all inserted rows. + * + * DuckDB needs one ON CONFLICT target for the whole statement. MySQL can + * resolve different rows against different unique keys, so this only accepts + * multi-row ODKU statements that fit one eligible unique target. + * + * @param string $table_name Table name. + * @param bool $temporary Whether the target is a temporary table. + * @param array> $rows Inserted rows keyed by lowercase column name. + * @return array{columns:string[],matched:bool} Conflict target columns and whether an existing row matched. + */ + private function select_on_duplicate_conflict_target_for_rows( string $table_name, bool $temporary, array $rows ): array { + $eligible_targets = array(); + + foreach ( $this->unique_key_column_sets( $table_name, $temporary ) as $column_set ) { + $has_all_values = true; + foreach ( $rows as $values_by_column ) { + foreach ( $column_set as $column_name ) { + if ( ! array_key_exists( strtolower( $column_name ), $values_by_column ) ) { + $has_all_values = false; + break 2; + } + } + } + + if ( ! $has_all_values ) { + continue; + } + + $eligible_targets[] = $column_set; + } + + if ( count( $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.' ); + } + + $matched_targets = array(); + foreach ( $rows as $values_by_column ) { + $row_matched_targets = array(); + foreach ( $eligible_targets as $column_set ) { + if ( $this->insert_values_conflict_with_target( $table_name, $temporary, $column_set, $values_by_column ) ) { + $row_matched_targets[] = $column_set; + } + } + + if ( count( $row_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 ( 1 === count( $row_matched_targets ) ) { + $matched_targets[ $this->unique_key_column_set_key( $row_matched_targets[0] ) ] = $row_matched_targets[0]; + } + } + + if ( 1 === count( $matched_targets ) ) { + $matched_targets = array_values( $matched_targets ); + 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.' ); + } + + return array( + 'columns' => $eligible_targets[0], + 'matched' => false, + ); + } + + /** + * Build a stable key for a unique key column set. + * + * @param string[] $column_set Unique key column set. + * @return string Stable key. + */ + private function unique_key_column_set_key( array $column_set ): string { + return implode( "\0", array_map( 'strtolower', $column_set ) ); + } + + /** + * 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, 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(); + $metadata_rows = $this->table_column_metadata_rows( $table_name, $temporary ); + $metadata_primary = $this->primary_key_columns_from_column_metadata_rows( $metadata_rows ); + $primary = 1 === count( $metadata_primary ) ? $metadata_primary : array(); + if ( count( $primary ) === 0 && ( count( $metadata_rows ) === 0 || count( $metadata_primary ) > 1 ) ) { + $primary = $this->primary_key_columns_for_table( $table_name, $temporary ); + } + if ( count( $primary ) > 0 ) { + $sets[] = $primary; + } + + $has_secondary_unique_key = false; + foreach ( $metadata_rows as $row ) { + if ( + isset( $row['column_key'] ) + && 'UNI' === strtoupper( (string) $row['column_key'] ) + ) { + $has_secondary_unique_key = true; + break; + } + } + + if ( ! $has_secondary_unique_key ) { + $this->unique_key_column_sets_cache[ $cache_key ] = $sets; + return $sets; + } + + $this->ensure_index_metadata_table( $temporary ); + $secondary = $this->execute_duckdb_query( + 'SELECT index_name, column_name FROM ' + . $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', + '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; + } + + $this->unique_key_column_sets_cache[ $cache_key ] = $sets; + return $sets; + } + + /** + * 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, bool $temporary, array $column_set, array $values_by_column ): bool { + $where = array(); + $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 ) ] ) ) { + $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 + . ')'; + } + + $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 ); + } + + /** + * 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, bool $temporary = false ): array { + $columns = array(); + $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; + } + $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. + * + * @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 $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 ); + 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.' ); + } + + $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, + $right_tokens, + $value_sql, + false + ); + } + + $items[] = $target['sql'] . ' = ' . $value_sql; + } + + return implode( ', ', $items ); + } + + /** + * Resolve an ODKU assignment target and strip target-table qualifiers. + * + * @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 on_duplicate_assignment_target( + array $left_tokens, + string $target_table_name, + string $requested_table_name + ): array { + if ( 1 === count( $left_tokens ) ) { + $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 + ) { + $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.' ); + } + + /** + * 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 ) ); + } + + /** + * 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_value_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. + */ + private function initialize_session_macros(): void { + try { + $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 ); + } + } + + /** + * 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. + * @param bool $binary Whether this is REGEXP BINARY. + * @return 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_expression = $this->pop_regexp_left_expression( $pieces ); + if ( ! $this->regexp_literal_pattern_is_valid( $pattern, $binary ) ) { + return $this->sqlite_invalid_regexp_literal_predicate_sql( $left_expression, $negated ); + } + + $left = 'COALESCE(CAST((' . $left_expression . ") AS VARCHAR), '')"; + $pattern_sql = 'COALESCE(CAST((' . $this->translate_token_to_duckdb_sql( $pattern ) . ") AS VARCHAR), '')"; + $predicate = $binary + ? sprintf( 'regexp_matches(%s, %s)', $left, $pattern_sql ) + : sprintf( "regexp_matches(%s, %s, 'i')", $left, $pattern_sql ); + + return $negated ? 'NOT ' . $predicate : $predicate; + } + + /** + * Build SQLite-compatible SQL for an invalid literal REGEXP pattern. + * + * @param string $left_expression Left-hand expression SQL. + * @param bool $negated Whether this is NOT REGEXP. + * @return string DuckDB SQL. + */ + private function sqlite_invalid_regexp_literal_predicate_sql( string $left_expression, bool $negated ): string { + $result = $negated ? 'TRUE' : 'FALSE'; + return '(CASE WHEN COALESCE(CAST((' . $left_expression . ") AS VARCHAR), '') IS NULL THEN " + . $result + . ' ELSE ' + . $result + . ' END)'; + } + + /** + * Check whether a literal REGEXP pattern is valid for SQLite's PHP matcher. + * + * Dynamic patterns cannot be prevalidated at translation time and stay on the + * normal DuckDB regexp_matches() path. + * + * @param WP_Parser_Token $pattern Pattern token. + * @param bool $binary Whether this is REGEXP BINARY. + * @return bool Whether the pattern is valid, or cannot be checked yet. + */ + private function regexp_literal_pattern_is_valid( WP_Parser_Token $pattern, bool $binary ): bool { + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $pattern->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $pattern->id + ) { + return true; + } + + $literal = $this->token_value( $pattern ); + if ( $binary ) { + $literal = "\x00" . $literal; + } + + if ( '' !== $literal && "\x00" === $literal[0] ) { + $literal = substr( $literal, 1 ); + $flags = ''; + } else { + $flags = 'i'; + } + + $php_pattern = '/' . str_replace( '/', '\/', $literal ) . '/' . $flags; + $previous_error_reporting = error_reporting( error_reporting() & ~E_WARNING & ~E_DEPRECATED ); + try { + return false !== preg_match( $php_pattern, '' ); + } finally { + error_reporting( $previous_error_reporting ); + } + } + + /** + * 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. + * + * @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( $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( $this->token_value( $token ) ); + } + + 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', '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 ) { + return null; + } + + $index = $end_index - 1; + 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 + ) + ) { + $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 ) { + if ( 2 !== count( $items ) || count( $items[0] ) === 0 || count( $items[1] ) === 0 ) { + return null; + } + + $start_sql = $translate( $items[0] ); + $end_sql = $translate( $items[1] ); + + $index = $end_index - 1; + return 'CAST(' . $this->translate_datediff_date_operand( $start_sql ) . ' - ' . $this->translate_datediff_date_operand( $end_sql ) . ' AS BIGINT)'; + } + + 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((' . $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 ) { + 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 = $translate( $items[0] ); + $value_sql = $translate( $value_tokens ); + $sign = 'DATE_SUB' === $name ? '-' : '+'; + if ( 'WEEK' === $unit ) { + $unit = 'DAY'; + $value_sql = '7 * (' . $value_sql . ')'; + } + + $index = $end_index - 1; + return 'strftime(TRY_CAST((' + . $date_sql + . ') AS TIMESTAMP) ' + . $sign + . ' CAST((' + . $value_sql + . ') AS BIGINT) * INTERVAL 1 ' + . $unit + . ", '%Y-%m-%d %H:%M:%S')"; + } + + /** + * Translate literal fallback wrappers around a SQLite-compatible date text comparison. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index, advanced on match. + * @return string|null Translated expression, or null when the pattern does not match. + */ + private function translate_coalesce_date_text_numeric_literal_comparison( + 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 { + $function_name = isset( $tokens[ $index ] ) ? strtoupper( $tokens[ $index ]->get_bytes() ) : ''; + if ( + ! isset( $tokens[ $index + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + || ( 'COALESCE' !== $function_name && 'IFNULL' !== $function_name ) + ) { + 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 ( + count( $items ) < 2 + || count( $items[0] ) === 0 + || ( 'IFNULL' === $function_name && 2 !== count( $items ) ) + ) { + return null; + } + + $comparison_index = 0; + $comparison_sql = $this->translate_date_text_numeric_literal_comparison( + $items[0], + $comparison_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, + true + ); + if ( null === $comparison_sql || count( $items[0] ) - 1 !== $comparison_index ) { + return null; + } + + $fallbacks = array(); + $has_string_literal = false; + foreach ( array_slice( $items, 1 ) as $item ) { + if ( 0 === count( $item ) ) { + return null; + } + + $fallback = $this->coalesce_date_text_numeric_literal_fallback_sql( $item ); + if ( null === $fallback ) { + return null; + } + + $fallbacks[] = $fallback; + $has_string_literal = $has_string_literal || $fallback['is_string_literal']; + } + + $index = $end_index - 1; + if ( $has_string_literal ) { + $comparison_sql = 'CAST(' . $comparison_sql . ' AS VARCHAR)'; + } + + $fallback_sql = array(); + foreach ( $fallbacks as $fallback ) { + if ( $has_string_literal && ! $fallback['is_string_literal'] && 'NULL' !== $fallback['sql'] ) { + $fallback_sql[] = 'CAST(' . $fallback['sql'] . ' AS VARCHAR)'; + continue; + } + + $fallback_sql[] = $fallback['sql']; + } + + return 'COALESCE(' . $comparison_sql . ', ' . implode( ', ', $fallback_sql ) . ')'; + } + + /** + * Translate a simple COALESCE() fallback for a date text comparison. + * + * @param WP_Parser_Token[] $tokens Fallback tokens. + * @return array{sql:string,is_string_literal:bool}|null Fallback metadata, or null when unsupported. + */ + private function coalesce_date_text_numeric_literal_fallback_sql( array $tokens ): ?array { + if ( 1 === count( $tokens ) ) { + if ( $this->is_number_token( $tokens[0] ) ) { + return array( + 'sql' => $tokens[0]->get_bytes(), + 'is_string_literal' => false, + ); + } + + if ( $this->is_string_literal_token( $tokens[0] ) ) { + return array( + 'sql' => $this->connection->quote( $this->token_value( $tokens[0] ) ), + 'is_string_literal' => true, + ); + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === $tokens[0]->id || WP_MySQL_Lexer::NULL2_SYMBOL === $tokens[0]->id ) { + return array( + 'sql' => 'NULL', + 'is_string_literal' => false, + ); + } + } + + if ( + 2 === count( $tokens ) + && $this->is_sign_token( $tokens[0] ) + && $this->is_number_token( $tokens[1] ) + ) { + return array( + 'sql' => $tokens[0]->get_bytes() . $tokens[1]->get_bytes(), + 'is_string_literal' => false, + ); + } + + return null; + } + + /** + * Translate SQLite-compatible date text comparisons against numeric literals. + * + * SQLite treats DATE() and non-numeric DATE_FORMAT() text results as text when + * compared with numeric literals. DuckDB tries numeric coercion for some + * operators and errors for others, so rewrite the bounded predicates before + * translating the scalar functions. The %H.%i DATE_FORMAT() format + * intentionally keeps the existing numeric translation used by WordPress time + * 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_date_text_numeric_literal_comparison( + 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, + bool $force_integer_result = false + ): ?string { + $start_index = $index; + $date_text = $this->date_text_expression_sql( + $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_text + && isset( $tokens[ $date_text['next_index'] + 1 ] ) + && $this->is_date_text_numeric_comparison_operator_token( $tokens[ $date_text['next_index'] ] ) + ) { + $operator_index = $date_text['next_index']; + $literal = $this->text_value_numeric_literal_sequence_sql( $tokens, $operator_index + 1 ); + $boundary_index = null !== $literal ? $literal['end_index'] + 1 : $operator_index + 1; + if ( + null !== $literal + && $this->date_scalar_comparison_has_boundary( $tokens, $start_index, $boundary_index ) + ) { + $index = $literal['end_index']; + return $this->sqlite_date_text_numeric_comparison_sql( + $date_text['sql'], + $tokens[ $operator_index ], + false, + $force_integer_result || $this->date_scalar_comparison_is_select_item_boundary( $tokens, $start_index, $boundary_index ) + ); + } + } + + $literal = $this->text_value_numeric_literal_sequence_sql( $tokens, $index ); + if ( null === $literal || ! isset( $tokens[ $literal['end_index'] + 1 ] ) ) { + return null; + } + + $operator_index = $literal['end_index'] + 1; + if ( ! $this->is_date_text_numeric_comparison_operator_token( $tokens[ $operator_index ] ) ) { + return null; + } + + $date_text = $this->date_text_expression_sql( + $tokens, + $operator_index + 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 + ); + $boundary_index = null !== $date_text ? $date_text['next_index'] : $operator_index + 1; + if ( + null === $date_text + || ! $this->date_scalar_comparison_has_boundary( $tokens, $start_index, $boundary_index ) + ) { + return null; + } + + $index = $date_text['next_index'] - 1; + return $this->sqlite_date_text_numeric_comparison_sql( + $date_text['sql'], + $tokens[ $operator_index ], + true, + $force_integer_result || $this->date_scalar_comparison_is_select_item_boundary( $tokens, $start_index, $boundary_index ) + ); + } + + /** + * Translate a date text function call for comparison rewrites. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Function token index. + * @return array{sql:string,next_index:int}|null Translated expression and next token index. + */ + private function date_text_expression_sql( + 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 + ): ?array { + $date_add_sub = $this->date_add_sub_text_expression_sql( + $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_add_sub ) { + return $date_add_sub; + } + + $datediff = $this->datediff_text_expression_sql( + $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 !== $datediff ) { + return $datediff; + } + + $date_format = $this->date_format_text_expression_sql( + $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_format ) { + return $date_format; + } + + return $this->date_function_text_expression_sql( + $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 + ); + } + + /** + * Translate a DATE_ADD() or DATE_SUB() call for comparison rewrites. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index DATE_ADD or DATE_SUB token index. + * @return array{sql:string,next_index:int}|null Translated expression and next token index. + */ + private function date_add_sub_text_expression_sql( + 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 + ): ?array { + if ( + ! isset( $tokens[ $index ] ) + || ! in_array( strtoupper( $tokens[ $index ]->get_bytes() ), array( 'DATE_ADD', 'DATE_SUB' ), true ) + ) { + return null; + } + + $function_index = $index; + $sql = $this->translate_date_time_function_call( + $tokens, + $function_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 === $sql ) { + return null; + } + + return array( + 'sql' => $sql, + 'next_index' => $function_index + 1, + ); + } + + /** + * Translate a DATEDIFF() call for comparison rewrites. + * + * SQLite's DATEDIFF() shim returns text, so numeric-literal comparisons use + * SQLite's text-vs-number truth table. Raw DATEDIFF() projection still uses + * the generic numeric translation. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index DATEDIFF token index. + * @return array{sql:string,next_index:int}|null Translated expression and next token index. + */ + private function datediff_text_expression_sql( + 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 + ): ?array { + if ( + ! isset( $tokens[ $index ] ) + || 0 !== strcasecmp( $tokens[ $index ]->get_bytes(), 'DATEDIFF' ) + ) { + return null; + } + + $function_index = $index; + $sql = $this->translate_date_time_function_call( + $tokens, + $function_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 === $sql ) { + return null; + } + + return array( + 'sql' => $sql, + 'next_index' => $function_index + 1, + ); + } + + /** + * Translate a DATE() call for comparison rewrites. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index DATE token index. + * @return array{sql:string,next_index:int}|null Translated expression and next token index. + */ + private function date_function_text_expression_sql( + 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 + ): ?array { + if ( + ! isset( $tokens[ $index ], $tokens[ $index + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + || 0 !== strcasecmp( $tokens[ $index ]->get_bytes(), 'DATE' ) + ) { + 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] ) === 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 + ); + + return array( + 'sql' => 'strftime(TRY_CAST((' . $date_sql . ") AS TIMESTAMP), '%Y-%m-%d')", + 'next_index' => $end_index, + ); + } + + /** + * Translate a non-numeric DATE_FORMAT() call for comparison rewrites. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index DATE_FORMAT token index. + * @return array{sql:string,next_index:int}|null Translated expression and next token index. + */ + private function date_format_text_expression_sql( + 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 + ): ?array { + if ( + ! isset( $tokens[ $index ], $tokens[ $index + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + || 0 !== strcasecmp( $tokens[ $index ]->get_bytes(), 'DATE_FORMAT' ) + ) { + 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 ( + 2 !== count( $items ) + || count( $items[0] ) === 0 + || 1 !== count( $items[1] ) + || ! $this->is_string_literal_token( $items[1][0] ) + ) { + return null; + } + + $literal_format = $this->token_value( $items[1][0] ); + if ( '%H.%i' === $literal_format ) { + 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 + ); + + return array( + 'sql' => 'strftime(TRY_CAST((' . $date_sql . ') AS TIMESTAMP), ' . $this->connection->quote( $this->mysql_date_format_to_duckdb( $literal_format ) ) . ')', + 'next_index' => $end_index, + ); + } + + /** + * Build SQLite-compatible date text-vs-number comparison SQL. + * + * @param string $date_text_sql Date text SQL expression. + * @param WP_Parser_Token $operator Comparison operator. + * @param bool $numeric_operand_left Whether the numeric literal is on the left. + * @return string DuckDB SQL. + */ + private function sqlite_date_text_numeric_comparison_sql( string $date_text_sql, WP_Parser_Token $operator, bool $numeric_operand_left, bool $integer_result = false ): string { + $result_sql = $this->sqlite_numeric_string_null_comparison_result_sql( $operator->id, $numeric_operand_left ); + if ( $integer_result ) { + $result_sql = 'TRUE' === $result_sql ? '1' : '0'; + } + + return '(CASE WHEN ' + . $date_text_sql + . ' IS NULL THEN NULL ELSE ' + . $result_sql + . ' END)'; + } + + /** + * Check whether a token is a date text numeric comparison operator. + * + * @param WP_Parser_Token|null $token Token. + * @return bool Whether the token is supported. + */ + private function is_date_text_numeric_comparison_operator_token( $token ): bool { + return $this->is_numeric_comparison_operator_token( $token ) + || ( + $token instanceof WP_Parser_Token + && WP_MySQL_Lexer::NOT_EQUAL_OPERATOR === $token->id + ); + } + + /** + * Translate date-part numeric functions compared with quoted string literals. + * + * SQLite compares expression result storage classes directly for scalar + * function results, so numeric date-part results compare against quoted + * strings as number-vs-text constants. DuckDB coerces quoted numeric strings + * to numbers instead, so handle this exact predicate shape before the generic + * date-part scalar translation. + * + * @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_date_part_string_literal_comparison( + 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 { + $start_index = $index; + $date_part = $this->date_part_numeric_expression_sql( + $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_part + && isset( $tokens[ $date_part['next_index'] + 1 ] ) + && $this->is_numeric_string_comparison_operator_token( $tokens[ $date_part['next_index'] ] ) + && $this->is_string_literal_token( $tokens[ $date_part['next_index'] + 1 ] ) + && $this->date_scalar_comparison_has_boundary( $tokens, $start_index, $date_part['next_index'] + 2 ) + ) { + $boundary_index = $date_part['next_index'] + 2; + $index = $date_part['next_index'] + 1; + return $this->sqlite_date_part_string_literal_comparison_sql( + $date_part['sql'], + $tokens[ $date_part['next_index'] ], + true, + $this->date_scalar_comparison_is_select_item_boundary( $tokens, $start_index, $boundary_index ) + ); + } + + if ( + ! isset( $tokens[ $index + 2 ] ) + || ! $this->is_string_literal_token( $tokens[ $index ] ) + || ! $this->is_numeric_string_comparison_operator_token( $tokens[ $index + 1 ] ) + ) { + return null; + } + + $operator = $tokens[ $index + 1 ]; + $date_part = $this->date_part_numeric_expression_sql( + $tokens, + $index + 2, + $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_part + || ! $this->date_scalar_comparison_has_boundary( $tokens, $start_index, $date_part['next_index'] ) + ) { + return null; + } + + $index = $date_part['next_index'] - 1; + return $this->sqlite_date_part_string_literal_comparison_sql( + $date_part['sql'], + $operator, + false, + $this->date_scalar_comparison_is_select_item_boundary( $tokens, $start_index, $date_part['next_index'] ) + ); + } + + /** + * Translate a supported date-part numeric scalar function call. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Function token index. + * @return array{sql:string,next_index:int}|null Translated expression, or null when unsupported. + */ + private function date_part_numeric_expression_sql( + 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 + ): ?array { + if ( + ! isset( $tokens[ $index ], $tokens[ $index + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + return null; + } + + $name = strtoupper( $tokens[ $index ]->get_bytes() ); + if ( + ! in_array( + $name, + array( '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 ); + if ( 'WEEK' === $name ) { + if ( count( $items ) < 1 || count( $items ) > 2 || count( $items[0] ) === 0 ) { + return null; + } + } elseif ( 1 !== count( $items ) || count( $items[0] ) === 0 ) { + return null; + } + + $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 ( in_array( $name, array( 'DAY', 'DAYOFMONTH', 'MONTH', 'MONTHNUM', 'YEAR' ), true ) ) { + $function = 'DAY' === $name ? 'dayofmonth' : strtolower( $name ); + $function = 'monthnum' === $function ? 'month' : $function; + $sql = $function . '(TRY_CAST((' . $translate( $items[0] ) . ') AS TIMESTAMP))'; + } elseif ( in_array( $name, array( 'HOUR', 'MINUTE', 'SECOND' ), true ) ) { + $sql = strtolower( $name ) . '(TRY_CAST((' . $translate( $items[0] ) . ') AS TIME))'; + } elseif ( 'DAYOFWEEK' === $name || 'WEEKDAY' === $name ) { + $day_of_week = 'dayofweek(TRY_CAST((' . $translate( $items[0] ) . ') AS DATE))'; + $sql = 'DAYOFWEEK' === $name ? '(' . $day_of_week . ' + 1)' : '((' . $day_of_week . ' + 6) % 7)'; + } else { + $sql = 'week(TRY_CAST((' . $translate( $items[0] ) . ') AS DATE))'; + } + + return array( + 'sql' => $sql, + 'next_index' => $end_index, + ); + } + + /** + * Build SQLite-compatible date-part number-vs-string comparison SQL. + * + * @param string $date_part_sql Date-part SQL expression. + * @param WP_Parser_Token $operator Comparison operator. + * @param bool $numeric_operand_left Whether the date-part function is on the left. + * @return string DuckDB SQL. + */ + private function sqlite_date_part_string_literal_comparison_sql( string $date_part_sql, WP_Parser_Token $operator, bool $numeric_operand_left, bool $integer_result = false ): string { + $result_sql = $this->sqlite_numeric_string_null_comparison_result_sql( $operator->id, $numeric_operand_left ); + if ( $integer_result ) { + $result_sql = 'TRUE' === $result_sql ? '1' : '0'; + } + + return '(CASE WHEN ' + . $date_part_sql + . ' IS NULL THEN ' + . $result_sql + . ' ELSE ' + . $result_sql + . ' END)'; + } + + /** + * Check whether a date scalar comparison reaches a safe predicate boundary. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $start_index Predicate start token index. + * @param int $boundary_index Token index after the predicate. + * @return bool Whether the next token is absent or a supported boundary. + */ + private function date_scalar_comparison_has_boundary( array $tokens, int $start_index, int $boundary_index ): bool { + if ( $this->text_value_numeric_comparison_has_boundary( $tokens, $boundary_index ) ) { + return true; + } + + return $this->date_scalar_comparison_is_select_item_boundary( $tokens, $start_index, $boundary_index ); + } + + /** + * Check whether a date scalar comparison ends at a SELECT-list item boundary. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $start_index Predicate start token index. + * @param int $boundary_index Token index after the predicate. + * @return bool Whether the predicate is a top-level SELECT-list item. + */ + private function date_scalar_comparison_is_select_item_boundary( array $tokens, int $start_index, int $boundary_index ): bool { + if ( ! $this->date_scalar_comparison_starts_top_level_select_item( $tokens, $start_index ) ) { + return false; + } + + if ( ! isset( $tokens[ $boundary_index ] ) ) { + return true; + } + + if ( + ! in_array( + $tokens[ $boundary_index ]->id, + array( + WP_MySQL_Lexer::AS_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::FROM_SYMBOL, + ), + true + ) + ) { + return false; + } + + return true; + } + + /** + * Check whether a date scalar comparison starts as a top-level SELECT item. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $start_index Predicate start token index. + * @return bool Whether the predicate starts at a SELECT-list item boundary. + */ + private function date_scalar_comparison_starts_top_level_select_item( array $tokens, int $start_index ): bool { + if ( ! isset( $tokens[ $start_index - 1 ] ) ) { + return false; + } + + $depth = 0; + for ( $index = 0; $index < $start_index; ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + } elseif ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + $depth = max( 0, $depth - 1 ); + } + } + + return 0 === $depth + && in_array( + $tokens[ $start_index - 1 ]->id, + array( + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + ), + true + ); + } + + /** + * 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 one DATEDIFF() date operand. + * + * SQLite's DATEDIFF() shim uses DateTime and raises on arbitrary invalid + * strings, but WordPress zero-date sentinels are still normalized to NULL + * by the DuckDB path for compatibility with existing date part behavior. + * + * @param string $sql DuckDB SQL expression. + * @return string DuckDB DATE expression. + */ + private function translate_datediff_date_operand( string $sql ): string { + $expression = '(' . $sql . ')'; + + return '(CASE' + . ' WHEN ' . $expression . ' IS NULL THEN NULL' + . ' WHEN CAST(' . $expression . " AS VARCHAR) IN ('0000-00-00', '0000-00-00 00:00:00') THEN NULL" + . ' WHEN TRY_CAST(' . $expression . ' AS DATE) IS NULL THEN error(' . $this->connection->quote( 'Failed to parse time string' ) . ')' + . ' ELSE TRY_CAST(' . $expression . ' AS DATE)' + . ' END)'; + } + + /** + * 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'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; + } + $expr_tokens = array_slice( $body, 0, $as_index ); + if ( count( $expr_tokens ) === 0 ) { + 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; + } + + $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 + ); + $cast_sql = $this->numeric_cast_like_display_sql( $expr_sql, $type_tokens ); + + $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_sql + . ( $is_not_like ? ' NOT LIKE ' : ' LIKE ' ) + . $this->connection->quote( $pattern ) + . $escape; + } + + /** + * Build SQLite/MySQL-like display SQL for numeric CAST(...) LIKE predicates. + * + * @param string $expr_sql DuckDB SQL expression. + * @param WP_Parser_Token[] $type_tokens Cast type tokens. + * @return string DuckDB SQL expression that yields the LIKE-facing cast string. + */ + private function numeric_cast_like_display_sql( string $expr_sql, array $type_tokens ): string { + $wrapped_expr_sql = '(' . $expr_sql . ')'; + + if ( $this->cast_type_is_integer_numeric( $type_tokens ) ) { + return 'CAST((CASE WHEN ' + . $wrapped_expr_sql + . ' IS NULL THEN NULL ELSE CAST(trunc(COALESCE(' + . $this->mysql_numeric_prefix_try_cast_sql( $wrapped_expr_sql ) + . ', 0)) AS BIGINT) END) AS VARCHAR)'; + } + + return 'CAST((' . $this->mysql_numeric_coercion_sql( $wrapped_expr_sql ) . ') AS VARCHAR)'; + } + + /** + * 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; + if ( $this->binary_expression_is_regexp_left_operand( $tokens, $next_index ) ) { + return $operand_sql; + } + + return 'hex(encode(CAST(' . $operand_sql . ' AS VARCHAR)))'; + } + + /** + * Check whether unary BINARY is immediately followed by REGEXP/RLIKE. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $next_index First token after the binary operand. + * @return bool Whether the binary operand is the left side of a regexp predicate. + */ + private function binary_expression_is_regexp_left_operand( array $tokens, int $next_index ): bool { + if ( ! isset( $tokens[ $next_index ] ) ) { + return false; + } + + if ( $this->is_regexp_operator( $tokens[ $next_index ] ) ) { + return true; + } + + return isset( $tokens[ $next_index + 1 ] ) + && in_array( $tokens[ $next_index ]->id, array( WP_MySQL_Lexer::NOT_SYMBOL, WP_MySQL_Lexer::NOT2_SYMBOL ), true ) + && $this->is_regexp_operator( $tokens[ $next_index + 1 ] ); + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * Check whether a cast type is integer-like numeric. + * + * @param WP_Parser_Token[] $type_tokens Cast type tokens. + * @return bool Whether the cast type is an integer numeric type. + */ + private function cast_type_is_integer_numeric( array $type_tokens ): bool { + foreach ( $type_tokens as $token ) { + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::SIGNED_SYMBOL, + WP_MySQL_Lexer::UNSIGNED_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. + * + * @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(). + * + * @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 WordPress REST ISO datetime literal comparisons. + * + * DuckDB does not compare WordPress DATETIME text columns directly against + * ISO-8601 `T...Z` literals the same way SQLite/MySQL do. Cast only known + * WordPress posts/comments datetime columns and ISO literals for this REST + * filter shape. + * + * @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_wordpress_datetime_iso_literal_comparison( array $tokens, int &$index ): ?string { + if ( ! $this->is_outer_query_token_offset( $tokens, $index ) ) { + return null; + } + + $table_context = $this->wordpress_datetime_comparison_table_context( $tokens ); + if ( null === $table_context ) { + return null; + } + + $left_operand = $this->wordpress_datetime_comparison_operand_sql( $tokens, $index, $table_context ); + if ( + null !== $left_operand + && isset( $tokens[ $left_operand['next_index'] + 1 ] ) + && $this->is_numeric_comparison_operator_token( $tokens[ $left_operand['next_index'] ] ) + && $this->is_iso_datetime_string_literal_token( $tokens[ $left_operand['next_index'] + 1 ] ) + && ( + ! isset( $tokens[ $left_operand['next_index'] + 2 ] ) + || $this->is_wordpress_datetime_comparison_boundary_token( $tokens[ $left_operand['next_index'] + 2 ] ) + ) + ) { + $operator_index = $left_operand['next_index']; + $literal_index = $operator_index + 1; + $index = $literal_index; + return $this->wordpress_datetime_iso_literal_comparison_sql( + $left_operand['sql'], + $tokens[ $operator_index ], + $tokens[ $literal_index ], + true + ); + } + + if ( + isset( $tokens[ $index + 2 ] ) + && $this->is_iso_datetime_string_literal_token( $tokens[ $index ] ) + && $this->is_numeric_comparison_operator_token( $tokens[ $index + 1 ] ) + ) { + $right_operand = $this->wordpress_datetime_comparison_operand_sql( $tokens, $index + 2, $table_context ); + if ( null !== $right_operand ) { + if ( + isset( $tokens[ $right_operand['next_index'] ] ) + && ! $this->is_wordpress_datetime_comparison_boundary_token( $tokens[ $right_operand['next_index'] ] ) + ) { + return null; + } + + $operator_token = $tokens[ $index + 1 ]; + $literal_token = $tokens[ $index ]; + $index = $right_operand['next_index'] - 1; + return $this->wordpress_datetime_iso_literal_comparison_sql( + $right_operand['sql'], + $operator_token, + $literal_token, + false + ); + } + } + + return null; + } + + /** + * Check whether a token can end a WordPress datetime ISO literal predicate. + * + * @param WP_Parser_Token $token Token. + * @return bool Whether the token is a predicate boundary. + */ + private function is_wordpress_datetime_comparison_boundary_token( WP_Parser_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::AND_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 + ); + } + + /** + * Resolve the simple WordPress posts/comments table context for REST datetime filters. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return array{table:array{table_name:string,alias:string,temporary:bool},kind:string}|null Table context, or null when unsupported. + */ + private function wordpress_datetime_comparison_table_context( 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::UNION_SYMBOL ) ) { + return null; + } + + $from_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::FROM_SYMBOL ); + if ( null === $from_index ) { + return null; + } + + $table = $this->parse_unresolved_simple_select_table_reference( + array_slice( + $tokens, + $from_index + 1, + $this->simple_select_from_clause_end( $tokens, $from_index + 1 ) - $from_index - 1 + ) + ); + if ( null === $table ) { + return null; + } + + if ( $this->is_wordpress_posts_table_name( $table['table_name'] ) ) { + return array( + 'table' => $table, + 'kind' => 'posts', + ); + } + + if ( $this->is_wordpress_comments_table_name( $table['table_name'] ) ) { + return array( + 'table' => $table, + 'kind' => 'comments', + ); + } + + return null; + } + + /** + * Check whether a token offset belongs to the outer SELECT rather than a nested SELECT. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $target Target token offset. + * @return bool Whether the token belongs to the outer query. + */ + private function is_outer_query_token_offset( array $tokens, int $target ): bool { + $depth = 0; + $subquery_by_depth = array(); + for ( $index = 0; $index < $target; ++$index ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index ]->id ) { + ++$depth; + $subquery_by_depth[ $depth ] = false; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $index ]->id ) { + unset( $subquery_by_depth[ $depth ] ); + --$depth; + continue; + } + + if ( WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $index ]->id && $depth > 0 ) { + $subquery_by_depth[ $depth ] = true; + } + } + + return ! in_array( true, $subquery_by_depth, true ); + } + + /** + * Build a WordPress datetime operand SQL fragment. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index. + * @param array{table:array{table_name:string,alias:string,temporary:bool},kind:string} $table_context Simple table context. + * @return array{sql:string,next_index:int}|null Operand SQL and next token index, or null when not matched. + */ + private function wordpress_datetime_comparison_operand_sql( array $tokens, int $index, array $table_context ): ?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 ( + ! $this->is_wordpress_datetime_comparison_column_for_table( $column_name, $table_context['kind'] ) + || ! $this->simple_select_column_qualifier_matches_table( $this->identifier_value( $tokens[ $index ] ), $table_context['table'] ) + ) { + 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 ( ! $this->is_wordpress_datetime_comparison_column_for_table( $column_name, $table_context['kind'] ) ) { + return null; + } + + return array( + 'sql' => $this->connection->quote_identifier( $column_name ), + 'next_index' => $index + 1, + ); + } + + /** + * Build a TRY_CAST comparison for a WordPress datetime column and ISO literal. + * + * @param string $column_sql Datetime column SQL. + * @param WP_Parser_Token $operator Comparison operator token. + * @param WP_Parser_Token $literal ISO datetime literal token. + * @param bool $column_left Whether the column operand is on the left. + * @return string Translated comparison SQL. + */ + private function wordpress_datetime_iso_literal_comparison_sql( string $column_sql, WP_Parser_Token $operator, WP_Parser_Token $literal, bool $column_left ): string { + $literal_sql = $this->connection->quote( $this->token_value( $literal ) ); + $column_sql = 'TRY_CAST(' . $column_sql . ' AS TIMESTAMP)'; + $literal_sql = 'TRY_CAST(' . $literal_sql . ' AS TIMESTAMP)'; + + return ( $column_left ? $column_sql : $literal_sql ) + . ' ' + . $operator->get_bytes() + . ' ' + . ( $column_left ? $literal_sql : $column_sql ); + } + + /** + * Check whether a column is a WordPress posts/comments datetime column. + * + * @param string $column_name Column name. + * @param string $table_kind WordPress table kind. + * @return bool Whether the column should use ISO datetime comparison parity. + */ + private function is_wordpress_datetime_comparison_column_for_table( string $column_name, string $table_kind ): bool { + if ( 'comments' === $table_kind ) { + return in_array( + strtolower( $column_name ), + array( + 'comment_date', + 'comment_date_gmt', + ), + true + ); + } + + return in_array( + strtolower( $column_name ), + array( + 'post_date', + 'post_date_gmt', + 'post_modified', + 'post_modified_gmt', + ), + true + ); + } + + /** + * 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_text_value_numeric_literal_comparison( array $tokens, int &$index ): ?string { + $has_leading_boundary = $this->text_value_numeric_comparison_has_leading_boundary( $tokens, $index ); + $left_operand = $has_leading_boundary ? $this->text_value_numeric_comparison_operand_sql( $tokens, $index ) : null; + if ( null !== $left_operand ) { + $operator_index = $left_operand['next_index']; + $literal = $this->text_value_numeric_comparison_value_sql( $tokens, $operator_index + 1 ); + if ( + null !== $literal + && $this->is_text_value_numeric_comparison_operator_token( $tokens[ $operator_index ] ?? null ) + && $this->text_value_numeric_comparison_has_boundary( $tokens, $literal['end_index'] + 1 ) + ) { + $index = $literal['end_index']; + return $this->text_value_numeric_literal_comparison_sql( + $left_operand['sql'], + $tokens[ $operator_index ], + $literal['sql'], + true + ); + } + + $between = $this->translate_text_value_numeric_between_predicate( $tokens, $left_operand, $operator_index ); + if ( null !== $between ) { + $index = $between['end_index']; + return $between['sql']; + } + + $in = $this->translate_text_value_numeric_in_predicate( $tokens, $left_operand, $operator_index ); + if ( null !== $in ) { + $index = $in['end_index']; + return $in['sql']; + } + + $like = $this->translate_text_value_numeric_like_predicate( $tokens, $left_operand, $operator_index ); + if ( null !== $like ) { + $index = $like['end_index']; + return $like['sql']; + } + } + + if ( + $has_leading_boundary + && isset( $tokens[ $index + 2 ] ) + ) { + $literal = $this->text_value_numeric_comparison_value_sql( $tokens, $index ); + $operator_index = null === $literal ? null : $literal['end_index'] + 1; + $right_operand = null === $operator_index ? null : $this->text_value_numeric_comparison_operand_sql( $tokens, $operator_index + 1 ); + if ( + null !== $right_operand + && $this->is_text_value_numeric_comparison_operator_token( $tokens[ $operator_index ] ?? null ) + && $this->text_value_numeric_comparison_has_boundary( $tokens, $right_operand['next_index'] ) + ) { + $index = $right_operand['next_index'] - 1; + return $this->text_value_numeric_literal_comparison_sql( + $right_operand['sql'], + $tokens[ $operator_index ], + $literal['sql'], + false + ); + } + + if ( null !== $literal && null !== $operator_index && ! $literal['expression'] ) { + $reverse_like = $this->translate_numeric_literal_text_value_like_predicate( $tokens, $literal, $operator_index ); + if ( null !== $reverse_like ) { + $index = $reverse_like['end_index']; + return $reverse_like['sql']; + } + } + } + + return null; + } + + /** + * Translate a text-value BETWEEN integer-literal predicate. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param array{sql:string,next_index:int} $operand Text-value operand. + * @param int $operator_index Operator index. + * @return array{sql:string,end_index:int}|null Translation and consumed index. + */ + private function translate_text_value_numeric_between_predicate( array $tokens, array $operand, int $operator_index ): ?array { + $not = isset( $tokens[ $operator_index ] ) + && in_array( $tokens[ $operator_index ]->id, array( WP_MySQL_Lexer::NOT_SYMBOL, WP_MySQL_Lexer::NOT2_SYMBOL ), true ); + + $between_index = $not ? $operator_index + 1 : $operator_index; + $lower = $this->text_value_between_literal_sequence_sql( $tokens, $between_index + 1 ); + $and_index = null === $lower ? null : $lower['end_index'] + 1; + $upper = null === $and_index ? null : $this->text_value_between_literal_sequence_sql( $tokens, $and_index + 1 ); + if ( + null === $lower + || null === $upper + || WP_MySQL_Lexer::BETWEEN_SYMBOL !== $tokens[ $between_index ]->id + || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $and_index ]->id + || ( ! $lower['numeric'] && ! $upper['numeric'] ) + || ! $this->text_value_numeric_comparison_has_boundary( $tokens, $upper['end_index'] + 1 ) + ) { + return null; + } + + return array( + 'sql' => $this->text_value_sqlite_text_operand_sql( $operand['sql'] ) + . ( $not ? ' NOT BETWEEN ' : ' BETWEEN ' ) + . $lower['sql'] + . ' AND ' + . $upper['sql'], + 'end_index' => $upper['end_index'], + ); + } + + /** + * Build text SQL for a text-value BETWEEN bound literal token sequence. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Literal start index. + * @return array{sql:string,end_index:int,numeric:bool}|null Literal SQL and consumed index. + */ + private function text_value_between_literal_sequence_sql( array $tokens, int $index ): ?array { + $expression = $this->numeric_constant_arithmetic_expression_sql( $tokens, $index ); + if ( null !== $expression ) { + return array( + 'sql' => $this->text_value_numeric_expression_text_sql( $expression ), + 'end_index' => $expression['end_index'], + 'numeric' => true, + ); + } + + $expression = $this->numeric_identifier_arithmetic_expression_sql( $tokens, $index ); + if ( null !== $expression ) { + return array( + 'sql' => $this->text_value_numeric_expression_text_sql( $expression ), + 'end_index' => $expression['end_index'], + 'numeric' => true, + ); + } + + $expression = $this->numeric_identifier_abs_arithmetic_expression_sql( $tokens, $index ); + if ( null !== $expression ) { + return array( + 'sql' => $this->text_value_numeric_expression_text_sql( $expression ), + 'end_index' => $expression['end_index'], + 'numeric' => true, + ); + } + + $numeric = $this->text_value_numeric_literal_sequence_sql( $tokens, $index ); + if ( null !== $numeric ) { + return array( + 'sql' => $numeric['sql'], + 'end_index' => $numeric['end_index'], + 'numeric' => true, + ); + } + + if ( ! isset( $tokens[ $index ] ) ) { + return null; + } + + if ( $this->is_string_literal_token( $tokens[ $index ] ) ) { + return array( + 'sql' => $this->connection->quote( $this->token_value( $tokens[ $index ] ) ), + 'end_index' => $index, + 'numeric' => false, + ); + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::NULL2_SYMBOL === $tokens[ $index ]->id ) { + return array( + 'sql' => 'NULL', + 'end_index' => $index, + 'numeric' => false, + ); + } + + return null; + } + + /** + * Translate a text-value IN integer-literal predicate. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param array{sql:string,next_index:int} $operand Text-value operand. + * @param int $operator_index Operator index. + * @return array{sql:string,end_index:int}|null Translation and consumed index. + */ + private function translate_text_value_numeric_in_predicate( array $tokens, array $operand, int $operator_index ): ?array { + $not = isset( $tokens[ $operator_index ] ) + && in_array( $tokens[ $operator_index ]->id, array( WP_MySQL_Lexer::NOT_SYMBOL, WP_MySQL_Lexer::NOT2_SYMBOL ), true ); + $in_index = $not ? $operator_index + 1 : $operator_index; + + if ( + ! isset( $tokens[ $in_index + 2 ] ) + || WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $in_index ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $in_index + 1 ]->id + ) { + return null; + } + + $literal_sql = array(); + $list_index = $in_index + 2; + while ( isset( $tokens[ $list_index ] ) ) { + $literal = $this->text_value_in_list_literal_sequence_sql( $tokens, $list_index ); + if ( null === $literal ) { + return null; + } + $literal_sql[] = $literal['sql']; + $list_index = $literal['end_index'] + 1; + + if ( ! isset( $tokens[ $list_index ] ) ) { + return null; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $list_index ]->id ) { + break; + } + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $list_index ]->id ) { + return null; + } + ++$list_index; + } + + if ( + array() === $literal_sql + || ! isset( $tokens[ $list_index ] ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $list_index ]->id + || ! $this->text_value_numeric_comparison_has_boundary( $tokens, $list_index + 1 ) + ) { + return null; + } + + return array( + 'sql' => $this->text_value_sqlite_text_operand_sql( $operand['sql'] ) + . ( $not ? ' NOT IN (' : ' IN (' ) + . implode( ', ', $literal_sql ) + . ')', + 'end_index' => $list_index, + ); + } + + /** + * Translate a text-value LIKE numeric-literal predicate. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param array{sql:string,next_index:int} $operand Text-value operand. + * @param int $operator_index Operator index. + * @return array{sql:string,end_index:int}|null Translation and consumed index. + */ + private function translate_text_value_numeric_like_predicate( array $tokens, array $operand, int $operator_index ): ?array { + $not = isset( $tokens[ $operator_index ] ) + && in_array( $tokens[ $operator_index ]->id, array( WP_MySQL_Lexer::NOT_SYMBOL, WP_MySQL_Lexer::NOT2_SYMBOL ), true ); + $like_index = $not ? $operator_index + 1 : $operator_index; + $pattern = $this->text_value_numeric_literal_sequence_sql( $tokens, $like_index + 1 ); + if ( + null === $pattern + || ! isset( $tokens[ $like_index ] ) + || WP_MySQL_Lexer::LIKE_SYMBOL !== $tokens[ $like_index ]->id + ) { + return null; + } + + $escape = ''; + $end_index = $pattern['end_index']; + if ( + isset( $tokens[ $pattern['end_index'] + 2 ] ) + && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern['end_index'] + 1 ]->id + && $this->is_string_literal_token( $tokens[ $pattern['end_index'] + 2 ] ) + ) { + $escape = ' ESCAPE ' . $this->connection->quote( $this->token_value( $tokens[ $pattern['end_index'] + 2 ] ) ); + $end_index = $pattern['end_index'] + 2; + } elseif ( isset( $tokens[ $pattern['end_index'] + 1 ] ) && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern['end_index'] + 1 ]->id ) { + return null; + } + + if ( ! $this->text_value_numeric_comparison_has_boundary( $tokens, $end_index + 1 ) ) { + return null; + } + + return array( + 'sql' => $this->text_value_sqlite_text_operand_sql( $operand['sql'] ) + . ( $not ? ' NOT LIKE ' : ' LIKE ' ) + . $pattern['sql'] + . $escape, + 'end_index' => $end_index, + ); + } + + /** + * Translate a numeric-literal LIKE text-value predicate. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param array{sql:string,end_index:int} $literal Numeric literal. + * @param int $operator_index Operator index. + * @return array{sql:string,end_index:int}|null Translation and consumed index. + */ + private function translate_numeric_literal_text_value_like_predicate( array $tokens, array $literal, int $operator_index ): ?array { + $not = isset( $tokens[ $operator_index ] ) + && in_array( $tokens[ $operator_index ]->id, array( WP_MySQL_Lexer::NOT_SYMBOL, WP_MySQL_Lexer::NOT2_SYMBOL ), true ); + $like_index = $not ? $operator_index + 1 : $operator_index; + if ( ! isset( $tokens[ $like_index ] ) || WP_MySQL_Lexer::LIKE_SYMBOL !== $tokens[ $like_index ]->id ) { + return null; + } + + $operand = $this->text_value_numeric_comparison_operand_sql( $tokens, $like_index + 1 ); + if ( null === $operand ) { + return null; + } + + $end_index = $operand['next_index'] - 1; + if ( isset( $tokens[ $operand['next_index'] ] ) && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $operand['next_index'] ]->id ) { + return null; + } + + if ( ! $this->text_value_numeric_comparison_has_boundary( $tokens, $end_index + 1 ) ) { + return null; + } + + return array( + 'sql' => $literal['sql'] + . ( $not ? ' NOT LIKE ' : ' LIKE ' ) + . $this->text_value_sqlite_text_operand_sql( $operand['sql'] ), + 'end_index' => $end_index, + ); + } + + /** + * Build text SQL for a text-value IN-list literal token sequence. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Literal start index. + * @return array{sql:string,end_index:int}|null Literal SQL and consumed index. + */ + private function text_value_in_list_literal_sequence_sql( array $tokens, int $index ): ?array { + $expression = $this->numeric_constant_arithmetic_expression_sql( $tokens, $index ); + if ( null !== $expression ) { + return array( + 'sql' => $this->text_value_numeric_expression_text_sql( $expression ), + 'end_index' => $expression['end_index'], + ); + } + + $expression = $this->numeric_identifier_arithmetic_expression_sql( $tokens, $index ); + if ( null !== $expression ) { + return array( + 'sql' => $this->text_value_numeric_expression_text_sql( $expression ), + 'end_index' => $expression['end_index'], + ); + } + + $expression = $this->numeric_identifier_abs_arithmetic_expression_sql( $tokens, $index ); + if ( null !== $expression ) { + return array( + 'sql' => $this->text_value_numeric_expression_text_sql( $expression ), + 'end_index' => $expression['end_index'], + ); + } + + $numeric = $this->text_value_numeric_literal_sequence_sql( $tokens, $index ); + if ( null !== $numeric ) { + return $numeric; + } + + if ( ! isset( $tokens[ $index ] ) ) { + return null; + } + + if ( $this->is_string_literal_token( $tokens[ $index ] ) ) { + return array( + 'sql' => $this->connection->quote( $this->token_value( $tokens[ $index ] ) ), + 'end_index' => $index, + ); + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $index ]->id || WP_MySQL_Lexer::NULL2_SYMBOL === $tokens[ $index ]->id ) { + return array( + 'sql' => 'NULL', + 'end_index' => $index, + ); + } + + return null; + } + + /** + * Cast a numeric constant expression to text with SQLite-compatible display. + * + * @param array{sql:string,end_index:int,has_decimal:bool} $expression Numeric expression. + * @return string DuckDB SQL. + */ + private function text_value_numeric_expression_text_sql( array $expression ): string { + if ( $expression['has_decimal'] ) { + return 'CAST(CAST(' . $expression['sql'] . ' AS DOUBLE) AS VARCHAR)'; + } + + return 'CAST(' . $expression['sql'] . ' AS VARCHAR)'; + } + + /** + * Build SQL for a constant numeric arithmetic expression. + * + * This intentionally accepts only numeric literals, optional signs, ABS() of + * signed numeric literals, and the arithmetic operators already used for + * text-value numeric coercion. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Expression start index. + * @return array{sql:string,end_index:int,has_decimal:bool}|null Expression SQL. + */ + private function numeric_constant_arithmetic_expression_sql( array $tokens, int $index ): ?array { + $first = $this->numeric_constant_arithmetic_operand_sql( $tokens, $index ); + if ( null === $first ) { + return null; + } + + $pieces = array( $first['sql'] ); + $end_index = $first['end_index']; + $has_decimal = $first['has_decimal']; + $operator_count = 0; + + if ( + ! empty( $first['standalone'] ) + && isset( $tokens[ $end_index + 1 ] ) + && $this->is_numeric_arithmetic_operator_token( $tokens[ $end_index + 1 ] ) + ) { + return null; + } + + while ( + isset( $tokens[ $end_index + 2 ] ) + && $this->is_numeric_arithmetic_operator_token( $tokens[ $end_index + 1 ] ) + ) { + $right = $this->numeric_constant_arithmetic_operand_sql( $tokens, $end_index + 2 ); + if ( null === $right || ! empty( $right['standalone'] ) ) { + return null; + } + + $pieces[] = $tokens[ $end_index + 1 ]->get_bytes(); + $pieces[] = $right['sql']; + $end_index = $right['end_index']; + $has_decimal = $has_decimal || $right['has_decimal']; + ++$operator_count; + } + + if ( 0 === $operator_count && empty( $first['standalone'] ) ) { + return null; + } + + return array( + 'sql' => implode( ' ', $pieces ), + 'end_index' => $end_index, + 'has_decimal' => $has_decimal, + ); + } + + /** + * Build SQL for a signed numeric literal in a constant arithmetic expression. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Operand start index. + * @return array{sql:string,end_index:int,has_decimal:bool,standalone?:bool}|null Operand SQL. + */ + private function numeric_constant_arithmetic_operand_sql( array $tokens, int $index ): ?array { + if ( ! isset( $tokens[ $index ] ) ) { + return null; + } + + if ( + isset( $tokens[ $index + 3 ] ) + && 0 === strcasecmp( $tokens[ $index ]->get_bytes(), 'ABS' ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $index + 1 ]->id + ) { + $argument = $this->numeric_constant_arithmetic_operand_sql( $tokens, $index + 2 ); + if ( + null !== $argument + && isset( $tokens[ $argument['end_index'] + 1 ] ) + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $argument['end_index'] + 1 ]->id + && empty( $argument['standalone'] ) + ) { + return array( + 'sql' => 'abs(' . $argument['sql'] . ')', + 'end_index' => $argument['end_index'] + 1, + 'has_decimal' => $argument['has_decimal'], + 'standalone' => true, + ); + } + } + + $sign_sql = ''; + if ( + $this->is_sign_token( $tokens[ $index ] ) + && isset( $tokens[ $index + 1 ] ) + && $this->is_number_token( $tokens[ $index + 1 ] ) + ) { + $sign_sql = $tokens[ $index ]->get_bytes(); + $number_token = $tokens[ $index + 1 ]; + $end_index = $index + 1; + } elseif ( $this->is_number_token( $tokens[ $index ] ) ) { + $number_token = $tokens[ $index ]; + $end_index = $index; + } else { + return null; + } + + return array( + 'sql' => $sign_sql . $number_token->get_bytes(), + 'end_index' => $end_index, + 'has_decimal' => ! $this->is_integer_number_token( $number_token ), + ); + } + + /** + * Build SQL for arithmetic over WordPress numeric identifier columns. + * + * This intentionally accepts only the numeric identifier allow-list already + * used for ID-vs-string predicates plus numeric literals and +, -, *. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Expression start index. + * @return array{sql:string,end_index:int,has_decimal:bool}|null Expression SQL. + */ + private function numeric_identifier_arithmetic_expression_sql( array $tokens, int $index ): ?array { + $first = $this->numeric_identifier_arithmetic_operand_sql( $tokens, $index ); + if ( null === $first ) { + return null; + } + + $pieces = array( $first['sql'] ); + $end_index = $first['end_index']; + $has_decimal = $first['has_decimal']; + $identifier_count = $first['identifier_count']; + $literal_count = $first['literal_count']; + $operator_count = 0; + + while ( + isset( $tokens[ $end_index + 2 ] ) + && $this->is_numeric_arithmetic_operator_token( $tokens[ $end_index + 1 ] ) + ) { + $right = $this->numeric_identifier_arithmetic_operand_sql( $tokens, $end_index + 2 ); + if ( null === $right ) { + return null; + } + + $pieces[] = $tokens[ $end_index + 1 ]->get_bytes(); + $pieces[] = $right['sql']; + $end_index = $right['end_index']; + $has_decimal = $has_decimal || $right['has_decimal']; + $identifier_count += $right['identifier_count']; + $literal_count += $right['literal_count']; + ++$operator_count; + } + + if ( 0 === $operator_count || 0 === $identifier_count || 0 === $literal_count ) { + return null; + } + + return array( + 'sql' => implode( ' ', $pieces ), + 'end_index' => $end_index, + 'has_decimal' => $has_decimal, + ); + } + + /** + * Build SQL for ABS() around WordPress numeric identifier arithmetic. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Expression start index. + * @return array{sql:string,end_index:int,has_decimal:bool}|null Expression SQL. + */ + private function numeric_identifier_abs_arithmetic_expression_sql( array $tokens, int $index ): ?array { + if ( + ! isset( $tokens[ $index + 3 ] ) + || 0 !== strcasecmp( $tokens[ $index ]->get_bytes(), 'ABS' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $index + 1 ]->id + ) { + return null; + } + + $argument = $this->numeric_identifier_arithmetic_expression_sql( $tokens, $index + 2 ); + if ( + null === $argument + || ! isset( $tokens[ $argument['end_index'] + 1 ] ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $argument['end_index'] + 1 ]->id + ) { + return null; + } + + return array( + 'sql' => 'abs(' . $argument['sql'] . ')', + 'end_index' => $argument['end_index'] + 1, + 'has_decimal' => $argument['has_decimal'], + ); + } + + /** + * Build one operand for numeric identifier arithmetic. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Operand start index. + * @return array{sql:string,end_index:int,has_decimal:bool,identifier_count:int,literal_count:int}|null Operand SQL. + */ + private function numeric_identifier_arithmetic_operand_sql( array $tokens, int $index ): ?array { + $identifier = $this->numeric_identifier_string_comparison_operand_sql( $tokens, $index ); + if ( null !== $identifier ) { + return array( + 'sql' => $identifier['sql'], + 'end_index' => $identifier['next_index'] - 1, + 'has_decimal' => false, + 'identifier_count' => 1, + 'literal_count' => 0, + ); + } + + if ( ! isset( $tokens[ $index ] ) ) { + return null; + } + + $sign_sql = ''; + if ( + $this->is_sign_token( $tokens[ $index ] ) + && isset( $tokens[ $index + 1 ] ) + && $this->is_number_token( $tokens[ $index + 1 ] ) + ) { + $sign_sql = $tokens[ $index ]->get_bytes(); + $number_token = $tokens[ $index + 1 ]; + $end_index = $index + 1; + } elseif ( $this->is_number_token( $tokens[ $index ] ) ) { + $number_token = $tokens[ $index ]; + $end_index = $index; + } else { + return null; + } + + return array( + 'sql' => $sign_sql . $number_token->get_bytes(), + 'end_index' => $end_index, + 'has_decimal' => ! $this->is_integer_number_token( $number_token ), + 'identifier_count' => 0, + 'literal_count' => 1, + ); + } + + /** + * Build a SQLite text-affinity comparison for text-value integer predicates. + * + * @param string $operand_sql Text column SQL. + * @param WP_Parser_Token $operator Comparison operator. + * @param string $literal_sql Numeric literal text SQL. + * @param bool $operand_left Whether the text operand is on the left. + * @return string Comparison SQL. + */ + private function text_value_numeric_literal_comparison_sql( string $operand_sql, WP_Parser_Token $operator, string $literal_sql, bool $operand_left ): string { + $text_operand_sql = $this->text_value_sqlite_text_operand_sql( $operand_sql ); + $left_sql = $operand_left ? $text_operand_sql : $literal_sql; + $right_sql = $operand_left ? $literal_sql : $text_operand_sql; + + return $left_sql + . ' ' + . $operator->get_bytes() + . ' ' + . $right_sql; + } + + /** + * Build text SQL for a numeric scalar comparison value. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Value start index. + * @return array{sql:string,end_index:int,expression:bool}|null Value SQL and consumed index. + */ + private function text_value_numeric_comparison_value_sql( array $tokens, int $index ): ?array { + $expression = $this->numeric_constant_arithmetic_expression_sql( $tokens, $index ); + if ( null !== $expression ) { + return array( + 'sql' => $this->text_value_numeric_expression_text_sql( $expression ), + 'end_index' => $expression['end_index'], + 'expression' => true, + ); + } + + $expression = $this->numeric_identifier_arithmetic_expression_sql( $tokens, $index ); + if ( null !== $expression ) { + return array( + 'sql' => $this->text_value_numeric_expression_text_sql( $expression ), + 'end_index' => $expression['end_index'], + 'expression' => true, + ); + } + + $expression = $this->numeric_identifier_abs_arithmetic_expression_sql( $tokens, $index ); + if ( null !== $expression ) { + return array( + 'sql' => $this->text_value_numeric_expression_text_sql( $expression ), + 'end_index' => $expression['end_index'], + 'expression' => true, + ); + } + + $literal = $this->text_value_numeric_literal_sequence_sql( $tokens, $index ); + if ( null === $literal ) { + return null; + } + + return array( + 'sql' => $literal['sql'], + 'end_index' => $literal['end_index'], + 'expression' => false, + ); + } + + /** + * Cast a text-value operand to DuckDB text for SQLite-affinity comparison. + * + * @param string $sql Text column SQL. + * @return string DuckDB SQL. + */ + private function text_value_sqlite_text_operand_sql( string $sql ): string { + return 'CAST(' . $sql . ' AS VARCHAR)'; + } + + /** + * Build text SQL for a text-value numeric literal token sequence. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Literal start index. + * @return array{sql:string,end_index:int}|null Literal SQL and consumed index. + */ + private function text_value_numeric_literal_sequence_sql( array $tokens, int $index ): ?array { + if ( ! isset( $tokens[ $index ] ) ) { + return null; + } + + $sign_token = null; + if ( + $this->is_sign_token( $tokens[ $index ] ) + && isset( $tokens[ $index + 1 ] ) + && $this->is_number_token( $tokens[ $index + 1 ] ) + ) { + $sign_token = $tokens[ $index ]; + $number_token = $tokens[ $index + 1 ]; + $end_index = $index + 1; + } elseif ( $this->is_number_token( $tokens[ $index ] ) ) { + $number_token = $tokens[ $index ]; + $end_index = $index; + } else { + return null; + } + + $literal_sql = $this->text_value_numeric_literal_text_sql( $number_token, $sign_token ); + + return array( + 'sql' => $literal_sql, + 'end_index' => $end_index, + ); + } + + /** + * Cast a numeric literal to text using SQLite-compatible numeric display. + * + * @param WP_Parser_Token $literal Numeric literal token. + * @param WP_Parser_Token|null $sign Optional sign token. + * @return string DuckDB SQL. + */ + private function text_value_numeric_literal_text_sql( WP_Parser_Token $literal, ?WP_Parser_Token $sign = null ): string { + $prefix = null !== $sign ? $sign->get_bytes() : ''; + $literal_sql = $prefix . $literal->get_bytes(); + + if ( + ( null === $sign || WP_MySQL_Lexer::PLUS_OPERATOR !== $sign->id ) + && $this->is_integer_number_token( $literal ) + ) { + return 'CAST(' . $literal_sql . ' AS VARCHAR)'; + } + + return $this->connection->quote( $this->sqlite_numeric_literal_text_value( $literal, $sign ) ); + } + + /** + * Format a numeric literal the way SQLite displays numeric values as text. + * + * @param WP_Parser_Token $literal Numeric literal token. + * @param WP_Parser_Token|null $sign Optional sign token. + * @return string SQLite-compatible text value. + */ + private function sqlite_numeric_literal_text_value( WP_Parser_Token $literal, ?WP_Parser_Token $sign = null ): string { + $value = null === $sign ? $this->number_token_value( $literal ) : $this->signed_number_token_value( $sign, $literal ); + + if ( $this->is_integer_number_token( $literal ) ) { + return (string) $value; + } + + $text = sprintf( '%.15g', (float) $value ); + if ( false === stripos( $text, 'e' ) && false === strpos( $text, '.' ) ) { + $text .= '.0'; + } + + return $text; + } + + /** + * Check whether a translated text-value numeric predicate reaches a boundary. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token index after the predicate. + * @return bool Whether the next token is absent or a supported boundary. + */ + private function text_value_numeric_comparison_has_boundary( array $tokens, int $index ): bool { + return ! isset( $tokens[ $index ] ) || $this->is_text_value_numeric_comparison_boundary_token( $tokens[ $index ] ); + } + + /** + * Check whether a text-value numeric predicate starts at an expression boundary. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Predicate start token index. + * @return bool Whether the previous token can precede a standalone predicate. + */ + private function text_value_numeric_comparison_has_leading_boundary( array $tokens, int $index ): bool { + if ( ! isset( $tokens[ $index - 1 ] ) ) { + return true; + } + + return in_array( + $tokens[ $index - 1 ]->id, + array( + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + WP_MySQL_Lexer::ON_SYMBOL, + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::XOR_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::WHEN_SYMBOL, + WP_MySQL_Lexer::THEN_SYMBOL, + ), + true + ); + } + + /** + * Translate WordPress text-value arithmetic that relies on MySQL numeric coercion. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Current index, advanced on match. + * @return string|null Translated expression, or null when the pattern does not match. + */ + private function translate_text_value_numeric_arithmetic_expression( array $tokens, int &$index ): ?string { + $left_operand = $this->text_value_numeric_comparison_operand_sql( $tokens, $index, false ); + if ( + null !== $left_operand + && isset( $tokens[ $left_operand['next_index'] + 1 ] ) + && $this->is_numeric_arithmetic_operator_token( $tokens[ $left_operand['next_index'] ] ) + && $this->is_number_token( $tokens[ $left_operand['next_index'] + 1 ] ) + ) { + $operator_index = $left_operand['next_index']; + $literal_index = $operator_index + 1; + $index = $literal_index; + return $this->mysql_numeric_coercion_sql( $left_operand['sql'] ) + . ' ' + . $tokens[ $operator_index ]->get_bytes() + . ' ' + . $tokens[ $literal_index ]->get_bytes(); + } + + if ( + isset( $tokens[ $index + 2 ] ) + && $this->is_number_token( $tokens[ $index ] ) + && $this->is_numeric_arithmetic_operator_token( $tokens[ $index + 1 ] ) + ) { + $right_operand = $this->text_value_numeric_comparison_operand_sql( $tokens, $index + 2, false ); + if ( null !== $right_operand ) { + $literal_sql = $tokens[ $index ]->get_bytes(); + $operator_sql = $tokens[ $index + 1 ]->get_bytes(); + $index = $right_operand['next_index'] - 1; + return $literal_sql + . ' ' + . $operator_sql + . ' ' + . $this->mysql_numeric_coercion_sql( $right_operand['sql'] ); + } + } + + return null; + } + + /** + * Translate numeric WordPress id comparisons against quoted strings. + * + * @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_numeric_identifier_string_literal_comparison( array $tokens, int &$index ): ?string { + $left_operand = $this->numeric_identifier_string_comparison_operand_sql( $tokens, $index ); + if ( + null !== $left_operand + && isset( $tokens[ $left_operand['next_index'] + 1 ] ) + && $this->is_numeric_string_comparison_operator_token( $tokens[ $left_operand['next_index'] ] ) + && $this->is_string_literal_token( $tokens[ $left_operand['next_index'] + 1 ] ) + ) { + $operator_index = $left_operand['next_index']; + $literal_index = $operator_index + 1; + $index = $literal_index; + return $this->sqlite_numeric_string_comparison_sql( + $left_operand['sql'], + $tokens[ $operator_index ], + $tokens[ $literal_index ], + true + ); + } + + if ( + isset( $tokens[ $index + 2 ] ) + && $this->is_string_literal_token( $tokens[ $index ] ) + && $this->is_numeric_string_comparison_operator_token( $tokens[ $index + 1 ] ) + ) { + $right_operand = $this->numeric_identifier_string_comparison_operand_sql( $tokens, $index + 2 ); + if ( null !== $right_operand ) { + $operator_token = $tokens[ $index + 1 ]; + $literal_token = $tokens[ $index ]; + $index = $right_operand['next_index'] - 1; + return $this->sqlite_numeric_string_comparison_sql( + $right_operand['sql'], + $operator_token, + $literal_token, + false + ); + } + } + + return null; + } + + /** + * Translate numeric WordPress id BETWEEN predicates against quoted strings. + * + * @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_numeric_identifier_string_literal_between_predicate( array $tokens, int &$index ): ?string { + $operand = $this->numeric_identifier_string_comparison_operand_sql( $tokens, $index ); + if ( null === $operand || ! isset( $tokens[ $operand['next_index'] ] ) ) { + return null; + } + + $not = false; + $operator_index = $operand['next_index']; + $between_index = $operator_index; + if ( + in_array( $tokens[ $operator_index ]->id, array( WP_MySQL_Lexer::NOT_SYMBOL, WP_MySQL_Lexer::NOT2_SYMBOL ), true ) + && isset( $tokens[ $operator_index + 1 ] ) + ) { + $not = true; + $between_index = $operator_index + 1; + } + + if ( WP_MySQL_Lexer::BETWEEN_SYMBOL !== $tokens[ $between_index ]->id ) { + return null; + } + + $lower = $this->numeric_identifier_between_bound_comparison_sql( + $tokens, + $between_index + 1, + $operand['sql'], + $not ? WP_MySQL_Lexer::LESS_THAN_OPERATOR : WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + $not ? '<' : '>=' + ); + if ( null === $lower ) { + return null; + } + + $and_index = $lower['end_index'] + 1; + if ( ! isset( $tokens[ $and_index ] ) || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $and_index ]->id ) { + return null; + } + + $upper = $this->numeric_identifier_between_bound_comparison_sql( + $tokens, + $and_index + 1, + $operand['sql'], + $not ? WP_MySQL_Lexer::GREATER_THAN_OPERATOR : WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + $not ? '>' : '<=' + ); + if ( + null === $upper + || ! $this->numeric_identifier_string_comparison_has_boundary( $tokens, $upper['end_index'] + 1 ) + ) { + return null; + } + + $index = $upper['end_index']; + if ( $not ) { + return '(' . $lower['sql'] . ' OR ' . $upper['sql'] . ')'; + } + + return '(' . $lower['sql'] . ' AND ' . $upper['sql'] . ')'; + } + + /** + * Build one numeric identifier BETWEEN-bound comparison. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Bound start index. + * @param string $operand_sql Numeric identifier SQL. + * @param int $operator_id Comparison operator token ID. + * @param string $operator_sql Comparison operator SQL. + * @return array{sql:string,end_index:int}|null Comparison SQL and consumed index. + */ + private function numeric_identifier_between_bound_comparison_sql( array $tokens, int $index, string $operand_sql, int $operator_id, string $operator_sql ): ?array { + $expression = $this->numeric_constant_arithmetic_expression_sql( $tokens, $index ); + if ( null !== $expression ) { + return array( + 'sql' => $operand_sql . ' ' . $operator_sql . ' ' . $expression['sql'], + 'end_index' => $expression['end_index'], + ); + } + + if ( ! isset( $tokens[ $index ] ) ) { + return null; + } + + $token = $tokens[ $index ]; + if ( $this->is_string_literal_token( $token ) ) { + return array( + 'sql' => $this->sqlite_numeric_string_literal_comparison_sql( + $operand_sql, + $operator_id, + $operator_sql, + $token, + true + ), + 'end_index' => $index, + ); + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id || WP_MySQL_Lexer::NULL2_SYMBOL === $token->id ) { + return array( + 'sql' => $operand_sql . ' ' . $operator_sql . ' NULL', + 'end_index' => $index, + ); + } + + $sign_sql = ''; + if ( + $this->is_sign_token( $token ) + && isset( $tokens[ $index + 1 ] ) + && $this->is_number_token( $tokens[ $index + 1 ] ) + ) { + $sign_sql = $token->get_bytes(); + $token = $tokens[ $index + 1 ]; + $end_index = $index + 1; + } elseif ( $this->is_number_token( $token ) ) { + $end_index = $index; + } else { + return null; + } + + return array( + 'sql' => $operand_sql . ' ' . $operator_sql . ' ' . $sign_sql . $token->get_bytes(), + 'end_index' => $end_index, + ); + } + + /** + * Check whether a translated numeric identifier string predicate reaches a boundary. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Token index after the predicate. + * @return bool Whether the next token is absent or a supported boundary. + */ + private function numeric_identifier_string_comparison_has_boundary( array $tokens, int $index ): bool { + return ! isset( $tokens[ $index ] ) || $this->is_text_value_numeric_comparison_boundary_token( $tokens[ $index ] ); + } + + /** + * Translate numeric WordPress id IN lists containing quoted strings, numbers, and NULL. + * + * @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_numeric_identifier_string_literal_in_predicate( array $tokens, int &$index ): ?string { + $operand = $this->numeric_identifier_string_comparison_operand_sql( $tokens, $index ); + if ( null === $operand || ! isset( $tokens[ $operand['next_index'] ] ) ) { + return null; + } + + $not = false; + $operator_index = $operand['next_index']; + $in_index = $operator_index; + if ( + in_array( $tokens[ $operator_index ]->id, array( WP_MySQL_Lexer::NOT_SYMBOL, WP_MySQL_Lexer::NOT2_SYMBOL ), true ) + && isset( $tokens[ $operator_index + 1 ] ) + ) { + $not = true; + $in_index = $operator_index + 1; + } + + if ( + ! isset( $tokens[ $in_index + 2 ] ) + || WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $in_index ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $in_index + 1 ]->id + ) { + return null; + } + + $comparison_sql = array(); + $list_index = $in_index + 2; + while ( isset( $tokens[ $list_index ] ) ) { + $token = $tokens[ $list_index ]; + $constant_expression = $this->numeric_constant_arithmetic_expression_sql( $tokens, $list_index ); + if ( null !== $constant_expression ) { + $comparison_sql[] = $operand['sql'] + . ( $not ? ' <> ' : ' = ' ) + . $constant_expression['sql']; + $list_index = $constant_expression['end_index'] + 1; + } elseif ( $this->is_string_literal_token( $token ) ) { + $comparison_sql[] = $this->sqlite_numeric_string_literal_comparison_sql( + $operand['sql'], + $not ? WP_MySQL_Lexer::NOT_EQUAL_OPERATOR : WP_MySQL_Lexer::EQUAL_OPERATOR, + $not ? '<>' : '=', + $token, + true + ); + ++$list_index; + } elseif ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id || WP_MySQL_Lexer::NULL2_SYMBOL === $token->id ) { + $comparison_sql[] = 'NULL'; + ++$list_index; + } else { + $sign_sql = ''; + if ( + $this->is_sign_token( $token ) + && isset( $tokens[ $list_index + 1 ] ) + && $this->is_number_token( $tokens[ $list_index + 1 ] ) + ) { + $sign_sql = $token->get_bytes(); + ++$list_index; + $token = $tokens[ $list_index ]; + } + + if ( ! $this->is_number_token( $token ) ) { + return null; + } + + $comparison_sql[] = $operand['sql'] + . ( $not ? ' <> ' : ' = ' ) + . $sign_sql + . $token->get_bytes(); + ++$list_index; + } + + if ( ! isset( $tokens[ $list_index ] ) ) { + return null; + } + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $list_index ]->id ) { + break; + } + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $list_index ]->id ) { + return null; + } + ++$list_index; + } + + if ( + array() === $comparison_sql + || ! isset( $tokens[ $list_index ] ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $list_index ]->id + ) { + return null; + } + + $index = $list_index; + return '(' . implode( $not ? ' AND ' : ' OR ', $comparison_sql ) . ')'; + } + + /** + * Translate numeric WordPress id LIKE 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_numeric_identifier_like_predicate( array $tokens, int &$index ): ?string { + $operand = $this->numeric_identifier_string_comparison_operand_sql( $tokens, $index ); + if ( null === $operand || ! isset( $tokens[ $operand['next_index'] ] ) ) { + return null; + } + + $is_not_like = false; + $operator_index = $operand['next_index']; + $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 ] ) || ! $this->is_string_literal_token( $tokens[ $pattern_index ] ) ) { + return null; + } + + $pattern = $this->token_value( $tokens[ $pattern_index ] ); + $escape = ''; + $end = $pattern_index; + if ( + isset( $tokens[ $pattern_index + 2 ] ) + && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern_index + 1 ]->id + && $this->is_string_literal_token( $tokens[ $pattern_index + 2 ] ) + ) { + $escape = ' ESCAPE ' . $this->connection->quote( $this->token_value( $tokens[ $pattern_index + 2 ] ) ); + $end = $pattern_index + 2; + } elseif ( isset( $tokens[ $pattern_index + 1 ] ) && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern_index + 1 ]->id ) { + return null; + } elseif ( false !== strpos( $pattern, '\\' ) ) { + $escape = ' ESCAPE ' . $this->connection->quote( '\\' ); + } + + if ( ! $this->numeric_identifier_string_comparison_has_boundary( $tokens, $end + 1 ) ) { + return null; + } + + $index = $end; + return 'CAST(' + . $operand['sql'] + . ' AS VARCHAR)' + . ( $is_not_like ? ' NOT LIKE ' : ' LIKE ' ) + . $this->connection->quote( $pattern ) + . $escape; + } + + /** + * 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. + * @param bool $allow_posts_table_columns Whether to allow posts-table text columns. + * @return array{sql:string,next_index:int}|null Operand SQL and next token index, or null when not matched. + */ + private function text_value_numeric_comparison_operand_sql( array $tokens, int $index, bool $allow_posts_table_columns = true ): ?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 ] ) + ) { + $qualifier = $this->identifier_value( $tokens[ $index ] ); + $column_name = $this->identifier_value( $tokens[ $index + 2 ] ); + if ( ! $this->is_text_value_numeric_comparison_operand_column( $tokens, $index, $column_name, $qualifier, $allow_posts_table_columns ) ) { + return null; + } + + return array( + 'sql' => $this->connection->quote_identifier( $qualifier ) + . '.' + . $this->connection->quote_identifier( $column_name ), + 'next_index' => $index + 3, + ); + } + + $column_name = $this->identifier_value( $tokens[ $index ] ); + if ( ! $this->is_text_value_numeric_comparison_operand_column( $tokens, $index, $column_name, null, $allow_posts_table_columns ) ) { + return null; + } + + return array( + 'sql' => $this->connection->quote_identifier( $column_name ), + 'next_index' => $index + 1, + ); + } + + /** + * Check whether a column operand should receive text-to-number comparison parity. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @param int $index Operand start index. + * @param string $column_name Column name. + * @param string|null $qualifier Optional column qualifier. + * @param bool $allow_posts_table_columns Whether to allow posts-table text columns. + * @return bool Whether the column operand is supported. + */ + private function is_text_value_numeric_comparison_operand_column( array $tokens, int $index, string $column_name, ?string $qualifier, bool $allow_posts_table_columns ): bool { + if ( $this->is_text_value_numeric_comparison_column( $column_name ) ) { + return true; + } + + if ( + ! $allow_posts_table_columns + || ! $this->is_wordpress_posts_text_value_numeric_comparison_column( $column_name ) + || ! $this->is_outer_query_token_offset( $tokens, $index ) + ) { + return false; + } + + $table = $this->text_value_numeric_posts_table_context( $tokens ); + return null !== $table && $this->simple_select_column_qualifier_matches_table( $qualifier, $table ); + } + + /** + * Resolve the simple WordPress posts table context for text-value comparisons. + * + * @param WP_Parser_Token[] $tokens Token stream. + * @return array{table_name:string,alias:string,temporary:bool}|null Table context, or null when unsupported. + */ + private function text_value_numeric_posts_table_context( 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::UNION_SYMBOL ) ) { + return null; + } + + $from_index = $this->find_top_level_token_index( $tokens, 1, WP_MySQL_Lexer::FROM_SYMBOL ); + if ( null === $from_index ) { + return null; + } + + $table = $this->parse_unresolved_simple_select_table_reference( + array_slice( + $tokens, + $from_index + 1, + $this->simple_select_from_clause_end( $tokens, $from_index + 1 ) - $from_index - 1 + ) + ); + if ( null === $table || ! $this->is_wordpress_posts_table_name( $table['table_name'] ) ) { + return null; + } + + return $table; + } + + /** + * Check whether a globally safe 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( + 'meta_value', + 'option_value', + ), + true + ); + } + + /** + * Check whether a posts-table column should receive text-to-number comparison parity. + * + * @param string $column_name Column name. + * @return bool Whether the column is a supported WordPress posts text column. + */ + private function is_wordpress_posts_text_value_numeric_comparison_column( string $column_name ): bool { + return in_array( + strtolower( $column_name ), + array( + 'post_content', + 'post_excerpt', + 'post_name', + 'post_title', + ), + true + ); + } + + /** + * Build a numeric identifier operand SQL fragment for quoted-string comparisons. + * + * @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 numeric_identifier_string_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 ( ! $this->is_numeric_identifier_string_comparison_column( $column_name ) ) { + 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 ( ! $this->is_numeric_identifier_string_comparison_column( $column_name ) ) { + return null; + } + + return array( + 'sql' => $this->connection->quote_identifier( $column_name ), + 'next_index' => $index + 1, + ); + } + + /** + * Check whether a column is commonly numeric in WordPress SQL. + * + * @param string $column_name Column name. + * @return bool Whether the column should use SQLite-compatible numeric string comparison semantics. + */ + private function is_numeric_identifier_string_comparison_column( string $column_name ): bool { + if ( 'ID' === $column_name ) { + return true; + } + + return in_array( + strtolower( $column_name ), + array( + 'blog_id', + 'comment_count', + 'comment_id', + 'comment_parent', + 'comment_post_id', + 'link_id', + 'menu_order', + 'meta_id', + 'object_id', + 'option_id', + 'post_author', + 'post_id', + 'post_parent', + 'site_id', + 'term_group', + 'term_id', + 'term_order', + 'term_taxonomy_id', + 'umeta_id', + 'user_id', + ), + true + ); + } + + /** + * Build a MySQL-like numeric coercion expression for text values. + * + * @param string $sql Text expression SQL. + * @return string Numeric SQL. + */ + private function mysql_numeric_coercion_sql( string $sql ): string { + return 'CASE WHEN ' + . $sql + . ' IS NULL THEN NULL ELSE COALESCE(' + . $this->mysql_numeric_prefix_try_cast_sql( $sql ) + . ', 0) END'; + } + + /** + * Build a numeric conversion for the leading MySQL/SQLite numeric prefix. + * + * @param string $sql Text expression SQL. + * @return string Numeric SQL that returns NULL when no numeric prefix is present. + */ + private function mysql_numeric_prefix_try_cast_sql( string $sql ): string { + return 'TRY_CAST(' . $this->mysql_numeric_prefix_match_sql( $sql ) . ' AS DOUBLE)'; + } + + /** + * Extract the leading MySQL/SQLite numeric prefix from a text expression. + * + * @param string $sql Text expression SQL. + * @return string Text SQL containing the numeric prefix, or NULL when absent. + */ + private function mysql_numeric_prefix_match_sql( string $sql ): string { + $pattern_sql = "'" + . '^\\s*[+-]?(?:(?:[0-9]+(?:\\.[0-9]*)?)|(?:\\.[0-9]+))(?:[eE][+-]?[0-9]+)?' + . "'"; + + return 'NULLIF(regexp_extract(CAST(' . $sql . ' AS VARCHAR), ' . $pattern_sql . ", 0), '')"; + } + + /** + * Build a SQLite-like numeric identifier comparison against a quoted string. + * + * @param string $operand_sql Numeric column SQL. + * @param WP_Parser_Token $operator Comparison operator token. + * @param WP_Parser_Token $literal Quoted string token. + * @param bool $operand_left Whether the numeric operand is on the left. + * @return string Comparison SQL. + */ + private function sqlite_numeric_string_comparison_sql( string $operand_sql, WP_Parser_Token $operator, WP_Parser_Token $literal, bool $operand_left ): string { + return $this->sqlite_numeric_string_literal_comparison_sql( + $operand_sql, + $operator->id, + $operator->get_bytes(), + $literal, + $operand_left + ); + } + + /** + * Build a SQLite-like numeric identifier comparison against a quoted string. + * + * @param string $operand_sql Numeric column SQL. + * @param int $operator_id Comparison operator token ID. + * @param string $operator_sql Comparison operator SQL. + * @param WP_Parser_Token $literal Quoted string token. + * @param bool $operand_left Whether the numeric operand is on the left. + * @return string Comparison SQL. + */ + private function sqlite_numeric_string_literal_comparison_sql( string $operand_sql, int $operator_id, string $operator_sql, WP_Parser_Token $literal, bool $operand_left ): string { + $literal_sql = $this->connection->quote( $this->token_value( $literal ) ); + $numeric_sql = 'TRY_CAST(' . $literal_sql . ' AS DOUBLE)'; + $left_sql = $operand_left ? $operand_sql : $numeric_sql; + $right_sql = $operand_left ? $numeric_sql : $operand_sql; + + return 'CASE WHEN ' + . $operand_sql + . ' IS NULL THEN NULL WHEN ' + . $numeric_sql + . ' IS NULL THEN ' + . $this->sqlite_numeric_string_null_comparison_result_sql( $operator_id, $operand_left ) + . ' ELSE ' + . $left_sql + . ' ' + . $operator_sql + . ' ' + . $right_sql + . ' END'; + } + + /** + * Return SQLite's boolean result for number-vs-nonnumeric-text comparisons. + * + * @param int $operator_id Comparison operator token ID. + * @param bool $operand_left Whether the numeric operand is on the left. + * @return string TRUE or FALSE. + */ + private function sqlite_numeric_string_null_comparison_result_sql( int $operator_id, bool $operand_left ): string { + switch ( $operator_id ) { + case WP_MySQL_Lexer::EQUAL_OPERATOR: + return 'FALSE'; + case WP_MySQL_Lexer::NOT_EQUAL_OPERATOR: + return 'TRUE'; + case WP_MySQL_Lexer::LESS_THAN_OPERATOR: + case WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR: + return $operand_left ? 'TRUE' : 'FALSE'; + case WP_MySQL_Lexer::GREATER_THAN_OPERATOR: + case WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR: + return $operand_left ? 'FALSE' : 'TRUE'; + } + + return 'FALSE'; + } + + /** + * 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 is a comparison operator for text-value integer predicates. + * + * @param WP_Parser_Token|null $token Token. + * @return bool Whether the token is a supported comparison operator. + */ + private function is_text_value_numeric_comparison_operator_token( $token ): bool { + return $this->is_numeric_comparison_operator_token( $token ) + || ( + $token instanceof WP_Parser_Token + && WP_MySQL_Lexer::NOT_EQUAL_OPERATOR === $token->id + ); + } + + /** + * Check whether a token is a comparison operator for numeric identifier/string coercion. + * + * @param WP_Parser_Token|null $token Token. + * @return bool Whether the token is a supported comparison operator. + */ + private function is_numeric_string_comparison_operator_token( $token ): bool { + return $this->is_numeric_comparison_operator_token( $token ) + || ( + $token instanceof WP_Parser_Token + && WP_MySQL_Lexer::NOT_EQUAL_OPERATOR === $token->id + ); + } + + /** + * Check whether a token is a numeric arithmetic operator. + * + * @param WP_Parser_Token|null $token Token. + * @return bool Whether the token is a supported arithmetic operator. + */ + private function is_numeric_arithmetic_operator_token( $token ): bool { + return $token instanceof WP_Parser_Token + && in_array( + $token->id, + array( + WP_MySQL_Lexer::PLUS_OPERATOR, + WP_MySQL_Lexer::MINUS_OPERATOR, + WP_MySQL_Lexer::MULT_OPERATOR, + ), + true + ); + } + + /** + * Check whether a token is a quoted string literal. + * + * @param WP_Parser_Token|null $token Token. + * @return bool Whether the token is a quoted string literal. + */ + private function is_string_literal_token( $token ): bool { + return $token instanceof WP_Parser_Token + && ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ); + } + + /** + * Check whether a token is an ISO-8601 datetime quoted string literal. + * + * @param WP_Parser_Token|null $token Token. + * @return bool Whether the token is a supported ISO datetime literal. + */ + private function is_iso_datetime_string_literal_token( $token ): bool { + return $this->is_string_literal_token( $token ) + && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?$/', $this->token_value( $token ) ); + } + + /** + * 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_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::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 + ); + } + + /** + * 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 { + $left_operand = $this->like_escape_left_operand_sql( $tokens, $index ); + if ( null === $left_operand ) { + return null; + } + + $operator_index = $left_operand['next_index']; + 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( $this->token_value( $tokens[ $pattern_index ] ), '\\' ) + || ( isset( $tokens[ $pattern_index + 1 ] ) && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern_index + 1 ]->id ) + ) { + return null; + } + + $index = $pattern_index; + return $left_operand['sql'] + . ( $is_not_like ? ' NOT LIKE ' : ' LIKE ' ) + . $this->connection->quote( $this->token_value( $tokens[ $pattern_index ] ) ) + . ' 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( $this->token_value( $tokens[ $index ] ) ), + '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. + * + * @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( $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; + } + + /** + * 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. + * + * @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 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. + * @param bool $allow_on_duplicate_returning_insert_id Whether proven ODKU insert branches may use RETURNING. + * @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, bool $allow_on_duplicate_returning_insert_id = false ): 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_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 ); + + $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 = $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 ) ) { + $insert_ignore_explicit_ids_existed[ $key ] = $this->auto_increment_value_exists( + $table_reference['table_name'], + $metadata['column_name'], + $insert_ignore_explicit_id + ); + } + } + } + $use_returning_insert_id = null !== $metadata + && null !== $table_reference + && null !== $table_index + && $column_was_omitted + && WP_DuckDB_Connection::class === get_class( $this->connection ) + && ( + $allow_on_duplicate_returning_insert_id + || ! $this->is_insert_on_duplicate_key_update_write( $tokens, $table_index ) + ); + + $before_max = null; + if ( ! $use_returning_insert_id && $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 + && ! $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'] ); + } + + if ( $use_returning_insert_id ) { + return $this->execute_auto_increment_returning_write( $sql, $context, $metadata['column_name'] ); + } + + $result = $this->execute_duckdb_query( $sql, $context ); + + if ( null !== $sequence_name && ( $result->rowCount() > 0 || ! $insert_ignore_write ) ) { + $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 ); + } + } elseif ( null !== $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; + } + } + } + + 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 ); + } + } + } + + 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; + } + + /** + * Execute an omitted AUTO_INCREMENT write with DuckDB RETURNING. + * + * @param string $sql DuckDB INSERT/REPLACE SQL. + * @param string $context Failure context. + * @param string $column_name AUTO_INCREMENT column name. + * @return WP_DuckDB_Result_Statement + */ + private function execute_auto_increment_returning_write( string $sql, string $context, string $column_name ): WP_DuckDB_Result_Statement { + $result = $this->execute_duckdb_query( + $sql . ' RETURNING ' . $this->connection->quote_identifier( $column_name ), + $context + ); + $ids = $result->fetchAll( PDO::FETCH_COLUMN ); + + if ( count( $ids ) > 0 ) { + $this->last_insert_id = max( array_map( 'intval', $ids ) ); + } + + return new WP_DuckDB_Result_Statement( array(), array(), count( $ids ) ); + } + + /** + * Check whether an INSERT write uses ON DUPLICATE KEY UPDATE. + * + * @param WP_Parser_Token[] $tokens MySQL token stream. + * @param int $table_index Index of the table token. + * @return bool Whether the statement is INSERT ... ON DUPLICATE KEY UPDATE. + */ + private function is_insert_on_duplicate_key_update_write( array $tokens, int $table_index ): bool { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + for ( $index = $table_index + 1; $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 true; + } + } + + return false; + } + + /** + * 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 { + $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 ) ) { + $inserted_ids[] = $explicit_id; + } + } + + return $inserted_ids; + } + + /** + * 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. + * + * @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 $temporary = false ): bool { + foreach ( $this->unique_key_column_sets( $table_name, $temporary ) 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. + * + * @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 { + $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 ]; + } + + if ( isset( $this->table_column_metadata_cache[ $cache_key ] ) ) { + foreach ( $this->table_column_metadata_cache[ $cache_key ] as $metadata ) { + if ( + ! isset( $metadata['column_name'] ) + || false === stripos( (string) ( $metadata['extra'] ?? '' ), 'auto_increment' ) + ) { + continue; + } + + $column_name = (string) $metadata['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 ]; + } + } + + try { + $stmt = $this->connection->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 LIMIT 1" + ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $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 ) { + $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; + $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 ]; + } + + /** + * 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. + * + * @param string $table_name Table name. + * @return string[] Sequence names. + */ + private function auto_increment_sequences_for_table( string $table_name, bool $temporary = false ): array { + $this->ensure_column_metadata_table( $temporary ); + + $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', + $temporary + ); + + $sequence_names = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN ) as $column_name ) { + $sequence_names[] = $this->sequence_name( $table_name, (string) $column_name, $temporary ); + } + + 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 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 ) { + $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 array(); + } + + 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 array(); + } + ++$index; + + $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_values[] = $value; + } + } + + if ( isset( $tokens[ $index ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $index ]->id ) { + ++$index; + continue; + } + break; + } + + return $explicit_values; + } + + /** + * 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+$/', $this->token_value( $token ) ) + ) { + return (int) $this->token_value( $token ); + } + } + + 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; + } + + /** + * 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. + * + * 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. + * + * @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; + $profile_enabled = self::query_profile_enabled(); + $profile_started_at = $profile_enabled ? microtime( true ) : 0.0; + $runtime_enabled = self::runtime_counters_enabled(); + $runtime_started_at = $runtime_enabled ? microtime( true ) : 0.0; + $runtime_error = false; + + try { + return $this->connection->query( $sql ); + } catch ( WP_DuckDB_Driver_Exception $e ) { + $runtime_error = true; + throw new WP_DuckDB_Driver_Exception( $context . ': ' . $e->getMessage(), 0, $e ); + } finally { + if ( $profile_enabled ) { + $this->profile_current_native_seconds += microtime( true ) - $profile_started_at; + ++$this->profile_current_native_queries; + } + if ( $runtime_enabled ) { + $runtime_seconds = microtime( true ) - $runtime_started_at; + self::record_runtime_counter( 'native_query', $runtime_seconds ); + self::record_runtime_native_context( $context, $runtime_seconds, $runtime_error ); + self::record_runtime_native_shape( + $this->normalize_query_profile_shape( $sql ), + $runtime_seconds, + $runtime_error + ); + } + } + } + + /** + * 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(); + $this->clear_schema_state_after_rollback(); + return $this->execute_duckdb_query( $sql, $context ); + } + } + + /** + * 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 ' + . ( $temporary ? 'TEMP ' : '' ) + . 'TABLE IF NOT EXISTS ' + . $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' + ); + } + + $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 ' : '' ) + . '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' + ); + + $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 ' : '' ) + . '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' + ); + + $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 ' : '' ) + . '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' + ); + + $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 ' : '' ) + . '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' + ); + + $this->ensured_metadata_tables[ $ensure_key ] = true; + } + + /** + * Check whether optional DuckDB query profiling is enabled. + * + * @return bool Whether profiling is enabled. + */ + private static function query_profile_enabled(): bool { + if ( null === self::$query_profile_enabled ) { + $value = getenv( 'WP_DUCKDB_QUERY_PROFILE' ); + self::$query_profile_enabled = is_string( $value ) + && '' !== $value + && '0' !== $value + && 'false' !== strtolower( $value ) + && 'off' !== strtolower( $value ) + && 'no' !== strtolower( $value ); + } + + if ( self::$query_profile_enabled && ! self::$query_profile_shutdown_registered ) { + register_shutdown_function( array( __CLASS__, 'emit_query_profile_shutdown_summary' ) ); + self::$query_profile_shutdown_registered = true; + } + + return self::$query_profile_enabled; + } + + /** + * Check whether optional runtime counters are enabled. + * + * @return bool Whether runtime counters are enabled. + */ + private static function runtime_counters_enabled(): bool { + if ( null === self::$runtime_counters_enabled ) { + $value = getenv( 'WP_DUCKDB_RUNTIME_COUNTERS' ); + self::$runtime_counters_enabled = is_string( $value ) + && '' !== $value + && '0' !== $value + && 'false' !== strtolower( $value ) + && 'off' !== strtolower( $value ) + && 'no' !== strtolower( $value ); + } + + if ( self::$runtime_counters_enabled && ! self::$runtime_counters_shutdown_registered ) { + register_shutdown_function( array( __CLASS__, 'emit_runtime_counters_shutdown_summary' ) ); + self::$runtime_counters_shutdown_registered = true; + } + + return self::$runtime_counters_enabled; + } + + /** + * Record an optional runtime counter sample. + * + * @param string $name Counter name. + * @param float $seconds Runtime seconds. + */ + private static function record_runtime_counter( string $name, float $seconds ): void { + if ( ! isset( self::$runtime_counters['counters'][ $name ] ) ) { + self::$runtime_counters['counters'][ $name ] = array( + 'count' => 0, + 'total_seconds' => 0.0, + ); + } + + ++self::$runtime_counters['counters'][ $name ]['count']; + self::$runtime_counters['counters'][ $name ]['total_seconds'] += $seconds; + } + + /** + * Record optional runtime attribution by native query context. + * + * @param string $context Native query context. + * @param float $seconds Runtime seconds. + * @param bool $error Whether the native query failed. + */ + private static function record_runtime_native_context( string $context, float $seconds, bool $error ): void { + $context = self::sanitize_runtime_counter_label( $context ); + if ( ! isset( self::$runtime_counters['native_contexts'][ $context ] ) ) { + self::$runtime_counters['native_contexts'][ $context ] = array( + 'count' => 0, + 'errors' => 0, + 'total_seconds' => 0.0, + ); + } + + ++self::$runtime_counters['native_contexts'][ $context ]['count']; + self::$runtime_counters['native_contexts'][ $context ]['total_seconds'] += $seconds; + if ( $error ) { + ++self::$runtime_counters['native_contexts'][ $context ]['errors']; + } + if ( count( self::$runtime_counters['native_contexts'] ) > 1000 ) { + self::prune_runtime_counter_rows( 'native_contexts' ); + } + } + + /** + * Record optional runtime attribution by normalized native SQL shape. + * + * @param string $shape Native SQL shape. + * @param float $seconds Runtime seconds. + * @param bool $error Whether the native query failed. + */ + private static function record_runtime_native_shape( string $shape, float $seconds, bool $error ): void { + $shape = self::sanitize_runtime_counter_label( $shape ); + if ( ! isset( self::$runtime_counters['native_shapes'][ $shape ] ) ) { + self::$runtime_counters['native_shapes'][ $shape ] = array( + 'count' => 0, + 'errors' => 0, + 'total_seconds' => 0.0, + ); + } + + ++self::$runtime_counters['native_shapes'][ $shape ]['count']; + self::$runtime_counters['native_shapes'][ $shape ]['total_seconds'] += $seconds; + if ( $error ) { + ++self::$runtime_counters['native_shapes'][ $shape ]['errors']; + } + if ( count( self::$runtime_counters['native_shapes'] ) > 1000 ) { + self::prune_runtime_counter_rows( 'native_shapes' ); + } + } + + /** + * Normalize one runtime counter label for single-line log output. + * + * @param string $label Label. + * @return string Sanitized label. + */ + private static function sanitize_runtime_counter_label( string $label ): string { + $label = preg_replace( '/\s+/', ' ', str_replace( array( "\r", "\n" ), ' ', $label ) ); + $label = trim( (string) $label ); + if ( strlen( $label ) > 300 ) { + $label = substr( $label, 0, 297 ) . '...'; + } + + return '' === $label ? '' : $label; + } + + /** + * Keep only the highest-cost runtime attribution rows. + * + * @param string $bucket Runtime counter bucket. + */ + private static function prune_runtime_counter_rows( string $bucket ): void { + $rows = self::$runtime_counters[ $bucket ]; + uasort( + $rows, + function ( array $left, array $right ): int { + return $right['total_seconds'] <=> $left['total_seconds']; + } + ); + + self::$runtime_counters[ $bucket ] = array_slice( $rows, 0, 500, true ); + } + + /** + * Record one MySQL-facing query in the optional profile. + * + * @param string $query MySQL query. + * @param float $total_seconds Query wall time. + * @param bool $error Whether the query ended in an error. + */ + private function record_query_profile( string $query, float $total_seconds, bool $error ): void { + $shape = $this->normalize_query_profile_shape( $query ); + if ( ! isset( self::$query_profile['shapes'][ $shape ] ) ) { + self::$query_profile['shapes'][ $shape ] = array( + 'count' => 0, + 'errors' => 0, + 'total_seconds' => 0.0, + 'parse_seconds' => 0.0, + 'native_seconds' => 0.0, + 'native_queries' => 0, + 'sample' => $this->query_profile_sample_query( $query ), + ); + } + + ++self::$query_profile['queries']; + self::$query_profile['total_seconds'] += $total_seconds; + self::$query_profile['parse_seconds'] += $this->profile_current_parse_seconds; + self::$query_profile['native_seconds'] += $this->profile_current_native_seconds; + self::$query_profile['native_queries'] += $this->profile_current_native_queries; + if ( $error ) { + ++self::$query_profile['errors']; + } + + ++self::$query_profile['shapes'][ $shape ]['count']; + self::$query_profile['shapes'][ $shape ]['total_seconds'] += $total_seconds; + self::$query_profile['shapes'][ $shape ]['parse_seconds'] += $this->profile_current_parse_seconds; + self::$query_profile['shapes'][ $shape ]['native_seconds'] += $this->profile_current_native_seconds; + self::$query_profile['shapes'][ $shape ]['native_queries'] += $this->profile_current_native_queries; + if ( $error ) { + ++self::$query_profile['shapes'][ $shape ]['errors']; + } + + if ( count( self::$query_profile['shapes'] ) > 1000 && 0 === self::$query_profile['queries'] % 1000 ) { + self::prune_query_profile_shapes(); + } + + $interval = self::query_profile_log_interval(); + if ( $interval > 0 && 0 === self::$query_profile['queries'] % $interval ) { + self::emit_query_profile_summary( 'interval' ); + } + } + + /** + * Normalize a query into a bounded, literal-free profile shape. + * + * @param string $query MySQL query. + * @return string Normalized shape. + */ + private function normalize_query_profile_shape( string $query ): string { + $shape = preg_replace( "/'(?:''|\\\\.|[^'\\\\])*'/s", "'?'", $query ); + $shape = preg_replace( '/"(?:\\\\"|\\\\.|[^"\\\\])*"/s', '"?"', (string) $shape ); + $shape = preg_replace( '/\b0x[0-9a-f]+\b/i', '?', (string) $shape ); + $shape = preg_replace( '/\b\d+(?:\.\d+)?\b/', '?', (string) $shape ); + $shape = preg_replace( '/\s+/', ' ', (string) $shape ); + $shape = trim( (string) $shape ); + + if ( strlen( $shape ) > 300 ) { + $shape = substr( $shape, 0, 297 ) . '...'; + } + + return '' === $shape ? '' : $shape; + } + + /** + * Return a bounded redacted sample query for one profile shape. + * + * @param string $query MySQL query. + * @return string Sample query. + */ + private function query_profile_sample_query( string $query ): string { + $sample = $this->redact_query_profile_string_literals( $query ); + if ( null !== $sample ) { + $sample = preg_replace( '/\b0x[0-9a-f]+\b/i', '?', (string) $sample ); + } + if ( null !== $sample ) { + $sample = preg_replace( '/\b\d+(?:\.\d+)?\b/', '?', (string) $sample ); + } + + if ( null === $sample ) { + $sample = ''; + } + + return self::sanitize_runtime_counter_label( (string) $sample ); + } + + /** + * Redact quoted SQL string literals without relying on regular expression limits. + * + * @param string $query MySQL query. + * @return string Query with quoted strings replaced. + */ + private function redact_query_profile_string_literals( string $query ): string { + $result = ''; + $length = strlen( $query ); + + for ( $i = 0; $i < $length; ++$i ) { + $char = $query[ $i ]; + if ( "'" !== $char && '"' !== $char ) { + $result .= $char; + continue; + } + + $quote = $char; + $result .= $quote . '?' . $quote; + ++$i; + + for ( ; $i < $length; ++$i ) { + if ( '\\' === $query[ $i ] ) { + ++$i; + continue; + } + if ( $quote !== $query[ $i ] ) { + continue; + } + if ( isset( $query[ $i + 1 ] ) && $quote === $query[ $i + 1 ] ) { + ++$i; + continue; + } + break; + } + } + + return $result; + } + + /** + * Read the optional profile logging interval. + * + * @return int Query interval. Zero disables interval logging. + */ + private static function query_profile_log_interval(): int { + $value = getenv( 'WP_DUCKDB_QUERY_PROFILE_INTERVAL' ); + if ( ! is_string( $value ) || '' === $value ) { + return 5000; + } + + return max( 0, (int) $value ); + } + + /** + * Keep only the highest-cost query shapes when the profiler map grows large. + */ + private static function prune_query_profile_shapes(): void { + $shapes = self::$query_profile['shapes']; + uasort( + $shapes, + function ( array $left, array $right ): int { + return $right['total_seconds'] <=> $left['total_seconds']; + } + ); + + self::$query_profile['shapes'] = array_slice( $shapes, 0, 500, true ); + } + + /** + * Emit the optional profile at PHP shutdown. + */ + public static function emit_query_profile_shutdown_summary(): void { + self::emit_query_profile_summary( 'shutdown' ); + } + + /** + * Emit the optional runtime counters at PHP shutdown. + */ + public static function emit_runtime_counters_shutdown_summary(): void { + self::emit_runtime_counters_summary( 'shutdown' ); + } + + /** + * Emit an optional profiler summary to the PHP error log. + * + * @param string $reason Why the summary is being emitted. + */ + private static function emit_query_profile_summary( string $reason ): void { + if ( ! self::query_profile_enabled() || 0 === self::$query_profile['queries'] ) { + return; + } + + error_log( + sprintf( + 'WP_DUCKDB_QUERY_PROFILE_SUMMARY reason=%s queries=%d errors=%d total_seconds=%.3f parse_seconds=%.3f native_seconds=%.3f native_queries=%d shapes=%d', + $reason, + self::$query_profile['queries'], + self::$query_profile['errors'], + self::$query_profile['total_seconds'], + self::$query_profile['parse_seconds'], + self::$query_profile['native_seconds'], + self::$query_profile['native_queries'], + count( self::$query_profile['shapes'] ) + ) + ); + + $shapes = self::$query_profile['shapes']; + uasort( + $shapes, + function ( array $left, array $right ): int { + return $right['total_seconds'] <=> $left['total_seconds']; + } + ); + + $rank = 0; + foreach ( array_slice( $shapes, 0, 15, true ) as $shape => $profile ) { + ++$rank; + error_log( + sprintf( + 'WP_DUCKDB_QUERY_PROFILE_TOP rank=%d count=%d errors=%d total_seconds=%.3f avg_ms=%.3f parse_seconds=%.3f native_seconds=%.3f native_queries=%d shape=%s', + $rank, + $profile['count'], + $profile['errors'], + $profile['total_seconds'], + 1000 * $profile['total_seconds'] / max( 1, $profile['count'] ), + $profile['parse_seconds'], + $profile['native_seconds'], + $profile['native_queries'], + str_replace( array( "\r", "\n" ), ' ', $shape ) + ) + ); + error_log( + sprintf( + 'WP_DUCKDB_QUERY_PROFILE_SAMPLE rank=%d sample=%s', + $rank, + isset( $profile['sample'] ) ? self::sanitize_runtime_counter_label( (string) $profile['sample'] ) : '' + ) + ); + } + } + + /** + * Emit optional runtime counter summaries to the PHP error log. + * + * @param string $reason Why the summary is being emitted. + */ + private static function emit_runtime_counters_summary( string $reason ): void { + if ( ! self::runtime_counters_enabled() ) { + return; + } + + $counters = self::$runtime_counters['counters']; + $native_contexts = self::$runtime_counters['native_contexts']; + $native_shapes = self::$runtime_counters['native_shapes']; + if ( count( $counters ) === 0 && count( $native_contexts ) === 0 && count( $native_shapes ) === 0 ) { + return; + } + + $mysql_queries = isset( $counters['mysql_query'] ) ? (int) $counters['mysql_query']['count'] : 0; + $native_queries = isset( $counters['native_query'] ) ? (int) $counters['native_query']['count'] : 0; + $native_seconds = isset( $counters['native_query'] ) ? (float) $counters['native_query']['total_seconds'] : 0.0; + + error_log( + sprintf( + 'WP_DUCKDB_RUNTIME_COUNTERS_SUMMARY reason=%s mysql_queries=%d native_queries=%d native_seconds=%.3f counters=%d native_contexts=%d native_shapes=%d', + $reason, + $mysql_queries, + $native_queries, + $native_seconds, + count( $counters ), + count( $native_contexts ), + count( $native_shapes ) + ) + ); + + self::emit_runtime_counter_table( 'WP_DUCKDB_RUNTIME_COUNTER', $counters, 'name' ); + self::emit_runtime_counter_table( 'WP_DUCKDB_RUNTIME_CONTEXT_TOP', $native_contexts, 'context' ); + self::emit_runtime_counter_table( 'WP_DUCKDB_RUNTIME_NATIVE_TOP', $native_shapes, 'shape' ); + } + + /** + * Emit one sorted runtime counter table. + * + * @param string $prefix Log prefix. + * @param array $rows Counter rows. + * @param string $label Label field name. + */ + private static function emit_runtime_counter_table( string $prefix, array $rows, string $label ): void { + uasort( + $rows, + function ( array $left, array $right ): int { + return $right['total_seconds'] <=> $left['total_seconds']; + } + ); + + $rank = 0; + foreach ( array_slice( $rows, 0, 15, true ) as $name => $row ) { + ++$rank; + $errors = isset( $row['errors'] ) ? sprintf( ' errors=%d', (int) $row['errors'] ) : ''; + error_log( + sprintf( + '%s rank=%d count=%d%s total_seconds=%.3f avg_ms=%.3f %s=%s', + $prefix, + $rank, + (int) $row['count'], + $errors, + (float) $row['total_seconds'], + 1000 * (float) $row['total_seconds'] / max( 1, (int) $row['count'] ), + $label, + self::sanitize_runtime_counter_label( (string) $name ) + ) + ); + } + } + + /** + * 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; + } + + /** + * 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 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. + * + * @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->visible_table_reference_cache = array(); + $this->table_metadata_cache = array(); + $this->column_metadata_cache = array(); + $this->table_column_metadata_cache = array(); + $this->wildcard_result_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->visible_table_reference_cache[ strtolower( $table_name ) ] ); + + $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->primary_key_index_rows_cache[ $key ], + $this->auto_increment_metadata_cache[ $key ], + $this->unique_key_column_sets_cache[ $key ] + ); + foreach ( array_keys( $this->wildcard_result_column_metadata_cache ) as $wildcard_key ) { + if ( 0 === strpos( $wildcard_key, $key . ':alias:' ) ) { + unset( $this->wildcard_result_column_metadata_cache[ $wildcard_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. + * + * @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; + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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. + * + * @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']; + $this->ensure_index_metadata_table( $temporary ); + + $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 ' + . $this->connection->quote_identifier( $this->index_metadata_table_name( $temporary ) ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ) + . ' AND index_name = ' + . $this->connection->quote( $index_name ), + 'Failed to reset DuckDB index metadata' + ); + + $value_rows = array(); + foreach ( $index_definition['columns'] as $offset => $column ) { + $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' + ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); + } + + /** + * 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, 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'], $temporary ) ), + '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, 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, index_type FROM ' + . $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', + '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'], + 'index_type' => null === $row['index_type'] ? 'BTREE' : (string) $row['index_type'], + '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'], + $temporary, + $definition['index_type'] + ); + } + + return $definitions; + } + + /** + * 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, bool $temporary = false ): void { + $this->ensure_column_metadata_table( $temporary ); + + $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', + $temporary + ); + + $value_rows = array(); + foreach ( $metadata as $offset => $column ) { + $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' + ); + + $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->wildcard_result_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. + * + * @param string $table_name Table name. + * @param array $metadata Table metadata. + */ + 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( $this->table_metadata_table_name( $temporary ) ) + . ' WHERE table_name = ' + . $this->connection->quote( $table_name ), + 'Failed to reset DuckDB table metadata' + ); + + $this->execute_duckdb_query( + 'INSERT INTO ' + . $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 ) + . ', ' + . $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' + ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); + } + + /** + * 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' + ); + + $value_rows = array(); + foreach ( $metadata as $constraint ) { + $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' + ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); + } + + /** + * 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' + ); + + $value_rows = array(); + foreach ( $metadata as $constraint ) { + foreach ( $constraint['columns'] as $offset => $column_name ) { + $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' + ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); + } + + /** + * 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 ); + } + + /** + * 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. + * + * @param string $table_name Table name. + * @param array $column Column metadata. + */ + private function append_column_metadata( string $table_name, array $column, bool $temporary = false ): void { + $this->ensure_column_metadata_table( $temporary ); + + $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', + $temporary + ); + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( ! is_array( $row ) || 0 === (int) $row['column_count'] ) { + return; + } + + $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 (' + . $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', + $temporary + ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); + } + + /** + * 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, 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, $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, $temporary ); + $this->delete_index_metadata( $table_name, $resolved_index_name, $temporary ); + $this->refresh_column_key_metadata( $table_name, $temporary ); + } + + /** + * 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, 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']; + } + } + + 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. + * + * @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. + * + * @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, 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, $temporary ) ), + '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, 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 ) + . ' AND index_name = ' + . $this->connection->quote( $index_name ), + 'Failed to delete DuckDB index metadata' + ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); + } + + /** + * 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' + ); + + $this->clear_schema_metadata_cache( $table_name, $temporary ); + } + + /** + * Delete all stored lifecycle metadata for a table. + * + * @param string $table_name Table name. + */ + 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 ); + $this->ensure_check_metadata_table( $temporary ); + $this->ensure_foreign_key_metadata_table( $temporary ); + + $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 ) { + $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 ); + } + + /** + * Refresh stored COLUMN_KEY values after index metadata changes. + * + * @param string $table_name 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; + } + + 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, $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_column_metadata_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', + $temporary + ); + } + + $this->clear_schema_metadata_cache( $table_name, $temporary ); + } + + /** + * 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, bool $temporary = false ): void { + $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( + '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 ) ); + } + + /** + * 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 { + $this->rebuild_table_from_metadata_plan( + $table_name, + $this->table_column_metadata_rows( $table_name, $temporary ), + $this->primary_key_columns_for_table( $table_name, $temporary ), + $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 + ); + } + + /** + * 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, + self::INFO_SCHEMA_COLUMNS_TABLE, + 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( + 'DROP TABLE IF EXISTS ' . $this->connection->quote_identifier( $table_name ), + 'Failed to invalidate DuckDB information_schema compatibility table' + ); + } + } + + /** + * 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. + * + * @param string $table_name Table name. + * @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_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', + $temporary + ); + + $this->column_metadata_cache[ $cache_key ] = $stmt->fetchAll( PDO::FETCH_ASSOC ); + return $this->column_metadata_cache[ $cache_key ]; + } + + /** + * 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 ( null !== $this->information_schema_tables_reference_length( $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 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' ); + } + + /** + * 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 ( null !== $this->information_schema_columns_reference_length( $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 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' ); + } + + /** + * 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 ( null !== $this->information_schema_statistics_reference_length( $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 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' ); + } + + /** + * 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 ( null !== $this->information_schema_table_constraints_reference_length( $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 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' ); + } + + /** + * 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 ( null !== $this->information_schema_key_column_usage_reference_length( $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 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' ); + } + + /** + * 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 ( null !== $this->information_schema_referential_constraints_reference_length( $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 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' ); + } + + /** + * 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 ( null !== $this->information_schema_check_constraints_reference_length( $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 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' ); + } + + /** + * Refresh a temporary MySQL-shaped information_schema.tables table. + */ + private function refresh_information_schema_tables_table( ?array $table_names = null ): void { + $rows = $this->information_schema_table_rows( $table_names ); + $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_recovering_aborted_transaction( + '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; + } + + $value_rows = array(); + foreach ( $rows as $row ) { + $values = array(); + foreach ( $columns as $column_name ) { + $values[] = $this->connection->quote( $row[ $column_name ] ); + } + + $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' + ); + } + + /** + * 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 $requested_table_names = null ): array { + $metadata_by_table = $this->table_metadata_by_table(); + $table_names = null === $requested_table_names + ? $this->user_table_names() + : $this->resolve_persistent_user_table_names( $requested_table_names ); + $table_row_counts = $this->table_row_counts( $table_names ); + $rows = array(); + + foreach ( $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, false, $table_row_counts[ $table_name ] ?? 0 ); + } + + return $rows; + } + + /** + * Resolve requested table names to persistent user tables. + * + * @param string[] $requested_table_names Requested table names. + * @return string[] Actual persistent table names. + */ + private function resolve_persistent_user_table_names( array $requested_table_names ): array { + $table_names = array(); + foreach ( $requested_table_names as $requested_table_name ) { + $table_name = $this->resolve_persistent_user_table_name( $requested_table_name ); + if ( null !== $table_name && ! in_array( $table_name, $table_names, true ) ) { + $table_names[] = $table_name; + } + } + + return $table_names; + } + + /** + * Build one MySQL-shaped information_schema.tables row. + * + * @param string $table_name Table name. + * @param array $metadata Table metadata. + * @param bool $temporary Whether the target is a temporary table. + * @param int $table_rows Current row count. + * @return array + */ + private function information_schema_table_row( string $table_name, array $metadata, bool $temporary = false, int $table_rows = 0 ): 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' => $table_rows, + '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, $temporary ), + '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'], + ); + } + + /** + * Count persistent DuckDB user table rows. + * + * @param string[] $table_names Table names. + * @return array Row counts keyed by table name. + */ + private function table_row_counts( array $table_names ): array { + if ( count( $table_names ) === 0 ) { + return array(); + } + + $catalog_schema = $this->duckdb_current_catalog_schema(); + $branches = array(); + foreach ( $table_names as $table_name ) { + $branches[] = 'SELECT ' + . $this->connection->quote( $table_name ) + . ' AS table_name, COUNT(*) AS table_rows FROM ' + . $this->connection->quote_identifier( $catalog_schema['catalog'] ) + . '.' + . $this->connection->quote_identifier( $catalog_schema['schema'] ) + . '.' + . $this->connection->quote_identifier( $table_name ); + } + + $stmt = $this->execute_duckdb_query( + implode( ' UNION ALL ', $branches ), + 'Failed to inspect DuckDB table row counts' + ); + + $counts = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $counts[ (string) $row['table_name'] ] = (int) $row['table_rows']; + } + + return $counts; + } + + /** + * Read the current DuckDB catalog and schema names. + * + * @return array{catalog:string,schema:string} + */ + private function duckdb_current_catalog_schema(): array { + $stmt = $this->execute_duckdb_query( + 'SELECT current_database() AS catalog, current_schema() AS schema', + 'Failed to inspect DuckDB catalog and schema' + ); + + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( false === $row ) { + return array( + 'catalog' => 'memory', + 'schema' => 'main', + ); + } + + return array( + 'catalog' => (string) $row['catalog'], + 'schema' => (string) $row['schema'], + ); + } + + /** + * Read recorded table metadata. + * + * @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( + 'SELECT table_name, engine, row_format, table_collation, table_comment, create_options, create_time FROM ' + . $this->connection->quote_identifier( $this->table_metadata_table_name( $temporary ) ) + . ' ORDER BY table_name', + 'Failed to inspect DuckDB table metadata' + ); + + $metadata = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + if ( ! array_key_exists( 'table_name', $row ) ) { + continue; + } + $metadata[ (string) $row['table_name'] ] = $row; + } + + $this->table_metadata_cache[ $cache_key ] = $metadata; + 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_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'] ) { + return (string) $metadata[ $table_name ]['table_collation']; + } + + return 'utf8mb4_0900_ai_ci'; + } + + /** + * 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, bool $temporary = false ): ?int { + $metadata = $this->auto_increment_metadata_for_table( $table_name, $temporary ); + if ( null === $metadata ) { + return null; + } + + $current = $this->sequence_currval( $metadata['sequence_name'] ); + 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. + * + * @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. + */ + private function refresh_information_schema_columns_table( ?array $table_names = null ): void { + $rows = $this->information_schema_column_rows( $table_names ); + $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; + } + + $value_rows = array(); + foreach ( $rows as $row ) { + $values = array(); + foreach ( $columns as $column_name ) { + $values[] = $this->connection->quote( $row[ $column_name ] ); + } + + $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' + ); + } + + /** + * 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', + ); + } + + /** + * Refresh a temporary MySQL-shaped information_schema.statistics table. + */ + private function refresh_information_schema_statistics_table( ?array $table_names = null ): void { + $rows = $this->information_schema_statistics_rows( $table_names ); + $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; + } + + $value_rows = array(); + foreach ( $rows as $row ) { + $values = array(); + foreach ( $columns as $column_name ) { + $values[] = $this->connection->quote( $row[ $column_name ] ); + } + + $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' + ); + } + + /** + * 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. + * + * @param string[]|null $requested_table_names Optional requested table names. + * @return array> + */ + private function information_schema_statistics_rows( ?array $requested_table_names = null ): array { + $table_names = null === $requested_table_names + ? $this->user_table_names() + : $this->resolve_persistent_user_table_names( $requested_table_names ); + $rows = array(); + foreach ( $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, 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 ); + } + + $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; + } + + /** + * 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. + * + * @param string[]|null $requested_table_names Optional requested table names. + * @return array> + */ + private function information_schema_table_constraints_rows( ?array $requested_table_names = null ): array { + $rows = array(); + $seen = array(); + + foreach ( $this->information_schema_key_constraint_rows( $requested_table_names ) 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_foreign_key_constraint_rows( $requested_table_names ) 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( $requested_table_names ) 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' => $constraint['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. + * + * @param string[]|null $requested_table_names Optional requested table names. + * @return array> + */ + private function information_schema_key_column_usage_rows( ?array $requested_table_names = null ): array { + $rows = array(); + foreach ( $this->information_schema_key_constraint_rows( $requested_table_names ) as $constraint ) { + $rows[] = $this->information_schema_key_column_usage_row( $constraint ); + } + foreach ( $this->information_schema_foreign_key_constraint_rows( $requested_table_names ) 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' => $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, + ); + } + + /** + * 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; + } + + /** + * 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. + * + * @param string[]|null $requested_table_names Optional requested table names. + * @return array> + */ + private function information_schema_referential_constraints_rows( ?array $requested_table_names = null ): array { + $table_names = null === $requested_table_names + ? $this->user_table_names() + : $this->resolve_persistent_user_table_names( $requested_table_names ); + $rows = array(); + $seen = array(); + + foreach ( $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. + * + * @param string[]|null $requested_constraint_names Optional requested constraint names. + * @return array> + */ + private function information_schema_check_constraints_rows( ?array $requested_constraint_names = null ): array { + $rows = array(); + $requested_constraint_names = null === $requested_constraint_names + ? null + : array_map( 'strtolower', $requested_constraint_names ); + + foreach ( $this->user_table_names() as $table_name ) { + foreach ( $this->check_constraint_metadata_rows( $table_name ) as $check_constraint ) { + if ( + null !== $requested_constraint_names + && ! in_array( strtolower( (string) $check_constraint['constraint_name'] ), $requested_constraint_names, true ) + ) { + continue; + } + + $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. + * + * @param string[]|null $requested_table_names Optional requested table names. + * @return array + */ + private function information_schema_key_constraint_rows( ?array $requested_table_names = null ): array { + $table_names = null === $requested_table_names + ? $this->user_table_names() + : $this->resolve_persistent_user_table_names( $requested_table_names ); + $rows = array(); + + foreach ( $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; + } + + /** + * Build normalized FOREIGN KEY constraint column rows. + * + * @param string[]|null $requested_table_names Optional requested table names. + * @return array + */ + private function information_schema_foreign_key_constraint_rows( ?array $requested_table_names = null ): array { + $table_names = null === $requested_table_names + ? $this->user_table_names() + : $this->resolve_persistent_user_table_names( $requested_table_names ); + $rows = array(); + + foreach ( $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. + * + * @param string[]|null $requested_table_names Optional requested table names. + * @return array + */ + private function information_schema_check_constraint_rows( ?array $requested_table_names = null ): array { + $table_names = null === $requested_table_names + ? $this->user_table_names() + : $this->resolve_persistent_user_table_names( $requested_table_names ); + $rows = array(); + + foreach ( $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', + 'enforced' => (string) $check_constraint['enforced'], + ); + } + } + + 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; + } + + $value_rows = array(); + foreach ( $rows as $row ) { + $values = array(); + foreach ( $columns as $column_name ) { + $values[] = $this->connection->quote( $row[ $column_name ] ); + } + + $value_rows[] = $values; + } + + $this->insert_rendered_value_rows( + $table_name, + $columns, + $value_rows, + 'Failed to populate DuckDB ' . $label . ' compatibility table' + ); + } + + /** + * Build MySQL-shaped information_schema.columns rows. + * + * @param string[]|null $requested_table_names Optional requested table names. + * @return array> + */ + private function information_schema_column_rows( ?array $requested_table_names = null ): array { + $table_names = null === $requested_table_names + ? $this->user_table_names() + : $this->resolve_persistent_user_table_names( $requested_table_names ); + $rows = array(); + foreach ( $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 { + $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'" + . ' 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 ) + . ' ORDER BY table_name', + 'Failed to inspect DuckDB tables' + ); + + $this->table_name_cache[ $cache_key ] = array_map( + 'strval', + $stmt->fetchAll( PDO::FETCH_COLUMN ) + ); + return $this->table_name_cache[ $cache_key ]; + } + + /** + * List non-internal DuckDB temporary tables visible in this session. + * + * @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 ' + . $this->connection->quote( '\_\_wp\_duckdb\_%' ) + . " ESCAPE '\\'" + . ' ORDER BY table_name', + 'Failed to inspect DuckDB temporary tables' + ); + + $this->table_name_cache[ $cache_key ] = array_map( + 'strval', + $stmt->fetchAll( PDO::FETCH_COLUMN ) + ); + return $this->table_name_cache[ $cache_key ]; + } + + /** + * 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 { + $normalized = strtolower( $table_name ); + foreach ( $this->user_table_names() as $candidate ) { + if ( strtolower( $candidate ) === $normalized ) { + return $candidate; + } + } + + return null; + } + + /** + * 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 { + $normalized = strtolower( $table_name ); + foreach ( $this->temporary_user_table_names() as $candidate ) { + if ( strtolower( $candidate ) === $normalized ) { + return $candidate; + } + } + + return null; + } + + /** + * 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 { + $cache_key = strtolower( $table_name ); + if ( isset( $this->visible_table_reference_cache[ $cache_key ] ) ) { + return $this->visible_table_reference_cache[ $cache_key ]; + } + + $temporary_table_name = $this->resolve_temporary_user_table_name( $table_name ); + if ( null !== $temporary_table_name ) { + $this->visible_table_reference_cache[ $cache_key ] = array( + 'table_name' => $temporary_table_name, + 'temporary' => true, + ); + return $this->visible_table_reference_cache[ $cache_key ]; + } + + $persistent_table_name = $this->resolve_persistent_user_table_name( $table_name ); + if ( null !== $persistent_table_name ) { + $this->visible_table_reference_cache[ $cache_key ] = array( + 'table_name' => $persistent_table_name, + 'temporary' => false, + ); + return $this->visible_table_reference_cache[ $cache_key ]; + } + + 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 { + $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; + } + + /** + * 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 { + return $this->resolve_persistent_user_table_name( $table_name ); + } + + /** + * 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 === stripos( $table_name, '__wp_duckdb_' ); + } + + /** + * 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 ( 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; + } 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; + } 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 ); + + 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', + 'real' => 'double', + 'dec' => 'decimal', + 'fixed' => 'decimal', + 'numeric' => 'decimal', + 'serial' => 'bigint', + 'geometrycollection' => 'geomcollection', + ); + + 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; + } + + /** + * 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. + * + * @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 { + if ( 'bit' === $data_type ) { + return array( $this->column_type_length( $column_type ) ?? 1, null ); + } + + $precision_map = array( + 'tinyint' => 3, + 'smallint' => 5, + 'mediumint' => 7, + 'int' => 10, + 'bigint' => false === strpos( $column_type, 'unsigned' ) ? 19 : 20, + ); + + 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 ); + } + 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; + } + if ( 'big5' === $charset ) { + return 2; + } + + return 1; + } + + /** + * 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. + * + * @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(); + } + + /** + * 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. + * + * @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. + * + * @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 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. + * + * @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, bool $temporary = false ): string { + return self::SEQUENCE_PREFIX . substr( hash( 'sha256', $this->table_namespace_key( $table_name, $temporary ) . "\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, 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; + } + + /** + * 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 ); + } + + /** + * 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. + * + * @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..83e44d05e --- /dev/null +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-prepared-statement.php @@ -0,0 +1,52 @@ +connection = $connection; + $this->statement = $statement; + $this->sql = $sql; + } + + /** + * 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(), $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/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..a1eafb940 --- /dev/null +++ b/packages/mysql-on-sqlite/src/duckdb/class-wp-duckdb-result-statement.php @@ -0,0 +1,582 @@ +> + */ + private $column_meta = array(); + + /** + * @var array> + */ + private $rows; + + /** + * @var int + */ + private $affected_rows; + + /** + * @var int + */ + private $cursor = 0; + + /** + * @var int + */ + private $default_fetch_mode = PDO::FETCH_BOTH; + + /** + * @var array + */ + private $default_fetch_args = array(); + + /** + * @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, array $column_meta = array() ) { + $this->columns = array_values( $columns ); + $this->rows = $this->normalize_rows( $rows ); + $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; + } + } + + /** + * 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 + $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; + } + + /** + * 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; + + $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 ); + } + + /** + * 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 + $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 ( $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 ); + + $rows = array(); + while ( isset( $this->rows[ $this->cursor ] ) ) { + $rows[] = $this->fetchColumn( $column ); + } + return $rows; + } + + if ( PDO::FETCH_KEY_PAIR === $fetch_mode ) { + return $this->fetch_all_key_pair(); + } + + $rows = array(); + while ( isset( $this->rows[ $this->cursor ] ) ) { + $row = $this->rows[ $this->cursor ]; + ++$this->cursor; + $rows[] = $this->format_row( $row, $fetch_mode, $fetch_args ); + } + 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 + $column = (int) $column; + $this->assert_valid_column_index( $column ); + + if ( ! isset( $this->rows[ $this->cursor ] ) ) { + return false; + } + + $row = $this->rows[ $this->cursor ]; + ++$this->cursor; + + return array_key_exists( $column, $row ) ? $row[ $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 $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. + * + * @return Traversable + */ + public function getIterator(): Traversable { + while ( false !== ( $row = $this->fetch() ) ) { + yield $row; + } + } + + /** + * 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. + * + * @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, array $args = array() ) { + if ( defined( 'PDO::FETCH_DEFAULT' ) && PDO::FETCH_DEFAULT === $mode ) { + $mode = $this->default_fetch_mode; + $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, $columns ); + case PDO::FETCH_NAMED: + return $this->named_row( $row, $columns ); + case PDO::FETCH_NUM: + return array_values( $row ); + case PDO::FETCH_OBJ: + 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 ); + 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, $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], array_values( $row ) ); + case PDO::FETCH_BOTH: + default: + 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, $columns = null ): array { + if ( null === $columns ) { + $columns = $this->columns; + } + + $assoc = array(); + foreach ( $columns as $index => $name ) { + $assoc[ $name ] = array_key_exists( $index, $row ) ? $row[ $index ] : null; + } + return $assoc; + } + + /** + * 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, $columns = null ): array { + if ( null === $columns ) { + $columns = $this->columns; + } + + $named = array(); + 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 ] ) ) { + $named[ $name ][] = $value; + } else { + $named[ $name ] = array( $named[ $name ], $value ); + } + } + return $named; + } + + /** + * 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, $columns = null ): array { + $both = $this->assoc_row( $row, $columns ); + foreach ( $row as $index => $value ) { + $both[ $index ] = $value; + } + return $both; + } + + /** + * Build a PDO::FETCH_CLASS row. + * + * @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, $columns = null ): object { + $object = new $class( ...$constructor_args ); + foreach ( $this->assoc_row( $row, $columns ) 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 ( 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 ( PHP_VERSION_ID >= 80000 ) { + throw new ValueError( 'Invalid column index' ); + } + throw new PDOException( 'Invalid column index' ); + } + } +} 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..e6911d40f --- /dev/null +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Connection_Tests.php @@ -0,0 +1,1183 @@ +assertSame( + array( + 'id' => array( 1, 2 ), + 'name' => 'Ada', + ), + $stmt->fetch( PDO::FETCH_NAMED ) + ); + $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' ), + 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_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_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' ), + 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_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_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_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_session_variable_is_supported_without_warning_lists(): 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 ) ), + ) + ); + + $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( + '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(TRY_CAST(d AS TIMESTAMP), f)', + ), + $duckdb->queries + ); + } + + 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(); + + $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_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(); + + $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_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_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(); + + $connection = new WP_DuckDB_Connection( array( 'path' => ':memory:' ) ); + $connection->query( 'CREATE TABLE t (id INTEGER, label VARCHAR)' ); + $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(); + + $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_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(); + + $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(); + + $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' ) ); + } + + 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; + 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; + } + }; + } + + 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_Differential_TestCase.php b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php new file mode 100644 index 000000000..82ce32b11 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Differential_TestCase.php @@ -0,0 +1,276 @@ +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 + if ( $this->isReplaceWriteSql( $sql ) ) { + $this->query_sqlite_row_count( $sql ); + $this->query_duckdb_row_count( $sql ); + $this->addToAssertionCount( 1 ); + return; + } + + $this->assertSame( + $this->query_sqlite_row_count( $sql ), + $this->query_duckdb_row_count( $sql ), + 'Row-count parity failed for SQL: ' . $sql + ); + } + + /** + * 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 ); + } + + /** + * 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. + * + * @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 ); + } + } + + /** + * Check whether a mutation uses REPLACE row-count semantics. + * + * DuckDB exposes MySQL/wpdb affected-row counts for REPLACE replacements, + * while the SQLite baseline reports SQLite's replacement count. Differential + * REPLACE tests still verify accepted writes and final row parity. + * + * @param string $sql SQL query. + * @return bool Whether this is a REPLACE write. + */ + private function isReplaceWriteSql( string $sql ): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return 1 === preg_match( '/^\s*REPLACE\b/i', $sql ); + } + + /** + * 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(); + } + + /** + * 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. + * + * @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..061ddabbd --- /dev/null +++ b/packages/mysql-on-sqlite/tests/duckdb/WP_DuckDB_Driver_Parity_Tests.php @@ -0,0 +1,8189 @@ +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_sum_aggregate_results_match_sqlite_and_remain_fetchable(): void { + $this->runParitySetup( + array( + 'CREATE TABLE aggregate_sums ( + bucket VARCHAR(20), + int_value INT, + double_value DOUBLE, + decimal_value DECIMAL(26,8) + )', + "INSERT INTO aggregate_sums (bucket, int_value, double_value, decimal_value) VALUES + ('a', 1, 1.25, 1.25000000), + ('a', 2, 2.50, 2.50000000), + ('a', 2, 2.50, 2.50000000), + ('b', 4, 4.75, 4.75000000)", + ) + ); + + $this->assertParityRows( + 'SELECT SUM(int_value) AS total_int, + SUM(DISTINCT int_value) AS distinct_int, + SUM(double_value) AS total_double, + SUM(decimal_value) AS total_decimal, + SUM(DISTINCT decimal_value) AS distinct_decimal + FROM aggregate_sums' + ); + $this->assertParityRows( + 'SELECT bucket, + SUM(int_value) AS total_int, + SUM(decimal_value) AS total_decimal + FROM aggregate_sums + GROUP BY bucket + ORDER BY total_int DESC, bucket ASC' + ); + } + + 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', + "SELECT DATE('2008-01-02 13:29:17') AS value_date", + "SELECT DATE('not-a-date') AS value_date", + "SELECT DATEDIFF('2008-01-09 13:29:17', '2008-01-02 00:00:00') AS day_delta", + "SELECT DATE_FORMAT('not-a-date', '%Y-%m-%d') AS formatted", + "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_ADD('not-a-date', INTERVAL 1 DAY) AS shifted", + "SELECT DATE_SUB('not-a-date', INTERVAL 1 DAY) 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 ); + } + } + + public function test_datediff_invalid_string_inputs_match_sqlite(): void { + foreach ( + array( + "SELECT DATEDIFF('not-a-date', '2020-01-01') AS day_delta", + "SELECT DATEDIFF('2020-01-01', 'not-a-date') AS day_delta", + "SELECT DATEDIFF('bad-a', 'bad-b') AS day_delta", + ) as $sql + ) { + $this->assertParityErrorContains( $sql, 'Failed to parse time string' ); + } + } + + public function test_datediff_numeric_literal_comparisons_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_datediff_posts ( + ID BIGINT, + post_date_gmt DATETIME + )', + "INSERT INTO wp_datediff_posts (ID, post_date_gmt) VALUES + (1, '2016-01-16 00:00:00'), + (2, '2016-01-17 00:00:00'), + (3, '2016-01-18 00:00:00'), + (12, '2016-01-16 12:34:56')", + ) + ); + + foreach ( + array( + "SELECT ID FROM wp_datediff_posts WHERE DATEDIFF(post_date_gmt, '2016-01-15') = 1 ORDER BY ID", + "SELECT ID FROM wp_datediff_posts WHERE DATEDIFF(post_date_gmt, '2016-01-15') != 1 ORDER BY ID", + "SELECT ID FROM wp_datediff_posts WHERE DATEDIFF(post_date_gmt, '2016-01-15') < 2 ORDER BY ID", + "SELECT ID FROM wp_datediff_posts WHERE DATEDIFF(post_date_gmt, '2016-01-15') <= 1 ORDER BY ID", + "SELECT ID FROM wp_datediff_posts WHERE DATEDIFF(post_date_gmt, '2016-01-15') > 0 ORDER BY ID", + "SELECT ID FROM wp_datediff_posts WHERE DATEDIFF(post_date_gmt, '2016-01-15') >= 1 ORDER BY ID", + "SELECT ID FROM wp_datediff_posts WHERE 1 = DATEDIFF(post_date_gmt, '2016-01-15') ORDER BY ID", + "SELECT ID FROM wp_datediff_posts WHERE 1 != DATEDIFF(post_date_gmt, '2016-01-15') ORDER BY ID", + "SELECT ID FROM wp_datediff_posts WHERE 2 > DATEDIFF(post_date_gmt, '2016-01-15') ORDER BY ID", + "SELECT ID FROM wp_datediff_posts WHERE 1 >= DATEDIFF(post_date_gmt, '2016-01-15') ORDER BY ID", + "SELECT ID FROM wp_datediff_posts WHERE DATEDIFF(post_date_gmt, '2016-01-15') = '1' ORDER BY ID", + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + + $this->assertParityRows( "SELECT DATEDIFF('2008-01-09 13:29:17', '2008-01-02 00:00:00') AS day_delta" ); + } + + public function test_date_format_numeric_literal_comparisons_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_date_format_posts (ID BIGINT, post_date_gmt DATETIME)', + "INSERT INTO wp_date_format_posts (ID, post_date_gmt) VALUES + (1, '2016-01-16 00:00:00'), + (2, '2016-01-17 00:00:00'), + (3, NULL)", + ) + ); + + foreach ( + array( + "SELECT ID FROM wp_date_format_posts WHERE DATE_FORMAT(post_date_gmt, '%Y%m%d') = 20160116 ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE DATE_FORMAT(post_date_gmt, '%Y%m%d') != 20160116 ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE DATE_FORMAT(post_date_gmt, '%Y%m%d') > 20160116 ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE DATE_FORMAT(post_date_gmt, '%Y%m%d') >= 20160116 ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE DATE_FORMAT(post_date_gmt, '%Y%m%d') < 20160116 ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE DATE_FORMAT(post_date_gmt, '%Y%m%d') <= 20160116 ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE 20160116 = DATE_FORMAT(post_date_gmt, '%Y%m%d') ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE 20160116 != DATE_FORMAT(post_date_gmt, '%Y%m%d') ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE 20160116 > DATE_FORMAT(post_date_gmt, '%Y%m%d') ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE 20160116 >= DATE_FORMAT(post_date_gmt, '%Y%m%d') ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE 20160116 < DATE_FORMAT(post_date_gmt, '%Y%m%d') ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE 20160116 <= DATE_FORMAT(post_date_gmt, '%Y%m%d') ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE DATE_FORMAT(post_date_gmt, '%Y%m%d') = '20160116' ORDER BY ID", + "SELECT ID FROM wp_date_format_posts WHERE DATE_FORMAT(post_date_gmt, '%H.%i') >= 0.00 ORDER BY ID", + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_date_function_numeric_literal_comparisons_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_date_function_posts (ID BIGINT, post_date_gmt DATETIME)', + "INSERT INTO wp_date_function_posts (ID, post_date_gmt) VALUES + (1, '2016-01-16 00:00:00'), + (2, '2016-01-17 00:00:00'), + (3, NULL)", + ) + ); + + foreach ( + array( + 'SELECT ID FROM wp_date_function_posts WHERE DATE(post_date_gmt) = 20160116 ORDER BY ID', + 'SELECT ID FROM wp_date_function_posts WHERE DATE(post_date_gmt) != 20160116 ORDER BY ID', + 'SELECT ID FROM wp_date_function_posts WHERE DATE(post_date_gmt) > 20160116 ORDER BY ID', + 'SELECT ID FROM wp_date_function_posts WHERE DATE(post_date_gmt) >= 20160116 ORDER BY ID', + 'SELECT ID FROM wp_date_function_posts WHERE DATE(post_date_gmt) < 20160116 ORDER BY ID', + 'SELECT ID FROM wp_date_function_posts WHERE DATE(post_date_gmt) <= 20160116 ORDER BY ID', + 'SELECT ID FROM wp_date_function_posts WHERE 20160116 = DATE(post_date_gmt) ORDER BY ID', + 'SELECT ID FROM wp_date_function_posts WHERE 20160116 != DATE(post_date_gmt) ORDER BY ID', + 'SELECT ID FROM wp_date_function_posts WHERE 20160116 > DATE(post_date_gmt) ORDER BY ID', + 'SELECT ID FROM wp_date_function_posts WHERE 20160116 >= DATE(post_date_gmt) ORDER BY ID', + 'SELECT ID FROM wp_date_function_posts WHERE 20160116 < DATE(post_date_gmt) ORDER BY ID', + 'SELECT ID FROM wp_date_function_posts WHERE 20160116 <= DATE(post_date_gmt) ORDER BY ID', + "SELECT ID FROM wp_date_function_posts WHERE DATE(post_date_gmt) = '2016-01-16' ORDER BY ID", + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_date_add_sub_numeric_literal_comparisons_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_date_add_sub_posts (ID BIGINT, post_date_gmt DATETIME)', + "INSERT INTO wp_date_add_sub_posts (ID, post_date_gmt) VALUES + (1, '2016-01-16 00:00:00'), + (2, '2016-01-17 12:30:00'), + (3, NULL), + (4, '2017-02-03 04:05:06')", + ) + ); + + foreach ( + array( + 'SELECT ID FROM wp_date_add_sub_posts WHERE DATE_ADD(post_date_gmt, INTERVAL 1 DAY) = 20160117 ORDER BY ID', + 'SELECT ID FROM wp_date_add_sub_posts WHERE DATE_ADD(post_date_gmt, INTERVAL 1 DAY) != 20160117 ORDER BY ID', + 'SELECT ID FROM wp_date_add_sub_posts WHERE DATE_ADD(post_date_gmt, INTERVAL 1 DAY) > 20160117 ORDER BY ID', + 'SELECT ID FROM wp_date_add_sub_posts WHERE DATE_ADD(post_date_gmt, INTERVAL 1 DAY) >= 20160117 ORDER BY ID', + 'SELECT ID FROM wp_date_add_sub_posts WHERE DATE_ADD(post_date_gmt, INTERVAL 1 DAY) < 20160117 ORDER BY ID', + 'SELECT ID FROM wp_date_add_sub_posts WHERE DATE_ADD(post_date_gmt, INTERVAL 1 DAY) <= 20160117 ORDER BY ID', + 'SELECT ID FROM wp_date_add_sub_posts WHERE 20160117 = DATE_ADD(post_date_gmt, INTERVAL 1 DAY) ORDER BY ID', + 'SELECT ID FROM wp_date_add_sub_posts WHERE 20160117 != DATE_ADD(post_date_gmt, INTERVAL 1 DAY) ORDER BY ID', + 'SELECT ID FROM wp_date_add_sub_posts WHERE 20160117 < DATE_SUB(post_date_gmt, INTERVAL 1 DAY) ORDER BY ID', + 'SELECT ID FROM wp_date_add_sub_posts WHERE DATE_ADD(post_date_gmt, INTERVAL 1 HOUR) <= 20160117133000 ORDER BY ID', + "SELECT ID FROM wp_date_add_sub_posts WHERE DATE_ADD(post_date_gmt, INTERVAL 1 DAY) = '2016-01-17 00:00:00' ORDER BY ID", + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_date_add_sub_numeric_literal_comparisons_preserve_expression_errors(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_date_add_sub_error_posts (ID BIGINT)', + ) + ); + + $this->assertParityErrorContains( + 'SELECT ID FROM wp_date_add_sub_error_posts WHERE DATE_ADD(missing_col, INTERVAL 1 DAY) = 20160117 ORDER BY ID', + 'missing_col' + ); + $this->assertParityErrorContains( + 'SELECT ID FROM wp_date_add_sub_error_posts WHERE 20160117 < DATE_SUB(missing_col, INTERVAL 1 DAY) ORDER BY ID', + 'missing_col' + ); + } + + public function test_coalesce_date_text_numeric_comparisons_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_coalesce_date_text_posts ( + ID BIGINT, + post_date_gmt DATETIME + )', + "INSERT INTO wp_coalesce_date_text_posts (ID, post_date_gmt) VALUES + (1, '2016-01-16 00:00:00'), + (2, '2016-01-17 12:34:56'), + (4, '2015-12-31 23:59:59')", + ) + ); + + foreach ( + array( + 'SELECT ID, COALESCE(DATE(post_date_gmt) = 20160116, 0) AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID', + "SELECT ID, COALESCE(DATE_FORMAT(post_date_gmt, '%Y%m%d') != 20160116, 0) AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID", + 'SELECT ID, COALESCE(DATE_ADD(post_date_gmt, INTERVAL 1 DAY) = 20160117, 0) AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID', + "SELECT ID, COALESCE(DATEDIFF(post_date_gmt, '2016-01-15') = 1, 0) AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID", + 'SELECT ID, COALESCE(20160116 < DATE(post_date_gmt), 0) AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID', + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + + $this->runParitySetup( + array( + 'INSERT INTO wp_coalesce_date_text_posts (ID, post_date_gmt) VALUES (3, NULL)', + ) + ); + foreach ( + array( + "SELECT ID, COALESCE(DATE(post_date_gmt) = 20160116, 'x') AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID", + "SELECT ID, COALESCE(DATE_FORMAT(post_date_gmt, '%Y%m%d') != 20160116, 'x') AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID", + 'SELECT ID, IFNULL(DATE(post_date_gmt) = 20160116, 0) AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID', + "SELECT ID, IFNULL(DATE(post_date_gmt) = 20160116, 'x') AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID", + "SELECT ID, IFNULL(DATE_FORMAT(post_date_gmt, '%Y%m%d') != 20160116, 'x') AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID", + "SELECT ID, COALESCE(DATE(post_date_gmt) = 20160116, NULL, 'x') AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID", + "SELECT ID, COALESCE(DATE_FORMAT(post_date_gmt, '%Y%m%d') != 20160116, NULL, 'x') AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID", + 'SELECT ID, COALESCE(DATE(post_date_gmt) = 20160116, NULL, 0) AS cmp FROM wp_coalesce_date_text_posts ORDER BY ID', + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_date_part_quoted_string_comparisons_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_date_part_posts (ID BIGINT, post_date_gmt DATETIME, post_date DATETIME)', + "INSERT INTO wp_date_part_posts (ID, post_date_gmt, post_date) VALUES + (1, '2016-01-16 00:00:00', '2016-01-16 13:14:15'), + (2, '2016-01-17 12:30:00', '2016-01-17 00:00:00'), + (3, '2015-12-31 23:59:59', '2015-12-31 23:59:59'), + (4, '2017-02-03 04:05:06', '2017-02-03 04:05:06')", + ) + ); + + foreach ( + array( + "SELECT ID FROM wp_date_part_posts WHERE YEAR(post_date_gmt) = '2016' ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE YEAR(post_date_gmt) != '2016' ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE YEAR(post_date_gmt) > '2016' ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE YEAR(post_date_gmt) >= '2016' ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE YEAR(post_date_gmt) < '2016' ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE YEAR(post_date_gmt) <= '2016' ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE '2016' = YEAR(post_date_gmt) ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE '2016' != YEAR(post_date_gmt) ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE '2016' > YEAR(post_date_gmt) ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE '2016' >= YEAR(post_date_gmt) ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE '2016' < YEAR(post_date_gmt) ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE '2016' <= YEAR(post_date_gmt) ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE MONTH(post_date_gmt) = '1' ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE DAYOFMONTH(post_date_gmt) > '16' ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE DAYOFWEEK(post_date_gmt) != '7' ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE WEEK(post_date_gmt, 1) = '2' ORDER BY ID", + "SELECT ID FROM wp_date_part_posts WHERE HOUR(post_date) = '13' ORDER BY ID", + 'SELECT ID FROM wp_date_part_posts WHERE YEAR(post_date_gmt) = 2016 ORDER BY ID', + 'SELECT ID FROM wp_date_part_posts WHERE MONTH(post_date_gmt) = 1 ORDER BY ID', + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_date_part_quoted_string_comparisons_match_sqlite_for_invalid_strings(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_date_part_invalid_posts (ID BIGINT, post_date_gmt VARCHAR(255), post_date VARCHAR(255))', + "INSERT INTO wp_date_part_invalid_posts (ID, post_date_gmt, post_date) VALUES + (1, 'not-a-date', 'not-a-time'), + (2, '2016-01-16 00:00:00', '2016-01-16 13:14:15')", + ) + ); + + foreach ( + array( + "SELECT ID FROM wp_date_part_invalid_posts WHERE YEAR(post_date_gmt) = '2016' ORDER BY ID", + "SELECT ID FROM wp_date_part_invalid_posts WHERE YEAR(post_date_gmt) != '2016' ORDER BY ID", + "SELECT ID FROM wp_date_part_invalid_posts WHERE YEAR(post_date_gmt) < '2016' ORDER BY ID", + "SELECT ID FROM wp_date_part_invalid_posts WHERE '2016' > YEAR(post_date_gmt) ORDER BY ID", + "SELECT ID FROM wp_date_part_invalid_posts WHERE HOUR(post_date) = '13' ORDER BY ID", + "SELECT ID FROM wp_date_part_invalid_posts WHERE HOUR(post_date) != '13' ORDER BY ID", + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_date_part_quoted_string_comparisons_preserve_expression_errors(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_date_part_error_posts (ID BIGINT)', + ) + ); + + $this->assertParityErrorContains( + "SELECT ID FROM wp_date_part_error_posts WHERE YEAR(missing_col) = '2016' ORDER BY ID", + 'missing_col' + ); + $this->assertParityErrorContains( + "SELECT ID FROM wp_date_part_error_posts WHERE '2016' != YEAR(missing_col) ORDER BY ID", + 'missing_col' + ); + } + + public function test_select_list_date_text_numeric_boundaries_match_sqlite(): void { + foreach ( + array( + "SELECT DATE('2016-01-16') = 20160116 AS cmp", + "SELECT DATE('2016-01-16') != 20160116 AS cmp", + "SELECT 20160116 < DATE('2016-01-16') AS cmp", + "SELECT DATE_FORMAT('2016-01-16 00:00:00', '%Y%m%d') = 20160116 AS cmp", + "SELECT 20160116 < DATE_FORMAT('2016-01-16 00:00:00', '%Y%m%d') AS cmp", + "SELECT DATE_ADD('2016-01-16 00:00:00', INTERVAL 1 DAY) = 20160117 AS cmp", + "SELECT DATE_SUB('2016-01-16 00:00:00', INTERVAL 1 DAY) = 20160115 AS cmp", + "SELECT DATEDIFF('2016-01-16', '2016-01-15') = 1 AS cmp", + "SELECT 1 < DATEDIFF('2016-01-16', '2016-01-15') AS cmp", + "SELECT YEAR('2016-01-16 00:00:00') = '2016' AS cmp", + "SELECT '2016' = YEAR('2016-01-16 00:00:00') AS cmp", + "SELECT DATE('2016-01-16') = 20160116 AS cmp, DATE_FORMAT('2016-01-16 00:00:00', '%Y%m%d') != 20160116 AS other_cmp", + ) 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_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_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( + '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_insert_set_seeded_rand_literals_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE seeded_rand_set (id INT, value DOUBLE, other DOUBLE)', + ) + ); + + $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->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->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 { + $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 { + 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( + '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_case_only_update_on_case_insensitive_column_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wp_users ( + ID bigint(20) unsigned NOT NULL auto_increment, + user_login varchar(60) NOT NULL default '', + user_email varchar(100) NOT NULL default '', + PRIMARY KEY (ID), + KEY user_email (user_email) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wp_users (ID, user_login, user_email) VALUES (1, 'editor', 'editor@example.com')", + ) + ); + + $this->assertParityRowCount( "UPDATE wp_users SET user_email = 'Editor@example.com' WHERE ID = 1" ); + $this->assertParityRows( 'SELECT ID, user_email FROM wp_users ORDER BY ID' ); + $this->runParitySetup( array( "UPDATE wp_users SET user_email = 'Editor@example.com' WHERE ID = 1" ) ); + $this->assertParityRows( 'SELECT ID, user_email FROM wp_users ORDER BY ID' ); + } + + public function test_attachment_mime_distinct_no_order_matches_sqlite_first_seen_order(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_posts (ID BIGINT PRIMARY KEY, post_type VARCHAR(20), post_mime_type VARCHAR(100))', + "INSERT INTO wp_posts (ID, post_type, post_mime_type) VALUES + (10, 'attachment', 'image/jpeg'), + (11, 'attachment', 'image/jpeg'), + (12, 'attachment', 'application/pdf'), + (13, 'post', 'text/plain'), + (14, 'attachment', '')", + ) + ); + + $this->assertParityRows( "SELECT DISTINCT post_mime_type FROM wp_posts WHERE post_type = 'attachment'" ); + $this->assertParityRows( "SELECT DISTINCT `post_mime_type` FROM `wp_posts` WHERE `post_type` = 'attachment'" ); + $this->assertParityRows( "SELECT DISTINCT post_mime_type FROM wp_posts WHERE post_type = 'attachment' AND post_mime_type != ''" ); + $this->assertParityRows( "SELECT DISTINCT `post_mime_type` FROM `wp_posts` WHERE `post_mime_type` <> '' AND `post_type` = 'attachment'" ); + } + + public function test_posts_date_order_ties_match_sqlite_index_order(): 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_modified DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + post_title VARCHAR(200) NOT NULL DEFAULT '', + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + PRIMARY KEY (ID), + KEY type_status_date (post_type, post_status, post_date, ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wptests_posts (ID, post_date, post_modified, post_title, post_type, post_status) VALUES + (1, '2024-01-01 00:00:00', '2024-02-01 00:00:00', 'same', 'post', 'publish'), + (2, '2024-01-01 00:00:00', '2024-02-01 00:00:00', 'same', 'post', 'publish'), + (3, '2024-01-01 00:00:00', '2024-02-01 00:00:00', 'same', 'post', 'publish'), + (4, '2024-01-02 00:00:00', '2024-02-02 00:00:00', 'same', 'post', 'publish'), + (5, '2024-01-03 00:00:00', '2024-02-03 00:00:00', 'same', 'post', 'draft')", + ) + ); + + $this->assertParityRows( + "SELECT ID FROM wptests_posts + WHERE post_type = 'post' AND post_status = 'publish' + ORDER BY post_date DESC" + ); + $this->assertParityRows( + "SELECT p.ID FROM wptests_posts AS p + WHERE p.post_type = 'post' AND p.post_status = 'publish' + ORDER BY p.post_modified ASC" + ); + $this->assertParityRows( + "SELECT ID FROM wptests_posts + WHERE post_type = 'post' AND post_status = 'publish' + ORDER BY post_modified DESC" + ); + $this->assertParityRows( + "SELECT ID FROM wptests_posts + WHERE post_type = 'post' AND post_status = 'publish' + ORDER BY post_date DESC, post_title ASC" + ); + } + + public function test_posts_slug_status_date_order_ties_match_sqlite_index_order(): void { + $this->runParitySetup( + array( + "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_name VARCHAR(200) NOT NULL DEFAULT '', + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + PRIMARY KEY (ID), + KEY post_name (post_name(191)), + KEY type_status_date (post_type, post_status, post_date, ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wptests_posts (ID, post_author, post_date, post_title, post_name, post_type, post_status) VALUES + (1, 10, '2026-01-01 00:00:00', 'Old page', 'target-slug', 'page', 'publish'), + (2, 20, '2026-05-01 00:00:00', 'Excluded page', 'target-slug', 'page', 'publish'), + (3, 30, '2026-02-01 00:00:00', 'Older post tie', 'target-slug', 'post', 'publish'), + (4, 40, '2026-04-01 00:00:00', 'Draft post', 'target-slug', 'post', 'draft'), + (5, 50, '2026-03-01 00:00:00', 'Attachment', 'target-slug', 'attachment', 'publish'), + (6, 60, '2026-02-01 00:00:00', 'Newer post tie', 'target-slug', 'post', 'publish'), + (7, 70, '2026-06-01 00:00:00', 'Wrong type', 'target-slug', 'product', 'publish'), + (8, 80, '2026-07-01 00:00:00', 'Wrong slug', 'other-slug', 'post', 'publish')", + ) + ); + + $this->assertParityRows( + "SELECT wptests_posts.ID + FROM wptests_posts + WHERE 1=1 + AND wptests_posts.post_name = 'target-slug' + AND wptests_posts.ID NOT IN (2) + AND wptests_posts.post_type IN ('post', 'page', 'attachment') + AND ((wptests_posts.post_status = 'publish')) + ORDER BY wptests_posts.post_date DESC" + ); + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1=1 + AND wptests_posts.post_name = 'target-slug' + AND wptests_posts.ID NOT IN (2) + AND wptests_posts.post_type IN ('post', 'page', 'attachment') + AND ((wptests_posts.post_status = 'publish')) + ORDER BY wptests_posts.post_date DESC" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + $this->assertParityRows( + "SELECT p.ID + FROM wptests_posts AS p + WHERE 1=1 + AND p.post_name = 'target-slug' + AND p.ID NOT IN (2) + AND p.post_type IN ('post', 'page', 'attachment') + AND ((p.post_status = 'publish')) + ORDER BY p.post_date DESC" + ); + } + + public function test_posts_page_menu_title_order_ties_match_sqlite_index_order(): void { + $this->runParitySetup( + array( + "CREATE TABLE wptests_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_parent BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', + post_title VARCHAR(200) NOT NULL DEFAULT '', + post_excerpt TEXT NOT NULL, + post_content TEXT NOT NULL, + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + menu_order INT(11) NOT NULL DEFAULT '0', + PRIMARY KEY (ID), + KEY post_parent (post_parent) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wptests_posts (ID, post_parent, post_title, post_excerpt, post_content, post_type, menu_order) VALUES + (145, 0, 'Top Level Page 1', '', '', 'page', 0), + (146, 0, 'Top Level Page 2', '', '', 'page', 0), + (147, 0, 'Top Level Page 3', '', '', 'page', 0), + (148, 0, 'Top Level Page 4', '', '', 'page', 0), + (149, 0, 'Top Level Page 5', '', '', 'page', 0), + (150, 145, 'Child 1', '', '', 'page', 0), + (151, 145, 'Child 2', '', '', 'page', 0), + (152, 145, 'Child 3', '', '', 'page', 0), + (153, 146, 'Child 1', '', '', 'page', 0), + (154, 146, 'Child 2', '', '', 'page', 0), + (155, 146, 'Child 3', '', '', 'page', 0), + (156, 147, 'Child 1', '', '', 'page', 0), + (157, 147, 'Child 2', '', '', 'page', 0), + (158, 147, 'Child 3', '', '', 'page', 0), + (159, 148, 'Child 1', '', '', 'page', 0), + (160, 148, 'Child 2', '', '', 'page', 0), + (161, 148, 'Child 3', '', '', 'page', 0), + (162, 149, 'Child 1', '', '', 'page', 0), + (163, 149, 'Child 2', '', '', 'page', 0), + (164, 149, 'Child 3', '', '', 'page', 0), + (165, 156, 'Child Grand 1', '', '', 'page', 0), + (166, 157, 'Child Grand 2', '', '', 'page', 0), + (167, 158, 'Child Grand 3', '', '', 'page', 0), + (168, 161, 'Child Grand 4', '', '', 'page', 0)", + ) + ); + + $this->assertParityRows( + "SELECT ID, post_parent, post_title + FROM wptests_posts + WHERE post_type = 'page' + AND (post_title LIKE '%Child%' OR post_excerpt LIKE '%Child%' OR post_content LIKE '%Child%') + ORDER BY menu_order ASC, post_title ASC" + ); + } + + public function test_rest_posts_tags_exclude_grouped_order_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_status VARCHAR(20) NOT NULL DEFAULT 'publish', + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + PRIMARY KEY (ID), + KEY type_status_date (post_type, post_status, post_date, ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "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", + "INSERT INTO wptests_posts (ID, post_date, post_status, post_type) VALUES + (4071, '2026-06-29 09:00:00', 'publish', 'post'), + (4072, '2026-06-29 09:00:00', 'publish', 'post'), + (4073, '2026-06-29 09:00:00', 'publish', 'post'), + (4074, '2026-06-29 09:00:00', 'publish', 'post')", + 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id, term_order) VALUES + (4071, 99, 0)', + ) + ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1=1 + AND ( wptests_posts.ID NOT IN ( + SELECT object_id + FROM wptests_term_relationships + WHERE term_taxonomy_id IN (99) + ) ) + 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" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + } + + public function test_rest_iso_datetime_comparisons_match_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wp_posts ( + ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + post_date_gmt DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + post_modified_gmt 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 wp_posts (ID, post_date_gmt, post_modified_gmt, post_type, post_status) VALUES + (1, '2020-01-01 00:00:00', '2020-02-01 00:00:00', 'post', 'publish'), + (2, '2020-01-02 12:00:00', '2020-02-02 12:00:00', 'post', 'publish'), + (3, '2020-01-03 00:00:00', '2020-02-03 00:00:00', 'post', 'publish'), + (4, '2020-01-02 12:00:00', '2020-02-02 12:00:00', 'page', 'publish'), + (5, '2020-01-02 12:00:00', '2020-02-02 12:00:00', 'attachment', 'inherit'), + (6, '2020-01-03 00:00:00', '2020-02-03 00:00:00', 'page', 'publish')", + "CREATE TABLE wp_comments ( + comment_ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + 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", + "INSERT INTO wp_comments (comment_ID, comment_date_gmt, comment_approved) VALUES + (11, '2020-01-01 00:00:00', '1'), + (12, '2020-01-02 12:00:00', '1'), + (13, '2020-01-03 00:00:00', '1')", + 'CREATE TABLE wp_rest_strings ( + id INT NOT NULL, + value VARCHAR(100) NOT NULL, + PRIMARY KEY (id) + )', + "INSERT INTO wp_rest_strings (id, value) VALUES + (1, '2020-01-02T00:00:00Z'), + (2, 'not-a-date')", + ) + ); + + $this->assertParityRows( + "SELECT ID FROM wp_posts + WHERE post_date_gmt >= '2020-01-02T00:00:00Z' + AND post_date_gmt <= '2020-01-02T23:59:59Z' + AND post_type = 'post' + ORDER BY ID" + ); + $this->assertParityRows( + "SELECT ID FROM wp_posts + WHERE post_date_gmt >= '2020-01-02T00:00:00Z' + AND post_date_gmt <= '2020-01-02T23:59:59Z' + AND post_type = 'page' + ORDER BY ID" + ); + $this->assertParityRows( + "SELECT ID FROM wp_posts + WHERE post_date_gmt >= '2020-01-02T00:00:00Z' + AND post_date_gmt <= '2020-01-02T23:59:59Z' + AND post_type = 'attachment' + ORDER BY ID" + ); + $this->assertParityRows( + "SELECT ID FROM wp_posts + WHERE post_modified_gmt >= '2020-02-02T00:00:00Z' + AND post_modified_gmt <= '2020-02-02T23:59:59Z' + ORDER BY ID" + ); + $this->assertParityRows( + "SELECT comment_ID FROM wp_comments + WHERE comment_date_gmt >= '2020-01-02T00:00:00Z' + AND comment_date_gmt <= '2020-01-02T23:59:59Z' + ORDER BY comment_ID" + ); + $this->assertParityRows( + "SELECT comment_ID FROM wp_comments + WHERE '2020-01-02T00:00:00Z' <= comment_date_gmt + AND '2020-01-02T23:59:59Z' >= comment_date_gmt + ORDER BY comment_ID" + ); + $this->assertParityRows( + "SELECT ID FROM wp_posts + WHERE post_date_gmt >= '2020-01-02 00:00:00' + AND post_date_gmt <= '2020-01-02 23:59:59' + ORDER BY ID" + ); + $this->assertParityRows( "SELECT id FROM wp_rest_strings WHERE value = '2020-01-02T00:00:00Z' ORDER BY id" ); + $this->assertParityRows( "SELECT ID FROM wp_posts WHERE post_type = '2020-01-02T00:00:00Z' ORDER BY ID" ); + } + + public function test_rest_date_query_sql_calc_found_rows_shapes_match_sqlite(): void { + $this->runParitySetup( + array( + "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_date_gmt 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), + KEY type_status_date (post_type, post_status, post_date, ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wp_posts (ID, post_date, post_date_gmt, post_type, post_status) VALUES + (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', 'post', 'publish'), + (2, '2020-01-02 12:00:00', '2020-01-02 12:00:00', 'post', 'publish'), + (3, '2020-01-03 00:00:00', '2020-01-03 00:00:00', 'post', 'publish'), + (4, '2020-01-02 11:00:00', '2020-01-02 12:00:00', 'page', 'publish'), + (5, '2020-01-02 10:00:00', '2020-01-02 12:00:00', 'attachment', 'inherit'), + (6, '2020-01-02 09:00:00', '2020-01-02 12:00:00', 'post', 'draft')", + "CREATE TABLE wp_comments ( + comment_ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + 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", + "INSERT INTO wp_comments (comment_ID, comment_date_gmt, comment_approved) VALUES + (11, '2020-01-01 00:00:00', '1'), + (12, '2020-01-02 12:00:00', '1'), + (13, '2020-01-03 00:00:00', '1'), + (14, '2020-01-02 12:00:00', 'spam')", + ) + ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS wp_posts.ID + FROM wp_posts + WHERE 1=1 + AND ( wp_posts.post_date_gmt >= '2020-01-02T00:00:00Z' + AND wp_posts.post_date_gmt <= '2020-01-02T23:59:59Z' ) + AND wp_posts.post_type = 'post' + AND wp_posts.post_status = 'publish' + ORDER BY wp_posts.post_date DESC + LIMIT 0, 10" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS wp_posts.ID + FROM wp_posts + WHERE 1=1 + AND ( wp_posts.post_date_gmt >= '2020-01-02T00:00:00Z' + AND wp_posts.post_date_gmt <= '2020-01-02T23:59:59Z' ) + AND wp_posts.post_type = 'page' + AND wp_posts.post_status = 'publish' + ORDER BY wp_posts.post_date DESC + LIMIT 0, 10" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS wp_posts.ID + FROM wp_posts + WHERE 1=1 + AND ( wp_posts.post_date_gmt >= '2020-01-02T00:00:00Z' + AND wp_posts.post_date_gmt <= '2020-01-02T23:59:59Z' ) + AND wp_posts.post_type = 'attachment' + AND wp_posts.post_status = 'inherit' + ORDER BY wp_posts.post_date DESC + LIMIT 0, 10" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS wp_comments.comment_ID + FROM wp_comments + WHERE 1=1 + AND ( wp_comments.comment_date_gmt >= '2020-01-02T00:00:00Z' + AND wp_comments.comment_date_gmt <= '2020-01-02T23:59:59Z' ) + AND comment_approved = '1' + ORDER BY wp_comments.comment_date_gmt DESC + LIMIT 0, 10" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + } + + public function test_rest_date_queries_after_rfc3339_z_writes_match_sqlite(): void { + $this->runParitySetup( + array( + "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_date_gmt 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), + KEY type_status_date (post_type, post_status, post_date, ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "CREATE TABLE wp_comments ( + comment_ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + 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", + ) + ); + + $this->assertParityRowCount( + "INSERT INTO wp_posts (ID, post_date, post_date_gmt, post_type, post_status) VALUES + (1, '2016-01-15T00:00:00Z', '2016-01-15T00:00:00Z', 'post', 'publish'), + (2, '2016-01-16T00:00:00Z', '2016-01-16T00:00:00Z', 'post', 'publish'), + (3, '2016-01-17T00:00:00Z', '2016-01-17T00:00:00Z', 'post', 'publish'), + (4, '2016-01-16T00:00:00Z', '2016-01-16T00:00:00Z', 'page', 'publish'), + (5, '2016-01-16T00:00:00Z', '2016-01-16T00:00:00Z', 'attachment', 'inherit')" + ); + $this->assertParityRowCount( + "INSERT INTO wp_comments (comment_ID, comment_date, comment_date_gmt, comment_approved) VALUES + (11, '2016-01-15T00:00:00Z', '2016-01-15T00:00:00Z', '1'), + (12, '2016-01-16T00:00:00Z', '2016-01-16T00:00:00Z', '1'), + (13, '2016-01-17T00:00:00Z', '2016-01-17T00:00:00Z', '1'), + (14, '2016-01-16T00:00:00Z', '2016-01-16T00:00:00Z', 'spam')" + ); + + $this->assertParityRows( 'SELECT ID, post_date, post_date_gmt, post_type, post_status FROM wp_posts ORDER BY ID' ); + $this->assertParityRows( 'SELECT comment_ID, comment_date, comment_date_gmt, comment_approved FROM wp_comments ORDER BY comment_ID' ); + + foreach ( + array( + array( 'post', 'publish' ), + array( 'page', 'publish' ), + array( 'attachment', 'inherit' ), + ) as $case + ) { + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS wp_posts.ID + FROM wp_posts + WHERE 1=1 + AND ( wp_posts.post_date > '2016-01-15T00:00:00Z' + AND wp_posts.post_date < '2016-01-17T00:00:00Z' ) + AND wp_posts.post_type = '{$case[0]}' + AND wp_posts.post_status = '{$case[1]}' + ORDER BY wp_posts.post_date DESC + LIMIT 0, 10" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + } + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS wp_comments.comment_ID + FROM wp_comments + WHERE 1=1 + AND ( wp_comments.comment_date_gmt > '2016-01-15T00:00:00Z' + AND wp_comments.comment_date_gmt < '2016-01-17T00:00:00Z' ) + AND comment_approved = '1' + ORDER BY wp_comments.comment_date_gmt DESC + LIMIT 0, 10" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + } + + public function test_rest_modified_order_after_single_digit_hour_update_uses_canonical_datetime(): void { + $driver = new WP_DuckDB_Driver( + array( + 'path' => ':memory:', + 'database' => 'wp', + ) + ); + + $driver->query( "SET SESSION sql_mode = ''" ); + $driver->query( + "CREATE TABLE wp_posts ( + ID BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + 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_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_modified, post_modified_gmt, post_type, post_status) VALUES + (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', 'post', 'publish'), + (2, '2020-01-01 00:00:00', '2020-01-01 00:00:00', 'post', 'publish'), + (3, '2020-01-01 00:00:00', '2020-01-01 00:00:00', 'post', 'publish')" + ); + $driver->query( + "UPDATE wp_posts + SET post_modified = '2016-04-20 4:26:20', post_modified_gmt = '2016-04-20 4:26:20' + WHERE ID = 1" + ); + $driver->query( + "UPDATE wp_posts + SET post_modified = '2016-02-01 20:24:02', post_modified_gmt = '2016-02-01 20:24:02' + WHERE ID = 2" + ); + $driver->query( + "UPDATE wp_posts + SET post_modified = '2016-02-21 12:24:02', post_modified_gmt = '2016-02-21 12:24:02' + WHERE ID = 3" + ); + + $this->assertSame( + array( + array( + 'ID' => 1, + 'post_modified' => '2016-04-20 04:26:20', + ), + array( + 'ID' => 3, + 'post_modified' => '2016-02-21 12:24:02', + ), + array( + 'ID' => 2, + 'post_modified' => '2016-02-01 20:24:02', + ), + ), + $driver->query( + 'SELECT ID, post_modified FROM wp_posts + WHERE ID IN (1, 2, 3) + ORDER BY post_modified DESC' + )->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + 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_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_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( + 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( + '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_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( + '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_view_metadata_limitations_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE view_source (id INT, name VARCHAR(20))', + ) + ); + + $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%'" ); + $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_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( + '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)' ) ); + + $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_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' ); + + 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 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 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' ); + $this->assertParityRows( 'SELECT @@sql_warnings, @@SESSION.sql_warnings' ); + + $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_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' ); + + $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 */;' + ); + $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( "/*!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->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"' + ); + $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' ); + $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' ); + + $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( + '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->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( + '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 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'" ); + } + + 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_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_sql_calc_found_rows_scalar_coercions_match_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wp_found_rows_coercion_users ( + ID BIGINT(20) UNSIGNED NOT NULL, + user_login VARCHAR(60) NOT NULL DEFAULT '', + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + 'CREATE TABLE wp_found_rows_coercion_usermeta ( + umeta_id BIGINT(20) UNSIGNED NOT NULL, + user_id BIGINT(20) UNSIGNED NOT NULL, + meta_key VARCHAR(255), + meta_value LONGTEXT, + PRIMARY KEY (umeta_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_found_rows_coercion_users (ID, user_login) VALUES + (0, 'zero'), + (1, 'one'), + (2, 'two')", + "INSERT INTO wp_found_rows_coercion_usermeta (umeta_id, user_id, meta_key, meta_value) VALUES + (10, 0, 'user_age', 'abc'), + (11, 1, 'user_age', '10'), + (12, 2, 'user_age', '2'), + (13, 1, 'empty_age', NULL), + (14, 1, 'numeric_prefix', '10abc'), + (15, 1, 'numeric_prefix', ' 11x'), + (16, 1, 'numeric_prefix', '-2.5z'), + (17, 1, 'numeric_prefix', '+3e2tail'), + (18, 1, 'numeric_prefix', '.75q'), + (19, 1, 'numeric_prefix', 'abc'), + (20, 1, 'numeric_prefix', ''), + (21, 1, 'numeric_prefix', '0x10'), + (22, 1, 'numeric_prefix', NULL)", + 'CREATE TABLE wp_found_rows_coercion_options ( + option_id BIGINT(20) UNSIGNED NOT NULL, + option_name VARCHAR(191) NOT NULL DEFAULT \'\', + option_value LONGTEXT, + PRIMARY KEY (option_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_found_rows_coercion_options (option_id, option_name, option_value) VALUES + (1, 'a', '10'), + (2, 'b', '010'), + (3, 'c', '10abc'), + (4, 'd', ' 11x'), + (5, 'e', 'abc'), + (6, 'f', ''), + (7, 'g', NULL)", + 'CREATE TABLE wp_found_rows_nullable_ids ( + ID BIGINT(20), + label VARCHAR(20) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_found_rows_nullable_ids (ID, label) VALUES + (NULL, 'null'), + (1, 'one')", + 'CREATE TABLE wp_plugin_text_ids ( + id VARCHAR(20), + count VARCHAR(20), + label VARCHAR(20) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_plugin_text_ids (id, count, label) VALUES + ('2', '5', 'two'), + ('abc', '9', 'abc')", + ) + ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS ID + FROM wp_found_rows_coercion_users + WHERE ID = 'yololololo' OR user_login LIKE '%yololololo%' + ORDER BY ID + LIMIT 0, 10" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS ID + FROM wp_found_rows_coercion_users + WHERE ID = '02' + ORDER BY ID" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS ID + FROM wp_found_rows_coercion_users + WHERE ID BETWEEN '1' AND 'yololololo' + ORDER BY ID" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( + "SELECT ID + FROM wp_found_rows_coercion_users + WHERE ID NOT BETWEEN '1' AND 'yololololo' + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS ID + FROM wp_found_rows_coercion_users + WHERE ID IN ('1', 'yololololo', '02') + ORDER BY ID" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS ID + FROM wp_found_rows_coercion_users + WHERE ID IN (1, NULL, 'yololololo', 2) + ORDER BY ID" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( + "SELECT ID + FROM wp_found_rows_coercion_users + WHERE ID IN (1 + 1, 'bad') + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_found_rows_coercion_users + WHERE ID NOT IN (1 + 1, 'bad') + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_found_rows_coercion_users + WHERE ID NOT IN ('1', 'yololololo', '02') + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_found_rows_coercion_users + WHERE ID NOT IN (1, NULL, 'yololololo') + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS ID + FROM wp_found_rows_coercion_users + WHERE ID LIKE '1%' + ORDER BY ID" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( + "SELECT ID + FROM wp_found_rows_coercion_users + WHERE ID NOT LIKE '1%' + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT wp_found_rows_coercion_users.ID + FROM wp_found_rows_coercion_users + WHERE wp_found_rows_coercion_users.ID IN ('1', 'bad') + ORDER BY wp_found_rows_coercion_users.ID" + ); + + $this->assertParityRows( + "SELECT wp_found_rows_coercion_users.ID + FROM wp_found_rows_coercion_users + WHERE wp_found_rows_coercion_users.ID BETWEEN '1' AND 'bad' + ORDER BY wp_found_rows_coercion_users.ID" + ); + + $this->assertParityRows( + "SELECT wp_found_rows_coercion_users.ID + FROM wp_found_rows_coercion_users + WHERE wp_found_rows_coercion_users.ID IN (1, 'bad') + ORDER BY wp_found_rows_coercion_users.ID" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE user_id IN ('1', 'bad', '2') + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE user_id BETWEEN '1' AND 'bad' + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE user_id LIKE '1%' + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE user_id IN (1, NULL, 'bad', 2) + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS wp_found_rows_coercion_users.ID + FROM wp_found_rows_coercion_users + INNER JOIN wp_found_rows_coercion_usermeta + ON ( wp_found_rows_coercion_users.ID = wp_found_rows_coercion_usermeta.user_id ) + WHERE wp_found_rows_coercion_usermeta.meta_key = 'user_age' + ORDER BY wp_found_rows_coercion_usermeta.meta_value+0 ASC + LIMIT 0, 2" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( + 'SELECT meta_value + 0 AS coerced + FROM wp_found_rows_coercion_usermeta + WHERE umeta_id = 13' + ); + + $this->assertParityRows( + "SELECT umeta_id, meta_value + 0 AS coerced + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value IS NOT NULL + ORDER BY meta_value + 0 ASC, umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' + ORDER BY meta_value + 0 ASC, umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' + ORDER BY meta_value + 0, umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' + ORDER BY meta_value + 0 DESC, umeta_id" + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + ORDER BY option_value + 0 ASC, option_id' + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND CAST(meta_value AS SIGNED) LIKE '10%' + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND CAST(meta_value AS SIGNED) NOT LIKE '10%' + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value LIKE 10 + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value NOT LIKE 10 + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND 10 LIKE meta_value + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND 10 NOT LIKE meta_value + ORDER BY umeta_id" + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value LIKE 10 + ORDER BY option_id' + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value NOT LIKE 10 + ORDER BY option_id' + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + WHERE 10 LIKE option_value + ORDER BY option_id' + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + WHERE 10 NOT LIKE option_value + ORDER BY option_id' + ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value < 11 + ORDER BY umeta_id" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value < 10.5 + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value = -2.5 + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND 11 > meta_value + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND 10.5 > meta_value + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value BETWEEN 10 AND 11 + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value BETWEEN 10 AND 'abc' + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value BETWEEN '10' AND 11 + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value BETWEEN -2.5 AND +3 + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value BETWEEN 10.0 AND 10.9 + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value NOT BETWEEN 10 AND 11 + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value NOT BETWEEN 10 AND 'abc' + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value IN (10, 11) + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value IN (-2.5, +3, 10.5) + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value IN (10, NULL, 'abc', 10.5) + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value IN (10 + 0, 10.50 + 0, +3 * 100, 'abc') + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value NOT IN (10, 11) + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value NOT IN (10, NULL, 'abc') + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value NOT IN (10 + 0, 'abc') + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND meta_value <> 10 + ORDER BY umeta_id" + ); + + $this->assertParityRows( + "SELECT umeta_id + FROM wp_found_rows_coercion_usermeta + WHERE meta_key = 'numeric_prefix' AND 10 != meta_value + ORDER BY umeta_id" + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value < 11 + ORDER BY option_id' + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value < 10.5 + ORDER BY option_id' + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + WHERE 11 > option_value + ORDER BY option_id' + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value BETWEEN 10 AND 11 + ORDER BY option_id' + ); + + $this->assertParityRows( + "SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value BETWEEN 10 AND 'abc' + ORDER BY option_id" + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value <> 10 + ORDER BY option_id' + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value NOT BETWEEN 10 AND 11 + ORDER BY option_id' + ); + + $this->assertParityRows( + "SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value NOT BETWEEN 10 AND 'abc' + ORDER BY option_id" + ); + + $this->assertParityRows( + 'SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value NOT IN (10, 11) + ORDER BY option_id' + ); + + $this->assertParityRows( + "SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value IN (10, NULL, '10abc') + ORDER BY option_id" + ); + + $this->assertParityRows( + "SELECT option_id + FROM wp_found_rows_coercion_options + WHERE option_value NOT IN (10, NULL, '10abc') + ORDER BY option_id" + ); + + $this->assertParityRows( + "SELECT label + FROM wp_found_rows_nullable_ids + WHERE ID != 'abc' + ORDER BY label" + ); + + $this->assertParityRows( + "SELECT id + FROM wp_plugin_text_ids + WHERE id < '10' + ORDER BY id" + ); + + $this->assertParityRows( + "SELECT wp_plugin_text_ids.count + FROM wp_plugin_text_ids + WHERE wp_plugin_text_ids.count < '10' + ORDER BY wp_plugin_text_ids.count" + ); + } + + public function test_constant_expression_in_list_items_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_expression_in_postmeta ( + meta_id BIGINT(20) UNSIGNED NOT NULL, + post_id BIGINT(20) UNSIGNED NOT NULL, + meta_value LONGTEXT, + PRIMARY KEY (meta_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_expression_in_postmeta (meta_id, post_id, meta_value) VALUES + (1, 1, '11'), + (2, 1, 'abc'), + (3, 2, '10'), + (4, 2, '10.5'), + (5, 3, '3'), + (6, 3, NULL)", + 'CREATE TABLE wp_expression_in_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_parent BIGINT(20) UNSIGNED NOT NULL, + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'INSERT INTO wp_expression_in_posts (ID, post_parent) VALUES + (1, 0), + (2, 1), + (3, 3), + (4, 11)', + ) + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_expression_in_postmeta + WHERE meta_value IN (10 + 1, 'abc') + ORDER BY meta_id" + ); + + $this->assertParityRows( + 'SELECT meta_id + FROM wp_expression_in_postmeta + WHERE meta_value IN (10.50 + 0, +3 * 1) + ORDER BY meta_id' + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_expression_in_postmeta + WHERE meta_value NOT IN (10 + 1, 'abc') + ORDER BY meta_id" + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_expression_in_postmeta + WHERE meta_value IN (ABS(11), 'abc') + ORDER BY meta_id" + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_expression_in_postmeta + WHERE meta_value NOT IN (ABS(11), 'abc') + ORDER BY meta_id" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_expression_in_posts + WHERE post_parent IN (10 + 1, 'abc') + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_expression_in_posts + WHERE post_parent NOT IN (10 + 1, 'abc') + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_expression_in_posts + WHERE post_parent IN (ABS(11), 'abc') + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_expression_in_posts + WHERE post_parent NOT IN (ABS(11), 'abc') + ORDER BY ID" + ); + } + + public function test_chained_constant_expression_in_list_items_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_chained_expression_in_postmeta ( + meta_id BIGINT(20) UNSIGNED NOT NULL, + post_id BIGINT(20) UNSIGNED NOT NULL, + meta_value LONGTEXT, + PRIMARY KEY (meta_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_chained_expression_in_postmeta (meta_id, post_id, meta_value) VALUES + (1, 1, '10'), + (2, 1, '11'), + (3, 2, '13'), + (4, 2, 'abc'), + (5, 3, NULL), + (6, 3, '6')", + 'CREATE TABLE wp_chained_expression_in_options ( + option_id BIGINT(20) UNSIGNED NOT NULL, + option_value LONGTEXT, + PRIMARY KEY (option_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_chained_expression_in_options (option_id, option_value) VALUES + (1, '13'), + (2, 'abc'), + (3, NULL), + (4, '10.5')", + 'CREATE TABLE wp_chained_expression_in_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_parent BIGINT(20) UNSIGNED NOT NULL, + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'INSERT INTO wp_chained_expression_in_posts (ID, post_parent) VALUES + (1, 0), + (2, 11), + (3, 13), + (4, 6)', + ) + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_chained_expression_in_postmeta + WHERE meta_value IN (10 + 1 + 2, 'abc') + ORDER BY meta_id" + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_chained_expression_in_postmeta + WHERE meta_value NOT IN (10 + 1 + 2, 'abc') + ORDER BY meta_id" + ); + + $this->assertParityRows( + "SELECT option_id + FROM wp_chained_expression_in_options + WHERE option_value IN (10 + 1 + 2, NULL, 'abc') + ORDER BY option_id" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_chained_expression_in_posts + WHERE post_parent IN (1 + 2 + 8, 'bad') + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_chained_expression_in_posts + WHERE post_parent NOT IN (1 + 2 + 8, 'bad') + ORDER BY ID" + ); + } + + public function test_constant_expression_between_bounds_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_expression_between_postmeta ( + meta_id BIGINT(20) UNSIGNED NOT NULL, + post_id BIGINT(20) UNSIGNED NOT NULL, + meta_value LONGTEXT, + PRIMARY KEY (meta_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_expression_between_postmeta (meta_id, post_id, meta_value) VALUES + (1, 1, '11'), + (2, 1, 'abc'), + (3, 2, '10'), + (4, 2, '10.5'), + (5, 3, '3'), + (6, 3, NULL), + (7, 4, '12')", + 'CREATE TABLE wp_expression_between_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_parent BIGINT(20) UNSIGNED NOT NULL, + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'INSERT INTO wp_expression_between_posts (ID, post_parent) VALUES + (1, 0), + (2, 1), + (3, 2), + (4, 3), + (5, 11)', + ) + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_expression_between_postmeta + WHERE meta_value BETWEEN 10 + 1 AND 'abc' + ORDER BY meta_id" + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_expression_between_postmeta + WHERE meta_value NOT BETWEEN 10 + 1 AND 'abc' + ORDER BY meta_id" + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_expression_between_postmeta + WHERE meta_value BETWEEN ABS(11) AND 'abc' + ORDER BY meta_id" + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_expression_between_postmeta + WHERE meta_value NOT BETWEEN ABS(11) AND 'abc' + ORDER BY meta_id" + ); + + $this->assertParityRows( + 'SELECT meta_id + FROM wp_expression_between_postmeta + WHERE meta_value BETWEEN 10.50 + 0 AND +3 * 4 + ORDER BY meta_id' + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_expression_between_postmeta + WHERE meta_value BETWEEN '10' AND 10 + 1 + ORDER BY meta_id" + ); + + $this->assertParityRows( + 'SELECT meta_id + FROM wp_expression_between_postmeta + WHERE meta_value BETWEEN 10 + 0 AND NULL + ORDER BY meta_id' + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_expression_between_posts + WHERE post_parent BETWEEN 1 AND 'bad' + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_expression_between_posts + WHERE post_parent NOT BETWEEN 1 AND 'bad' + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_expression_between_posts + WHERE post_parent BETWEEN 1 + 1 AND 'bad' + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_expression_between_posts + WHERE post_parent NOT BETWEEN 1 + 1 AND 'bad' + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_expression_between_posts + WHERE post_parent BETWEEN ABS(1) AND 'bad' + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_expression_between_posts + WHERE post_parent NOT BETWEEN ABS(1) AND 'bad' + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_expression_between_posts + WHERE post_parent BETWEEN 'bad' AND 3 + 0 + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT SQL_CALC_FOUND_ROWS ID + FROM wp_expression_between_posts + WHERE post_parent BETWEEN 1 + 1 AND 'bad' + ORDER BY ID + LIMIT 10" + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + } + + public function test_chained_constant_expression_between_bounds_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_chained_expression_between_postmeta ( + meta_id BIGINT(20) UNSIGNED NOT NULL, + post_id BIGINT(20) UNSIGNED NOT NULL, + meta_value LONGTEXT, + PRIMARY KEY (meta_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_chained_expression_between_postmeta (meta_id, post_id, meta_value) VALUES + (1, 1, '10'), + (2, 1, '11'), + (3, 2, '13'), + (4, 2, 'abc'), + (5, 3, NULL), + (6, 3, '6')", + 'CREATE TABLE wp_chained_expression_between_options ( + option_id BIGINT(20) UNSIGNED NOT NULL, + option_value LONGTEXT, + PRIMARY KEY (option_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_chained_expression_between_options (option_id, option_value) VALUES + (1, '10'), + (2, '11'), + (3, '13'), + (4, 'abc'), + (5, NULL)", + 'CREATE TABLE wp_chained_expression_between_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_parent BIGINT(20) UNSIGNED NOT NULL, + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'INSERT INTO wp_chained_expression_between_posts (ID, post_parent) VALUES + (1, 0), + (2, 11), + (3, 13), + (4, 6)', + ) + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_chained_expression_between_postmeta + WHERE meta_value BETWEEN 10 + 1 + 2 AND 'abc' + ORDER BY meta_id" + ); + + $this->assertParityRows( + "SELECT meta_id + FROM wp_chained_expression_between_postmeta + WHERE meta_value NOT BETWEEN 10 + 1 + 2 AND 'abc' + ORDER BY meta_id" + ); + + $this->assertParityRows( + "SELECT option_id + FROM wp_chained_expression_between_options + WHERE option_value BETWEEN '10' AND 10 + 1 + 2 + ORDER BY option_id" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_chained_expression_between_posts + WHERE post_parent BETWEEN 1 + 2 + 8 AND 'bad' + ORDER BY ID" + ); + + $this->assertParityRows( + "SELECT ID + FROM wp_chained_expression_between_posts + WHERE post_parent NOT BETWEEN 1 + 2 + 8 AND 'bad' + ORDER BY ID" + ); + } + + public function test_constant_expression_text_value_scalar_comparisons_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_expression_scalar_postmeta ( + meta_id BIGINT(20) UNSIGNED NOT NULL, + post_id BIGINT(20) UNSIGNED NOT NULL, + meta_value LONGTEXT, + PRIMARY KEY (meta_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_expression_scalar_postmeta (meta_id, post_id, meta_value) VALUES + (1, 1, '10'), + (2, 1, '11'), + (3, 2, '11.5'), + (4, 2, '3'), + (5, 3, 'abc'), + (6, 3, '10.5'), + (7, 4, NULL), + (8, 4, '-3')", + 'CREATE TABLE wp_expression_scalar_options ( + option_id BIGINT(20) UNSIGNED NOT NULL, + option_value LONGTEXT, + PRIMARY KEY (option_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_expression_scalar_options (option_id, option_value) VALUES + (1, '10'), + (2, '11'), + (3, 'abc'), + (4, NULL)", + ) + ); + + foreach ( + array( + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE meta_value = 10 + 1 ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE meta_value <> 10 + 1 ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE meta_value < 10 + 1 ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE meta_value > 10 + 1 ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE 10 + 1 = meta_value ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE 10 + 1 <> meta_value ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE 10 + 1 < meta_value ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE 10 + 1 > meta_value ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE meta_value = 10.50 + 0 ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE +3 * 1 = meta_value ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE meta_value = ABS(11) ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE ABS(11) = meta_value ORDER BY meta_id', + 'SELECT meta_id FROM wp_expression_scalar_postmeta WHERE meta_value <> ABS(11) ORDER BY meta_id', + 'SELECT option_id FROM wp_expression_scalar_options WHERE option_value < 10 + 1 ORDER BY option_id', + 'SELECT option_id FROM wp_expression_scalar_options WHERE option_value < ABS(11) ORDER BY option_id', + 'SELECT option_id FROM wp_expression_scalar_options WHERE +3 * 1 < option_value ORDER BY option_id', + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + + $this->assertParityRows( + 'SELECT SQL_CALC_FOUND_ROWS meta_id + FROM wp_expression_scalar_postmeta + WHERE meta_value < 10 + 1 + ORDER BY meta_id + LIMIT 10' + ); + $this->assertParityRows( 'SELECT FOUND_ROWS() AS found_rows' ); + } + + public function test_numeric_identifier_text_value_predicates_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_numeric_identifier_text_postmeta ( + meta_id BIGINT(20) UNSIGNED NOT NULL, + post_id BIGINT(20) UNSIGNED NOT NULL, + meta_value LONGTEXT, + PRIMARY KEY (meta_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_numeric_identifier_text_postmeta (meta_id, post_id, meta_value) VALUES + (1, 1, '11'), + (2, 1, '13'), + (3, 2, 'abc'), + (4, 2, NULL), + (5, 3, '13abc'), + (6, 3, '13'), + (7, 4, 'zz'), + (8, 4, '14')", + 'CREATE TABLE wp_numeric_identifier_text_options ( + option_id BIGINT(20) UNSIGNED NOT NULL, + option_value LONGTEXT, + PRIMARY KEY (option_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_numeric_identifier_text_options (option_id, option_value) VALUES + (1, '11'), + (2, '12'), + (3, 'abc'), + (4, NULL)", + ) + ); + + foreach ( + array( + 'SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value = post_id + 10 ORDER BY meta_id', + 'SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE post_id + 10 = meta_value ORDER BY meta_id', + 'SELECT pm.meta_id FROM wp_numeric_identifier_text_postmeta pm WHERE pm.meta_value = pm.post_id + 10 ORDER BY pm.meta_id', + 'SELECT option_id FROM wp_numeric_identifier_text_options WHERE option_value = option_id + 10 ORDER BY option_id', + 'SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value = ABS(post_id + 10) ORDER BY meta_id', + 'SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE ABS(post_id + 10) = meta_value ORDER BY meta_id', + 'SELECT pm.meta_id FROM wp_numeric_identifier_text_postmeta pm WHERE pm.meta_value = ABS(pm.post_id + 10) ORDER BY pm.meta_id', + 'SELECT option_id FROM wp_numeric_identifier_text_options WHERE option_value = ABS(option_id + 10) ORDER BY option_id', + 'SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value <> post_id + 10 ORDER BY meta_id', + 'SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value < post_id + 10 ORDER BY meta_id', + 'SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE post_id + 10 < meta_value ORDER BY meta_id', + 'SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value <> ABS(post_id + 10) ORDER BY meta_id', + 'SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value < ABS(post_id + 10) ORDER BY meta_id', + 'SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE ABS(post_id + 10) < meta_value ORDER BY meta_id', + "SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value IN (post_id + 10, 'abc') ORDER BY meta_id", + "SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value NOT IN (post_id + 10, 'abc') ORDER BY meta_id", + "SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value BETWEEN post_id + 10 AND 'zz' ORDER BY meta_id", + "SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value NOT BETWEEN post_id + 10 AND 'zz' ORDER BY meta_id", + "SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value IN (ABS(post_id + 10), 'abc') ORDER BY meta_id", + "SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value NOT IN (ABS(post_id + 10), 'abc') ORDER BY meta_id", + "SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value BETWEEN ABS(post_id + 10) AND 'zz' ORDER BY meta_id", + "SELECT meta_id FROM wp_numeric_identifier_text_postmeta WHERE meta_value NOT BETWEEN ABS(post_id + 10) AND 'zz' ORDER BY meta_id", + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_post_text_numeric_identifier_predicates_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_post_text_numeric_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_author BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + post_parent BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + post_title TEXT NOT NULL, + post_name VARCHAR(200) NOT NULL DEFAULT \'\', + post_content LONGTEXT NOT NULL, + post_excerpt TEXT NOT NULL, + PRIMARY KEY (ID) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_post_text_numeric_posts (ID, post_author, post_parent, post_title, post_name, post_content, post_excerpt) VALUES + (1, 1, 1, '11', '11', '11', '11'), + (2, 1, 2, 'abc', 'abc', 'abc', 'abc'), + (3, 2, 3, '13', '13', '13', '13'), + (4, 2, 4, '13abc', '13abc', '13abc', '13abc'), + (5, 3, 5, '15', '15', '15', '15'), + (6, 4, 6, 'zz', 'zz', 'zz', 'zz')", + ) + ); + + foreach ( + array( + 'SELECT ID FROM wp_post_text_numeric_posts WHERE post_title = ID + 10 ORDER BY ID', + 'SELECT ID FROM wp_post_text_numeric_posts WHERE ID + 10 = post_title ORDER BY ID', + 'SELECT ID FROM wp_post_text_numeric_posts WHERE post_title = ABS(ID + 10) ORDER BY ID', + 'SELECT ID FROM wp_post_text_numeric_posts WHERE ABS(ID + 10) = post_title ORDER BY ID', + 'SELECT p.ID FROM wp_post_text_numeric_posts p WHERE p.post_title = p.ID + 10 ORDER BY p.ID', + 'SELECT ID FROM wp_post_text_numeric_posts WHERE post_name = ID + 10 ORDER BY ID', + 'SELECT ID FROM wp_post_text_numeric_posts WHERE post_content = ID + 10 ORDER BY ID', + 'SELECT ID FROM wp_post_text_numeric_posts WHERE post_excerpt = post_parent + 10 ORDER BY ID', + 'SELECT ID FROM wp_post_text_numeric_posts WHERE post_title <> ID + 10 ORDER BY ID', + 'SELECT ID FROM wp_post_text_numeric_posts WHERE post_title < ID + 10 ORDER BY ID', + 'SELECT ID FROM wp_post_text_numeric_posts WHERE ID + 10 < post_title ORDER BY ID', + "SELECT ID FROM wp_post_text_numeric_posts WHERE post_title IN (ID + 10, 'abc') ORDER BY ID", + "SELECT ID FROM wp_post_text_numeric_posts WHERE post_title NOT IN (ID + 10, 'abc') ORDER BY ID", + "SELECT ID FROM wp_post_text_numeric_posts WHERE post_title BETWEEN ID + 10 AND 'zz' ORDER BY ID", + "SELECT ID FROM wp_post_text_numeric_posts WHERE post_title NOT BETWEEN ID + 10 AND 'zz' ORDER BY ID", + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } + + public function test_chained_constant_expression_text_value_scalar_comparisons_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_chained_expression_scalar_postmeta ( + meta_id BIGINT(20) UNSIGNED NOT NULL, + post_id BIGINT(20) UNSIGNED NOT NULL, + meta_value LONGTEXT, + PRIMARY KEY (meta_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_chained_expression_scalar_postmeta (meta_id, post_id, meta_value) VALUES + (1, 1, '10'), + (2, 1, '11'), + (3, 2, '13'), + (4, 2, 'abc'), + (5, 3, NULL), + (6, 3, '6'), + (7, 4, '10.5')", + 'CREATE TABLE wp_chained_expression_scalar_options ( + option_id BIGINT(20) UNSIGNED NOT NULL, + option_value LONGTEXT, + PRIMARY KEY (option_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + "INSERT INTO wp_chained_expression_scalar_options (option_id, option_value) VALUES + (1, '10'), + (2, '11'), + (3, '13'), + (4, 'abc'), + (5, NULL), + (6, '10.5')", + ) + ); + + foreach ( + array( + 'SELECT meta_id FROM wp_chained_expression_scalar_postmeta WHERE meta_value = 10 + 1 + 2 ORDER BY meta_id', + 'SELECT meta_id FROM wp_chained_expression_scalar_postmeta WHERE meta_value <> 10 + 1 + 2 ORDER BY meta_id', + 'SELECT meta_id FROM wp_chained_expression_scalar_postmeta WHERE meta_value < 10 + 1 + 2 ORDER BY meta_id', + 'SELECT meta_id FROM wp_chained_expression_scalar_postmeta WHERE 10 + 1 + 2 = meta_value ORDER BY meta_id', + 'SELECT meta_id FROM wp_chained_expression_scalar_postmeta WHERE 10 + 1 + 2 < meta_value ORDER BY meta_id', + 'SELECT option_id FROM wp_chained_expression_scalar_options WHERE option_value < 10 + 1 + 2 ORDER BY option_id', + 'SELECT option_id FROM wp_chained_expression_scalar_options WHERE option_value = 10.50 + 0 + 0 ORDER BY option_id', + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + + $this->assertParityRows( + 'SELECT SQL_CALC_FOUND_ROWS meta_id + FROM wp_chained_expression_scalar_postmeta + WHERE 10 + 1 + 2 < meta_value + ORDER BY meta_id + LIMIT 10' + ); + $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_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_terms_aggregate_group_by_primary_key_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wptests_terms ( + term_id BIGINT(20) UNSIGNED NOT NULL, + name VARCHAR(200) NOT NULL DEFAULT '', + slug VARCHAR(200) NOT NULL DEFAULT '', + term_group BIGINT(10) NOT NULL DEFAULT 0, + PRIMARY KEY (term_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "CREATE TABLE wptests_term_taxonomy ( + term_taxonomy_id BIGINT(20) UNSIGNED NOT NULL, + term_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + taxonomy VARCHAR(32) NOT NULL DEFAULT '', + description LONGTEXT NOT NULL, + parent BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + count BIGINT(20) NOT NULL DEFAULT 0, + PRIMARY KEY (term_taxonomy_id), + UNIQUE KEY term_id_taxonomy (term_id, taxonomy) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + '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', + "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", + "INSERT INTO wptests_terms (term_id, name, slug, term_group) VALUES + (1, 'Alpha', 'alpha', 0), + (2, 'Beta', 'beta', 0)", + "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent, count) VALUES + (101, 1, 'wptests_tax', 'First description', 0, 1), + (102, 2, 'wptests_tax', 'Second description', 0, 1)", + "INSERT INTO wptests_posts (ID, post_type, post_status) VALUES + (201, 'post', 'publish'), + (202, 'post', 'publish'), + (203, 'page', 'publish')", + 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id, term_order) VALUES + (201, 101, 0), + (202, 102, 0), + (203, 101, 0)', + ) + ); + + $this->assertParityRows( + "SELECT DISTINCT t.term_id, tt.term_taxonomy_id, tt.taxonomy, tt.description, tt.parent, COUNT(p.post_type) AS count + FROM wptests_terms AS t + INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id + LEFT JOIN wptests_term_relationships AS r ON r.term_taxonomy_id = tt.term_taxonomy_id + LEFT JOIN wptests_posts AS p ON p.ID = r.object_id + WHERE tt.taxonomy IN ('wptests_tax') + AND (p.post_type = 'post' OR p.post_type IS NULL) + AND (p.post_status = 'publish') + GROUP BY t.term_id + ORDER BY t.name ASC" + ); + } + + public function test_select_terms_nonaggregate_group_by_primary_key_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wptests_terms ( + term_id BIGINT(20) UNSIGNED NOT NULL, + name VARCHAR(200) NOT NULL DEFAULT '', + slug VARCHAR(200) NOT NULL DEFAULT '', + term_group BIGINT(10) NOT NULL DEFAULT 0, + PRIMARY KEY (term_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "CREATE TABLE wptests_term_taxonomy ( + term_taxonomy_id BIGINT(20) UNSIGNED NOT NULL, + term_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + taxonomy VARCHAR(32) NOT NULL DEFAULT '', + description LONGTEXT NOT NULL, + parent BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + count BIGINT(20) NOT NULL DEFAULT 0, + PRIMARY KEY (term_taxonomy_id), + UNIQUE KEY term_id_taxonomy (term_id, taxonomy) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + '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', + "INSERT INTO wptests_terms (term_id, name, slug, term_group) VALUES + (1, 'Alpha', 'alpha', 0), + (2, 'Beta', 'beta', 0), + (3, 'Gamma', 'gamma', 0)", + "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent, count) VALUES + (101, 1, 'wptests_tax', 'Alpha description', 0, 1), + (102, 2, 'wptests_tax', 'Beta description', 0, 1), + (301, 3, 'category', 'Gamma category', 0, 1)", + 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id, term_order) VALUES + (201, 101, 0), + (202, 102, 0), + (203, 101, 0), + (204, 301, 0)', + ) + ); + + $this->assertParityRows( + "SELECT t.term_id, tt.term_taxonomy_id + FROM wptests_terms AS t + INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id + INNER JOIN wptests_term_relationships AS tr ON tr.term_taxonomy_id = tt.term_taxonomy_id + WHERE tt.taxonomy = 'wptests_tax' + AND tr.object_id IN (201, 202, 203) + GROUP BY t.term_id + ORDER BY t.name ASC" + ); + } + + public function test_select_split_shared_term_probe_group_by_primary_key_matches_sqlite(): void { + $this->runParitySetup( + array( + "CREATE TABLE wptests_terms ( + term_id BIGINT(20) UNSIGNED NOT NULL, + name VARCHAR(200) NOT NULL DEFAULT '', + slug VARCHAR(200) NOT NULL DEFAULT '', + term_group BIGINT(10) NOT NULL DEFAULT 0, + PRIMARY KEY (term_id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "CREATE TABLE wptests_term_taxonomy ( + term_taxonomy_id BIGINT(20) UNSIGNED NOT NULL, + term_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + taxonomy VARCHAR(32) NOT NULL DEFAULT '', + description LONGTEXT NOT NULL, + parent BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + count BIGINT(20) NOT NULL DEFAULT 0, + PRIMARY KEY (term_taxonomy_id), + UNIQUE KEY term_id_taxonomy (term_id, taxonomy) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + "INSERT INTO wptests_terms (term_id, name, slug, term_group) VALUES + (1, 'Alpha', 'alpha', 0), + (3, 'Shared', 'shared', 0)", + "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent, count) VALUES + (101, 1, 'category', 'Alpha category', 0, 0), + (301, 3, 'category', 'Shared category', 0, 0), + (302, 3, 'post_tag', 'Shared tag', 0, 0)", + ) + ); + + $this->assertParityRows( + 'SELECT tt.term_id, t.*, count(*) AS term_tt_count + FROM wptests_term_taxonomy tt + LEFT JOIN wptests_terms t ON t.term_id = tt.term_id + GROUP BY t.term_id + HAVING term_tt_count > 1 + LIMIT 1' + ); + } + + 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-02-03 00:00:00', 'post', 'publish'), + (3, '2024-01-10 00:00:00', 'post', 'publish'), + (4, '2023-12-31 00:00:00', 'post', 'publish'), + (5, '2024-02-04 00:00:00', 'page', 'publish'), + (6, '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_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( + "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( + '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_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' ); + + $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 { + $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_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( + '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( + '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' ); + + $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 { + $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_unqualified_unique_aliased_target_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 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 { + $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( + "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_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_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_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( + '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( + '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_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( + '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_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 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" ); + $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( + 'CREATE TABLE options (option_name VARCHAR(100))', + "INSERT INTO options VALUES ('rss_123'), ('RSS_456'), ('transient'), ('alpha'), ('ALPS')", + ) + ); + + $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 lower(option_name), option_name DESC" ); + $this->assertParityRows( "SELECT option_name FROM options WHERE option_name NOT RLIKE BINARY '^RSS_.+$' ORDER BY lower(option_name), option_name DESC" ); + $this->assertParityRows( "SELECT option_name FROM options WHERE BINARY option_name REGEXP '^a' ORDER BY lower(option_name), option_name DESC" ); + $this->assertParityRows( "SELECT option_name FROM options WHERE BINARY option_name NOT REGEXP '^a' ORDER BY lower(option_name), option_name DESC" ); + $this->assertParityRows( "SELECT o.option_name FROM options o WHERE BINARY o.option_name RLIKE '^a' ORDER BY lower(o.option_name), o.option_name DESC" ); + } + + public function test_regexp_null_empty_pattern_and_null_haystack_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_regexp_null_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_title VARCHAR(255), + PRIMARY KEY (ID) + )', + "INSERT INTO wp_regexp_null_posts (ID, post_title) VALUES + (1, 'Apple'), + (2, 'apple'), + (3, ''), + (4, NULL), + (11, 'Banana'), + (12, 'Alpha')", + ) + ); + + $previous_error_reporting = error_reporting( error_reporting() & ~E_WARNING & ~E_DEPRECATED ); + try { + foreach ( + array( + 'SELECT ID FROM wp_regexp_null_posts WHERE post_title REGEXP NULL ORDER BY ID', + 'SELECT ID FROM wp_regexp_null_posts WHERE post_title RLIKE NULL ORDER BY ID', + 'SELECT ID FROM wp_regexp_null_posts WHERE post_title NOT REGEXP NULL ORDER BY ID', + 'SELECT ID FROM wp_regexp_null_posts WHERE post_title NOT RLIKE NULL ORDER BY ID', + "SELECT ID FROM wp_regexp_null_posts WHERE post_title REGEXP '' ORDER BY ID", + "SELECT ID FROM wp_regexp_null_posts WHERE post_title RLIKE '' ORDER BY ID", + "SELECT ID FROM wp_regexp_null_posts WHERE post_title NOT REGEXP '' ORDER BY ID", + "SELECT ID FROM wp_regexp_null_posts WHERE post_title NOT RLIKE '' ORDER BY ID", + "SELECT ID FROM wp_regexp_null_posts WHERE post_title REGEXP '^A' ORDER BY ID", + "SELECT ID FROM wp_regexp_null_posts WHERE post_title NOT REGEXP '^A' ORDER BY ID", + "SELECT ID FROM wp_regexp_null_posts WHERE BINARY post_title REGEXP '' ORDER BY ID", + "SELECT ID FROM wp_regexp_null_posts WHERE BINARY post_title NOT REGEXP '^A' ORDER BY ID", + "SELECT p.ID FROM wp_regexp_null_posts p WHERE BINARY p.post_title RLIKE '^A' ORDER BY p.ID", + "SELECT ID FROM wp_regexp_null_posts WHERE post_title REGEXP BINARY '^A' ORDER BY ID", + "SELECT ID FROM wp_regexp_null_posts WHERE post_title REGEXP BINARY '^a' ORDER BY ID", + "SELECT ID FROM wp_regexp_null_posts WHERE post_title REGEXP BINARY '' ORDER BY ID", + 'SELECT ID FROM wp_regexp_null_posts WHERE post_title REGEXP BINARY NULL ORDER BY ID', + 'SELECT ID FROM wp_regexp_null_posts WHERE post_title NOT REGEXP BINARY NULL ORDER BY ID', + 'SELECT ID FROM wp_regexp_null_posts WHERE post_title RLIKE BINARY NULL ORDER BY ID', + 'SELECT ID FROM wp_regexp_null_posts WHERE post_title NOT RLIKE BINARY NULL ORDER BY ID', + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } finally { + error_reporting( $previous_error_reporting ); + } + } + + public function test_invalid_literal_regexp_patterns_match_sqlite(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_invalid_regexp_posts ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_title VARCHAR(255), + PRIMARY KEY (ID) + )', + "INSERT INTO wp_invalid_regexp_posts (ID, post_title) VALUES + (1, 'Apple'), + (2, 'apple'), + (3, ''), + (4, NULL)", + ) + ); + + $previous_error_reporting = error_reporting( error_reporting() & ~E_WARNING & ~E_DEPRECATED ); + try { + foreach ( + array( + "SELECT ID FROM wp_invalid_regexp_posts WHERE post_title REGEXP '[' ORDER BY ID", + 'SELECT ID FROM wp_invalid_regexp_posts WHERE post_title RLIKE "[" ORDER BY ID', + "SELECT ID FROM wp_invalid_regexp_posts WHERE post_title NOT REGEXP '[' ORDER BY ID", + 'SELECT ID FROM wp_invalid_regexp_posts WHERE post_title NOT RLIKE "[" ORDER BY ID', + "SELECT ID FROM wp_invalid_regexp_posts WHERE post_title REGEXP BINARY '[' ORDER BY ID", + "SELECT ID FROM wp_invalid_regexp_posts WHERE post_title NOT REGEXP BINARY '[' ORDER BY ID", + "SELECT ID FROM wp_invalid_regexp_posts WHERE post_title RLIKE BINARY '[' ORDER BY ID", + "SELECT ID FROM wp_invalid_regexp_posts WHERE post_title NOT RLIKE BINARY '[' ORDER BY ID", + "SELECT ID FROM wp_invalid_regexp_posts WHERE BINARY post_title REGEXP '[' ORDER BY ID", + "SELECT ID FROM wp_invalid_regexp_posts WHERE BINARY post_title NOT RLIKE '[' ORDER BY ID", + ) as $sql + ) { + $this->assertParityRows( $sql ); + } + } finally { + error_reporting( $previous_error_reporting ); + } + } + + public function test_invalid_literal_regexp_patterns_preserve_left_expression_errors(): void { + $this->runParitySetup( + array( + 'CREATE TABLE wp_invalid_regexp_errors ( + ID BIGINT(20) UNSIGNED NOT NULL, + post_title VARCHAR(255), + PRIMARY KEY (ID) + )', + "INSERT INTO wp_invalid_regexp_errors (ID, post_title) VALUES (1, 'Apple')", + ) + ); + + foreach ( + array( + "SELECT ID FROM wp_invalid_regexp_errors WHERE missing_col REGEXP '[' ORDER BY ID", + "SELECT ID FROM wp_invalid_regexp_errors WHERE missing_col NOT REGEXP '[' ORDER BY ID", + ) as $sql + ) { + $this->assertParityErrorContains( $sql, 'missing_col' ); + } + } + + 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_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_on_duplicate_key_update_multirow_term_relationships_match_sqlite(): void { + $this->runParitySetup( + array( + "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", + 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id, term_order) + VALUES (1, 11, 0), (2, 11, 0)', + ) + ); + + $this->assertParityRowCount( + 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id, term_order) + VALUES (1, 11, 7), (1, 12, 0), (3, 11, 0) + ON DUPLICATE KEY UPDATE term_order = VALUES(term_order)' + ); + $this->assertParityRows( 'SELECT object_id, term_taxonomy_id, term_order FROM wptests_term_relationships ORDER BY object_id, term_taxonomy_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