diff --git a/.github/workflows/cs.yml b/.github/workflows/cs.yml index 64fa997d4..b6582ad7b 100644 --- a/.github/workflows/cs.yml +++ b/.github/workflows/cs.yml @@ -1,23 +1,25 @@ name: CS on: - # Run on all relevant pushes (except to main) and on all relevant pull requests. + # Run on all relevant pushes and pull requests. push: paths: - '**.php' + - '.github/workflows/**' + - 'bin/verify-workflow-ci-gates.js' - 'composer.json' - 'composer.lock' - '.phpcs.xml.dist' - 'phpcs.xml.dist' - - '.github/workflows/cs.yml' pull_request: paths: - '**.php' + - '.github/workflows/**' + - 'bin/verify-workflow-ci-gates.js' - 'composer.json' - 'composer.lock' - '.phpcs.xml.dist' - 'phpcs.xml.dist' - - '.github/workflows/cs.yml' # Allow manually triggering the workflow. workflow_dispatch: @@ -55,6 +57,11 @@ jobs: - name: Validate Composer installation run: composer validate --no-check-all + - name: Verify workflow CI gates + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: composer run verify-workflow-ci-gates + # Install dependencies and handle caching in one go. # @link https://github.com/marketplace/actions/install-composer-dependencies - name: Install Composer dependencies diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 7be965a7c..3c12f356f 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - trunk pull_request: # Disable permissions for all available scopes by default. @@ -34,8 +35,12 @@ jobs: run: npx playwright install --with-deps - name: Run end-to-end tests + env: + WP_TEST_DB_BACKEND: sqlite run: composer run test-e2e - name: Stop Docker containers if: always() + env: + WP_TEST_DB_BACKEND: sqlite run: composer run wp-test-clean diff --git a/.github/workflows/lexer-benchmark.yml b/.github/workflows/lexer-benchmark.yml index 9c41005f0..584d0a835 100644 --- a/.github/workflows/lexer-benchmark.yml +++ b/.github/workflows/lexer-benchmark.yml @@ -24,7 +24,8 @@ jobs: timeout-minutes: 15 permissions: contents: read # Required to clone the repo. - pull-requests: write # Required to post/update the result comment. + issues: write # Required to post/update the result comment. + pull-requests: write # Required to inspect pull request metadata. steps: - name: Checkout repository @@ -84,8 +85,10 @@ jobs: echo '```' } > "$RUNNER_TEMP/comment.md" echo "COMMENT_FILE=$RUNNER_TEMP/comment.md" >> "$GITHUB_ENV" + cat "$RUNNER_TEMP/comment.md" >> "$GITHUB_STEP_SUMMARY" - name: Post or update the PR comment + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} uses: actions/github-script@v7 with: script: | diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 5126e2ea3..5d27bfb52 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -3,6 +3,7 @@ name: PHPUnit Tests on: push: branches: + - main - trunk paths: - '.github/workflows/phpunit-tests.yml' @@ -30,9 +31,9 @@ permissions: {} jobs: test: # The pure-PHP parser is exercised across the full PHP/SQLite range; the - # native Rust parser extension is exercised on PHP 8.0+ (its minimum). Both - # run the same mysql-on-sqlite suite, just with a different parser engine. - name: PHP ${{ matrix.php }}${{ matrix.extension && ' + ext-wp-mysql-parser' || '' }} / SQLite ${{ matrix.sqlite }} + # native Rust parser extension is exercised on PHP 8.0+ (its minimum). + # PostgreSQL-specific tests run in one bounded adapter lane. + name: PHP ${{ matrix.php }}${{ matrix.extension && ' + ext-wp-mysql-parser' || '' }} / SQLite ${{ matrix.sqlite }} / ${{ matrix.testsuite }} runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -43,22 +44,24 @@ jobs: include: # Pure-PHP parser, across the supported PHP versions, each pinned to a # representative SQLite version spanning the supported range. - - { php: '7.2', sqlite: '3.27.0', extension: false } # minimum with WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS - - { php: '7.3', sqlite: '3.31.1', extension: false } # Ubuntu 20.04 LTS - - { php: '7.4', sqlite: '3.34.1', extension: false } # Debian 11 (Bullseye) - - { php: '8.0', sqlite: '3.37.0', extension: false } # minimum supported version (STRICT tables) - - { php: '8.1', sqlite: '3.40.1', extension: false } # Debian 12 (Bookworm) - - { php: '8.2', sqlite: '3.45.1', extension: false } # Ubuntu 24.04 LTS - - { php: '8.3', sqlite: '3.46.1', extension: false } # Debian 13 (Trixie) - - { php: '8.4', sqlite: '3.51.2', extension: false } # First 2026 release - - { php: '8.5', sqlite: 'latest', extension: false } + - { php: '7.2', sqlite: '3.27.0', extension: false, testsuite: default } # minimum with WP_SQLITE_UNSAFE_ENABLE_UNSUPPORTED_VERSIONS + - { php: '7.3', sqlite: '3.31.1', extension: false, testsuite: default } # Ubuntu 20.04 LTS + - { php: '7.4', sqlite: '3.34.1', extension: false, testsuite: default } # Debian 11 (Bullseye) + - { php: '8.0', sqlite: '3.37.0', extension: false, testsuite: default } # minimum supported version (STRICT tables) + - { php: '8.1', sqlite: '3.40.1', extension: false, testsuite: default } # Debian 12 (Bookworm) + - { php: '8.2', sqlite: '3.45.1', extension: false, testsuite: default } # Ubuntu 24.04 LTS + - { php: '8.3', sqlite: '3.46.1', extension: false, testsuite: default } # Debian 13 (Trixie) + - { php: '8.4', sqlite: '3.51.2', extension: false, testsuite: default } # First 2026 release + - { php: '8.5', sqlite: 'latest', extension: false, testsuite: default } + # PostgreSQL adapter tests run once in a bounded package lane. + - { php: '8.3', sqlite: '3.46.1', extension: false, testsuite: postgresql } # Native Rust parser extension (requires PHP 8.0+). - - { php: '8.0', sqlite: '3.37.0', extension: true } - - { php: '8.1', sqlite: '3.40.1', extension: true } - - { php: '8.2', sqlite: '3.45.1', extension: true } - - { php: '8.3', sqlite: '3.46.1', extension: true } - - { php: '8.4', sqlite: '3.51.2', extension: true } - - { php: '8.5', sqlite: 'latest', extension: true } + - { php: '8.0', sqlite: '3.37.0', extension: true, testsuite: default } + - { php: '8.1', sqlite: '3.40.1', extension: true, testsuite: default } + - { php: '8.2', sqlite: '3.45.1', extension: true, testsuite: default } + - { php: '8.3', sqlite: '3.46.1', extension: true, testsuite: default } + - { php: '8.4', sqlite: '3.51.2', extension: true, testsuite: default } + - { php: '8.5', sqlite: 'latest', extension: true, testsuite: default } steps: - name: Checkout repository @@ -179,10 +182,15 @@ jobs: if: matrix.extension env: WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION: '1' - run: php -d extension="$GITHUB_WORKSPACE/packages/php-ext-wp-mysql-parser/target/release/libwp_mysql_parser.so" ./vendor/bin/phpunit -c ./phpunit.xml.dist + run: php -d extension="$GITHUB_WORKSPACE/packages/php-ext-wp-mysql-parser/target/release/libwp_mysql_parser.so" ./vendor/bin/phpunit -c ./phpunit.xml.dist --testsuite default working-directory: packages/mysql-on-sqlite - name: Run PHPUnit suite - if: ${{ ! matrix.extension }} - run: php ./vendor/bin/phpunit -c ./phpunit.xml.dist + if: ${{ ! matrix.extension && matrix.testsuite == 'default' }} + run: php ./vendor/bin/phpunit -c ./phpunit.xml.dist --testsuite default + working-directory: packages/mysql-on-sqlite + + - name: Run PostgreSQL PHPUnit suite + if: ${{ matrix.testsuite == 'postgresql' }} + run: php ./vendor/bin/phpunit -c ./phpunit.xml.dist --testsuite postgresql working-directory: packages/mysql-on-sqlite diff --git a/.github/workflows/wp-tests-end-to-end.yml b/.github/workflows/wp-tests-end-to-end.yml index 7b3637e4f..d1c1ad72e 100644 --- a/.github/workflows/wp-tests-end-to-end.yml +++ b/.github/workflows/wp-tests-end-to-end.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - trunk pull_request: # Disable permissions for all available scopes by default. @@ -12,11 +13,17 @@ permissions: {} jobs: test: - name: WordPress End-to-end Tests + name: WordPress End-to-end Tests / ${{ matrix.backend }} runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 60 permissions: contents: read # Required to clone the repo. + strategy: + fail-fast: false + matrix: + backend: + - sqlite + - postgresql steps: - name: Checkout repository @@ -34,8 +41,14 @@ jobs: run: npx playwright install --with-deps - name: Run WordPress end-to-end tests + env: + WP_TEST_DB_BACKEND: ${{ matrix.backend }} run: composer run wp-test-e2e - name: Stop Docker containers if: always() + env: + WP_TEST_DB_BACKEND: ${{ matrix.backend }} + LOCAL_DB_TYPE: mysql + LOCAL_PHP_MEMCACHED: 'false' run: composer run wp-test-clean diff --git a/.github/workflows/wp-tests-phpunit-native-extension-setup.sh b/.github/workflows/wp-tests-phpunit-native-extension-setup.sh index 943b06c59..dbc8b2bf3 100644 --- a/.github/workflows/wp-tests-phpunit-native-extension-setup.sh +++ b/.github/workflows/wp-tests-phpunit-native-extension-setup.sh @@ -14,6 +14,11 @@ if [ ! -f "$COMPOSE_OVERRIDE" ]; then exit 1 fi +if ! grep -Fq 'DB_ENGINE: sqlite' "$COMPOSE_OVERRIDE" || ! grep -Fq 'DATABASE_ENGINE: sqlite' "$COMPOSE_OVERRIDE"; then + echo "Stale $COMPOSE_OVERRIDE. Run WP_TEST_DB_BACKEND=sqlite composer run wp-setup before this helper." >&2 + exit 1 +fi + add_volume_to_service() { local service="$1" local volume="$2" diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index ae4f5f6a3..ccd3cb945 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -5,18 +5,21 @@ * Unexpected errors/failures still fail the workflow. Expected failures that * stop happening are reported so this allowlist can be reduced over time. */ -const { execSync } = require( 'child_process' ); +const { execFileSync, execSync } = require( 'child_process' ); const fs = require( 'fs' ); const path = require( 'path' ); +const repositoryRoot = path.join( __dirname, '..', '..' ); +const backend = normalizeBackend( process.env.WP_TEST_DB_BACKEND || 'sqlite' ); const requiresNativeParserExtension = process.env.WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION === '1'; +const phpunitArgs = getPhpUnitArgs(); -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', @@ -66,19 +69,19 @@ const expectedFailures = [ '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_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_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_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_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_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', @@ -90,116 +93,874 @@ const expectedFailures = [ 'WP_Test_REST_Posts_Controller::test_get_items_orderby_modified_query', ]; -console.log( 'Running WordPress PHPUnit tests with expected failures tracking...' ); +const expectedByBackend = { + mysql: { + errors: [], + failures: [], + }, + sqlite: { + errors: sqliteExpectedErrors, + failures: sqliteExpectedFailures, + }, + postgresql: { + errors: [], + failures: [], + }, +}; + +console.log( `Running WordPress PHPUnit tests with ${ backend } expected-result tracking...` ); if ( requiresNativeParserExtension ) { console.log( 'Native parser extension is required for this PHPUnit run.' ); } -console.log( 'Expected errors:', expectedErrors ); -console.log( 'Expected failures:', expectedFailures ); - -function verifyNativeParserExtension() { - const verifier = path.join( __dirname, '..', '..', 'wordpress', 'native-verify-extension.php' ); - if ( ! fs.existsSync( verifier ) ) { - console.error( `Error: Native parser verifier not found at ${ verifier }.` ); - process.exit( 1 ); - } - - execSync( 'composer run wp-test-ensure-env', { stdio: 'inherit' } ); - execSync( - 'cd wordpress && node tools/local-env/scripts/docker.js run --rm php php /var/www/native-verify-extension.php', - { stdio: 'inherit' } - ); +if ( phpunitArgs.length > 0 ) { + console.log( 'PHPUnit arguments:', phpunitArgs ); } +console.log( 'Expected errors:', expectedByBackend[ backend ].errors ); +console.log( 'Expected failures:', expectedByBackend[ backend ].failures ); try { + ensureGeneratedBackendFiles(); + ensureWordPressTestEnvironment(); + validateGeneratedBackendFiles(); + if ( requiresNativeParserExtension ) { verifyNativeParserExtension(); } + if ( 'postgresql' === backend ) { + verifyPostgreSqlPhpExtension(); + } + + const junitOutputFile = path.join( repositoryRoot, 'wordpress', 'phpunit-results.xml' ); + removeStaleTestOutput( junitOutputFile ); + removeStaleTestOutput( getResultSummaryFile() ); + let phpunitCommandError = null; try { - execSync( - `composer run wp-test-php -- --log-junit=phpunit-results.xml --verbose`, - { stdio: 'inherit' } - ); - console.log( '\n⚠️ All tests passed, checking if expected errors/failures occurred...' ); + runPhpUnit(); + console.log( '\nAll tests passed, checking if expected errors/failures occurred...' ); } catch ( error ) { - console.log( '\n⚠️ Some tests errored/failed (expected). Analyzing results...' ); + phpunitCommandError = error; + console.log( '\nSome tests errored/failed. 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!' ); + console.error( 'Error: JUnit output file not found.' ); + writeResultSummary( emptySummary() ); + process.exit( 1 ); + } + if ( 0 === fs.statSync( junitOutputFile ).size ) { + console.error( 'Error: JUnit output file is empty.' ); + writeResultSummary( emptySummary() ); process.exit( 1 ); } - const junitXml = fs.readFileSync( junitOutputFile, 'utf8' ); - - // Extract test info from the XML: - const actualErrors = []; - const actualFailures = []; - for ( const testcase of junitXml.matchAll( /]*)\/>|]*)>([\s\S]*?)<\/testcase>/g ) ) { - const attributes = {}; - const attributesString = testcase[2] ?? testcase[1]; - for ( const attribute of attributesString.matchAll( /(\w+)="([^"]*)"/g ) ) { - attributes[attribute[1]] = attribute[2]; - } - - const content = testcase[3] ?? ''; - const fqn = attributes.class ? `${attributes.class}::${attributes.name}` : attributes.name; - const hasError = content.includes( ' testcase.hasError ).map( testcase => testcase.name ); + const actualFailures = testcases.filter( testcase => testcase.hasFailure ).map( testcase => testcase.name ); + let isSuccess = true; + const expectedErrors = expectedByBackend[ backend ].errors; + const expectedFailures = expectedByBackend[ backend ].failures; - // Check if all expected errors actually errored const unexpectedNonErrors = expectedErrors.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}` ) ); + console.error( '\nThe following tests were expected to error but did not:' ); + unexpectedNonErrors.forEach( test => console.error( ` - ${ test }` ) ); isSuccess = false; } - // Check if all expected failures actually failed const unexpectedPasses = expectedFailures.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}` ) ); + console.error( '\nThe following tests were expected to fail but passed:' ); + unexpectedPasses.forEach( test => console.error( ` - ${ test }` ) ); isSuccess = false; } - // Check for unexpected errors const unexpectedErrors = actualErrors.filter( test => ! expectedErrors.includes( test ) ); if ( unexpectedErrors.length > 0 ) { - console.error( '\n❌ The following tests errored unexpectedly:' ); - unexpectedErrors.forEach( test => console.error( ` - ${test}` ) ); + console.error( '\nThe following tests errored unexpectedly:' ); + unexpectedErrors.forEach( test => console.error( ` - ${ test }` ) ); isSuccess = false; } - // Check for unexpected failures const unexpectedFailures = actualFailures.filter( test => ! expectedFailures.includes( test ) ); if ( unexpectedFailures.length > 0 ) { - console.error( '\n❌ The following tests failed unexpectedly:' ); - unexpectedFailures.forEach( test => console.error( ` - ${test}` ) ); + console.error( '\nThe following tests failed unexpectedly:' ); + unexpectedFailures.forEach( test => console.error( ` - ${ test }` ) ); isSuccess = false; } if ( isSuccess ) { - console.log( '\n✅ All tests behaved as expected!' ); + console.log( '\nAll tests behaved as expected.' ); process.exit( 0 ); - } else { - console.log( '\n❌ Some tests did not behave as expected!' ); - process.exit( 1 ); } + + console.log( '\nSome tests did not behave as expected.' ); + process.exit( 1 ); } catch ( error ) { - console.error( '\n❌ Script execution error:', error.message ); + console.error( '\nScript execution error:', error.message ); + writeResultSummary( emptySummary() ); process.exit( 1 ); } + +function normalizeBackend( value ) { + const normalized = String( value ).toLowerCase(); + + if ( [ 'postgres', 'pgsql', 'postgresql' ].includes( normalized ) ) { + return 'postgresql'; + } + + if ( [ 'mysql', 'sqlite' ].includes( normalized ) ) { + return normalized; + } + + throw new Error( `Unsupported WP_TEST_DB_BACKEND: ${ value }` ); +} + +function getPhpUnitArgs() { + const args = []; + + if ( process.env.WP_TEST_PHPUNIT_FILTER ) { + args.push( '--filter', process.env.WP_TEST_PHPUNIT_FILTER ); + } + + return args; +} + +function verifyNativeParserExtension() { + const verifier = path.join( repositoryRoot, 'wordpress', 'native-verify-extension.php' ); + if ( ! fs.existsSync( verifier ) ) { + console.error( `Error: Native parser verifier not found at ${ verifier }.` ); + process.exit( 1 ); + } + + execSync( 'composer run wp-test-ensure-env', { stdio: 'inherit' } ); + execSync( + 'cd wordpress && node tools/local-env/scripts/docker.js run --rm php php /var/www/native-verify-extension.php', + { stdio: 'inherit' } + ); +} + +function verifyPostgreSqlPhpExtension() { + const verifier = writePostgreSqlPhpExtensionVerifier(); + verifyContainerPhpExtension( 'php', verifier ); + verifyContainerPhpExtension( 'cli', verifier ); +} + +function writePostgreSqlPhpExtensionVerifier() { + const verifier = path.join( repositoryRoot, 'wordpress', 'postgresql-verify-extension.php' ); + fs.writeFileSync( + verifier, + `]*)\/>|]*)>([\s\S]*?)<\/testcase>/g; + let match; + + while ( ( match = testcasePattern.exec( junitXml ) ) !== null ) { + const attributes = parseXmlAttributes( match[1] || match[2] || '' ); + const body = match[3] || ''; + const className = attributes.class || ''; + const testName = attributes.name || ''; + const fullName = className ? `${ className }::${ testName }` : testName; + + testcases.push( { + name: fullName, + hasError: hasJunitChild( body, 'error' ), + hasFailure: hasJunitChild( body, 'failure' ), + hasSkipped: hasJunitChild( body, 'skipped' ), + hasIncomplete: hasJunitChild( body, 'incomplete' ), + hasRisky: hasJunitChild( body, 'risky' ), + hasWarning: hasJunitChild( body, 'warning' ), + } ); + } + + return testcases; +} + +function parseXmlAttributes( attributesXml ) { + const attributes = {}; + const attributePattern = /([A-Za-z_:][A-Za-z0-9_.:-]*)="([^"]*)"/g; + let match; + + while ( ( match = attributePattern.exec( attributesXml ) ) !== null ) { + attributes[ match[1] ] = decodeXmlEntities( match[2] ); + } + + return attributes; +} + +function hasJunitChild( body, childName ) { + return new RegExp( `<${ childName }(?:[\\s>/])` ).test( body ); +} + +function decodeXmlEntities( value ) { + return String( value ).replace( /&(#x[0-9a-f]+|#[0-9]+|amp|lt|gt|quot|apos);/gi, entity => { + const normalized = entity.slice( 1, -1 ).toLowerCase(); + if ( normalized.startsWith( '#x' ) ) { + return String.fromCodePoint( parseInt( normalized.slice( 2 ), 16 ) ); + } + if ( normalized.startsWith( '#' ) ) { + return String.fromCodePoint( parseInt( normalized.slice( 1 ), 10 ) ); + } + + return { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + }[ normalized ]; + } ); +} + +function summarizeTestcases( testcases ) { + const summary = emptySummary(); + + for ( const testcase of testcases ) { + summary.total += 1; + + if ( testcase.hasError ) { + summary.errors += 1; + } + if ( testcase.hasFailure ) { + summary.failures += 1; + } + if ( testcase.hasSkipped ) { + summary.skipped += 1; + } + if ( testcase.hasIncomplete ) { + summary.incomplete += 1; + } + if ( testcase.hasRisky ) { + summary.risky += 1; + } + if ( testcase.hasWarning ) { + summary.warnings += 1; + } + if ( + ! testcase.hasError + && ! testcase.hasFailure + && ! testcase.hasSkipped + && ! testcase.hasIncomplete + && ! testcase.hasRisky + && ! testcase.hasWarning + ) { + summary.passed += 1; + } + } + + return summary; +} + +function emptySummary() { + return { + backend, + filter: process.env.WP_TEST_PHPUNIT_FILTER || '', + total: 0, + passed: 0, + errors: 0, + failures: 0, + skipped: 0, + incomplete: 0, + risky: 0, + warnings: 0, + }; +} + +function writeResultSummary( summary ) { + const outputPath = getResultSummaryFile(); + fs.writeFileSync( outputPath, `${ JSON.stringify( summary, null, 2 ) }\n` ); + + if ( process.env.GITHUB_OUTPUT ) { + const output = [ + `backend=${ summary.backend }`, + `total=${ summary.total }`, + `passed=${ summary.passed }`, + `errors=${ summary.errors }`, + `failures=${ summary.failures }`, + ].join( '\n' ); + fs.appendFileSync( process.env.GITHUB_OUTPUT, `${ output }\n` ); + } +} + +function getResultSummaryFile() { + return path.join( repositoryRoot, `wp-phpunit-results-${ backend }.json` ); +} diff --git a/.github/workflows/wp-tests-phpunit.yml b/.github/workflows/wp-tests-phpunit.yml index 810b77b8a..4cb359d4c 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -4,15 +4,20 @@ on: push: branches: - main + - trunk pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + # Disable permissions for all available scopes by default. # Any needed permissions should be configured at the job level. permissions: {} jobs: - test: - name: WordPress PHPUnit Tests + sqlite-test: + name: WordPress PHPUnit Tests / SQLite runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -31,12 +36,205 @@ jobs: echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV - name: Run WordPress PHPUnit tests + env: + WP_TEST_DB_BACKEND: sqlite run: node .github/workflows/wp-tests-phpunit-run.js + - name: Upload PHPUnit count + if: always() + uses: actions/upload-artifact@v4 + with: + name: wp-phpunit-sqlite + path: wp-phpunit-results-sqlite.json + if-no-files-found: warn + - name: Stop Docker containers if: always() run: composer run wp-test-clean + postgresql-test: + name: WordPress PHPUnit Tests / PostgreSQL + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # Required to clone the repo. + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set UID and GID for PHP in WordPress images + run: | + echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV + echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV + + - name: Run WordPress PHPUnit tests + env: + WP_TEST_DB_BACKEND: postgresql + run: node .github/workflows/wp-tests-phpunit-run.js + + - name: Upload PHPUnit count + if: always() + uses: actions/upload-artifact@v4 + with: + name: wp-phpunit-postgresql + path: wp-phpunit-results-postgresql.json + if-no-files-found: warn + + - name: Stop Docker containers + if: always() + env: + LOCAL_DB_TYPE: mysql + LOCAL_PHP_MEMCACHED: 'false' + run: | + if [ -f wordpress/docker-compose.yml ]; then + cd wordpress + if [ -f docker-compose.override.yml ]; then + docker compose -f docker-compose.yml -f docker-compose.override.yml down -v --remove-orphans + else + docker compose -f docker-compose.yml down -v --remove-orphans + fi + rm -rf src/wp-content/database/.ht.sqlite + fi + + update-pr-description: + name: Update PR PHPUnit Progress + needs: + - sqlite-test + - postgresql-test + if: github.event_name == 'pull_request' && always() + runs-on: ubuntu-latest + permissions: + actions: read # Required to download artifacts. + contents: read + pull-requests: write + + steps: + - name: Download PHPUnit count artifacts + uses: actions/download-artifact@v4 + with: + path: wp-phpunit-artifacts + pattern: wp-phpunit-sqlite + + - name: Download PostgreSQL PHPUnit count artifact + uses: actions/download-artifact@v4 + with: + path: wp-phpunit-artifacts + pattern: wp-phpunit-postgresql + + - name: Update PR description + uses: actions/github-script@v7 + with: + script: | + const fs = require( 'fs' ); + const path = require( 'path' ); + + const artifactRoot = path.join( process.cwd(), 'wp-phpunit-artifacts' ); + const startMarker = ''; + const endMarker = ''; + + function findResultFile( backend ) { + const expected = `wp-phpunit-results-${ backend }.json`; + const stack = [ artifactRoot ]; + + while ( stack.length > 0 ) { + const current = stack.pop(); + if ( ! fs.existsSync( current ) ) { + continue; + } + + const stat = fs.statSync( current ); + if ( stat.isDirectory() ) { + for ( const child of fs.readdirSync( current ) ) { + stack.push( path.join( current, child ) ); + } + continue; + } + + if ( path.basename( current ) === expected ) { + return current; + } + } + + return null; + } + + function readResult( backend ) { + const file = findResultFile( backend ); + if ( ! file ) { + return { backend, total: 0, passed: 0 }; + } + + return JSON.parse( fs.readFileSync( file, 'utf8' ) ); + } + + function formatNumber( value ) { + return Number( value || 0 ).toLocaleString( 'en-US' ); + } + + function renderProgressBar( current, target ) { + const width = 20; + const ratio = target > 0 ? Math.min( current / target, 1 ) : 0; + const filled = Math.round( ratio * width ); + const percent = target > 0 ? Math.round( ratio * 100 ) : 0; + return `[${ '#'.repeat( filled ) }${ '-'.repeat( width - filled ) }] ${ percent }%`; + } + + const sqlite = readResult( 'sqlite' ); + const postgresql = readResult( 'postgresql' ); + const postgresqlLabel = postgresql.filter + ? `PostgreSQL ${ formatNumber( postgresql.passed ) }/${ formatNumber( postgresql.total ) } passed with filter \`${ postgresql.filter }\`` + : `PostgreSQL ${ formatNumber( postgresql.passed ) } passed`; + const generated = [ + startMarker, + `WordPress PHPUnit: SQLite ${ formatNumber( sqlite.passed ) } passed; ${ postgresqlLabel }`, + postgresql.filter ? 'PostgreSQL is running a bounded PR validation subset.' : `\`${ renderProgressBar( postgresql.passed, sqlite.passed ) }\``, + endMarker, + ].join( '\n' ); + + const { owner, repo } = context.repo; + const pull_number = context.payload.pull_request.number; + const headRepo = context.payload.pull_request.head.repo.full_name; + const baseRepo = `${ owner }/${ repo }`; + + await core.summary + .addRaw( generated ) + .addRaw( '\n' ) + .write(); + + if ( headRepo !== baseRepo ) { + core.warning( 'Skipping PR body update for forked PR because pull_request GITHUB_TOKEN is read-only.' ); + return; + } + + const pull = await github.rest.pulls.get( { owner, repo, pull_number } ); + if ( pull.data.head.sha !== context.payload.pull_request.head.sha ) { + core.warning( 'Skipping PR body update because a newer PR head is available.' ); + return; + } + + const body = pull.data.body || ''; + const startIndex = body.indexOf( startMarker ); + const endIndex = body.indexOf( endMarker ); + + let nextBody; + if ( startIndex !== -1 && endIndex !== -1 && endIndex > startIndex ) { + nextBody = [ + body.slice( 0, startIndex ), + generated, + body.slice( endIndex + endMarker.length ), + ].join( '' ); + } else if ( body.trim().length > 0 ) { + nextBody = `${ body }\n\n${ generated }`; + } else { + nextBody = generated; + } + + await github.rest.pulls.update( { owner, repo, pull_number, body: nextBody } ); + native-parser-test: name: WordPress PHPUnit Tests / Rust extension runs-on: ubuntu-latest @@ -57,6 +255,8 @@ jobs: echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV - name: Set up WordPress test environment + env: + WP_TEST_DB_BACKEND: sqlite run: composer run wp-setup - name: Build and load parser extension in WordPress PHP containers @@ -65,8 +265,17 @@ jobs: - name: Run WordPress PHPUnit tests with parser extension env: WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION: '1' + WP_TEST_DB_BACKEND: sqlite run: node .github/workflows/wp-tests-phpunit-run.js + - name: Upload PHPUnit count + if: always() + uses: actions/upload-artifact@v4 + with: + name: wp-phpunit-sqlite-native + path: wp-phpunit-results-sqlite.json + if-no-files-found: warn + - name: Stop Docker containers if: always() run: composer run wp-test-clean diff --git a/.gitignore b/.gitignore index b75ec524b..f5649a0ff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ vendor/ composer.lock .idea/ .phpunit.result.cache +/wp-phpunit-results-*.json ._.DS_Store .DS_Store ._* diff --git a/bin/verify-workflow-ci-gates.js b/bin/verify-workflow-ci-gates.js new file mode 100644 index 000000000..48bdb9a89 --- /dev/null +++ b/bin/verify-workflow-ci-gates.js @@ -0,0 +1,233 @@ +#!/usr/bin/env node + +const { execSync } = require( 'child_process' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +const root = path.resolve( __dirname, '..' ); +const workflowDir = path.join( root, '.github', 'workflows' ); +const failures = []; + +function readWorkflow( filename ) { + return fs.readFileSync( path.join( workflowDir, filename ), 'utf8' ); +} + +function fail( filename, message ) { + failures.push( `${ filename }: ${ message }` ); +} + +function getRemoteDefaultBranch() { + for ( const ref of [ 'refs/remotes/upstream/HEAD', 'refs/remotes/origin/HEAD' ] ) { + try { + const output = execSync( `git symbolic-ref --quiet --short ${ ref }`, { + cwd: root, + encoding: 'utf8', + stdio: [ 'ignore', 'pipe', 'ignore' ], + } ).trim(); + + if ( output.includes( '/' ) ) { + return output.split( '/' ).pop(); + } + } catch ( error ) { + // Fall through to the next ref or static default below. + } + } + + return 'trunk'; +} + +const defaultBranch = process.env.DEFAULT_BRANCH || getRemoteDefaultBranch(); + +function getPushBranches( contents ) { + const lines = contents.split( /\r?\n/ ); + const branches = []; + + for ( let i = 0; i < lines.length; i++ ) { + if ( lines[ i ] !== ' push:' ) { + continue; + } + + for ( i++; i < lines.length; i++ ) { + const line = lines[ i ]; + if ( line.startsWith( ' ' ) && ! line.startsWith( ' ' ) ) { + break; + } + + if ( line !== ' branches:' ) { + continue; + } + + for ( i++; i < lines.length; i++ ) { + const branchLine = lines[ i ]; + if ( ! branchLine.startsWith( ' - ' ) ) { + break; + } + branches.push( branchLine.slice( 8 ).trim().replace( /^['"]|['"]$/g, '' ) ); + } + + return branches; + } + } + + return branches; +} + +function getJobBlock( contents, jobName ) { + const lines = contents.split( /\r?\n/ ); + const start = lines.findIndex( ( line ) => line === ` ${ jobName }:` ); + if ( -1 === start ) { + return ''; + } + + let end = lines.length; + for ( let i = start + 1; i < lines.length; i++ ) { + if ( /^ [A-Za-z0-9_-]+:$/.test( lines[ i ] ) ) { + end = i; + break; + } + } + + return lines.slice( start, end ).join( '\n' ); +} + +function assertNoContinueOnError( filename ) { + const contents = readWorkflow( filename ); + if ( contents.includes( 'continue-on-error' ) ) { + fail( filename, 'must not use continue-on-error; failing jobs should fail CI.' ); + } +} + +function assertDefaultBranchPush( filename ) { + const contents = readWorkflow( filename ); + const branches = getPushBranches( contents ); + if ( ! branches.includes( defaultBranch ) ) { + fail( filename, `push.branches must include repository default branch "${ defaultBranch }".` ); + } + + for ( const branch of [ 'main', 'trunk' ] ) { + if ( ! branches.includes( branch ) ) { + fail( filename, `push.branches must include "${ branch }" to avoid fork/upstream default-branch skips.` ); + } + } +} + +function assertJobHasNoTopLevelIf( filename, jobName ) { + const block = getJobBlock( readWorkflow( filename ), jobName ); + if ( ! block ) { + fail( filename, `missing ${ jobName } job.` ); + return; + } + + if ( /^ if:/m.test( block ) ) { + fail( filename, `${ jobName } job must not have a job-level if gate.` ); + } +} + +function assertIncludes( filename, needle, message ) { + if ( ! readWorkflow( filename ).includes( needle ) ) { + fail( filename, message ); + } +} + +for ( const filename of fs.readdirSync( workflowDir ).filter( ( file ) => file.endsWith( '.yml' ) ) ) { + assertNoContinueOnError( filename ); +} + +for ( const filename of [ + 'phpunit-tests.yml', + 'wp-tests-phpunit.yml', + 'end-to-end-tests.yml', + 'wp-tests-end-to-end.yml', +] ) { + assertDefaultBranchPush( filename ); +} + +assertIncludes( + 'phpunit-tests.yml', + 'testsuite: postgresql', + 'package PHPUnit matrix must include the PostgreSQL testsuite lane.' +); +assertIncludes( + 'phpunit-tests.yml', + '--testsuite postgresql', + 'package PHPUnit workflow must run the PostgreSQL testsuite.' +); + +assertJobHasNoTopLevelIf( 'wp-tests-phpunit.yml', 'postgresql-test' ); +assertIncludes( + 'wp-tests-phpunit.yml', + 'WP_TEST_DB_BACKEND: postgresql', + 'WordPress PostgreSQL PHPUnit job must set WP_TEST_DB_BACKEND=postgresql.' +); +assertIncludes( + 'wp-tests-phpunit.yml', + 'run: node .github/workflows/wp-tests-phpunit-run.js', + 'WordPress PostgreSQL PHPUnit job must run the PHPUnit helper.' +); + +for ( const [ filename, composerCommand ] of [ + [ 'end-to-end-tests.yml', 'composer run test-e2e' ], + [ 'wp-tests-end-to-end.yml', 'composer run wp-test-e2e' ], +] ) { + assertJobHasNoTopLevelIf( filename, 'test' ); + assertIncludes( filename, ' pull_request:', 'e2e workflow must run for pull requests.' ); + assertIncludes( filename, `run: ${ composerCommand }`, `e2e workflow must run ${ composerCommand }.` ); +} + +assertIncludes( + 'end-to-end-tests.yml', + 'WP_TEST_DB_BACKEND: sqlite', + 'plugin Query Monitor e2e workflow must explicitly run the SQLite backend.' +); +assertIncludes( + 'wp-tests-end-to-end.yml', + 'backend:', + 'WordPress e2e workflow must define a database backend matrix.' +); +assertIncludes( + 'wp-tests-end-to-end.yml', + '- postgresql', + 'WordPress e2e workflow matrix must include PostgreSQL.' +); +assertIncludes( + 'wp-tests-end-to-end.yml', + 'WP_TEST_DB_BACKEND: ${{ matrix.backend }}', + 'WordPress e2e workflow must pass the selected database backend to composer.' +); + +const progressBlock = getJobBlock( readWorkflow( 'wp-tests-phpunit.yml' ), 'update-pr-description' ); +if ( ! progressBlock ) { + fail( 'wp-tests-phpunit.yml', 'missing update-pr-description job.' ); +} else { + for ( const needed of [ 'sqlite-test', 'postgresql-test' ] ) { + if ( ! progressBlock.includes( ` - ${ needed }` ) ) { + fail( 'wp-tests-phpunit.yml', `update-pr-description must need ${ needed }.` ); + } + } + + if ( ! progressBlock.includes( "if: github.event_name == 'pull_request' && always()" ) ) { + fail( 'wp-tests-phpunit.yml', 'update-pr-description must be PR-only and informational.' ); + } + + if ( /\n (checks|statuses):\s*write/m.test( progressBlock ) ) { + fail( 'wp-tests-phpunit.yml', 'update-pr-description must not write checks or commit statuses.' ); + } + + if ( ! progressBlock.includes( 'core.summary' ) || ! progressBlock.includes( 'github.rest.pulls.update' ) ) { + fail( 'wp-tests-phpunit.yml', 'update-pr-description must only publish summary/PR body progress.' ); + } + + if ( /github\.rest\.(checks|repos\.createCommitStatus)/.test( progressBlock ) ) { + fail( 'wp-tests-phpunit.yml', 'update-pr-description must not create checks or commit statuses.' ); + } +} + +if ( failures.length > 0 ) { + console.error( 'Workflow CI gate verification failed:' ); + for ( const failure of failures ) { + console.error( `- ${ failure }` ); + } + process.exit( 1 ); +} + +console.log( `Workflow CI gate verification passed for default branch "${ defaultBranch }".` ); diff --git a/bin/wp-test-install-postgresql-site.js b/bin/wp-test-install-postgresql-site.js new file mode 100644 index 000000000..a4399a54f --- /dev/null +++ b/bin/wp-test-install-postgresql-site.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +const { execFileSync } = require( 'child_process' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +const root = path.resolve( __dirname, '..' ); +const backend = normalizeBackend( process.env.WP_TEST_DB_BACKEND || 'sqlite' ); + +if ( 'postgresql' !== backend ) { + process.exit( 0 ); +} + +const wordpressDir = path.join( root, 'wordpress' ); +if ( ! fs.existsSync( path.join( wordpressDir, 'package.json' ) ) ) { + throw new Error( 'Generated WordPress checkout is missing. Run composer run wp-setup first.' ); +} + +if ( isWordPressInstalled() ) { + console.log( 'PostgreSQL WordPress site is already installed.' ); + process.exit( 0 ); +} + +console.log( 'Installing PostgreSQL WordPress site for e2e tests...' ); +runEnvCli( [ + 'core', + 'install', + '--path=/var/www/src', + `--url=${ getBaseUrl() }`, + '--title=WordPress', + '--admin_user=admin', + '--admin_password=password', + '--admin_email=test@test.com', + '--skip-email', +] ); + +function isWordPressInstalled() { + try { + runEnvCli( [ 'core', 'is-installed', '--path=/var/www/src' ], 'ignore' ); + return true; + } catch ( error ) { + return false; + } +} + +function runEnvCli( args, stdio = 'inherit' ) { + execFileSync( + 'npm', + [ + '--prefix', + 'wordpress', + 'run', + 'env:cli', + '--', + ...args, + ], + { + cwd: root, + env: getDockerEnv(), + stdio, + } + ); +} + +function getDockerEnv() { + return { + ...process.env, + LOCAL_DB_TYPE: process.env.LOCAL_DB_TYPE || 'mysql', + LOCAL_PHP_MEMCACHED: process.env.LOCAL_PHP_MEMCACHED || 'false', + COMPOSE_IGNORE_ORPHANS: 'true', + }; +} + +function getBaseUrl() { + const env = readWordPressDotenv(); + const port = process.env.LOCAL_PORT || env.LOCAL_PORT || '8889'; + return process.env.WP_BASE_URL || expandEnvValue( env.WP_BASE_URL || 'http://localhost:${LOCAL_PORT}', { ...env, LOCAL_PORT: port } ); +} + +function readWordPressDotenv() { + const file = path.join( wordpressDir, '.env' ); + if ( ! fs.existsSync( file ) ) { + return {}; + } + + const values = {}; + for ( const line of fs.readFileSync( file, 'utf8' ).split( /\r?\n/ ) ) { + const match = line.match( /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/ ); + if ( match ) { + values[ match[1] ] = match[2]; + } + } + + return values; +} + +function expandEnvValue( value, values ) { + return String( value ).replace( /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, ( match, name ) => values[ name ] || process.env[ name ] || '' ); +} + +function normalizeBackend( value ) { + const normalized = String( value ).toLowerCase(); + if ( [ 'postgres', 'pgsql', 'postgresql' ].includes( normalized ) ) { + return 'postgresql'; + } + if ( [ 'mysql', 'sqlite' ].includes( normalized ) ) { + return normalized; + } + + throw new Error( `Unsupported WP_TEST_DB_BACKEND: ${ value }` ); +} diff --git a/composer.json b/composer.json index 689b44ed7..b00cdeaa2 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,9 @@ "check-cs": [ "@php ./vendor/bin/phpcs" ], + "verify-workflow-ci-gates": [ + "node ./bin/verify-workflow-ci-gates.js" + ], "fix-cs": [ "@php ./vendor/bin/phpcbf" ], @@ -42,6 +45,7 @@ ], "test-e2e": [ "@wp-test-ensure-env @no_additional_args", + "npm --prefix wordpress run env:cli -- plugin install query-monitor --force @no_additional_args", "rm -f tests/e2e/package.json tests/e2e/node_modules @no_additional_args", "ln -s ../../wordpress/package.json tests/e2e/package.json @no_additional_args", "ln -s ../../wordpress/node_modules tests/e2e/node_modules @no_additional_args", @@ -54,15 +58,22 @@ "npm --prefix wordpress run" ], "wp-test-start": [ + "@wp-test-ensure-backend @no_additional_args", + "@wp-test-ensure-wordpress-node-deps @no_additional_args", + "@putenv COMPOSE_IGNORE_ORPHANS=true", "npm --prefix wordpress run env:start", "npm --prefix wordpress run env:install", "npm --prefix wordpress run env:cli -- plugin install gutenberg --version=22.3.0", "npm --prefix wordpress run env:cli -- plugin install query-monitor" ], + "wp-test-ensure-backend": "node -e 'const { execSync } = require( \"child_process\" ); const fs = require( \"fs\" ); const quote = String.fromCharCode( 39 ); const aliases = new Map( [ [ \"postgres\", \"postgresql\" ], [ \"pgsql\", \"postgresql\" ] ] ); let backend = String( process.env.WP_TEST_DB_BACKEND || \"sqlite\" ).toLowerCase(); backend = aliases.get( backend ) || backend; if ( ! [ \"mysql\", \"sqlite\", \"postgresql\" ].includes( backend ) ) { throw new Error( `Unsupported WP_TEST_DB_BACKEND: ${ backend }` ); } const checks = [ [ \"wordpress/src/wp-load.php\" ] ]; if ( \"mysql\" !== backend ) { const helperNeedles = \"postgresql\" === backend ? [ `require_once ABSPATH . ${ quote }wp-content/plugins/sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php${ quote };`, \"class WpdbExposedMethodsForTesting extends WP_PostgreSQL_DB {\" ] : [ \"class WpdbExposedMethodsForTesting extends WP_SQLite_DB {\" ]; checks.push( [ \"wordpress/src/wp-content/db.php\", [ `: ${ quote }${ backend }${ quote }`, `/wp-includes/db.php${ quote }` ] ], [ \"wordpress/docker-compose.override.yml\", [ `DB_ENGINE: ${ backend }`, `DATABASE_ENGINE: ${ backend }` ] ], [ \"wordpress/tests/phpunit/includes/utils.php\", helperNeedles ] ); } if ( \"postgresql\" === backend ) { checks.push( [ \"wordpress/tools/local-env/Dockerfile.postgresql-php\", [ \"docker-php-ext-install pdo_pgsql\", \"git config --system --add safe.directory /var/www\" ] ], [ \"wordpress/tools/local-env/Dockerfile.postgresql-cli\", [ \"docker-php-ext-install pdo_pgsql\", \"git config --system --add safe.directory /var/www\" ] ], [ \"wordpress/tools/local-env/scripts/install.js\", [ \"--dbhost=postgres\", \"--skip-check\", \"DB_ENGINE postgresql\", \"DATABASE_ENGINE postgresql\", `const fs = require( ${ quote }fs${ quote } );`, `const { existsSync, renameSync, readFileSync, writeFileSync } = fs;`, \"install_postgresql_test_environment();\", \"write_postgresql_wp_config();\", \"write_postgresql_wp_tests_config();\", `if ( existsSync( ${ quote }src/wp-config.php${ quote } ) ) {`, `if ( ! existsSync( ${ quote }wp-config.php${ quote } ) ) {`, \"wp-config.php was not generated.\" ] ] ); } const staleIfPresent = [ \"wordpress/src/wp-content/db.php.bak\", \"wordpress/tests/phpunit/includes/utils.php.bak\", ...( \"mysql\" === backend ? [ \"wordpress/src/wp-content/db.php\", \"wordpress/docker-compose.override.yml\", \"wordpress/tools/local-env/Dockerfile.postgresql-php\", \"wordpress/tools/local-env/Dockerfile.postgresql-cli\" ] : [] ) ]; const validate = () => { const stale = []; for ( const [ file, needles = [] ] of checks ) { if ( ! fs.existsSync( file ) ) { stale.push( `${ file } is missing` ); continue; } const contents = needles.length ? fs.readFileSync( file, \"utf8\" ) : \"\"; for ( const needle of needles ) { if ( ! contents.includes( needle ) ) { stale.push( `${ file } lacks ${ needle }` ); } } } for ( const file of staleIfPresent ) { if ( fs.existsSync( file ) ) { stale.push( `${ file } should not exist for ${ backend }` ); } } return stale; }; const report = ( message, stale ) => { console.error( message ); stale.forEach( item => console.error( `- ${ item }` ) ); }; let stale = validate(); if ( stale.length ) { report( `Generated WordPress checkout is stale for ${ backend }; rerunning composer run wp-setup.`, stale ); execSync( \"composer run wp-setup\", { env: { ...process.env, WP_TEST_DB_BACKEND: backend, ...( \"postgresql\" === backend ? { WP_TEST_SKIP_WORDPRESS_NPM: \"1\" } : {} ) }, stdio: \"inherit\" } ); stale = validate(); if ( stale.length ) { report( `Generated WordPress checkout is still stale for ${ backend }.`, stale ); process.exit( 1 ); } }'", + "wp-test-ensure-wordpress-node-deps": "node -e 'const { execSync } = require( \"child_process\" ); const fs = require( \"fs\" ); const aliases = new Map( [ [ \"postgres\", \"postgresql\" ], [ \"pgsql\", \"postgresql\" ] ] ); let backend = String( process.env.WP_TEST_DB_BACKEND || \"sqlite\" ).toLowerCase(); backend = aliases.get( backend ) || backend; if ( \"postgresql\" !== backend ) { process.exit( 0 ); } const required = [ \"wordpress/node_modules/dotenv/package.json\", \"wordpress/node_modules/dotenv-expand/package.json\", \"wordpress/node_modules/wait-on/package.json\" ]; const missing = required.filter( file => ! fs.existsSync( file ) ); if ( ! missing.length ) { process.exit( 0 ); } console.error( \"Generated WordPress checkout is missing or has broken npm dependencies for postgresql; rerunning composer run wp-setup.\" ); missing.forEach( file => console.error( `- ${ file } is missing` ) ); execSync( \"composer run wp-setup\", { env: { ...process.env, WP_TEST_DB_BACKEND: backend, WP_TEST_SKIP_WORDPRESS_NPM: \"1\" }, stdio: \"inherit\" } ); const stillMissing = required.filter( file => ! fs.existsSync( file ) ); if ( stillMissing.length ) { console.error( \"Generated WordPress checkout is still missing or has broken npm dependencies for postgresql.\" ); stillMissing.forEach( file => console.error( `- ${ file } is missing` ) ); process.exit( 1 ); }'", "wp-test-ensure-env": [ - "if [ ! -f wordpress/src/wp-load.php ]; then composer run wp-setup; fi", + "@wp-test-ensure-backend @no_additional_args", + "@wp-test-ensure-wordpress-node-deps @no_additional_args", "@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" + "npm --prefix wordpress run env:start", + "npm --prefix wordpress run env:install" ], "wp-test-php": [ "@wp-test-ensure-env @no_additional_args", @@ -71,8 +82,12 @@ ], "wp-test-e2e": [ "@wp-test-ensure-env @no_additional_args", + "@wp-test-install-postgresql-site @no_additional_args", + "npm --prefix wordpress run env:cli -- plugin install gutenberg --version=22.3.0", + "npm --prefix wordpress run env:cli -- plugin install query-monitor", "npm --prefix wordpress run test:e2e -- @additional_args" ], + "wp-test-install-postgresql-site": "node ./bin/wp-test-install-postgresql-site.js", "wp-test-clean": [ "npm --prefix wordpress run env:clean", "rm -rf wordpress/src/wp-content/database/.ht.sqlite" diff --git a/goal.md b/goal.md new file mode 100644 index 000000000..34f97dce3 --- /dev/null +++ b/goal.md @@ -0,0 +1,146 @@ +# PostgreSQL MySQL Compatibility Goals + +## Objective + +Keep MySQL queries working the same way across the PostgreSQL and SQLite backends. Prefer MySQL-compatible emulation in the query layer over relying on PostgreSQL-specific behavior. + +## Tasks + +- [x] Centralize SQL mode parsing and normalization so PostgreSQL does not store `sql_mode` as an opaque raw string. +- [x] Make PostgreSQL's default SQL modes match SQLite's defaults: + - `ERROR_FOR_DIVISION_BY_ZERO` + - `NO_ENGINE_SUBSTITUTION` + - `NO_ZERO_DATE` + - `NO_ZERO_IN_DATE` + - `ONLY_FULL_GROUP_BY` + - `STRICT_TRANS_TABLES` +- [x] Support the same session SQL mode syntaxes as SQLite: + - `SET sql_mode = ...` + - `SET @@sql_mode = ...` + - `SET SESSION sql_mode = ...` + - `SET @@SESSION.sql_mode = ...` + - user-variable save/restore flows such as `SET @old_sql_mode = @@SESSION.sql_mode` followed by `SET SESSION sql_mode = @old_sql_mode` +- [x] Ensure `SELECT @@sql_mode`, `SELECT @@SESSION.sql_mode`, `SELECT @@GLOBAL.sql_mode`, and `SHOW VARIABLES LIKE 'sql_mode'` report normalized emulated state. +- [x] Pass active SQL modes into every PostgreSQL MySQL lexer/parser construction, including direct lexer calls outside the main tokenization helper. +- [x] Add `ANSI_QUOTES` support to the PHP MySQL lexer. +- [x] Add `ANSI_QUOTES` support to the Rust native MySQL parser path. +- [x] When `ANSI_QUOTES` is active, tokenize double-quoted text as identifier-like, equivalent to backtick-quoted identifiers. +- [x] When `ANSI_QUOTES` is inactive, keep double-quoted text as string literals. +- [x] Preserve existing parser-mode behavior for: + - `NO_BACKSLASH_ESCAPES` + - `PIPES_AS_CONCAT` + - `IGNORE_SPACE` + - `HIGH_NOT_PRECEDENCE` +- [x] Update the PostgreSQL wpdb adapter so `set_sql_mode()` mirrors SQLite/core behavior when called without explicit modes. +- [x] Ensure PostgreSQL wpdb no-argument `set_sql_mode()` applies core incompatible-mode filtering to zero-date and strict modes instead of preserving backend defaults. +- [x] Stop treating `ANSI_QUOTES` as inherently incompatible for PostgreSQL once lexer/parser support exists. +- [x] Emulate `NO_AUTO_VALUE_ON_ZERO` for PostgreSQL INSERT translation against auto-increment columns. +- [x] Enforce `NO_ZERO_DATE` and `NO_ZERO_IN_DATE` before PostgreSQL receives invalid MySQL date values. +- [x] Enforce strict-mode behavior for invalid values, truncation cases, invalid dates, and impossible coercions where PostgreSQL differs from MySQL. +- [x] Keep unsupported SQL explicit: translate/emulate supported MySQL constructs, and return clear unsupported-SQL errors for unsupported constructs. +- [x] Do not silently swallow unsupported SQL. +- [x] Treat `FULLTEXT` and `SPATIAL` index declarations as metadata-only compatibility while keeping unsupported search/spatial query semantics explicit. +- [x] Audit regex/string-based PostgreSQL SQL translation paths that may bypass mode-aware tokenization. +- [x] Prefer tokenized translation paths where SQL mode affects parsing. + +## CI And PR Gates + +- [x] Remove `continue-on-error: true` from PostgreSQL and e2e jobs; failures should fail the PR once the expected failures are fixed or explicitly skipped. +- [x] Ensure any temporary PR skip logic cannot become a permanent default-branch skip after merge. +- [x] Keep end-to-end workflows running on default-branch pushes, not only pull requests. +- [x] If e2e jobs are skipped on PRs, make the skip condition explicit, documented, and limited to the intended event/path/label. +- [x] Keep PostgreSQL PHPUnit as a required, non-optional CI lane rather than a best-effort signal. +- [x] Keep the PR progress summary informational only; it must not hide failing PostgreSQL jobs. + +## WP-CLI + +- [x] Avoid relying on WP-CLI for PostgreSQL environment install/reset paths that assume a MySQL connection. +- [x] Generate PostgreSQL `wp-config.php` and `wp-tests-config.php` directly for WordPress PHPUnit runs. +- [x] Preserve PostgreSQL `DB_ENGINE` and `DATABASE_ENGINE` constants in both runtime and test configs. +- [x] Keep a small WP-CLI smoke check after config generation so WP-CLI still loads the PostgreSQL adapter and reports the expected constants. +- [x] Ensure PostgreSQL Docker PHP and CLI images both install and enable `pdo_pgsql`. + +## DDL And Unsupported SQL + +- [x] Translate supported `CREATE TABLE ... [AS] SELECT` forms for PostgreSQL. +- [x] Store MySQL-facing metadata for translated `CREATE TABLE ... [AS] SELECT` result tables. +- [x] Reject `CREATE TABLE ... [AS] SELECT` variants that mix unsupported table definitions, constraints, indexes, or MySQL-only options. +- [x] Return explicit unsupported-SQL errors for unsupported MySQL DDL instead of swallowing or silently passing through incompatible SQL. +- [x] Translate/emulate the supported constructs identified in this work, including metadata-only `FULLTEXT` and `SPATIAL` index declarations, while unsupported search/spatial query semantics remain explicit errors. +- [x] Tolerate MySQL `FIRST`/`AFTER ` placement suffixes inside parenthesized `ALTER TABLE ... ADD (...)` column batches while preserving explicit errors for malformed placement. +- [x] Tolerate supported MySQL table/storage options in `ALTER TABLE` with either `OPTION=value` or `OPTION value` spelling as PostgreSQL no-ops. +- [x] Emulate the common plugin upsert side effect `ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)` for deterministic single-row AUTO_INCREMENT self-assignments. +- [x] Harden `ON DUPLICATE KEY UPDATE` expression assignments so resolved current-row columns and `VALUES(column)` work, while unknown column references fail before backend execution. +- [x] Support safe literal-argument `COUNT(...)` scalar subqueries inside PostgreSQL `ON DUPLICATE KEY UPDATE` assignments. +- [x] Replay deterministic multi-row `ON DUPLICATE KEY UPDATE` batches per row when each row needs a different unique-key arbiter. +- [x] Fail closed for unsupported bounded single-table `UPDATE ... ORDER BY/LIMIT` shapes before backend execution. +- [x] Fail closed for PostgreSQL `ALTER TABLE ... DROP CONSTRAINT` when the generic name is missing, ambiguous across constraint metadata, or only names a non-unique index. + +## Runtime And Metadata Parity + +- [x] Emulate MySQL session identity runtime functions for PostgreSQL: `CURRENT_USER`, `CURRENT_USER()`, `USER()`, `SESSION_USER()`, and `SYSTEM_USER()`. +- [x] Emulate zero-argument `CONNECTION_ID()` using the same synthetic session ID exposed by `SHOW PROCESSLIST` and `information_schema.processlist`. +- [x] Emulate zero-argument `LAST_INSERT_ID()` without exact-query caching, so repeated calls reflect mutable insert state. +- [x] Emulate narrow standalone `LAST_INSERT_ID(expr)` scalar `SELECT` assignments for non-negative integer literals, and fail closed for table-backed, embedded, nonliteral, negative, and overflow forms. +- [x] Emulate zero-argument `ROW_COUNT()` without exact-query caching, including DML affected-row values and result-set `-1` semantics. +- [x] Translate MySQL `COALESCE()` as a common runtime function for PostgreSQL expression paths. +- [x] Fail closed for unsupported MySQL runtime function forms such as unsupported `LAST_INSERT_ID(expr)` shapes, `CURRENT_USER(expr)`, `USER(expr)`, `ROW_COUNT(expr)`, and `UUID()`. +- [x] Emulate `group_concat_max_len` as MySQL session state for `SET`, `SELECT @@...`, and `SHOW VARIABLES`, while keeping global and expression forms explicit errors. +- [x] Enforce `group_concat_max_len` for supported `GROUP_CONCAT(expr [ORDER BY ...] [SEPARATOR ...])` translations and fail closed for unsupported `GROUP_CONCAT` shapes. +- [x] Synthesize direct `information_schema.TABLES.AUTO_INCREMENT` values with schema-aware lookup instead of assuming only `public`. +- [x] Expose direct `information_schema.plugins` as an empty queryable relation with MySQL-compatible columns. +- [x] Expose direct privilege/security `information_schema` relations (`user_privileges`, `schema_privileges`, `table_privileges`, `column_privileges`, `applicable_roles`, `administrable_role_authorizations`, and `enabled_roles`) as empty queryable relations with MySQL-compatible columns. +- [x] Expose direct MySQL `information_schema` role grant relations (`role_table_grants`, `role_column_grants`, and `role_routine_grants`) as empty queryable relations with MySQL-compatible columns. +- [x] Emulate `ROW_COUNT()` after failed backend statements and explicit unsupported-SQL errors. +- [x] Decide whether to expose further MySQL `information_schema` role grant tables beyond the currently supported relations and empty routine/view/trigger/parameter/privilege/security shims; unsupported relations continue to fail explicitly. +- [x] Preserve derivable numeric and time `DATE_FORMAT()` parts for zero or partial-zero literal dates while keeping calendar-dependent specifiers conservative. +- [x] Preserve derivable numeric and time `DATE_FORMAT()` parts for zero or partial-zero dates when the format mask is a runtime expression. +- [x] Support bounded `SHOW ... WHERE` predicates using `BINARY` string comparison and `LIKE ... ESCAPE`. +- [x] Emulate `SHOW PLUGINS` as an empty MySQL-shaped metadata result with supported `LIKE` and bounded `WHERE` filters. +- [x] Keep `FOUND_ROWS()` state accurate after empty static metadata result sets such as `SHOW PLUGINS`. +- [x] Route explicit main database-qualified application-table writes and table administration after `USE information_schema` while keeping unqualified `information_schema` writes blocked. +- [x] Support PostgreSQL `TIMESTAMPADD()` composite MySQL interval literal units with the same safe interval-component translation used by `DATE_ADD()`/`DATE_SUB()`, while keeping dynamic or malformed composite values explicit unsupported errors. +- [x] Support exact-match `BINARY` predicates in direct PostgreSQL `information_schema` SELECT rewrites without sending raw MySQL `BINARY` syntax to the backend. +- [x] Fail closed for `ALTER TABLE ... DROP CHECK` and `DROP FOREIGN KEY` when MySQL metadata has no matching constraint, and resolve `DROP CONSTRAINT` metadata with the table's schema. +- [x] Support renderable MySQL timestamp runtime functions such as `NOW()` and `CURRENT_TIMESTAMP()` in `ON DUPLICATE KEY UPDATE` assignments while keeping unsupported forms explicit errors. +- [x] Translate MySQL `CHECK (json_valid(...))` constraints to PostgreSQL JSON validation for backend DDL while preserving MySQL-facing metadata and SQL `NULL` CHECK semantics. +- [x] Emulate runtime `JSON_VALID(...)` for PostgreSQL queries with MySQL-compatible `NULL`/`0`/`1` results while keeping unsupported arities explicit errors. +- [x] Support MySQL `DEFAULT(column)` assignments in `ON DUPLICATE KEY UPDATE` using stored MySQL column metadata while keeping unknown columns explicit errors. +- [x] Support `CREATE TABLE ... LIKE` for PostgreSQL by copying stored MySQL-facing columns, indexes, checks, defaults, comments, and temporary-table metadata while keeping missing sources explicit errors. + +## Tests + +- [x] Port relevant SQLite SQL mode tests to PostgreSQL. +- [x] Add PostgreSQL tests for every supported `SET sql_mode` syntax. +- [x] Add PostgreSQL tests for SQL mode reporting through `SELECT @@...` and `SHOW VARIABLES`. +- [x] Add lexer tests proving `ANSI_QUOTES` changes double-quoted tokens from string literals to identifiers. +- [x] Add PostgreSQL translation tests for double-quoted identifiers under `ANSI_QUOTES`. +- [x] Add PostgreSQL tests proving double-quoted values remain string literals without `ANSI_QUOTES`. +- [x] Add tests for `NO_BACKSLASH_ESCAPES`, `PIPES_AS_CONCAT`, `IGNORE_SPACE`, and `HIGH_NOT_PRECEDENCE` parity. +- [x] Add PostgreSQL tests for `NO_AUTO_VALUE_ON_ZERO` insert behavior. +- [x] Add PostgreSQL tests for zero-date and zero-in-date behavior in strict and non-strict modes. +- [x] Add wpdb adapter tests for no-argument `set_sql_mode()` parity with SQLite/core. +- [x] Add wpdb adapter regression coverage proving no-argument `set_sql_mode()` permits WordPress zero datetime inserts after filtering core-incompatible modes. +- [x] Add CI assertions or workflow checks proving PostgreSQL/e2e jobs are not best-effort and not skipped on default-branch pushes. +- [x] Add/keep WP-CLI smoke tests for PostgreSQL config loading without using WP-CLI for MySQL-specific install/reset steps. +- [x] Add PostgreSQL tests for supported `CREATE TABLE ... [AS] SELECT` translations and unsupported variant errors. +- [x] Add PostgreSQL runtime-function tests for emulated session identity, `CONNECTION_ID()`, `LAST_INSERT_ID()`, and fail-closed unsupported forms. +- [x] Add PostgreSQL tests for `ROW_COUNT()` mutable state, `group_concat_max_len`, parenthesized `ALTER TABLE ... ADD (...)` placement, direct `information_schema.TABLES.AUTO_INCREMENT`, and `LAST_INSERT_ID(id)` upsert side effects. +- [x] Add PostgreSQL tests for standalone `LAST_INSERT_ID(expr)` assignment behavior, `GROUP_CONCAT` truncation and fail-closed forms, `information_schema.plugins`, optional-equals `ALTER TABLE` options, and upsert expression column validation. +- [x] Add PostgreSQL regression tests for literal-argument `COUNT(...)` upsert scalar subquery assignments and unresolved `COUNT(column)` failures. +- [x] Add PostgreSQL tests for empty privilege/security `information_schema` relation reads, metadata columns, `USE information_schema` routing, and joins. +- [x] Add PostgreSQL tests for `ROW_COUNT()` after backend failures and explicit unsupported-SQL failures. +- [x] Add PostgreSQL tests for deterministic multi-row ambiguous upsert replay, unsupported bounded `UPDATE` fail-closed behavior, role grant `information_schema` shims, zero-date `DATE_FORMAT()` literal masks, and `SHOW WHERE` `BINARY`/`ESCAPE` filters. +- [x] Add PostgreSQL tests for empty `SHOW PLUGINS` results, stale `FOUND_ROWS()` reset behavior, and unsupported `SHOW PLUGINS` clauses. +- [x] Add PostgreSQL tests for main database-qualified writes and administration after `USE information_schema`. +- [x] Add PostgreSQL tests for `TIMESTAMPADD()` composite interval translation and dynamic/malformed composite interval fail-closed behavior before backend execution. +- [x] Add PostgreSQL regressions for generic `ALTER TABLE ... DROP CONSTRAINT` missing, ambiguous, and non-unique-index metadata cases. +- [x] Add PostgreSQL regression coverage for direct `information_schema.TABLES` `BINARY` exact-match predicates. +- [x] Add PostgreSQL regressions for missing `ALTER TABLE ... DROP CHECK` and `DROP FOREIGN KEY` metadata. +- [x] Add PostgreSQL tests for SQLite-UDF-style runtime compatibility functions (`CURDATE()`, `UTC_DATE()`, `UTC_TIME()`, `NOW()`, `DATABASE()`, `GET_LOCK()`, and related forms). +- [x] Add PostgreSQL tests for runtime `DATE_FORMAT()` masks over zero/partial-zero dates. +- [x] Add PostgreSQL tests for timestamp runtime functions inside `ON DUPLICATE KEY UPDATE` assignments. +- [x] Add PostgreSQL tests for `json_valid(...)` CHECK translation, MySQL metadata preservation, unsupported CHECK shapes, runtime `JSON_VALID(...)` emulation, and unsupported runtime arities. +- [x] Add PostgreSQL regression tests for `DEFAULT(column)` assignments inside `ON DUPLICATE KEY UPDATE`. +- [x] Add PostgreSQL regression tests for permanent and temporary `CREATE TABLE ... LIKE` metadata copying and missing-source fail-closed behavior. +- [x] Add PostgreSQL regression tests for metadata-only `FULLTEXT`/`SPATIAL` index declarations and fail-closed `MATCH ... AGAINST` search syntax. diff --git a/packages/mysql-on-sqlite/composer.json b/packages/mysql-on-sqlite/composer.json index c7ef2b417..4d2197866 100644 --- a/packages/mysql-on-sqlite/composer.json +++ b/packages/mysql-on-sqlite/composer.json @@ -1,6 +1,8 @@ { "name": "wordpress/mysql-on-sqlite", + "description": "A MySQL emulation layer on top of SQLite with a PDO-compatible API.", "type": "library", + "license": "GPL-2.0-or-later", "scripts": { "test": "phpunit", "bench-lexer": [ diff --git a/packages/mysql-on-sqlite/phpunit.xml.dist b/packages/mysql-on-sqlite/phpunit.xml.dist index a41280c83..ffd0b6781 100644 --- a/packages/mysql-on-sqlite/phpunit.xml.dist +++ b/packages/mysql-on-sqlite/phpunit.xml.dist @@ -15,10 +15,24 @@ - + tests/ tests/tools + tests/WP_PostgreSQL_Connection_Tests.php + tests/WP_PostgreSQL_Create_Table_Translator_Tests.php + tests/WP_PostgreSQL_DB_Tests.php + tests/WP_PostgreSQL_Driver_RegExp_Tests.php + tests/WP_PostgreSQL_Driver_Tests.php + tests/WP_PostgreSQL_Install_Functions_Tests.php + + + tests/WP_PostgreSQL_Connection_Tests.php + tests/WP_PostgreSQL_Create_Table_Translator_Tests.php + tests/WP_PostgreSQL_DB_Tests.php + tests/WP_PostgreSQL_Driver_RegExp_Tests.php + tests/WP_PostgreSQL_Driver_Tests.php + tests/WP_PostgreSQL_Install_Functions_Tests.php diff --git a/packages/mysql-on-sqlite/src/load.php b/packages/mysql-on-sqlite/src/load.php index 62387a2e7..20436aadd 100644 --- a/packages/mysql-on-sqlite/src/load.php +++ b/packages/mysql-on-sqlite/src/load.php @@ -43,3 +43,6 @@ 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__ . '/postgresql/class-wp-postgresql-connection.php'; +require_once __DIR__ . '/postgresql/class-wp-postgresql-create-table-translator.php'; +require_once __DIR__ . '/postgresql/class-wp-postgresql-driver.php'; diff --git a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php index d6ee9970e..4dfef44fd 100644 --- a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php +++ b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php @@ -32,6 +32,7 @@ class WP_MySQL_Lexer { const SQL_MODE_PIPES_AS_CONCAT = 2; const SQL_MODE_IGNORE_SPACE = 4; const SQL_MODE_NO_BACKSLASH_ESCAPES = 8; + const SQL_MODE_ANSI_QUOTES = 16; /** * Character masks for frequently used character classes. @@ -2209,6 +2210,8 @@ public function __construct( $this->sql_modes |= self::SQL_MODE_IGNORE_SPACE; } elseif ( 'NO_BACKSLASH_ESCAPES' === $sql_mode ) { $this->sql_modes |= self::SQL_MODE_NO_BACKSLASH_ESCAPES; + } elseif ( 'ANSI_QUOTES' === $sql_mode ) { + $this->sql_modes |= self::SQL_MODE_ANSI_QUOTES; } } } @@ -2951,7 +2954,9 @@ private function read_quoted_text(): ?int { if ( '`' === $quote ) { return self::BACK_TICK_QUOTED_ID; } elseif ( '"' === $quote ) { - return self::DOUBLE_QUOTED_TEXT; + return $this->is_sql_mode_active( self::SQL_MODE_ANSI_QUOTES ) + ? self::BACK_TICK_QUOTED_ID + : self::DOUBLE_QUOTED_TEXT; } else { return self::SINGLE_QUOTED_TEXT; } diff --git a/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-lexer.php b/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-lexer.php index def8ca3fa..ad4af2343 100644 --- a/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-lexer.php +++ b/packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-lexer.php @@ -1,3 +1,9 @@ pdo = $options['pdo']; + } else { + $dsn = isset( $options['dsn'] ) ? (string) $options['dsn'] : self::build_dsn( $options ); + $user = isset( $options['user'] ) ? (string) $options['user'] : null; + $password = isset( $options['password'] ) ? (string) $options['password'] : null; + $pdo_class = PHP_VERSION_ID >= 80400 && class_exists( 'PDO\Pgsql' ) ? PDO\Pgsql::class : PDO::class; + + $this->pdo = new $pdo_class( $dsn, $user, $password ); + } + + $this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $this->pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + } + + /** + * Builds a PostgreSQL PDO DSN from connection options. + * + * @param array $options Connection options. + * @return string PostgreSQL PDO DSN. + * + * @throws InvalidArgumentException When no DSN or dbname option is provided. + */ + public static function build_dsn( array $options ): string { + if ( ! isset( $options['dbname'] ) || '' === (string) $options['dbname'] ) { + throw new InvalidArgumentException( 'Option "dbname" is required when "dsn" or "pdo" is not provided.' ); + } + + $parts = array(); + foreach ( array( 'host', 'port', 'dbname' ) as $key ) { + if ( isset( $options[ $key ] ) && '' !== (string) $options[ $key ] ) { + $parts[] = $key . '=' . self::format_dsn_value( (string) $options[ $key ] ); + } + } + + return 'pgsql:' . implode( ';', $parts ); + } + + /** + * Quote a PostgreSQL identifier value. + * + * @param string $unquoted_identifier The unquoted identifier value. + * @return string The quoted identifier value. + * + * @throws InvalidArgumentException When the identifier contains a NUL byte. + */ + public static function quote_identifier_value( string $unquoted_identifier ): string { + if ( false !== strpos( $unquoted_identifier, "\0" ) ) { + throw new InvalidArgumentException( 'PostgreSQL identifiers cannot contain NUL bytes.' ); + } + + return '"' . str_replace( '"', '""', $unquoted_identifier ) . '"'; + } + + /** + * Execute a query in PostgreSQL. + * + * @param string $sql The query to execute. + * @param array $params The query parameters. + * @return PDOStatement The PDO statement object. + * + * @throws PDOException When the query execution fails. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( $this->query_logger ) { + ( $this->query_logger )( $sql, $params ); + } + + $release_savepoint_on_success = false; + $savepoint_exists = false; + $savepoint = $this->get_statement_savepoint_name( $sql, $release_savepoint_on_success, $savepoint_exists ); + if ( null !== $savepoint && ! $savepoint_exists ) { + $this->ensure_statement_savepoint_exists( $savepoint ); + } + + try { + $stmt = $this->pdo->prepare( $sql ); + $stmt->execute( $params ); + + if ( null !== $savepoint && $release_savepoint_on_success ) { + $this->release_statement_savepoint( $savepoint ); + } + + $this->maybe_reset_read_savepoint_after_transaction_control( $sql ); + + return $stmt; + } catch ( Throwable $exception ) { + if ( null !== $savepoint ) { + $this->rollback_statement_savepoint( $savepoint ); + } + + throw $exception; + } + } + + /** + * Prepare a PostgreSQL query for execution. + * + * @param string $sql The query to prepare. + * @return PDOStatement The prepared statement. + * + * @throws PDOException When the query preparation fails. + */ + public function prepare( string $sql ): PDOStatement { + if ( $this->query_logger ) { + ( $this->query_logger )( $sql, array() ); + } + $this->consume_active_read_savepoint(); + return $this->pdo->prepare( $sql ); + } + + /** + * Returns the ID of the last inserted row. + * + * @param string|null $sequence Optional PostgreSQL sequence name. + * @return string The ID of the last inserted row. + */ + public function get_last_insert_id( ?string $sequence = null ): string { + return null === $sequence ? $this->pdo->lastInsertId() : $this->pdo->lastInsertId( $sequence ); + } + + /** + * Quote a value for use in a query. + * + * @param mixed $value The value to quote. + * @param int $type The type of the value. + * @return string The quoted value. + */ + public function quote( $value, int $type = PDO::PARAM_STR ): string { + if ( + PDO::PARAM_STR === $type + && is_string( $value ) + && 'pgsql' === $this->get_driver_name() + ) { + $value = self::encode_mysql_text_for_postgresql( $value ); + if ( self::requires_postgresql_escape_string_syntax( $value ) ) { + return self::quote_escaped_string_value( $value ); + } + } + + return $this->pdo->quote( $value, $type ); + } + + /** + * Get the backing PDO driver name. + * + * @return string PDO driver name. + */ + public function get_driver_name(): string { + return (string) $this->pdo->getAttribute( PDO::ATTR_DRIVER_NAME ); + } + + /** + * Quote a PostgreSQL identifier. + * + * @param string $unquoted_identifier The unquoted identifier value. + * @return string The quoted identifier value. + */ + public function quote_identifier( string $unquoted_identifier ): string { + return self::quote_identifier_value( $unquoted_identifier ); + } + + /** + * Get the PDO object. + * + * @return PDO + */ + public function get_pdo(): PDO { + return $this->pdo; + } + + /** + * Set a logger for the queries. + * + * @param callable(string, array): void $logger A query logger callback. + */ + public function set_query_logger( callable $logger ): void { + $this->query_logger = $logger; + } + + /** + * Reset generated statement savepoint state after direct transaction control. + */ + public function reset_statement_savepoint_state(): void { + $this->active_read_savepoint = null; + $this->active_read_savepoint_needs_creation = false; + } + + /** + * Get a generated statement savepoint name for an active PostgreSQL transaction. + * + * PostgreSQL marks the whole transaction as failed after a statement error. + * Isolating each emulated statement in a savepoint preserves MySQL's behavior + * where the failed statement can be reported without poisoning later queries. + * + * Consecutive non-locking reads share one generated savepoint. If any read + * fails, rolling back to that savepoint only discards prior reads, while the + * transaction remains usable. The next write or locking statement consumes + * and releases the shared savepoint as its own statement guard. + * + * @param string $sql SQL statement. + * @param bool $release_savepoint_on_success Whether the caller should release the savepoint after success. + * @param bool $savepoint_exists Whether the savepoint already exists in PostgreSQL. + * @return string|null Savepoint name, or null when no statement savepoint is needed. + */ + private function get_statement_savepoint_name( string $sql, bool &$release_savepoint_on_success, bool &$savepoint_exists ): ?string { + $release_savepoint_on_success = false; + $savepoint_exists = false; + + if ( + 'pgsql' !== $this->get_driver_name() + || ! $this->pdo->inTransaction() + || $this->is_postgresql_transaction_control_statement( $sql ) + ) { + return null; + } + + if ( $this->is_postgresql_shared_read_savepoint_statement( $sql ) ) { + $savepoint_exists = null !== $this->active_read_savepoint && ! $this->active_read_savepoint_needs_creation; + return $this->get_or_create_active_read_savepoint_name(); + } + + $release_savepoint_on_success = true; + if ( null !== $this->active_read_savepoint ) { + $savepoint_exists = ! $this->active_read_savepoint_needs_creation; + $savepoint = $this->active_read_savepoint; + $this->active_read_savepoint = null; + $this->active_read_savepoint_needs_creation = false; + return $savepoint; + } + + ++$this->savepoint_counter; + return 'wp_statement_' . $this->savepoint_counter; + } + + /** + * Check whether SQL directly controls the active transaction. + * + * @param string $sql SQL statement. + * @return bool Whether this is a transaction-control statement. + */ + private function is_postgresql_transaction_control_statement( string $sql ): bool { + return 1 === preg_match( + '/^\s*(BEGIN|START\s+TRANSACTION|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)(?:\s|;|$)/i', + $sql + ); + } + + /** + * Check whether SQL is a non-locking SELECT that may share a read savepoint. + * + * These high-volume reads do not acquire row locks in WordPress's query + * shapes, so consecutive reads can reuse a generated savepoint while still + * preserving PostgreSQL failed-statement isolation. + * + * @param string $sql SQL statement. + * @return bool Whether this is a non-locking SELECT statement. + */ + private function is_postgresql_shared_read_savepoint_statement( string $sql ): bool { + return 1 === preg_match( + '/^\s*SELECT\b(?![\s\S]*(?:\bFOR\s+(?:KEY\s+SHARE|NO\s+KEY\s+UPDATE|SHARE|UPDATE)\b|\bLOCK\s+IN\s+SHARE\s+MODE\b|\bINTO\b))/i', + $sql + ); + } + + /** + * Get or create the active generated read savepoint. + * + * @return string Savepoint name. + */ + private function get_or_create_active_read_savepoint_name(): string { + if ( null === $this->active_read_savepoint ) { + ++$this->savepoint_counter; + $this->active_read_savepoint = 'wp_statement_' . $this->savepoint_counter; + $this->active_read_savepoint_needs_creation = true; + } + + return $this->active_read_savepoint; + } + + /** + * Ensure a generated savepoint exists in PostgreSQL. + * + * @param string $savepoint Savepoint name. + */ + private function ensure_statement_savepoint_exists( string $savepoint ): void { + if ( $savepoint === $this->active_read_savepoint && ! $this->active_read_savepoint_needs_creation ) { + return; + } + + $this->pdo->exec( 'SAVEPOINT ' . $savepoint ); + + if ( $savepoint === $this->active_read_savepoint ) { + $this->active_read_savepoint_needs_creation = false; + } + } + + /** + * Consume the active generated read savepoint before unguarded PDO execution. + */ + private function consume_active_read_savepoint(): void { + if ( null === $this->active_read_savepoint ) { + return; + } + + $savepoint = $this->active_read_savepoint; + if ( ! $this->active_read_savepoint_needs_creation ) { + $this->release_statement_savepoint( $savepoint ); + } + + $this->reset_statement_savepoint_state(); + } + + /** + * Release a generated statement savepoint. + * + * @param string $savepoint Savepoint name. + */ + private function release_statement_savepoint( string $savepoint ): void { + $this->pdo->exec( 'RELEASE SAVEPOINT ' . $savepoint ); + } + + /** + * Reset cached read savepoint state when SQL controls the transaction. + * + * @param string $sql SQL statement. + */ + private function maybe_reset_read_savepoint_after_transaction_control( string $sql ): void { + if ( $this->is_postgresql_transaction_control_statement( $sql ) ) { + $this->reset_statement_savepoint_state(); + } + } + + /** + * Roll back and release a generated statement savepoint. + * + * @param string $savepoint Savepoint name. + */ + private function rollback_statement_savepoint( string $savepoint ): void { + try { + if ( $savepoint === $this->active_read_savepoint ) { + $this->reset_statement_savepoint_state(); + } + $this->pdo->exec( 'ROLLBACK TO SAVEPOINT ' . $savepoint ); + $this->pdo->exec( 'RELEASE SAVEPOINT ' . $savepoint ); + } catch ( Throwable $rollback_exception ) { + return; + } + } + + /** + * Formats a structured PostgreSQL DSN value. + * + * Direct DSNs may still be supplied through the "dsn" option. Structured + * options reject DSN separators instead of escaping them ambiguously. + * + * @param string $value DSN part value. + * @return string Formatted DSN part value. + * + * @throws InvalidArgumentException When a DSN part contains an unsafe byte. + */ + private static function format_dsn_value( string $value ): string { + if ( false !== strpos( $value, "\0" ) || false !== strpos( $value, ';' ) ) { + throw new InvalidArgumentException( 'PostgreSQL DSN parts cannot contain NUL bytes or semicolons.' ); + } + + return $value; + } + + /** + * Encode MySQL text bytes that PostgreSQL text cannot store directly. + * + * @param string $value MySQL text value. + * @return string PostgreSQL-safe text value. + */ + private static function encode_mysql_text_for_postgresql( string $value ): string { + if ( false === strpos( $value, "\0" ) && ! self::starts_with_mysql_text_encoding_prefix( $value ) ) { + return $value; + } + + return self::MYSQL_TEXT_ENCODING_PREFIX + . strlen( $value ) + . ':' + . hash( 'sha256', self::MYSQL_TEXT_ENCODING_HASH_CONTEXT . $value ) + . ':' + . bin2hex( $value ); + } + + /** + * Check whether a value starts with the MySQL text encoding prefix. + * + * @param string $value String value. + * @return bool Whether the value starts with the encoding prefix. + */ + private static function starts_with_mysql_text_encoding_prefix( string $value ): bool { + return 0 === strpos( $value, self::MYSQL_TEXT_ENCODING_PREFIX ); + } + + /** + * Check whether a PostgreSQL string value needs E'' syntax. + * + * @param string $value String value. + * @return bool Whether the value contains escape-string bytes. + */ + private static function requires_postgresql_escape_string_syntax( string $value ): bool { + return 1 === preg_match( '/[\x01-\x1F\\\\]/', $value ); + } + + /** + * Quote a string value using PostgreSQL escape string syntax. + * + * pdo_pgsql scans SQL text for placeholders before sending it to the server. + * Rendering backslash-bearing values as E'' strings keeps the client-side + * parser from treating a trailing backslash as escaping the closing quote. + * + * @param string $value String value. + * @return string PostgreSQL escaped string literal. + */ + private static function quote_escaped_string_value( string $value ): string { + $escaped = ''; + $length = strlen( $value ); + for ( $i = 0; $i < $length; $i++ ) { + $byte = $value[ $i ]; + if ( '\\' === $byte ) { + $escaped .= '\\\\'; + } elseif ( "'" === $byte ) { + $escaped .= "''"; + } elseif ( "\n" === $byte ) { + $escaped .= '\\n'; + } elseif ( "\r" === $byte ) { + $escaped .= '\\r'; + } elseif ( "\t" === $byte ) { + $escaped .= '\\t'; + } elseif ( ord( $byte ) < 32 ) { + $escaped .= sprintf( '\\%03o', ord( $byte ) ); + } else { + $escaped .= $byte; + } + } + + return "E'" . $escaped . "'"; + } +} diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-create-table-translator.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-create-table-translator.php new file mode 100644 index 000000000..8f7ff30c2 --- /dev/null +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-create-table-translator.php @@ -0,0 +1,3547 @@ + 'ascii_general_ci', + 'big5' => 'big5_chinese_ci', + 'binary' => 'binary', + 'cp1251' => 'cp1251_general_ci', + 'hebrew' => 'hebrew_general_ci', + 'koi8r' => 'koi8r_general_ci', + 'latin1' => 'latin1_swedish_ci', + 'tis620' => 'tis620_thai_ci', + 'ujis' => 'ujis_japanese_ci', + 'utf8' => 'utf8_general_ci', + 'utf8mb4' => 'utf8mb4_unicode_ci', + ); + + /** + * Reusable MySQL grammar. + * + * @var WP_Parser_Grammar|null + */ + private static $mysql_grammar = null; + + /** + * SQL modes active while tokenizing CREATE TABLE statements. + * + * @var string[] + */ + private $sql_modes; + + /** + * Whether metadata-only index options are allowed while parsing ALTER fragments. + * + * @var bool + */ + private $allow_metadata_only_index_options; + + /** + * Constructor. + * + * @param string[] $sql_modes Active SQL modes. + * @param bool $allow_metadata_only_index_options Whether metadata-only index options are allowed. + */ + public function __construct( array $sql_modes = array(), bool $allow_metadata_only_index_options = false ) { + $this->sql_modes = $sql_modes; + $this->allow_metadata_only_index_options = $allow_metadata_only_index_options; + } + + /** + * Parse and translate all CREATE TABLE statements in a schema string. + * + * @param string $sql MySQL schema SQL. + * @return string[] PostgreSQL DDL statements. + */ + public function translate_schema( string $sql ): array { + $parser = $this->create_parser( $sql ); + $statements = array(); + + while ( $parser->next_query() ) { + $ast = $parser->get_query_ast(); + if ( ! $ast || ! $ast->has_child() ) { + continue; + } + + foreach ( $this->translate( $ast ) as $statement ) { + $statements[] = $statement; + } + } + + return $statements; + } + + /** + * Translate a parsed CREATE TABLE statement. + * + * @param WP_Parser_Node $create_statement Parsed query/createStatement/createTable node. + * @return string[] PostgreSQL DDL statements. + */ + public function translate( WP_Parser_Node $create_statement ): array { + $create_table = $this->get_create_table_node( $create_statement ); + if ( ! $create_table ) { + throw new InvalidArgumentException( 'Only CREATE TABLE statements are supported by the PostgreSQL DDL translator.' ); + } + + $element_list = $create_table->get_first_child_node( 'tableElementList' ); + if ( ! $element_list ) { + throw new InvalidArgumentException( 'CREATE TABLE ... AS SELECT is not supported by the PostgreSQL DDL translator.' ); + } + + $table_name = $this->get_table_name( $create_table ); + $if_not_exists = $create_table->has_child_node( 'ifNotExists' ); + $column_types = $this->get_create_table_column_types( $element_list ); + $columns = array(); + $constraints = array(); + $indexes = array(); + $foreign_key_ordinal = 1; + $foreign_key_names = array(); + $check_ordinal = 1; + + foreach ( $element_list->get_child_nodes( 'tableElement' ) as $table_element ) { + $column_definition = $table_element->get_first_child_node( 'columnDefinition' ); + if ( $column_definition ) { + $columns[] = $this->translate_column_definition( $column_definition, $table_name, $foreign_key_ordinal, $foreign_key_names, $check_ordinal ); + continue; + } + + $table_constraint = $table_element->get_first_child_node( 'tableConstraintDef' ); + if ( ! $table_constraint ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE element.' ); + } + + if ( $table_constraint->get_first_child_node( 'checkConstraint' ) ) { + $check_sql = $this->translate_check_constraint_definition( $table_constraint, $table_name, $check_ordinal ); + if ( null !== $check_sql ) { + $constraints[] = $check_sql; + } + continue; + } + + if ( $this->is_table_foreign_key_constraint( $table_constraint ) ) { + $constraints[] = $this->translate_table_foreign_key_constraint_definition( $table_constraint, $table_name, $foreign_key_ordinal, $foreign_key_names ); + continue; + } + + $this->validate_mysql_table_constraint_index_options( $table_constraint ); + + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ) ) { + $constraints[] = 'PRIMARY KEY (' . implode( ', ', $this->quote_key_parts( $table_constraint ) ) . ')'; + continue; + } + + $index = $this->translate_secondary_index( $table_constraint, $table_name, $if_not_exists, $column_types ); + if ( null !== $index ) { + $indexes[] = $index; + } + } + + $definitions = array_merge( $columns, $constraints ); + if ( empty( $definitions ) ) { + throw new InvalidArgumentException( 'CREATE TABLE statement does not define any columns.' ); + } + + $create_sql = sprintf( + 'CREATE %sTABLE %s%s (%s%s%s)', + $create_table->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL ) ? 'TEMPORARY ' : '', + $if_not_exists ? 'IF NOT EXISTS ' : '', + $this->quote_identifier( $table_name ), + "\n ", + implode( ",\n ", $definitions ), + "\n" + ); + + return array_merge( array( $create_sql ), $indexes ); + } + + /** + * Extract MySQL charset metadata from CREATE TABLE statements. + * + * @param string $sql MySQL schema SQL. + * @return array[] Table metadata. + */ + public function extract_schema_metadata( string $sql, bool $include_indexes = false ): array { + $parser = $this->create_parser( $sql ); + $tables = array(); + + while ( $parser->next_query() ) { + $ast = $parser->get_query_ast(); + if ( ! $ast || ! $ast->has_child() ) { + continue; + } + + $create_table = $this->get_create_table_node( $ast ); + if ( $create_table ) { + $tables[] = $this->extract_create_table_metadata( $create_table, $include_indexes ); + } + } + + return $tables; + } + + /** + * Create a parser for a MySQL SQL string. + * + * @param string $sql MySQL SQL. + * @return WP_MySQL_Parser Parser instance. + */ + private function create_parser( string $sql ): WP_MySQL_Parser { + $sql = $this->normalize_parser_unsafe_long_character_aliases( $sql ); + $lexer = new WP_MySQL_Lexer( $sql, 80038, $this->sql_modes ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer + ? $lexer->native_token_stream() + : $lexer->remaining_tokens(); + + return new WP_MySQL_Parser( $this->get_mysql_grammar(), $tokens ); + } + + /** + * Normalize LONG CHAR aliases that can make the parser fail to converge. + * + * SQLite treats these as MEDIUMTEXT metadata, same as LONG VARCHAR. Rewrite + * only the tokenized type alias so parsing remains bounded while downstream + * metadata normalization still sees a LONG-prefixed text alias. + * + * @param string $sql MySQL SQL. + * @return string SQL with parser-safe LONG character aliases. + */ + private function normalize_parser_unsafe_long_character_aliases( string $sql ): string { + $lexer = new WP_MySQL_Lexer( $sql, 80038, $this->sql_modes ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer + ? $lexer->native_token_stream() + : $lexer->remaining_tokens(); + + $rewritten = ''; + $cursor = 0; + $changed = false; + for ( $i = 0; isset( $tokens[ $i ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $i ]->id; ++$i ) { + if ( + WP_MySQL_Lexer::LONG_SYMBOL !== $tokens[ $i ]->id + || ! isset( $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::CHAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + + $start_token = $tokens[ $i ]; + $end_token = $tokens[ $i + 1 ]; + if ( isset( $tokens[ $i + 2 ] ) && WP_MySQL_Lexer::VARYING_SYMBOL === $tokens[ $i + 2 ]->id ) { + $end_token = $tokens[ $i + 2 ]; + $i = $i + 2; + } else { + ++$i; + } + + $start = $start_token->start; + $end = $end_token->start + $end_token->length; + $rewritten .= substr( $sql, $cursor, $start - $cursor ) . 'LONG VARCHAR'; + $cursor = $end; + $changed = true; + } + + if ( ! $changed ) { + return $sql; + } + + return $rewritten . substr( $sql, $cursor ); + } + + /** + * Get the parser grammar. + * + * @return WP_Parser_Grammar MySQL grammar. + */ + private function get_mysql_grammar(): WP_Parser_Grammar { + if ( null === self::$mysql_grammar ) { + self::$mysql_grammar = new WP_Parser_Grammar( require self::MYSQL_GRAMMAR_PATH ); + } + + return self::$mysql_grammar; + } + + /** + * Locate a createTable node from accepted AST entry points. + * + * @param WP_Parser_Node $node Parsed node. + * @return WP_Parser_Node|null createTable node. + */ + private function get_create_table_node( WP_Parser_Node $node ): ?WP_Parser_Node { + if ( 'createTable' === $node->rule_name ) { + return $node; + } + + if ( 'createStatement' === $node->rule_name ) { + return $node->get_first_child_node( 'createTable' ); + } + + $simple_statement = $node->get_first_child_node( 'simpleStatement' ); + if ( $simple_statement ) { + $create_statement = $simple_statement->get_first_child_node( 'createStatement' ); + return $create_statement ? $create_statement->get_first_child_node( 'createTable' ) : null; + } + + return null; + } + + /** + * Translate a MySQL column definition. + * + * @param WP_Parser_Node $column_definition Column definition node. + * @param string $table_name Table name. + * @param int $foreign_key_ordinal Next inline foreign key ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @param int $check_ordinal Next inline CHECK ordinal. + * @return string PostgreSQL column definition. + */ + private function translate_column_definition( WP_Parser_Node $column_definition, string $table_name, int &$foreign_key_ordinal, array &$foreign_key_names, int &$check_ordinal ): string { + $name = $this->get_identifier_value( $column_definition->get_first_child_node( 'fieldIdentifier' ) ); + $field_definition = $column_definition->get_first_child_node( 'fieldDefinition' ); + if ( ! $field_definition ) { + throw new InvalidArgumentException( 'Column definition is missing a field definition.' ); + } + if ( $this->has_unsupported_mysql_column_attribute( $field_definition ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE column attribute.' ); + } + + $data_type = $field_definition->get_first_child_node( 'dataType' ); + $is_serial = $this->is_serial_data_type( $data_type ); + $is_auto_increment = $is_serial || null !== $field_definition->get_first_descendant_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ); + $has_not_null = false; + $has_primary_key = false; + $has_unique_key = false; + $parts = array( + $this->quote_identifier( $name ), + $this->translate_data_type( $data_type, $is_auto_increment ), + ); + + $column_attributes = $field_definition->get_child_nodes( 'columnAttribute' ); + foreach ( $column_attributes as $attribute_index => $attribute ) { + if ( $attribute->has_child_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ) ) { + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::NOT_SYMBOL ) ) { + $has_not_null = true; + $parts[] = 'NOT NULL'; + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ) ) { + $has_primary_key = true; + $parts[] = 'PRIMARY KEY'; + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ) ) { + $has_unique_key = true; + $parts[] = 'UNIQUE'; + continue; + } + + $check_constraint = $attribute->get_first_child_node( 'checkConstraint' ); + if ( $check_constraint ) { + $check_sql = $this->translate_check_constraint_definition( + $attribute, + $table_name, + $check_ordinal, + $this->is_followed_by_not_enforced_column_attribute( $column_attributes, $attribute_index ) + ); + if ( null !== $check_sql ) { + $parts[] = $check_sql; + } + continue; + } + + if ( $attribute->get_first_child_node( 'constraintEnforcement' ) ) { + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { + $parts[] = $this->translate_default_attribute( $attribute, $data_type ); + } + } + + if ( $is_serial ) { + if ( ! $has_not_null && ! $has_primary_key ) { + $parts[] = 'NOT NULL'; + } + + if ( ! $has_primary_key && ! $has_unique_key ) { + $parts[] = 'UNIQUE'; + } + } + + $references = $this->get_inline_references_node( $column_definition ); + if ( $references ) { + $constraint_name = $this->get_next_implicit_foreign_key_constraint_name( $table_name, $foreign_key_ordinal, $foreign_key_names ); + $parts[] = 'CONSTRAINT ' . $this->quote_identifier( $constraint_name ) . ' ' . $this->translate_inline_references( $references ); + } + + $check_constraint = $this->get_inline_check_constraint_node( $column_definition ); + if ( $check_constraint ) { + $check_sql = $this->translate_check_constraint_definition( $check_constraint, $table_name, $check_ordinal ); + if ( null !== $check_sql ) { + $parts[] = $check_sql; + } + } + + return implode( ' ', $parts ); + } + + /** + * Get MySQL column types keyed by lowercase column name for DDL index translation. + * + * @param WP_Parser_Node $element_list CREATE TABLE element list. + * @return array Column types keyed by lowercase name. + */ + private function get_create_table_column_types( WP_Parser_Node $element_list ): array { + $column_types = array(); + + foreach ( $element_list->get_child_nodes( 'tableElement' ) as $table_element ) { + $column_definition = $table_element->get_first_child_node( 'columnDefinition' ); + if ( ! $column_definition ) { + continue; + } + + $name = $this->get_identifier_value( $column_definition->get_first_child_node( 'fieldIdentifier' ) ); + $field_definition = $column_definition->get_first_child_node( 'fieldDefinition' ); + if ( ! $field_definition ) { + throw new InvalidArgumentException( 'Column definition is missing a field definition.' ); + } + + $data_type = $field_definition->get_first_child_node( 'dataType' ); + + $column_types[ strtolower( $name ) ] = $this->get_mysql_column_type( $data_type, $field_definition ); + } + + return $column_types; + } + + /** + * Check whether a MySQL column attribute changes semantics PostgreSQL does not emulate. + * + * @param WP_Parser_Node $node Field definition or column attribute node. + * @return bool Whether the attribute is unsupported. + */ + private function has_unsupported_mysql_column_attribute( WP_Parser_Node $node ): bool { + return null !== $node->get_first_descendant_token( WP_MySQL_Lexer::GENERATED_SYMBOL ) + || null !== $node->get_first_descendant_token( WP_MySQL_Lexer::COLUMN_FORMAT_SYMBOL ) + || null !== $node->get_first_descendant_token( WP_MySQL_Lexer::STORAGE_SYMBOL ) + || null !== $node->get_first_descendant_token( WP_MySQL_Lexer::VISIBLE_SYMBOL ) + || null !== $node->get_first_descendant_token( WP_MySQL_Lexer::INVISIBLE_SYMBOL ); + } + + /** + * Get a MySQL inline REFERENCES node from a field definition. + * + * @param WP_Parser_Node $column_definition Column definition node. + * @return WP_Parser_Node|null REFERENCES node. + */ + private function get_inline_references_node( WP_Parser_Node $column_definition ): ?WP_Parser_Node { + $check_or_references = $column_definition->get_first_child_node( 'checkOrReferences' ); + return $check_or_references ? $check_or_references->get_first_child_node( 'references' ) : null; + } + + /** + * Get a MySQL inline CHECK node after a column definition. + * + * @param WP_Parser_Node|null $column_definition Column definition node. + * @return WP_Parser_Node|null Inline CHECK node. + */ + private function get_inline_check_constraint_node( ?WP_Parser_Node $column_definition ): ?WP_Parser_Node { + $check_or_references = $column_definition ? $column_definition->get_first_child_node( 'checkOrReferences' ) : null; + return $check_or_references ? $check_or_references->get_first_child_node( 'checkConstraint' ) : null; + } + + /** + * Translate a MySQL CHECK constraint to PostgreSQL. + * + * @param WP_Parser_Node $node Node containing a checkConstraint child. + * @param string $table_name Table name used for implicit constraint names. + * @param int $check_ordinal Next implicit CHECK ordinal. + * @param bool $not_enforced Whether enforcement was represented by a following sibling node. + * @return string|null PostgreSQL CHECK constraint SQL, or null for metadata-only checks. + */ + private function translate_check_constraint_definition( WP_Parser_Node $node, string $table_name, int &$check_ordinal, bool $not_enforced = false ): ?string { + $check_constraint = 'checkConstraint' === $node->rule_name ? $node : $node->get_first_child_node( 'checkConstraint' ); + if ( ! $check_constraint ) { + throw new InvalidArgumentException( 'Expected CHECK constraint node.' ); + } + + $constraint_name = $this->get_check_constraint_name( $node, $table_name, $check_ordinal ); + $expression = $this->translate_check_constraint_expression( $check_constraint ); + if ( $not_enforced || $this->is_check_constraint_not_enforced( $node ) ) { + return null; + } + + return sprintf( + 'CONSTRAINT %s CHECK (%s)', + $this->quote_identifier( $constraint_name ), + $expression + ); + } + + /** + * Get the MySQL-compatible CHECK constraint name. + * + * @param WP_Parser_Node $node Node containing an optional constraintName child. + * @param string $table_name Table name used for implicit constraint names. + * @param int $check_ordinal Next implicit CHECK ordinal. + * @return string Constraint name. + */ + private function get_check_constraint_name( WP_Parser_Node $node, string $table_name, int &$check_ordinal ): string { + $constraint_name = $node->get_first_child_node( 'constraintName' ); + if ( $constraint_name ) { + return $this->get_identifier_value( $constraint_name->get_first_child_node( 'identifier' ) ); + } + + return $table_name . '_chk_' . $check_ordinal++; + } + + /** + * Check whether a CHECK constraint is explicitly NOT ENFORCED. + * + * @param WP_Parser_Node $node Node to inspect. + * @return bool Whether the node contains NOT ENFORCED. + */ + private function is_check_constraint_not_enforced( WP_Parser_Node $node ): bool { + $enforcement = $node->get_first_child_node( 'constraintEnforcement' ); + return $enforcement && $enforcement->has_child_token( WP_MySQL_Lexer::NOT_SYMBOL ); + } + + /** + * Check whether a column CHECK attribute is followed by NOT ENFORCED. + * + * @param WP_Parser_Node[] $attributes Column attribute nodes. + * @param int $attribute_index Current CHECK attribute index. + * @return bool Whether the following attribute is NOT ENFORCED. + */ + private function is_followed_by_not_enforced_column_attribute( array $attributes, int $attribute_index ): bool { + $next_attribute = $attributes[ $attribute_index + 1 ] ?? null; + return $next_attribute instanceof WP_Parser_Node && $this->is_check_constraint_not_enforced( $next_attribute ); + } + + /** + * Translate a MySQL CHECK expression to PostgreSQL SQL. + * + * @param WP_Parser_Node $check_constraint CHECK constraint node. + * @return string PostgreSQL expression SQL. + */ + private function translate_check_constraint_expression( WP_Parser_Node $check_constraint ): string { + return $this->render_check_constraint_expression( $check_constraint, true ); + } + + /** + * Render a MySQL CHECK expression. + * + * @param WP_Parser_Node $check_constraint CHECK constraint node. + * @param bool $for_postgresql Whether to translate MySQL-only functions for backend execution. + * @return string CHECK expression SQL. + */ + private function render_check_constraint_expression( WP_Parser_Node $check_constraint, bool $for_postgresql ): string { + $expression = $check_constraint->get_first_descendant_node( 'expr' ); + if ( ! $expression ) { + throw new InvalidArgumentException( 'CHECK constraint is missing an expression.' ); + } + + return $this->translate_check_constraint_tokens( $expression->get_descendant_tokens(), $for_postgresql ); + } + + /** + * Translate CHECK expression tokens. + * + * @param WP_MySQL_Token[] $tokens MySQL tokens. + * @param bool $for_postgresql Whether to translate MySQL-only functions for backend execution. + * @return string PostgreSQL SQL. + */ + private function translate_check_constraint_tokens( array $tokens, bool $for_postgresql ): string { + $sql = ''; + $previous_token = null; + + for ( $position = 0; $position < count( $tokens ); ++$position ) { + $token = $tokens[ $position ]; + $fragment = null; + if ( $for_postgresql ) { + $json_valid = $this->translate_json_valid_check_constraint_function( $tokens, $position ); + if ( null !== $json_valid ) { + $fragment = $json_valid['sql']; + $position = $json_valid['position']; + } + } + + if ( null === $fragment ) { + $fragment = $this->translate_check_constraint_token( $token ); + } + if ( '' === $fragment ) { + continue; + } + + if ( '' !== $sql && $this->check_constraint_tokens_need_space( $previous_token, $token ) ) { + $sql .= ' '; + } + + $sql .= $fragment; + $previous_token = $tokens[ $position ]; + } + + return $sql; + } + + /** + * Translate MySQL json_valid(expr) CHECK calls to PostgreSQL JSON validation. + * + * PostgreSQL has JSON casts but no MySQL/SQLite json_valid() function. Casting + * invalid JSON fails the statement, which keeps invalid data out instead of + * emitting unsupported raw function SQL. + * + * @param WP_MySQL_Token[] $tokens MySQL tokens. + * @param int $position Current token position. + * @return array{sql: string, position: int}|null Translation data, or null when this is not json_valid(). + */ + private function translate_json_valid_check_constraint_function( array $tokens, int $position ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || ! $this->is_json_valid_identifier_token( $tokens[ $position ] ) + ) { + return null; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $close_position = $this->get_check_constraint_parenthesized_end( $tokens, $position + 1 ); + if ( null === $close_position ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + + $arguments = $this->split_check_constraint_function_arguments( $tokens, $position + 2, $close_position ); + if ( 1 !== count( $arguments ) || $arguments[0]['start'] >= $arguments[0]['end'] ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + + $argument_sql = $this->translate_check_constraint_tokens( + array_slice( $tokens, $arguments[0]['start'], $arguments[0]['end'] - $arguments[0]['start'] ), + true + ); + + return array( + 'sql' => sprintf( '(CASE WHEN %1$s IS NULL THEN NULL ELSE (CAST(%1$s AS jsonb) IS NOT NULL) END)', $argument_sql ), + 'position' => $close_position, + ); + } + + /** + * Check whether a token names json_valid. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a json_valid identifier. + */ + private function is_json_valid_identifier_token( WP_MySQL_Token $token ): bool { + return in_array( $token->id, array( WP_MySQL_Lexer::IDENTIFIER, WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), true ) + && 'json_valid' === strtolower( $token->get_value() ); + } + + /** + * Find the closing parenthesis for a CHECK function call. + * + * @param WP_MySQL_Token[] $tokens MySQL tokens. + * @param int $open_position Opening parenthesis position. + * @return int|null Closing parenthesis position, or null when malformed. + */ + private function get_check_constraint_parenthesized_end( array $tokens, int $open_position ): ?int { + $depth = 0; + for ( $position = $open_position; $position < count( $tokens ); ++$position ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + --$depth; + if ( 0 === $depth ) { + return $position; + } + } + + return null; + } + + /** + * Split top-level CHECK function arguments. + * + * @param WP_MySQL_Token[] $tokens MySQL tokens. + * @param int $start First argument token position. + * @param int $end Closing parenthesis position, exclusive. + * @return array Argument token ranges. + */ + private function split_check_constraint_function_arguments( array $tokens, int $start, int $end ): array { + $arguments = array(); + $argument_start = $start; + $depth = 0; + + for ( $position = $start; $position < $end; ++$position ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + --$depth; + continue; + } + + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + $arguments[] = array( + 'start' => $argument_start, + 'end' => $position, + ); + $argument_start = $position + 1; + } + } + + $arguments[] = array( + 'start' => $argument_start, + 'end' => $end, + ); + + return $arguments; + } + + /** + * Translate one CHECK expression token. + * + * @param WP_MySQL_Token $token MySQL token. + * @return string PostgreSQL SQL fragment. + */ + private function translate_check_constraint_token( WP_MySQL_Token $token ): string { + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return $this->quote_identifier( $token->get_value() ); + } + + return $token->get_bytes(); + } + + /** + * Decide whether two CHECK expression tokens need a separating space. + * + * @param WP_MySQL_Token|null $previous Previous token, or null. + * @param WP_MySQL_Token $current Current token. + * @return bool Whether to add a space. + */ + private function check_constraint_tokens_need_space( ?WP_MySQL_Token $previous, WP_MySQL_Token $current ): bool { + if ( null === $previous ) { + return false; + } + + if ( + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $current->id + || WP_MySQL_Lexer::COMMA_SYMBOL === $current->id + || WP_MySQL_Lexer::DOT_SYMBOL === $current->id + || WP_MySQL_Lexer::DOT_SYMBOL === $previous->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous->id + ) { + return false; + } + + if ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $current->id + && $this->is_check_constraint_identifier_like_token( $previous ) + ) { + return false; + } + + return true; + } + + /** + * Check whether a CHECK token is identifier-like. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is identifier-like. + */ + private function is_check_constraint_identifier_like_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::IDENTIFIER, + WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, + ), + true + ); + } + + /** + * Translate inline MySQL REFERENCES syntax. + * + * @param WP_Parser_Node $references REFERENCES node. + * @return string PostgreSQL REFERENCES clause. + */ + private function translate_inline_references( WP_Parser_Node $references ): string { + $reference = $this->extract_inline_reference_metadata( $references ); + $table_sql = $this->quote_table_reference( + $reference['referenced_schema'], + $reference['referenced_table'] + ); + $columns = array_map( array( $this, 'quote_identifier' ), $reference['referenced_columns'] ); + + $sql = sprintf( + 'REFERENCES %s (%s)', + $table_sql, + implode( ', ', $columns ) + ); + + if ( 'NO ACTION' !== $reference['delete_rule'] ) { + $sql .= ' ON DELETE ' . $reference['delete_rule']; + } + + if ( 'NO ACTION' !== $reference['update_rule'] ) { + $sql .= ' ON UPDATE ' . $reference['update_rule']; + } + + return $sql; + } + + /** + * Check whether a table constraint is a FOREIGN KEY definition. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return bool Whether the node is a FOREIGN KEY constraint. + */ + private function is_table_foreign_key_constraint( WP_Parser_Node $table_constraint ): bool { + return $table_constraint->has_child_token( WP_MySQL_Lexer::FOREIGN_SYMBOL ) + && null !== $table_constraint->get_first_child_node( 'references' ); + } + + /** + * Translate a table-level MySQL FOREIGN KEY constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param string $table_name Table name used for implicit constraint names. + * @param int $foreign_key_ordinal Next implicit FOREIGN KEY ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @return string PostgreSQL FOREIGN KEY constraint SQL. + */ + private function translate_table_foreign_key_constraint_definition( WP_Parser_Node $table_constraint, string $table_name, int &$foreign_key_ordinal, array &$foreign_key_names ): string { + $foreign_key = $this->extract_table_foreign_key_metadata( $table_constraint, $table_name, $foreign_key_ordinal, $foreign_key_names ); + $table_sql = $this->quote_table_reference( + $foreign_key['referenced_schema'], + $foreign_key['referenced_table'] + ); + + $sql = sprintf( + 'CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)', + $this->quote_identifier( $foreign_key['name'] ), + implode( ', ', array_map( array( $this, 'quote_identifier' ), $foreign_key['columns'] ) ), + $table_sql, + implode( ', ', array_map( array( $this, 'quote_identifier' ), $foreign_key['referenced_columns'] ) ) + ); + + if ( 'NO ACTION' !== $foreign_key['delete_rule'] ) { + $sql .= ' ON DELETE ' . $foreign_key['delete_rule']; + } + + if ( 'NO ACTION' !== $foreign_key['update_rule'] ) { + $sql .= ' ON UPDATE ' . $foreign_key['update_rule']; + } + + return $sql; + } + + /** + * Extract the referenced table, columns, and rules from inline REFERENCES. + * + * @param WP_Parser_Node $references REFERENCES node. + * @return array{referenced_schema: string|null, referenced_table: string, referenced_columns: string[], update_rule: string, delete_rule: string} + */ + private function extract_inline_reference_metadata( WP_Parser_Node $references ): array { + if ( $references->has_child_token( WP_MySQL_Lexer::MATCH_SYMBOL ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + $table_reference = $this->get_table_reference_parts( $references->get_first_child_node( 'tableRef' ) ); + $columns = $this->get_identifier_list_values( $references->get_first_child_node( 'identifierListWithParentheses' ) ); + if ( empty( $columns ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + $rules = $this->get_inline_reference_rules( $references ); + + return array( + 'referenced_schema' => $table_reference['schema'], + 'referenced_table' => $table_reference['table'], + 'referenced_columns' => $columns, + 'update_rule' => $rules['update_rule'], + 'delete_rule' => $rules['delete_rule'], + ); + } + + /** + * Get table reference parts from a tableRef node. + * + * @param WP_Parser_Node|null $table_ref Table reference node. + * @return array{schema: string|null, table: string} + */ + private function get_table_reference_parts( ?WP_Parser_Node $table_ref ): array { + if ( ! $table_ref ) { + throw new InvalidArgumentException( 'Expected table reference node.' ); + } + + $identifiers = array(); + foreach ( $table_ref->get_descendant_nodes( 'identifier' ) as $identifier ) { + $identifiers[] = $this->get_identifier_value( $identifier ); + } + + if ( 1 === count( $identifiers ) ) { + return array( + 'schema' => null, + 'table' => $identifiers[0], + ); + } + + if ( 2 === count( $identifiers ) ) { + return array( + 'schema' => $identifiers[0], + 'table' => $identifiers[1], + ); + } + + throw new InvalidArgumentException( 'Unsupported table reference.' ); + } + + /** + * Quote a possibly schema-qualified table reference. + * + * @param string|null $schema_name Schema name, or null. + * @param string $table_name Table name. + * @return string Quoted table reference. + */ + private function quote_table_reference( ?string $schema_name, string $table_name ): string { + if ( null === $schema_name ) { + return $this->quote_identifier( $table_name ); + } + + return $this->quote_identifier( $schema_name ) . '.' . $this->quote_identifier( $table_name ); + } + + /** + * Get identifier values from an identifierListWithParentheses node. + * + * @param WP_Parser_Node|null $identifier_list Identifier list node. + * @return string[] Identifier values. + */ + private function get_identifier_list_values( ?WP_Parser_Node $identifier_list ): array { + if ( ! $identifier_list ) { + return array(); + } + + $identifiers = array(); + foreach ( $identifier_list->get_descendant_nodes( 'identifier' ) as $identifier ) { + $identifiers[] = $this->get_identifier_value( $identifier ); + } + + return $identifiers; + } + + /** + * Parse inline foreign key ON UPDATE/ON DELETE rules. + * + * @param WP_Parser_Node $references REFERENCES node. + * @return array{update_rule: string, delete_rule: string} + */ + private function get_inline_reference_rules( WP_Parser_Node $references ): array { + $rules = array( + 'update_rule' => 'NO ACTION', + 'delete_rule' => 'NO ACTION', + ); + $seen = array(); + $tokens = $references->get_descendant_tokens(); + + for ( $position = 0; $position < count( $tokens ); ++$position ) { + if ( WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + if ( ! isset( $tokens[ $position + 1 ] ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + if ( WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $position + 1 ]->id ) { + $rule_key = 'update_rule'; + } elseif ( WP_MySQL_Lexer::DELETE_SYMBOL === $tokens[ $position + 1 ]->id ) { + $rule_key = 'delete_rule'; + } else { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + if ( isset( $seen[ $rule_key ] ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + $option_position = $position + 2; + $rules[ $rule_key ] = $this->get_inline_reference_option( $tokens, $option_position ); + $seen[ $rule_key ] = true; + $position = $option_position - 1; + } + + return $rules; + } + + /** + * Parse one inline foreign key reference option. + * + * @param WP_MySQL_Token[] $tokens REFERENCES token stream. + * @param int $position Current token position, updated on success. + * @return string Reference option. + */ + private function get_inline_reference_option( array $tokens, int &$position ): string { + if ( ! isset( $tokens[ $position ] ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + if ( in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::CASCADE_SYMBOL, WP_MySQL_Lexer::RESTRICT_SYMBOL ), true ) ) { + $rule = strtoupper( $tokens[ $position ]->get_value() ); + ++$position; + return $rule; + } + + if ( WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position ]->id ) { + if ( ! isset( $tokens[ $position + 1 ] ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $position + 1 ]->id ) { + $position += 2; + return 'SET NULL'; + } + + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $tokens[ $position + 1 ]->id ) { + $position += 2; + return 'SET DEFAULT'; + } + + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + if ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::NO_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::ACTION_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + return 'NO ACTION'; + } + + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + /** + * Get the MySQL-style implicit foreign key constraint name. + * + * @param string $table_name Table name. + * @param int $ordinal Constraint ordinal. + * @return string Constraint name. + */ + private function get_implicit_foreign_key_constraint_name( string $table_name, int $ordinal ): string { + return $table_name . '_ibfk_' . $ordinal; + } + + /** + * Get a MySQL-compatible FOREIGN KEY constraint name. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param string $table_name Table name used for implicit constraint names. + * @param int $foreign_key_ordinal Next implicit FOREIGN KEY ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @return string Constraint name. + */ + private function get_foreign_key_constraint_name( WP_Parser_Node $table_constraint, string $table_name, int &$foreign_key_ordinal, array &$foreign_key_names ): string { + $constraint_name = $table_constraint->get_first_child_node( 'constraintName' ); + if ( $constraint_name ) { + $name = $this->get_identifier_value( $constraint_name->get_first_child_node( 'identifier' ) ); + $key = strtolower( $name ); + if ( isset( $foreign_key_names[ $key ] ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE foreign key option.' ); + } + + $foreign_key_names[ $key ] = true; + return $name; + } + + return $this->get_next_implicit_foreign_key_constraint_name( $table_name, $foreign_key_ordinal, $foreign_key_names ); + } + + /** + * Get the next implicit MySQL FOREIGN KEY constraint name. + * + * @param string $table_name Table name used for implicit constraint names. + * @param int $foreign_key_ordinal Next implicit FOREIGN KEY ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @return string Constraint name. + */ + private function get_next_implicit_foreign_key_constraint_name( string $table_name, int &$foreign_key_ordinal, array &$foreign_key_names ): string { + do { + $constraint_name = $this->get_implicit_foreign_key_constraint_name( $table_name, $foreign_key_ordinal ); + ++$foreign_key_ordinal; + $key = strtolower( $constraint_name ); + } while ( isset( $foreign_key_names[ $key ] ) ); + + $foreign_key_names[ $key ] = true; + return $constraint_name; + } + + /** + * Translate a MySQL data type. + * + * @param WP_Parser_Node|null $data_type Data type node. + * @param bool $is_auto_increment Whether AUTO_INCREMENT is present. + * @return string PostgreSQL data type. + */ + private function translate_data_type( ?WP_Parser_Node $data_type, bool $is_auto_increment ): string { + if ( ! $data_type ) { + throw new InvalidArgumentException( 'Column definition is missing a data type.' ); + } + + $type = $this->get_normalized_mysql_data_type( $data_type ); + if ( 'bigint' === $type ) { + $postgresql_type = 'bigint'; + } elseif ( 'serial' === $type ) { + $postgresql_type = 'bigint'; + } elseif ( in_array( $type, array( 'bit', 'bool', 'boolean', 'int', 'integer', 'mediumint', 'smallint', 'tinyint' ), true ) ) { + $postgresql_type = 'integer'; + } elseif ( in_array( $type, array( 'varchar', 'char' ), true ) ) { + $length = $this->get_field_length( $data_type ); + if ( 'char' === $type && null === $length ) { + $length = 1; + } + $postgresql_type = $length ? sprintf( '%s(%d)', $type, $length ) : $type; + } elseif ( + in_array( $type, array( 'tinytext', 'text', 'mediumtext', 'longtext', 'json', 'datetime', 'timestamp', 'date', 'time', 'year' ), true ) + || $this->is_mysql_spatial_column_type( $type ) + ) { + $postgresql_type = 'text'; + } elseif ( in_array( $type, array( 'enum', 'set' ), true ) ) { + $postgresql_type = 'text'; + } elseif ( in_array( $type, array( 'binary', 'varbinary', 'tinyblob', 'blob', 'mediumblob', 'longblob' ), true ) ) { + $postgresql_type = 'bytea'; + } elseif ( in_array( $type, array( 'float', 'double', 'real' ), true ) ) { + $precision_fragment = $this->get_numeric_precision_fragment( $data_type ); + $postgresql_type = '' === $precision_fragment ? 'double precision' : 'numeric' . $precision_fragment; + } elseif ( in_array( $type, array( 'dec', 'decimal', 'fixed', 'numeric' ), true ) ) { + $postgresql_type = 'numeric' . $this->get_numeric_precision_fragment( $data_type ); + } else { + throw new InvalidArgumentException( sprintf( 'Unsupported MySQL column type for PostgreSQL install DDL: %s.', $type ) ); + } + + if ( $is_auto_increment ) { + return $postgresql_type . ' GENERATED BY DEFAULT AS IDENTITY'; + } + + return $postgresql_type; + } + + /** + * Translate a DEFAULT attribute. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @param WP_Parser_Node|null $data_type Column data type node. + * @return string PostgreSQL DEFAULT clause. + */ + private function translate_default_attribute( WP_Parser_Node $attribute, ?WP_Parser_Node $data_type = null ): string { + $value_tokens = $this->get_default_attribute_value_tokens( $attribute ); + $expression_tokens = $this->strip_default_attribute_outer_parentheses( $value_tokens ); + + if ( + 1 === count( $expression_tokens ) + && $this->is_unquoted_mysql_null_token( $expression_tokens[0] ) + ) { + return 'DEFAULT NULL'; + } + + $current_timestamp_default = $this->get_current_timestamp_default_data( $attribute ); + if ( null !== $current_timestamp_default ) { + return 'DEFAULT ' . $this->get_postgresql_mysql_current_timestamp_sql( $current_timestamp_default['fsp'] ); + } + + if ( $this->is_generated_default_attribute( $attribute ) ) { + $expression = $this->translate_generated_default_expression( + $expression_tokens, + $data_type + ); + if ( null === $expression ) { + throw new InvalidArgumentException( 'Unsupported column DEFAULT expression.' ); + } + + return 'DEFAULT (' . $expression . ')'; + } + + foreach ( $attribute->get_descendant_tokens() as $token ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + continue; + } + + if ( $this->is_unquoted_mysql_null_token( $token ) ) { + return 'DEFAULT NULL'; + } + + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::INT_NUMBER === $token->id + || WP_MySQL_Lexer::LONG_NUMBER === $token->id + || WP_MySQL_Lexer::ULONGLONG_NUMBER === $token->id + || WP_MySQL_Lexer::DECIMAL_NUMBER === $token->id + || WP_MySQL_Lexer::FLOAT_NUMBER === $token->id + ) { + return 'DEFAULT ' . $this->quote_string_literal( $token->get_value() ); + } + } + + throw new InvalidArgumentException( 'Unsupported column DEFAULT expression.' ); + } + + /** + * Translate a MySQL generated DEFAULT expression to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param WP_Parser_Node|null $data_type Column data type node. + * @return string|null PostgreSQL expression SQL, or null when unsupported. + */ + private function translate_generated_default_expression( array $tokens, ?WP_Parser_Node $data_type = null ): ?string { + $expression = $this->translate_generated_default_expression_tokens( $tokens, 0, count( $tokens ) ); + if ( null === $expression ) { + return null; + } + + if ( empty( $expression['temporal'] ) ) { + return $expression['sql']; + } + + $base_type = $data_type ? $this->get_base_mysql_column_type( $this->get_node_value( $data_type ) ) : ''; + if ( in_array( $base_type, array( 'datetime', 'timestamp' ), true ) ) { + return $this->get_postgresql_mysql_temporal_expression_sql( $expression['sql'], 'YYYY-MM-DD HH24:MI:SS', $expression['fsp'] ); + } + + if ( 'date' === $base_type ) { + return sprintf( "TO_CHAR(%s, 'YYYY-MM-DD')", $expression['sql'] ); + } + + if ( 'time' === $base_type ) { + return sprintf( "TO_CHAR(%s, 'HH24:MI:SS')", $expression['sql'] ); + } + + return $expression['sql']; + } + + /** + * Translate supported generated DEFAULT expression tokens. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $start Start offset, inclusive. + * @param int $end End offset, exclusive. + * @return array{sql: string, temporal: bool, fsp: int}|null PostgreSQL SQL and type hint, or null when unsupported. + */ + private function translate_generated_default_expression_tokens( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $sql = ''; + $temporal = false; + $fsp = 0; + + for ( $position = $start; $position < $end; ++$position ) { + $function = $this->translate_generated_default_function_call( $tokens, $position, $end ); + if ( null !== $function ) { + $sql = $this->append_generated_default_sql_fragment( $sql, $function['sql'] ); + $temporal = $temporal || $function['temporal']; + $fsp = max( $fsp, $function['fsp'] ); + $position = $function['next'] - 1; + continue; + } + + $token = $tokens[ $position ]; + if ( $this->is_generated_default_literal_token( $token ) ) { + $sql = $this->append_generated_default_sql_fragment( + $sql, + $this->translate_generated_default_literal_token( $token ) + ); + continue; + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id || $this->is_unquoted_mysql_null_token( $token ) ) { + $sql = $this->append_generated_default_sql_fragment( $sql, 'NULL' ); + continue; + } + + if ( 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 ), true ) ) { + $sql = rtrim( $sql ) . ' ' . $token->get_bytes() . ' '; + continue; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + $sql = $this->append_generated_default_sql_fragment( $sql, '(' ); + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id ) { + $sql = rtrim( $sql ) . ')'; + continue; + } + + return null; + } + + $sql = trim( $sql ); + if ( '' === $sql || ! $this->generated_default_parentheses_are_balanced( $sql ) ) { + return null; + } + + return array( + 'sql' => $sql, + 'temporal' => $temporal, + 'fsp' => $fsp, + ); + } + + /** + * Translate a supported generated DEFAULT function call. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $position Function token offset. + * @param int $end End offset, exclusive. + * @return array{sql: string, temporal: bool, fsp: int, next: int}|null Function SQL and next offset, or null. + */ + private function translate_generated_default_function_call( array $tokens, int $position, int $end ): ?array { + $token = $tokens[ $position ] ?? null; + if ( ! $token ) { + return null; + } + + if ( $this->is_generated_default_current_timestamp_function_token( $token ) ) { + $next = $position + 1; + $fsp = 0; + if ( isset( $tokens[ $next ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $next ]->id ) { + $close = $this->find_matching_generated_default_parenthesis( $tokens, $next, $end ); + if ( $next + 1 !== $close ) { + if ( $next + 2 !== $close ) { + return null; + } + + $fsp = $this->get_mysql_fractional_seconds_precision_token_value( $tokens[ $next + 1 ] ?? null ); + if ( null === $fsp ) { + return null; + } + } + + $next = $close + 1; + } + + return array( + 'sql' => $this->get_postgresql_current_timestamp_expression_sql( $fsp ), + 'temporal' => true, + 'fsp' => $fsp, + 'next' => $next, + ); + } + + $function_name = strtoupper( $token->get_value() ); + if ( 'CONCAT' === $function_name ) { + return $this->translate_generated_default_concat_function( $tokens, $position, $end ); + } + + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::ADDDATE_SYMBOL, + WP_MySQL_Lexer::DATE_ADD_SYMBOL, + WP_MySQL_Lexer::DATE_SUB_SYMBOL, + WP_MySQL_Lexer::SUBDATE_SYMBOL, + ), + true + ) + ) { + return $this->translate_generated_default_date_arithmetic_function( $tokens, $position, $end ); + } + + return null; + } + + /** + * Translate a generated DEFAULT CONCAT(...) function call. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $position Function token offset. + * @param int $end End offset, exclusive. + * @return array{sql: string, temporal: bool, fsp: int, next: int}|null Function SQL and next offset, or null. + */ + private function translate_generated_default_concat_function( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position + 1 ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $close = $this->find_matching_generated_default_parenthesis( $tokens, $position + 1, $end ); + if ( null === $close ) { + return null; + } + + $arguments = $this->split_generated_default_arguments( $tokens, $position + 2, $close ); + if ( null === $arguments ) { + return null; + } + + if ( empty( $arguments ) ) { + return array( + 'sql' => $this->quote_string_literal( '' ), + 'temporal' => false, + 'fsp' => 0, + 'next' => $close + 1, + ); + } + + $sql_arguments = array(); + foreach ( $arguments as $argument ) { + $expression = $this->translate_generated_default_expression_tokens( $tokens, $argument[0], $argument[1] ); + if ( null === $expression ) { + return null; + } + + $sql_arguments[] = 'CAST(' . $expression['sql'] . ' AS text)'; + } + + return array( + 'sql' => '(' . implode( ' || ', $sql_arguments ) . ')', + 'temporal' => false, + 'fsp' => 0, + 'next' => $close + 1, + ); + } + + /** + * Translate a generated DEFAULT DATE_ADD/DATE_SUB function call. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $position Function token offset. + * @param int $end End offset, exclusive. + * @return array{sql: string, temporal: bool, fsp: int, next: int}|null Function SQL and next offset, or null. + */ + private function translate_generated_default_date_arithmetic_function( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position + 1 ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $close = $this->find_matching_generated_default_parenthesis( $tokens, $position + 1, $end ); + if ( null === $close ) { + return null; + } + + $arguments = $this->split_generated_default_arguments( $tokens, $position + 2, $close ); + if ( null === $arguments || 2 !== count( $arguments ) ) { + return null; + } + + $base = $this->translate_generated_default_expression_tokens( $tokens, $arguments[0][0], $arguments[0][1] ); + if ( null === $base || empty( $base['temporal'] ) ) { + return null; + } + + $interval = $this->translate_generated_default_interval_argument( $tokens, $arguments[1][0], $arguments[1][1] ); + if ( null === $interval ) { + return null; + } + + $operator = in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::DATE_SUB_SYMBOL, WP_MySQL_Lexer::SUBDATE_SYMBOL ), true ) ? '-' : '+'; + + return array( + 'sql' => '(' . $base['sql'] . ' ' . $operator . ' (' . $interval . '))', + 'temporal' => true, + 'fsp' => $base['fsp'], + 'next' => $close + 1, + ); + } + + /** + * Translate a simple INTERVAL argument for generated DEFAULT DATE_ADD/DATE_SUB. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $start Start offset, inclusive. + * @param int $end End offset, exclusive. + * @return string|null PostgreSQL interval SQL, or null when unsupported. + */ + private function translate_generated_default_interval_argument( array $tokens, int $start, int $end ): ?string { + if ( $start + 2 > $end || WP_MySQL_Lexer::INTERVAL_SYMBOL !== $tokens[ $start ]->id ) { + return null; + } + + $unit = $this->get_generated_default_interval_unit( $tokens[ $end - 1 ] ); + if ( null === $unit ) { + return null; + } + + $value = $this->translate_generated_default_expression_tokens( $tokens, $start + 1, $end - 1 ); + if ( null === $value || ! empty( $value['temporal'] ) ) { + return null; + } + + return $value['sql'] . ' * INTERVAL ' . $this->quote_string_literal( $unit ); + } + + /** + * Get PostgreSQL interval unit text for a supported MySQL interval unit token. + * + * @param WP_MySQL_Token $token MySQL interval unit token. + * @return string|null PostgreSQL interval unit, or null when unsupported. + */ + private function get_generated_default_interval_unit( WP_MySQL_Token $token ): ?string { + $units = array( + WP_MySQL_Lexer::MICROSECOND_SYMBOL => '1 microsecond', + WP_MySQL_Lexer::SECOND_SYMBOL => '1 second', + WP_MySQL_Lexer::MINUTE_SYMBOL => '1 minute', + WP_MySQL_Lexer::HOUR_SYMBOL => '1 hour', + WP_MySQL_Lexer::DAY_SYMBOL => '1 day', + WP_MySQL_Lexer::WEEK_SYMBOL => '1 week', + WP_MySQL_Lexer::MONTH_SYMBOL => '1 month', + WP_MySQL_Lexer::QUARTER_SYMBOL => '3 months', + WP_MySQL_Lexer::YEAR_SYMBOL => '1 year', + ); + + return $units[ $token->id ] ?? null; + } + + /** + * Split top-level function arguments within a generated DEFAULT expression. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $start Start offset, inclusive. + * @param int $end End offset, exclusive. + * @return array|null Argument offset ranges, or null for malformed input. + */ + private function split_generated_default_arguments( array $tokens, int $start, int $end ): ?array { + if ( $start === $end ) { + return array(); + } + + $arguments = array(); + $argument_start = $start; + $depth = 0; + + for ( $position = $start; $position < $end; ++$position ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + if ( $argument_start === $position ) { + return null; + } + + $arguments[] = array( $argument_start, $position ); + $argument_start = $position + 1; + } + } + + if ( 0 !== $depth || $argument_start === $end ) { + return null; + } + + $arguments[] = array( $argument_start, $end ); + + return $arguments; + } + + /** + * Find the closing parenthesis for a generated DEFAULT expression range. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @param int $open_position Opening parenthesis offset. + * @param int $end End offset, exclusive. + * @return int|null Closing parenthesis offset, or null. + */ + private function find_matching_generated_default_parenthesis( array $tokens, int $open_position, int $end ): ?int { + if ( ! isset( $tokens[ $open_position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $open_position ]->id ) { + return null; + } + + $depth = 0; + for ( $position = $open_position; $position < $end; ++$position ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + } elseif ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + --$depth; + if ( 0 === $depth ) { + return $position; + } + } + } + + return null; + } + + /** + * Append an expression fragment with minimal spacing. + * + * @param string $sql Existing SQL. + * @param string $fragment Fragment to append. + * @return string Combined SQL. + */ + private function append_generated_default_sql_fragment( string $sql, string $fragment ): string { + if ( '' === $sql || '(' === $fragment || '(' === substr( rtrim( $sql ), -1 ) || ' ' === substr( $sql, -1 ) ) { + return $sql . $fragment; + } + + if ( ')' === $fragment || ',' === $fragment ) { + return rtrim( $sql ) . $fragment; + } + + return $sql . ' ' . $fragment; + } + + /** + * Check whether serialized generated DEFAULT SQL has balanced parentheses. + * + * @param string $sql PostgreSQL expression SQL. + * @return bool Whether parentheses are balanced. + */ + private function generated_default_parentheses_are_balanced( string $sql ): bool { + $depth = 0; + $quote = null; + $length = strlen( $sql ); + + for ( $i = 0; $i < $length; ++$i ) { + $character = $sql[ $i ]; + if ( "'" === $quote ) { + if ( "'" === $character ) { + if ( isset( $sql[ $i + 1 ] ) && "'" === $sql[ $i + 1 ] ) { + ++$i; + continue; + } + + $quote = null; + } + continue; + } + + if ( "'" === $character ) { + $quote = "'"; + continue; + } + + if ( '(' === $character ) { + ++$depth; + } elseif ( ')' === $character ) { + --$depth; + if ( $depth < 0 ) { + return false; + } + } + } + + return 0 === $depth && null === $quote; + } + + /** + * Check whether a token is a supported generated DEFAULT literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is supported. + */ + private function is_generated_default_literal_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + 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, + ), + true + ); + } + + /** + * Translate a supported generated DEFAULT literal token. + * + * @param WP_MySQL_Token $token MySQL token. + * @return string PostgreSQL literal SQL. + */ + private function translate_generated_default_literal_token( WP_MySQL_Token $token ): string { + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $this->quote_string_literal( $token->get_value() ); + } + + return $token->get_value(); + } + + /** + * Check whether a token is CURRENT_TIMESTAMP or NOW for generated DEFAULTs. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a supported timestamp function token. + */ + private function is_generated_default_current_timestamp_function_token( WP_MySQL_Token $token ): bool { + return $this->is_current_timestamp_token( $token ) + || WP_MySQL_Lexer::NOW_SYMBOL === $token->id; + } + + /** + * Translate a non-primary MySQL key to a PostgreSQL CREATE INDEX statement. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param string $table_name Table name. + * @param bool $if_not_exists Whether CREATE TABLE used IF NOT EXISTS. + * @param array $column_types Column types keyed by lowercase name. + * @return string|null PostgreSQL CREATE INDEX statement, or null for metadata-only MySQL index types. + */ + private function translate_secondary_index( WP_Parser_Node $table_constraint, string $table_name, bool $if_not_exists, array $column_types ): ?string { + if ( + $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) + || $table_constraint->has_child_token( WP_MySQL_Lexer::SPATIAL_SYMBOL ) + ) { + return null; + } + + $index_name_node = $table_constraint->get_first_child_node( 'indexNameAndType' ); + $index_name = $index_name_node ? $this->get_identifier_value( $index_name_node->get_first_child_node( 'indexName' ) ) : null; + if ( null === $index_name || '' === $index_name ) { + $key_parts = $this->get_key_parts( $table_constraint ); + $index_name = $key_parts[0]; + } + + return sprintf( + 'CREATE %sINDEX %s%s ON %s (%s)', + $table_constraint->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ) ? 'UNIQUE ' : '', + $if_not_exists ? 'IF NOT EXISTS ' : '', + $this->quote_identifier( $table_name . '__' . $index_name ), + $this->quote_identifier( $table_name ), + implode( ', ', $this->quote_key_parts( $table_constraint, $table_constraint->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ), true, $column_types ) ) + ); + } + + /** + * Validate MySQL index options that PostgreSQL DDL can safely ignore. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + */ + private function validate_mysql_table_constraint_index_options( WP_Parser_Node $table_constraint ): void { + $is_metadata_only = $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) + || $table_constraint->has_child_token( WP_MySQL_Lexer::SPATIAL_SYMBOL ); + + if ( + $is_metadata_only + && ( + ! empty( $table_constraint->get_child_nodes( 'indexOption' ) ) + || ! empty( $table_constraint->get_child_nodes( 'fulltextIndexOption' ) ) + || ! empty( $table_constraint->get_child_nodes( 'spatialIndexOption' ) ) + ) + ) { + if ( ! $this->allow_metadata_only_index_options || ! $this->has_only_supported_metadata_index_options( $table_constraint ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE index option.' ); + } + } + + if ( + ! $is_metadata_only + && ( + ! empty( $table_constraint->get_child_nodes( 'fulltextIndexOption' ) ) + || ! empty( $table_constraint->get_child_nodes( 'spatialIndexOption' ) ) + ) + ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE index option.' ); + } + + foreach ( $table_constraint->get_descendant_nodes( 'indexType' ) as $index_type ) { + if ( ! $this->is_supported_mysql_btree_index_type( $index_type ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE index option.' ); + } + } + + $is_primary = $table_constraint->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ); + foreach ( $table_constraint->get_child_nodes( 'indexOption' ) as $index_option ) { + if ( ! $this->is_supported_mysql_table_index_option( $index_option, $is_primary ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE index option.' ); + } + } + } + + /** + * Check whether metadata-only index options can be preserved or ignored safely. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return bool Whether all metadata-only index options are supported. + */ + private function has_only_supported_metadata_index_options( WP_Parser_Node $table_constraint ): bool { + foreach ( array( 'indexOption', 'fulltextIndexOption', 'spatialIndexOption' ) as $rule_name ) { + foreach ( $table_constraint->get_child_nodes( $rule_name ) as $option ) { + if ( + 'fulltextIndexOption' === $rule_name + && $option->has_child_token( WP_MySQL_Lexer::WITH_SYMBOL ) + && $option->has_child_token( WP_MySQL_Lexer::PARSER_SYMBOL ) + && $option->get_first_child_node( 'identifier' ) + ) { + continue; + } + + $common_option = $option->get_first_child_node( 'commonIndexOption' ); + if ( ! $common_option ) { + return false; + } + + if ( + ! $common_option->has_child_token( WP_MySQL_Lexer::COMMENT_SYMBOL ) + && ! $common_option->has_child_token( WP_MySQL_Lexer::KEY_BLOCK_SIZE_SYMBOL ) + && ! $common_option->has_child_node( 'visibility' ) + ) { + return false; + } + } + } + + return true; + } + + /** + * Check whether a table index option can be ignored by PostgreSQL DDL. + * + * @param WP_Parser_Node $index_option Index option node. + * @param bool $is_primary Whether this is a PRIMARY KEY constraint. + * @return bool Whether the option is supported. + */ + private function is_supported_mysql_table_index_option( WP_Parser_Node $index_option, bool $is_primary ): bool { + $index_type_clause = $index_option->get_first_child_node( 'indexTypeClause' ); + if ( $index_type_clause ) { + $index_type = $index_type_clause->get_first_child_node( 'indexType' ); + return $index_type && $this->is_supported_mysql_btree_index_type( $index_type ); + } + + $common_option = $index_option->get_first_child_node( 'commonIndexOption' ); + if ( ! $common_option ) { + return false; + } + + if ( + $common_option->has_child_token( WP_MySQL_Lexer::COMMENT_SYMBOL ) + || $common_option->has_child_token( WP_MySQL_Lexer::KEY_BLOCK_SIZE_SYMBOL ) + ) { + return true; + } + + $visibility = $common_option->get_first_child_node( 'visibility' ); + if ( ! $visibility ) { + return false; + } + + return ! $is_primary || $visibility->has_child_token( WP_MySQL_Lexer::VISIBLE_SYMBOL ); + } + + /** + * Check whether a MySQL index type maps to a PostgreSQL btree index. + * + * InnoDB reports HASH declarations as BTREE, matching the SQLite backend's + * metadata normalization. + * + * @param WP_Parser_Node $index_type Index type node. + * @return bool Whether the index type is supported as a btree index. + */ + private function is_supported_mysql_btree_index_type( WP_Parser_Node $index_type ): bool { + return $index_type->has_child_token( WP_MySQL_Lexer::BTREE_SYMBOL ) + || $index_type->has_child_token( WP_MySQL_Lexer::HASH_SYMBOL ); + } + + /** + * Get quoted key parts from a MySQL key constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param bool $use_prefix_expressions Whether explicit key-part prefix lengths should become expressions. + * @param bool $include_direction Whether ASC/DESC key-part direction should be included. + * @param array $column_types Column types keyed by lowercase name. + * @return string[] Quoted PostgreSQL column names. + */ + private function quote_key_parts( WP_Parser_Node $table_constraint, bool $use_prefix_expressions = false, bool $include_direction = false, array $column_types = array() ): array { + $quoted_parts = array(); + + foreach ( $table_constraint->get_descendant_nodes( 'keyPart' ) as $key_part ) { + $column_name = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + $sub_part = $this->get_field_length( $key_part ); + if ( $use_prefix_expressions && null === $sub_part ) { + $sub_part = $this->get_implicit_index_sub_part( $column_name, $column_types ); + } + + if ( $use_prefix_expressions && null !== $sub_part ) { + $quoted_part = $this->get_prefix_key_part_expression_sql( $column_name, $sub_part ); + } else { + $quoted_part = $this->quote_identifier( $column_name ); + } + + if ( $include_direction ) { + $quoted_part .= $this->get_key_part_direction_sql( $key_part ); + } + + $quoted_parts[] = $quoted_part; + } + + if ( empty( $quoted_parts ) ) { + throw new InvalidArgumentException( 'Index definition does not contain any key parts.' ); + } + + return $quoted_parts; + } + + /** + * Get PostgreSQL ASC/DESC SQL for a MySQL key part. + * + * @param WP_Parser_Node $key_part Key part node. + * @return string Direction SQL, including leading space, or empty string. + */ + private function get_key_part_direction_sql( WP_Parser_Node $key_part ): string { + $direction = $key_part->get_first_child_node( 'direction' ); + if ( ! $direction ) { + return ''; + } + + $value = strtoupper( $this->get_node_value( $direction ) ); + if ( 'ASC' === $value || 'DESC' === $value ) { + return ' ' . $value; + } + + return ''; + } + + /** + * Get PostgreSQL SQL for a MySQL prefix key part. + * + * @param string $column_name Column name. + * @param int $sub_part Prefix length. + * @return string PostgreSQL expression SQL. + */ + private function get_prefix_key_part_expression_sql( string $column_name, int $sub_part ): string { + return sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $this->quote_identifier( $column_name ), + $sub_part + ); + } + + /** + * Get key part column names. + * + * Prefix lengths are handled by quote_key_parts() when a PostgreSQL index + * expression is needed; this helper returns only the underlying names. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return string[] Column names. + */ + private function get_key_parts( WP_Parser_Node $table_constraint ): array { + $key_parts = array(); + + foreach ( $table_constraint->get_descendant_nodes( 'keyPart' ) as $key_part ) { + $key_parts[] = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + } + + if ( empty( $key_parts ) ) { + throw new InvalidArgumentException( 'Index definition does not contain any key parts.' ); + } + + return $key_parts; + } + + /** + * Get a CREATE TABLE table name. + * + * @param WP_Parser_Node $create_table Create table node. + * @return string Table name. + */ + private function get_table_name( WP_Parser_Node $create_table ): string { + return $this->get_last_identifier_value( $create_table->get_first_child_node( 'tableName' ) ); + } + + /** + * Get the last identifier value in a node. + * + * @param WP_Parser_Node|null $node Node containing an identifier. + * @return string Identifier value. + */ + private function get_last_identifier_value( ?WP_Parser_Node $node ): string { + if ( ! $node ) { + throw new InvalidArgumentException( 'Expected identifier node.' ); + } + + $identifier = null; + $tokens = $node->get_descendant_tokens(); + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::IDENTIFIER === $token->id || WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + $identifier = $token->get_value(); + } + } + + if ( null !== $identifier ) { + return $identifier; + } + + if ( 1 === count( $tokens ) && '' !== $tokens[0]->get_value() ) { + return $tokens[0]->get_value(); + } + + throw new InvalidArgumentException( 'Expected identifier token.' ); + } + + /** + * Get the first identifier value in a node. + * + * @param WP_Parser_Node|null $node Node containing an identifier. + * @return string Identifier value. + */ + private function get_identifier_value( ?WP_Parser_Node $node ): string { + if ( ! $node ) { + throw new InvalidArgumentException( 'Expected identifier node.' ); + } + + $tokens = $node->get_descendant_tokens(); + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::IDENTIFIER === $token->id || WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return $token->get_value(); + } + } + + if ( 1 === count( $tokens ) && '' !== $tokens[0]->get_value() ) { + return $tokens[0]->get_value(); + } + + throw new InvalidArgumentException( 'Expected identifier token.' ); + } + + /** + * Extract metadata for a CREATE TABLE statement. + * + * @param WP_Parser_Node $create_table Create table node. + * @return array Table metadata. + */ + private function extract_create_table_metadata( WP_Parser_Node $create_table, bool $include_indexes = false ): array { + $table_name = $this->get_table_name( $create_table ); + $charset = $this->get_table_charset_and_collation( $create_table ); + $table_comment = $this->get_table_comment( $create_table ); + $columns = array(); + $column_types = array(); + $indexes = array(); + $foreign_keys = array(); + $checks = array(); + $ordinal = 1; + $index_ordinal = 1; + $foreign_key_ordinal = 1; + $foreign_key_names = array(); + $check_ordinal = 1; + + list ( $table_charset, $table_collation ) = $charset; + + $element_list = $create_table->get_first_child_node( 'tableElementList' ); + if ( ! $element_list ) { + return array( + 'table_name' => $table_name, + 'comment' => $table_comment, + 'columns' => array(), + ); + } + + foreach ( $element_list->get_child_nodes( 'tableElement' ) as $table_element ) { + $column_definition = $table_element->get_first_child_node( 'columnDefinition' ); + if ( $column_definition ) { + $name = $this->get_identifier_value( $column_definition->get_first_child_node( 'fieldIdentifier' ) ); + $field_definition = $column_definition->get_first_child_node( 'fieldDefinition' ); + $data_type = $field_definition ? $field_definition->get_first_child_node( 'dataType' ) : null; + $column_type = $this->get_mysql_column_type( $data_type, $field_definition ); + $is_serial = $this->is_serial_data_type( $data_type ); + $is_inline_primary = $field_definition && $this->has_inline_primary_key( $field_definition ); + + list ( $charset, $collation ) = $this->get_column_charset_and_collation( + $field_definition, + $this->get_base_mysql_column_type( $column_type ), + $table_charset, + $table_collation + ); + + $column_metadata = array( + 'name' => $name, + 'type' => $column_type, + 'charset' => $charset, + 'collation' => $collation, + 'comment' => $field_definition ? $this->get_column_comment( $field_definition ) : '', + 'ordinal' => $ordinal, + ); + + if ( $include_indexes ) { + $column_metadata['nullable'] = $is_serial || $is_inline_primary || ( $field_definition && $field_definition->get_first_descendant_token( WP_MySQL_Lexer::NOT_SYMBOL ) ) ? 'NO' : 'YES'; + $column_metadata['default'] = $field_definition ? $this->get_column_default_metadata( $field_definition ) : null; + $column_metadata['extra'] = $field_definition ? $this->get_column_extra_metadata( $field_definition, $is_serial ) : ''; + } + + $columns[] = $column_metadata; + $column_types[ strtolower( $name ) ] = $column_type; + + if ( $include_indexes && $field_definition ) { + foreach ( $this->extract_inline_index_metadata( $name, $field_definition, $column_type, $index_ordinal ) as $index ) { + $indexes[] = $index; + ++$index_ordinal; + } + + $foreign_key = $this->extract_inline_foreign_key_metadata( $table_name, $name, $field_definition, $column_definition, $foreign_key_ordinal, $foreign_key_names ); + if ( null !== $foreign_key ) { + $foreign_keys[] = $foreign_key; + } + + $column_attributes = $field_definition->get_child_nodes( 'columnAttribute' ); + foreach ( $column_attributes as $attribute_index => $attribute ) { + if ( ! $attribute->get_first_child_node( 'checkConstraint' ) ) { + continue; + } + + $checks[] = $this->extract_check_constraint_metadata( + $attribute, + $table_name, + $check_ordinal, + $this->is_followed_by_not_enforced_column_attribute( $column_attributes, $attribute_index ) + ); + } + + $inline_check = $this->get_inline_check_constraint_node( $column_definition ); + if ( $inline_check ) { + $checks[] = $this->extract_check_constraint_metadata( $inline_check, $table_name, $check_ordinal ); + } + } + + ++$ordinal; + continue; + } + + if ( $include_indexes ) { + $table_constraint = $table_element->get_first_child_node( 'tableConstraintDef' ); + if ( $table_constraint ) { + if ( $table_constraint->get_first_child_node( 'checkConstraint' ) ) { + $checks[] = $this->extract_check_constraint_metadata( $table_constraint, $table_name, $check_ordinal ); + continue; + } + + if ( $this->is_table_foreign_key_constraint( $table_constraint ) ) { + $foreign_keys[] = $this->extract_table_foreign_key_metadata( $table_constraint, $table_name, $foreign_key_ordinal, $foreign_key_names ); + continue; + } + + $this->validate_mysql_table_constraint_index_options( $table_constraint ); + $indexes[] = $this->extract_index_metadata( $table_constraint, $index_ordinal, $column_types ); + ++$index_ordinal; + } + } + } + + $metadata = array( + 'table_name' => $table_name, + 'comment' => $table_comment, + 'columns' => $columns, + ); + + if ( $include_indexes ) { + $metadata['indexes'] = $indexes; + $metadata['foreign_keys'] = $foreign_keys; + $metadata['checks'] = $checks; + } + + return $metadata; + } + + /** + * Extract metadata for a CHECK constraint. + * + * @param WP_Parser_Node $node Node containing a checkConstraint child. + * @param string $table_name Table name used for implicit constraint names. + * @param int $check_ordinal Next implicit CHECK ordinal. + * @param bool $not_enforced Whether enforcement was represented by a following sibling node. + * @return array CHECK constraint metadata. + */ + private function extract_check_constraint_metadata( WP_Parser_Node $node, string $table_name, int &$check_ordinal, bool $not_enforced = false ): array { + $check_constraint = 'checkConstraint' === $node->rule_name ? $node : $node->get_first_child_node( 'checkConstraint' ); + if ( ! $check_constraint ) { + throw new InvalidArgumentException( 'Expected CHECK constraint node.' ); + } + + return array( + 'name' => $this->get_check_constraint_name( $node, $table_name, $check_ordinal ), + 'check_clause' => $this->render_check_constraint_expression( $check_constraint, false ), + 'enforced' => ( $not_enforced || $this->is_check_constraint_not_enforced( $node ) ) ? 'NO' : 'YES', + ); + } + + /** + * Check whether a field definition has an inline PRIMARY KEY attribute. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return bool Whether inline PRIMARY KEY is present. + */ + private function has_inline_primary_key( WP_Parser_Node $field_definition ): bool { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( $attribute->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a field definition has an inline UNIQUE attribute. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return bool Whether inline UNIQUE is present. + */ + private function has_inline_unique_key( WP_Parser_Node $field_definition ): bool { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( $attribute->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ) ) { + return true; + } + } + + return false; + } + + /** + * Extract metadata for inline PRIMARY KEY and UNIQUE attributes. + * + * @param string $column_name Column name. + * @param WP_Parser_Node $field_definition Field definition node. + * @param string $column_type MySQL column type. + * @param int $index_ordinal Current index ordinal. + * @return array[] Index metadata rows. + */ + private function extract_inline_index_metadata( string $column_name, WP_Parser_Node $field_definition, string $column_type, int $index_ordinal ): array { + $indexes = array(); + $column_types = array( strtolower( $column_name ) => $column_type ); + $sub_part = $this->get_implicit_index_sub_part( $column_name, $column_types ); + $column = array( + 'column_name' => $column_name, + 'seq_in_index' => 1, + 'sub_part' => $sub_part, + ); + + if ( $this->has_inline_primary_key( $field_definition ) ) { + $indexes[] = array( + 'name' => 'PRIMARY', + 'ordinal' => $index_ordinal, + 'non_unique' => '0', + 'index_type' => 'BTREE', + 'comment' => '', + 'columns' => array( $column ), + ); + ++$index_ordinal; + } + + if ( + ! $this->has_inline_primary_key( $field_definition ) + && ( + $this->has_inline_unique_key( $field_definition ) + || $this->is_serial_data_type( $field_definition->get_first_child_node( 'dataType' ) ) + ) + ) { + $indexes[] = array( + 'name' => $column_name, + 'ordinal' => $index_ordinal, + 'non_unique' => '0', + 'index_type' => 'BTREE', + 'comment' => '', + 'columns' => array( $column ), + ); + } + + return $indexes; + } + + /** + * Extract metadata for an inline foreign key reference. + * + * @param string $table_name Table name. + * @param string $column_name Local column name. + * @param WP_Parser_Node $field_definition Field definition node. + * @param WP_Parser_Node $column_definition Column definition node. + * @param int $foreign_key_ordinal Current foreign key ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @return array|null Foreign key metadata, or null. + */ + private function extract_inline_foreign_key_metadata( string $table_name, string $column_name, WP_Parser_Node $field_definition, WP_Parser_Node $column_definition, int &$foreign_key_ordinal, array &$foreign_key_names ): ?array { + $references = $this->get_inline_references_node( $column_definition ); + if ( ! $references ) { + return null; + } + + $reference = $this->extract_inline_reference_metadata( $references ); + if ( 1 !== count( $reference['referenced_columns'] ) ) { + throw new InvalidArgumentException( 'Unsupported inline REFERENCES option.' ); + } + + return array( + 'name' => $this->get_next_implicit_foreign_key_constraint_name( $table_name, $foreign_key_ordinal, $foreign_key_names ), + 'columns' => array( $column_name ), + 'referenced_schema' => $reference['referenced_schema'], + 'referenced_table' => $reference['referenced_table'], + 'referenced_columns' => $reference['referenced_columns'], + 'update_rule' => $reference['update_rule'], + 'delete_rule' => $reference['delete_rule'], + ); + } + + /** + * Extract metadata for a table-level foreign key reference. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param string $table_name Table name. + * @param int $foreign_key_ordinal Current foreign key ordinal. + * @param array $foreign_key_names Foreign key names already emitted. + * @return array Foreign key metadata. + */ + private function extract_table_foreign_key_metadata( WP_Parser_Node $table_constraint, string $table_name, int &$foreign_key_ordinal, array &$foreign_key_names ): array { + $references = $table_constraint->get_first_child_node( 'references' ); + if ( ! $references ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE foreign key option.' ); + } + + $columns = $this->get_foreign_key_columns( $table_constraint ); + $reference = $this->extract_inline_reference_metadata( $references ); + if ( count( $columns ) !== count( $reference['referenced_columns'] ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE foreign key option.' ); + } + + return array( + 'name' => $this->get_foreign_key_constraint_name( $table_constraint, $table_name, $foreign_key_ordinal, $foreign_key_names ), + 'columns' => $columns, + 'referenced_schema' => $reference['referenced_schema'], + 'referenced_table' => $reference['referenced_table'], + 'referenced_columns' => $reference['referenced_columns'], + 'update_rule' => $reference['update_rule'], + 'delete_rule' => $reference['delete_rule'], + ); + } + + /** + * Get local column names from a table-level FOREIGN KEY constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return string[] Local column names. + */ + private function get_foreign_key_columns( WP_Parser_Node $table_constraint ): array { + $columns = array(); + foreach ( $table_constraint->get_descendant_nodes( 'keyPart' ) as $key_part ) { + if ( null !== $this->get_field_length( $key_part ) || $key_part->get_first_child_node( 'direction' ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE foreign key option.' ); + } + + $columns[] = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + } + + if ( empty( $columns ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE foreign key option.' ); + } + + return $columns; + } + + /** + * Extract metadata for a table index. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param int $index_ordinal Index ordinal. + * @param array $column_types Column types keyed by lowercase name. + * @return array Index metadata. + */ + private function extract_index_metadata( WP_Parser_Node $table_constraint, int $index_ordinal, array $column_types ): array { + $is_primary = $table_constraint->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ); + $is_spatial_index = $this->is_mysql_spatial_index_metadata( $table_constraint, $column_types ); + $key_parts = $this->get_key_part_metadata( $table_constraint, $column_types, $is_spatial_index ); + $key_name = $is_primary ? 'PRIMARY' : $this->get_index_name( $table_constraint, $key_parts ); + + return array( + 'name' => $key_name, + 'ordinal' => $index_ordinal, + 'non_unique' => $is_primary || $table_constraint->has_child_token( WP_MySQL_Lexer::UNIQUE_SYMBOL ) ? '0' : '1', + 'index_type' => $this->get_mysql_index_type_metadata( $table_constraint, $is_spatial_index ), + 'comment' => $this->get_index_comment( $table_constraint ), + 'columns' => $key_parts, + ); + } + + /** + * Get table comment metadata from a CREATE TABLE node. + * + * @param WP_Parser_Node $create_table CREATE TABLE node. + * @return string Table comment. + */ + private function get_table_comment( WP_Parser_Node $create_table ): string { + foreach ( $create_table->get_descendant_nodes( 'createTableOption' ) as $option ) { + if ( ! $option->has_child_token( WP_MySQL_Lexer::COMMENT_SYMBOL ) ) { + continue; + } + + $comment = $option->get_first_child_node( 'textStringLiteral' ); + return $comment ? $this->get_node_value( $comment ) : ''; + } + + return ''; + } + + /** + * Get column comment metadata from a field definition node. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return string Column comment. + */ + private function get_column_comment( WP_Parser_Node $field_definition ): string { + foreach ( $field_definition->get_descendant_nodes( 'columnAttribute' ) as $attribute ) { + if ( ! $attribute->has_child_token( WP_MySQL_Lexer::COMMENT_SYMBOL ) ) { + continue; + } + + $comment = $attribute->get_first_child_node( 'textLiteral' ); + return $comment ? $this->get_node_value( $comment ) : ''; + } + + return ''; + } + + /** + * Get index comment metadata from a table constraint node. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return string Index comment. + */ + private function get_index_comment( WP_Parser_Node $table_constraint ): string { + foreach ( $table_constraint->get_descendant_nodes( 'commonIndexOption' ) as $option ) { + if ( ! $option->has_child_token( WP_MySQL_Lexer::COMMENT_SYMBOL ) ) { + continue; + } + + $comment = $option->get_first_child_node( 'textLiteral' ); + return $comment ? $this->get_node_value( $comment ) : ''; + } + + return ''; + } + + /** + * Get an index name from a table constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param array $key_parts Key part metadata. + * @return string Index name. + */ + private function get_index_name( WP_Parser_Node $table_constraint, array $key_parts ): string { + $index_name_node = $table_constraint->get_first_child_node( 'indexNameAndType' ); + $index_name_node = $index_name_node ? $index_name_node->get_first_child_node( 'indexName' ) : $table_constraint->get_first_child_node( 'indexName' ); + + if ( $index_name_node ) { + return $this->get_identifier_value( $index_name_node ); + } + + return (string) $key_parts[0]['column_name']; + } + + /** + * Get MySQL SHOW INDEX Index_type metadata. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param bool $is_spatial_index Whether the index targets spatial data. + * @return string Index type. + */ + private function get_mysql_index_type_metadata( WP_Parser_Node $table_constraint, bool $is_spatial_index ): string { + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) ) { + return 'FULLTEXT'; + } + + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::SPATIAL_SYMBOL ) || $is_spatial_index ) { + return 'SPATIAL'; + } + + return 'BTREE'; + } + + /** + * Check whether index metadata should use MySQL SPATIAL semantics. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param array $column_types Column types keyed by lowercase name. + * @return bool Whether the index is spatial. + */ + private function is_mysql_spatial_index_metadata( WP_Parser_Node $table_constraint, array $column_types ): bool { + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::SPATIAL_SYMBOL ) ) { + return true; + } + + $key_part = $table_constraint->get_first_descendant_node( 'keyPart' ); + if ( ! $key_part ) { + return false; + } + + $column_name = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + $column_type = $column_types[ strtolower( $column_name ) ] ?? null; + + return is_string( $column_type ) && $this->is_mysql_spatial_column_type( $column_type ); + } + + /** + * Get key part metadata from a MySQL key constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @param array $column_types Column types keyed by lowercase name. + * @param bool $is_spatial_index Whether the index targets spatial data. + * @return array[] Key part metadata. + */ + private function get_key_part_metadata( WP_Parser_Node $table_constraint, array $column_types, bool $is_spatial_index ): array { + $key_parts = array(); + + foreach ( $table_constraint->get_descendant_nodes( 'keyPart' ) as $key_part ) { + $column_name = $this->get_identifier_value( $key_part->get_first_child_node( 'identifier' ) ); + $sub_part = $this->get_field_length( $key_part ); + if ( null === $sub_part && $is_spatial_index ) { + $sub_part = 32; + } elseif ( null === $sub_part && ! $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) ) { + $sub_part = $this->get_implicit_index_sub_part( $column_name, $column_types ); + } + + $key_parts[] = array( + 'column_name' => $column_name, + 'seq_in_index' => count( $key_parts ) + 1, + 'collation' => $table_constraint->has_child_token( WP_MySQL_Lexer::FULLTEXT_SYMBOL ) ? null : $this->get_key_part_collation_metadata( $key_part ), + 'sub_part' => $sub_part, + ); + } + + if ( empty( $key_parts ) ) { + throw new InvalidArgumentException( 'Index definition does not contain any key parts.' ); + } + + return $key_parts; + } + + /** + * Get MySQL SHOW INDEX Collation metadata for a key part. + * + * @param WP_Parser_Node $key_part Key part node. + * @return string MySQL collation metadata. + */ + private function get_key_part_collation_metadata( WP_Parser_Node $key_part ): string { + $direction = $key_part->get_first_child_node( 'direction' ); + if ( ! $direction ) { + return 'A'; + } + + return 'DESC' === strtoupper( $this->get_node_value( $direction ) ) ? 'D' : 'A'; + } + + /** + * Get the implicit MySQL prefix length for oversized utf8mb4 string indexes. + * + * @param string $column_name Column name. + * @param array $column_types Column types keyed by lowercase name. + * @return int|null Sub part length. + */ + private function get_implicit_index_sub_part( string $column_name, array $column_types ): ?int { + $column_type = $column_types[ strtolower( $column_name ) ] ?? null; + if ( ! is_string( $column_type ) || ! preg_match( '/^(?:var)?char\((\d+)\)$/i', $column_type, $matches ) ) { + return null; + } + + $length = (int) $matches[1]; + return $length > 191 ? 191 : null; + } + + /** + * Get a MySQL default value for DESCRIBE metadata. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return string|null Default value. + */ + private function get_column_default_metadata( WP_Parser_Node $field_definition ): ?string { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( ! $attribute->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { + continue; + } + + $value_tokens = $this->get_default_attribute_value_tokens( $attribute ); + $expression_tokens = $this->strip_default_attribute_outer_parentheses( $value_tokens ); + + if ( + 1 === count( $expression_tokens ) + && $this->is_unquoted_mysql_null_token( $expression_tokens[0] ) + ) { + return null; + } + + $current_timestamp_default = $this->get_current_timestamp_default_metadata( $attribute ); + if ( null !== $current_timestamp_default ) { + return $current_timestamp_default; + } + + if ( $this->is_generated_default_attribute( $attribute ) ) { + return $this->get_generated_default_metadata_expression( + $expression_tokens + ); + } + + foreach ( $attribute->get_descendant_tokens() as $token ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + continue; + } + + if ( $this->is_unquoted_mysql_null_token( $token ) ) { + return null; + } + + return $token->get_value(); + } + } + + return null; + } + + /** + * Get MySQL-facing metadata SQL for a generated DEFAULT expression. + * + * @param WP_MySQL_Token[] $tokens DEFAULT expression tokens. + * @return string MySQL-facing expression SQL. + */ + private function get_generated_default_metadata_expression( array $tokens ): string { + $sql = ''; + $previous_token = null; + + foreach ( $tokens as $token ) { + if ( '' !== $sql && $this->generated_default_metadata_tokens_need_space( $previous_token, $token ) ) { + $sql .= ' '; + } + + $sql .= $token->get_bytes(); + $previous_token = $token; + } + + return $sql; + } + + /** + * Decide whether two generated DEFAULT metadata tokens need a separating space. + * + * @param WP_MySQL_Token|null $previous Previous token, or null. + * @param WP_MySQL_Token $current Current token. + * @return bool Whether to add a space. + */ + private function generated_default_metadata_tokens_need_space( ?WP_MySQL_Token $previous, WP_MySQL_Token $current ): bool { + if ( null === $previous ) { + return false; + } + + if ( + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $current->id + || WP_MySQL_Lexer::COMMA_SYMBOL === $current->id + || WP_MySQL_Lexer::DOT_SYMBOL === $current->id + || WP_MySQL_Lexer::DOT_SYMBOL === $previous->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous->id + ) { + return false; + } + + if ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $current->id + && $this->is_generated_default_function_like_token( $previous ) + ) { + return false; + } + + return true; + } + + /** + * Check whether a token can be followed by function-call parentheses. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is function-like. + */ + private function is_generated_default_function_like_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::ADDDATE_SYMBOL, + WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL, + WP_MySQL_Lexer::DATE_ADD_SYMBOL, + WP_MySQL_Lexer::DATE_SUB_SYMBOL, + WP_MySQL_Lexer::IDENTIFIER, + WP_MySQL_Lexer::NOW_SYMBOL, + WP_MySQL_Lexer::SUBDATE_SYMBOL, + ), + true + ); + } + + /** + * Get MySQL column extra metadata. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @param bool $is_serial Whether the data type implies AUTO_INCREMENT. + * @return string Extra metadata. + */ + private function get_column_extra_metadata( WP_Parser_Node $field_definition, bool $is_serial ): string { + $extras = array(); + if ( $is_serial || $field_definition->get_first_descendant_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ) ) { + $extras[] = 'auto_increment'; + } + + if ( $this->field_definition_has_generated_default( $field_definition ) ) { + $extras[] = 'DEFAULT_GENERATED'; + } + + if ( $this->field_definition_has_on_update_current_timestamp( $field_definition ) ) { + $extras[] = 'on update CURRENT_TIMESTAMP'; + } + + return implode( ' ', $extras ); + } + + /** + * Check whether a field definition has a generated default expression. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return bool Whether the default should be reported as generated metadata. + */ + private function field_definition_has_generated_default( WP_Parser_Node $field_definition ): bool { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( + $attribute->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) + && $this->is_generated_default_attribute( $attribute ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a field definition has ON UPDATE CURRENT_TIMESTAMP. + * + * @param WP_Parser_Node $field_definition Field definition node. + * @return bool Whether ON UPDATE CURRENT_TIMESTAMP is present. + */ + private function field_definition_has_on_update_current_timestamp( WP_Parser_Node $field_definition ): bool { + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( $this->tokens_have_on_update_current_timestamp( $attribute->get_descendant_tokens() ) ) { + return true; + } + } + + return $this->tokens_have_on_update_current_timestamp( $field_definition->get_descendant_tokens() ); + } + + /** + * Check whether a token stream contains ON UPDATE CURRENT_TIMESTAMP. + * + * @param WP_MySQL_Token[] $tokens Token stream. + * @return bool Whether ON UPDATE CURRENT_TIMESTAMP is present. + */ + private function tokens_have_on_update_current_timestamp( array $tokens ): bool { + for ( $i = 0; $i + 2 < count( $tokens ); ++$i ) { + if ( + WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $i ]->id + && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $i + 1 ]->id + && $this->is_current_timestamp_token( $tokens[ $i + 2 ] ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a DEFAULT attribute is generated rather than a literal. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return bool Whether the default is generated. + */ + private function is_generated_default_attribute( WP_Parser_Node $attribute ): bool { + $tokens = $this->get_default_attribute_value_tokens( $attribute ); + if ( empty( $tokens ) ) { + return false; + } + + return WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id + || $this->is_current_timestamp_default_attribute( $attribute ); + } + + /** + * Check whether a DEFAULT attribute is CURRENT_TIMESTAMP/NOW. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return bool Whether the default is a current timestamp expression. + */ + private function is_current_timestamp_default_attribute( WP_Parser_Node $attribute ): bool { + return null !== $this->get_current_timestamp_default_metadata( $attribute ); + } + + /** + * Get metadata for a CURRENT_TIMESTAMP/NOW default attribute. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return string|null MySQL-facing default metadata, or null. + */ + private function get_current_timestamp_default_metadata( WP_Parser_Node $attribute ): ?string { + $data = $this->get_current_timestamp_default_data( $attribute ); + return null === $data ? null : $data['metadata']; + } + + /** + * Get metadata and precision data for a CURRENT_TIMESTAMP/NOW default attribute. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return array{metadata: string, fsp: int}|null MySQL-facing default metadata and precision, or null. + */ + private function get_current_timestamp_default_data( WP_Parser_Node $attribute ): ?array { + $tokens = $this->strip_default_attribute_outer_parentheses( + $this->get_default_attribute_value_tokens( $attribute ) + ); + $count = count( $tokens ); + + if ( 1 === $count && $this->is_current_timestamp_token( $tokens[0] ) ) { + return array( + 'metadata' => 'CURRENT_TIMESTAMP', + 'fsp' => 0, + ); + } + + if ( + 3 === $count + && $this->is_current_timestamp_token( $tokens[0] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[2]->id + ) { + return array( + 'metadata' => 'CURRENT_TIMESTAMP', + 'fsp' => 0, + ); + } + + if ( + 3 === $count + && WP_MySQL_Lexer::NOW_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[2]->id + ) { + return array( + 'metadata' => 'now()', + 'fsp' => 0, + ); + } + + if ( + 4 === $count + && $this->is_current_timestamp_token( $tokens[0] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[3]->id + ) { + $fsp = $this->get_mysql_fractional_seconds_precision_token_value( $tokens[2] ); + if ( null === $fsp ) { + return null; + } + + return array( + 'metadata' => sprintf( 'CURRENT_TIMESTAMP(%d)', $fsp ), + 'fsp' => $fsp, + ); + } + + if ( + 4 === $count + && WP_MySQL_Lexer::NOW_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[3]->id + ) { + $fsp = $this->get_mysql_fractional_seconds_precision_token_value( $tokens[2] ); + if ( null === $fsp ) { + return null; + } + + return array( + 'metadata' => sprintf( 'now(%d)', $fsp ), + 'fsp' => $fsp, + ); + } + + return null; + } + + /** + * Check whether a token represents CURRENT_TIMESTAMP. + * + * @param WP_MySQL_Token $token Token. + * @return bool Whether the token is CURRENT_TIMESTAMP. + */ + private function is_current_timestamp_token( WP_MySQL_Token $token ): bool { + return WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL === $token->id + || ( + WP_MySQL_Lexer::NOW_SYMBOL === $token->id + && 'CURRENT_TIMESTAMP' === strtoupper( $token->get_value() ) + ); + } + + /** + * Get a bounded MySQL fractional seconds precision from a token. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return int|null Precision, or null when unsupported. + */ + private function get_mysql_fractional_seconds_precision_token_value( ?WP_MySQL_Token $token ): ?int { + if ( null === $token || WP_MySQL_Lexer::INT_NUMBER !== $token->id ) { + return null; + } + + $value = trim( $token->get_value() ); + return 1 === preg_match( '/^[0-6]$/', $value ) ? (int) $value : null; + } + + /** + * Build a PostgreSQL current timestamp expression with optional precision. + * + * @param int $fsp Fractional seconds precision, 0 through 6. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_current_timestamp_expression_sql( int $fsp ): string { + return 0 === $fsp + ? "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'" + : sprintf( "CURRENT_TIMESTAMP(%d) AT TIME ZONE 'UTC'", $fsp ); + } + + /** + * Format a temporal expression as MySQL-compatible text. + * + * @param string $expression_sql PostgreSQL temporal expression SQL. + * @param string $format PostgreSQL TO_CHAR format. + * @param int $fsp Fractional seconds precision, 0 through 6. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_temporal_expression_sql( string $expression_sql, string $format, int $fsp ): string { + if ( 0 === $fsp ) { + return sprintf( "TO_CHAR(%s, '%s')", $expression_sql, $format ); + } + + $output_prefix_lengths = array( + 'YYYY-MM-DD HH24:MI:SS' => 20, + 'HH24:MI:SS' => 9, + ); + + return sprintf( + "LEFT(TO_CHAR(%s, '%s.US'), %d)", + $expression_sql, + $format, + ( $output_prefix_lengths[ $format ] ?? 0 ) + $fsp + ); + } + + /** + * Format the emulated MySQL current timestamp default. + * + * @param int $fsp Fractional seconds precision, 0 through 6. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_current_timestamp_sql( int $fsp ): string { + return $this->get_postgresql_mysql_temporal_expression_sql( + $this->get_postgresql_current_timestamp_expression_sql( $fsp ), + 'YYYY-MM-DD HH24:MI:SS', + $fsp + ); + } + + /** + * Get DEFAULT attribute value tokens without the DEFAULT keyword. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return WP_MySQL_Token[] Value tokens. + */ + private function get_default_attribute_value_tokens( WP_Parser_Node $attribute ): array { + $value_tokens = array(); + $seen_default = false; + foreach ( $attribute->get_descendant_tokens() as $token ) { + if ( ! $seen_default ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + $seen_default = true; + } + continue; + } + + $value_tokens[] = $token; + } + + return $value_tokens; + } + + /** + * Strip simple wrapping parentheses from DEFAULT value tokens. + * + * @param WP_MySQL_Token[] $tokens Value tokens. + * @return WP_MySQL_Token[] Unwrapped tokens. + */ + private function strip_default_attribute_outer_parentheses( array $tokens ): array { + while ( + count( $tokens ) >= 2 + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ count( $tokens ) - 1 ]->id + ) { + $depth = 0; + $wraps_all = true; + $last_index = count( $tokens ) - 1; + foreach ( $tokens as $index => $token ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + ++$depth; + } elseif ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $token->id ) { + --$depth; + if ( 0 === $depth && $index < $last_index ) { + $wraps_all = false; + break; + } + } + } + + if ( ! $wraps_all || 0 !== $depth ) { + break; + } + + $tokens = array_slice( $tokens, 1, -1 ); + } + + return $tokens; + } + + /** + * Check whether a token represents an unquoted MySQL NULL literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is unquoted NULL. + */ + private function is_unquoted_mysql_null_token( WP_MySQL_Token $token ): bool { + return 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 + && 0 === strcasecmp( $token->get_value(), 'null' ); + } + + /** + * Extract table default charset and collation. + * + * @param WP_Parser_Node $create_table Create table node. + * @return array{string, string} Charset and collation. + */ + private function get_table_charset_and_collation( WP_Parser_Node $create_table ): array { + $charset = 'utf8mb4'; + $collation = null; + + foreach ( $create_table->get_child_nodes( 'createTableOptions' ) as $options ) { + foreach ( $options->get_child_nodes( 'createTableOption' ) as $option ) { + $default_charset = $option->get_first_child_node( 'defaultCharset' ); + if ( $default_charset ) { + $charset_name = $default_charset->get_first_child_node( 'charsetName' ); + if ( $charset_name ) { + $charset = $this->normalize_charset( $this->get_node_value( $charset_name ) ); + } + } + + $default_collation = $option->get_first_child_node( 'defaultCollation' ); + if ( $default_collation ) { + $collation_name = $default_collation->get_first_child_node( 'collationName' ); + if ( $collation_name ) { + $collation = $this->normalize_collation( $this->get_node_value( $collation_name ) ); + } + } + } + } + + if ( null === $collation ) { + $collation = $this->get_default_collation_for_charset( $charset ); + } else { + $charset = $this->get_charset_from_collation( $collation ); + } + + return array( $charset, $collation ); + } + + /** + * Extract MySQL column charset and collation metadata. + * + * @param WP_Parser_Node|null $field_definition Field definition node. + * @param string $data_type Column data type. + * @param string $table_charset Table default charset. + * @param string $table_collation Table default collation. + * @return array{string|null, string|null} Charset and collation. + */ + private function get_column_charset_and_collation( ?WP_Parser_Node $field_definition, string $data_type, string $table_charset, string $table_collation ): array { + if ( ! $field_definition || ! $this->is_mysql_character_data_type( $data_type ) ) { + return array( null, null ); + } + + $charset = null; + $collation = null; + $is_binary = false; + $is_national = $this->is_national_character_data_type( $field_definition->get_first_child_node( 'dataType' ) ); + + $charset_node = $field_definition->get_first_descendant_node( 'charsetWithOptBinary' ); + if ( $charset_node ) { + $charset_name = $charset_node->get_first_child_node( 'charsetName' ); + if ( $charset_name ) { + $charset = $this->normalize_charset( $this->get_node_value( $charset_name ) ); + } elseif ( $charset_node->has_child_token( WP_MySQL_Lexer::ASCII_SYMBOL ) ) { + $charset = 'latin1'; + } elseif ( $charset_node->has_child_token( WP_MySQL_Lexer::UNICODE_SYMBOL ) ) { + $charset = 'ucs2'; + } + + if ( $charset_node->has_child_token( WP_MySQL_Lexer::BINARY_SYMBOL ) ) { + $is_binary = true; + } + } + + $collation_node = $field_definition->get_first_descendant_node( 'collationName' ); + if ( $collation_node ) { + $collation = $this->normalize_collation( $this->get_node_value( $collation_node ) ); + } + + if ( null === $charset && null === $collation && $is_national ) { + $charset = 'utf8'; + $collation = $this->get_default_collation_for_charset( $charset ); + } elseif ( null === $charset && null === $collation ) { + $charset = $table_charset; + $collation = $table_collation; + } elseif ( null === $collation ) { + $collation = $is_binary ? $charset . '_bin' : $this->get_default_collation_for_charset( $charset ); + } elseif ( null === $charset ) { + $charset = $this->get_charset_from_collation( $collation ); + } + + return array( $charset, $collation ); + } + + /** + * Get a MySQL column type for metadata. + * + * @param WP_Parser_Node|null $data_type Data type node. + * @param WP_Parser_Node|null $field_definition Field definition node. + * @return string MySQL column type. + */ + private function get_mysql_column_type( ?WP_Parser_Node $data_type, ?WP_Parser_Node $field_definition = null ): string { + if ( ! $data_type ) { + throw new InvalidArgumentException( 'Column definition is missing a data type.' ); + } + + $type = $this->get_normalized_mysql_data_type( $data_type ); + if ( 'integer' === $type ) { + $type = 'int'; + } + + if ( 'serial' === $type ) { + return 'bigint unsigned'; + } + + if ( in_array( $type, array( 'enum', 'set' ), true ) ) { + return $this->get_enum_or_set_column_type( $type, $data_type ); + } + + $numeric_precision = $this->get_numeric_precision_fragment( $data_type ); + if ( '' !== $numeric_precision && in_array( $type, array( 'dec', 'decimal', 'double', 'fixed', 'float', 'numeric' ), true ) ) { + $type .= $numeric_precision; + } + if ( in_array( $type, array( 'datetime', 'time', 'timestamp' ), true ) ) { + $temporal_precision = $numeric_precision; + if ( '' === $temporal_precision ) { + $temporal_precision = $this->get_temporal_precision_fragment( $data_type ); + } + if ( '' !== $temporal_precision ) { + $type .= $temporal_precision; + } + } + + $length = $this->get_field_length( $data_type ); + if ( null === $length && in_array( $type, array( 'binary', 'bit', 'char' ), true ) ) { + $length = 1; + } + if ( null !== $length && in_array( $type, array( 'bigint', 'binary', 'bit', 'char', 'int', 'mediumint', 'smallint', 'tinyint', 'varbinary', 'varchar' ), true ) ) { + $type = sprintf( '%s(%d)', $type, $length ); + } + + if ( $field_definition && $field_definition->get_first_descendant_token( WP_MySQL_Lexer::UNSIGNED_SYMBOL ) ) { + $type .= ' unsigned'; + } + + return $type; + } + + /** + * Get a normalized MySQL data type name from a dataType node. + * + * @param WP_Parser_Node $data_type Data type node. + * @return string Normalized type name. + */ + private function get_normalized_mysql_data_type( WP_Parser_Node $data_type ): string { + if ( $this->is_serial_data_type( $data_type ) ) { + return 'serial'; + } + + $long_alias = $this->get_normalized_mysql_long_data_type( $data_type ); + if ( null !== $long_alias ) { + return $long_alias; + } + + $character_alias = $this->get_normalized_mysql_character_data_type( $data_type ); + if ( null !== $character_alias ) { + return $character_alias; + } + + $type_token = $data_type->get_first_child_token(); + if ( ! $type_token ) { + throw new InvalidArgumentException( 'Column data type is empty.' ); + } + + return strtolower( $type_token->get_value() ); + } + + /** + * Normalize MySQL LONG-prefixed data type aliases. + * + * @param WP_Parser_Node $data_type Data type node. + * @return string|null Normalized data type, or null for non-LONG aliases. + */ + private function get_normalized_mysql_long_data_type( WP_Parser_Node $data_type ): ?string { + $tokens = $data_type->get_descendant_tokens(); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::LONG_SYMBOL !== $tokens[0]->id + ) { + return null; + } + + if ( WP_MySQL_Lexer::VARBINARY_SYMBOL === $tokens[1]->id ) { + return 'mediumblob'; + } + + if ( in_array( $tokens[1]->id, array( WP_MySQL_Lexer::CHAR_SYMBOL, WP_MySQL_Lexer::VARCHAR_SYMBOL, WP_MySQL_Lexer::VARCHARACTER_SYMBOL ), true ) ) { + return 'mediumtext'; + } + + return null; + } + + /** + * Normalize MySQL character data type aliases. + * + * @param WP_Parser_Node $data_type Data type node. + * @return string|null Normalized character type, or null for non-character types. + */ + private function get_normalized_mysql_character_data_type( WP_Parser_Node $data_type ): ?string { + $tokens = $data_type->get_descendant_tokens(); + if ( empty( $tokens ) ) { + return null; + } + + $first_id = $tokens[0]->id; + $has_varchar = false; + $has_varying = false; + foreach ( $tokens as $token ) { + if ( in_array( $token->id, array( WP_MySQL_Lexer::VARCHAR_SYMBOL, WP_MySQL_Lexer::VARCHARACTER_SYMBOL, WP_MySQL_Lexer::NVARCHAR_SYMBOL ), true ) ) { + $has_varchar = true; + } + if ( WP_MySQL_Lexer::VARYING_SYMBOL === $token->id ) { + $has_varying = true; + } + } + + if ( + $has_varchar + || $has_varying + || in_array( $first_id, array( WP_MySQL_Lexer::VARCHAR_SYMBOL, WP_MySQL_Lexer::VARCHARACTER_SYMBOL, WP_MySQL_Lexer::NVARCHAR_SYMBOL ), true ) + ) { + return 'varchar'; + } + + if ( in_array( $first_id, array( WP_MySQL_Lexer::CHAR_SYMBOL, WP_MySQL_Lexer::NCHAR_SYMBOL, WP_MySQL_Lexer::NATIONAL_SYMBOL ), true ) ) { + return 'char'; + } + + return null; + } + + /** + * Check whether a data type is SERIAL. + * + * @param WP_Parser_Node|null $data_type Data type node. + * @return bool Whether SERIAL is present. + */ + private function is_serial_data_type( ?WP_Parser_Node $data_type ): bool { + return $data_type && null !== $data_type->get_first_descendant_token( WP_MySQL_Lexer::SERIAL_SYMBOL ); + } + + /** + * Check whether a data type uses MySQL's national character set aliases. + * + * @param WP_Parser_Node|null $data_type Data type node. + * @return bool Whether the data type is national character based. + */ + private function is_national_character_data_type( ?WP_Parser_Node $data_type ): bool { + return $data_type && ( + null !== $data_type->get_first_descendant_token( WP_MySQL_Lexer::NATIONAL_SYMBOL ) + || null !== $data_type->get_first_descendant_token( WP_MySQL_Lexer::NCHAR_SYMBOL ) + || null !== $data_type->get_first_descendant_token( WP_MySQL_Lexer::NVARCHAR_SYMBOL ) + ); + } + + /** + * Get the MySQL COLUMN_TYPE metadata for ENUM and SET columns. + * + * @param string $type Base MySQL data type. + * @param WP_Parser_Node $data_type Data type node. + * @return string MySQL column type. + */ + private function get_enum_or_set_column_type( string $type, WP_Parser_Node $data_type ): string { + $values = array(); + foreach ( $data_type->get_descendant_tokens() as $token ) { + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id + ) { + $values[] = $this->quote_string_literal( $token->get_value() ); + } + } + + if ( empty( $values ) ) { + throw new InvalidArgumentException( sprintf( 'Unsupported MySQL column type for PostgreSQL install DDL: %s.', $type ) ); + } + + return sprintf( '%s(%s)', $type, implode( ',', $values ) ); + } + + /** + * Get the base MySQL column type without length. + * + * @param string $column_type Column type. + * @return string Base type. + */ + private function get_base_mysql_column_type( string $column_type ): string { + $length_position = strpos( $column_type, '(' ); + if ( false === $length_position ) { + return strtolower( $column_type ); + } + + return strtolower( substr( $column_type, 0, $length_position ) ); + } + + /** + * Check whether a MySQL type has character set metadata. + * + * @param string $data_type Base MySQL data type. + * @return bool Whether the type is textual. + */ + private function is_mysql_character_data_type( string $data_type ): bool { + return in_array( + $data_type, + array( 'char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'enum', 'set' ), + true + ); + } + + /** + * Check whether a MySQL column type is spatial. + * + * @param string $data_type MySQL data type. + * @return bool Whether the type is spatial. + */ + private function is_mysql_spatial_column_type( string $data_type ): bool { + $base_type = $this->get_base_mysql_column_type( $data_type ); + return in_array( + $base_type, + array( + 'geometry', + 'point', + 'linestring', + 'polygon', + 'multipoint', + 'multilinestring', + 'multipolygon', + 'geometrycollection', + 'geomcollection', + ), + true + ); + } + + /** + * Normalize MySQL charset names for WordPress metadata expectations. + * + * @param string $charset Charset name. + * @return string Normalized charset. + */ + private function normalize_charset( string $charset ): string { + $charset = strtolower( trim( $charset, "'\"` \t\n\r\0\x0B" ) ); + return 'utf8mb3' === $charset ? 'utf8' : $charset; + } + + /** + * Normalize MySQL collation names for WordPress metadata expectations. + * + * @param string $collation Collation name. + * @return string Normalized collation. + */ + private function normalize_collation( string $collation ): string { + $collation = strtolower( trim( $collation, "'\"` \t\n\r\0\x0B" ) ); + if ( 0 === strpos( $collation, 'utf8mb3_' ) ) { + return 'utf8_' . substr( $collation, strlen( 'utf8mb3_' ) ); + } + + return $collation; + } + + /** + * Get the charset prefix from a collation name. + * + * @param string $collation Collation name. + * @return string Charset name. + */ + private function get_charset_from_collation( string $collation ): string { + $underscore = strpos( $collation, '_' ); + if ( false === $underscore ) { + return $this->normalize_charset( $collation ); + } + + return $this->normalize_charset( substr( $collation, 0, $underscore ) ); + } + + /** + * Get the default MySQL collation for a charset. + * + * @param string $charset Charset name. + * @return string Collation name. + */ + private function get_default_collation_for_charset( string $charset ): string { + $charset = $this->normalize_charset( $charset ); + if ( isset( self::CHARSET_DEFAULT_COLLATION_MAP[ $charset ] ) ) { + return self::CHARSET_DEFAULT_COLLATION_MAP[ $charset ]; + } + + return $charset . '_general_ci'; + } + + /** + * Serialize a parser node value. + * + * @param WP_Parser_Node $node Parser node. + * @return string Node value. + */ + private function get_node_value( WP_Parser_Node $node ): string { + $value = ''; + foreach ( $node->get_children() as $child ) { + if ( $child instanceof WP_Parser_Node ) { + $value .= $this->get_node_value( $child ); + } else { + $value .= $child->get_value(); + } + } + + return $value; + } + + /** + * Get a numeric field length. + * + * @param WP_Parser_Node $node Node that may contain a fieldLength child. + * @return int|null Field length. + */ + private function get_field_length( WP_Parser_Node $node ): ?int { + $field_length = $node->get_first_child_node( 'fieldLength' ); + if ( ! $field_length ) { + return null; + } + + $token = $field_length->get_first_descendant_token( WP_MySQL_Lexer::INT_NUMBER ); + return $token ? (int) $token->get_value() : null; + } + + /** + * Get a numeric precision/scale SQL fragment. + * + * @param WP_Parser_Node $data_type Data type node. + * @return string Precision fragment, including parentheses, or empty string. + */ + private function get_numeric_precision_fragment( WP_Parser_Node $data_type ): string { + $precision = $data_type->get_first_descendant_node( 'precision' ); + if ( $precision ) { + return $this->get_node_value( $precision ); + } + + $field_length = $data_type->get_first_descendant_node( 'fieldLength' ); + return $field_length ? $this->get_node_value( $field_length ) : ''; + } + + /** + * Get a temporal fractional seconds precision SQL fragment. + * + * @param WP_Parser_Node $data_type Data type node. + * @return string Precision fragment, including parentheses, or empty string. + */ + private function get_temporal_precision_fragment( WP_Parser_Node $data_type ): string { + $tokens = $data_type->get_descendant_tokens(); + for ( $i = 0; $i + 2 < count( $tokens ); ++$i ) { + if ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id + && WP_MySQL_Lexer::INT_NUMBER === $tokens[ $i + 1 ]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i + 2 ]->id + ) { + $fsp = (int) $tokens[ $i + 1 ]->get_value(); + return $fsp >= 0 && $fsp <= 6 ? sprintf( '(%d)', $fsp ) : ''; + } + } + + return ''; + } + + /** + * Quote a PostgreSQL identifier. + * + * @param string $identifier Identifier. + * @return string Quoted identifier. + */ + private function quote_identifier( string $identifier ): string { + return WP_PostgreSQL_Connection::quote_identifier_value( $identifier ); + } + + /** + * Quote a PostgreSQL string literal. + * + * @param string $value Literal value. + * @return string Quoted literal. + */ + private function quote_string_literal( string $value ): string { + if ( false !== strpos( $value, "\0" ) ) { + throw new InvalidArgumentException( 'PostgreSQL string literals cannot contain NUL bytes.' ); + } + + return "'" . str_replace( "'", "''", $value ) . "'"; + } +} diff --git a/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php new file mode 100644 index 000000000..9fcd9fdbe --- /dev/null +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -0,0 +1,55074 @@ + array( + 'REAL_AS_FLOAT', + 'PIPES_AS_CONCAT', + 'ANSI_QUOTES', + 'IGNORE_SPACE', + 'ONLY_FULL_GROUP_BY', + ), + 'TRADITIONAL' => array( + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'NO_ZERO_IN_DATE', + 'NO_ZERO_DATE', + 'ERROR_FOR_DIVISION_BY_ZERO', + 'NO_ENGINE_SUBSTITUTION', + ), + ); + + private const MYSQL_SESSION_USER = 'root@%'; + + private const MYSQL_CONNECTION_ID = '1'; + + private const MYSQL_SHOW_GRANTS_COLUMN = 'Grants for root@%'; + + private const MYSQL_SHOW_GRANTS_VALUE = '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'; + + /** + * Prefix for encoded MySQL text bytes PostgreSQL text cannot store directly. + */ + private const MYSQL_TEXT_ENCODING_PREFIX = "\xEE\x80\x80WP_MYSQL_TEXT_V1:"; + + /** + * Hash context for the MySQL text encoding envelope. + */ + private const MYSQL_TEXT_ENCODING_HASH_CONTEXT = 'wp-mysql-text-v1:'; + + /** + * Bit mask for the base PDO fetch style without fetchAll() grouping flags. + */ + private const PDO_FETCH_STYLE_MASK = 0x0f; + + /** + * Hidden column used to carry FOUND_ROWS() accounting with a paged result. + */ + private const SQL_CALC_FOUND_ROWS_WINDOW_COLUMN = '__wp_pg_found_rows'; + + /** + * Maximum number of exact MySQL query translations cached per connection. + */ + private const MYSQL_QUERY_TRANSLATION_CACHE_LIMIT = 256; + + /** + * Practical SQL substring length cap for effectively unbounded GROUP_CONCAT. + */ + private const MYSQL_GROUP_CONCAT_MAX_LEN_SQL_LIMIT = 2147483647; + + /** + * Private helper used to emulate MySQL JSON_VALID(). + */ + private const MYSQL_JSON_VALID_FUNCTION = '__wp_pg_mysql_json_valid'; + + /** + * Private helper used to validate MySQL temporal values at runtime. + */ + private const MYSQL_VALIDATE_TEMPORAL_FUNCTION = '__wp_pg_mysql_validate_temporal'; + + /** + * PostgreSQL server version string. + * + * @var string + */ + public $client_info; + + /** + * MySQL server version emulated by the driver. + * + * @var int + */ + private $mysql_version; + + /** + * PostgreSQL connection. + * + * @var WP_PostgreSQL_Connection + */ + private $connection; + + /** + * Configured main MySQL-facing database name. + * + * @var string + */ + private $main_db_name; + + /** + * Current MySQL-facing database name. + * + * @var string + */ + private $db_name; + + /** + * Result of the last query. + * + * @var mixed + */ + private $last_result; + + /** + * Column metadata for the last result set. + * + * @var array + */ + private $last_column_meta = array(); + + /** + * Number of exposed columns for the last result set. + * + * This is tracked separately so callers can ask for the column count without + * forcing PDO metadata normalization for common WordPress result fetches. + * + * @var int + */ + private $last_column_count = 0; + + /** + * Statement whose column metadata can be normalized lazily. + * + * @var PDOStatement|null + */ + private $last_column_meta_statement = null; + + /** + * Lazy metadata column names hidden from MySQL-facing callers. + * + * @var array + */ + private $last_column_meta_excluded_names = array(); + + /** + * Incoming MySQL-dialect query for the last request. + * + * @var string|null + */ + private $last_mysql_query; + + /** + * PostgreSQL queries executed for the last request. + * + * @var array + */ + private $last_postgresql_queries = array(); + + /** + * Whether the MySQL metadata side tables were ensured for this connection. + * + * @var bool + */ + private $mysql_schema_metadata_tables_ensured = false; + + /** + * Resolved backend schema names for MySQL table introspection. + * + * @var array + */ + private $mysql_table_schema_introspection_cache = array(); + + /** + * Ordered DML column metadata rows keyed by backend schema and table. + * + * @var array + */ + private $mysql_dml_column_metadata_cache = array(); + + /** + * DML identity metadata rows keyed by backend schema and table. + * + * @var array + */ + private $mysql_dml_identity_column_metadata_cache = array(); + + /** + * MySQL column type metadata keyed by backend schema, table, and column. + * + * @var array> + */ + private $mysql_table_column_type_cache = array(); + + /** + * MySQL column collation metadata keyed by backend schema, table, and column. + * + * @var array> + */ + private $mysql_table_column_collation_cache = array(); + + /** + * Stored MySQL column metadata existence keyed by backend schema and table. + * + * @var array + */ + private $mysql_table_has_column_metadata_cache = array(); + + /** + * Stored MySQL column names keyed by backend schema, table, and requested column. + * + * @var array> + */ + private $mysql_table_column_name_cache = array(); + + /** + * Cached MySQL upsert conflict targets keyed by table and inserted columns. + * + * @var array + */ + private $mysql_upsert_conflict_target_cache = array(); + + /** + * Cached MySQL introspection results keyed by query shape. + * + * @var array + */ + private $mysql_introspection_result_cache = array(); + + /** + * Cached exact MySQL SELECT translations keyed by query hash. + * + * @var array + */ + private $mysql_select_translation_cache = array(); + + /** + * Cached exact SQL_CALC_FOUND_ROWS count SQL keyed by source query hash. + * + * @var array + */ + private $mysql_sql_calc_found_rows_count_query_cache = array(); + + /** + * Whether the MySQL JSON_VALID() helper function is available on this connection. + * + * @var bool + */ + private $postgresql_mysql_json_valid_function_ensured = false; + + /** + * Whether the MySQL temporal validation helper function is available on this connection. + * + * @var bool + */ + private $postgresql_mysql_validate_temporal_function_ensured = false; + + /** + * Most recently tokenized MySQL query. + * + * @var string|null + */ + private $mysql_token_cache_query = null; + + /** + * SQL mode string for the most recently tokenized MySQL query. + * + * @var string|null + */ + private $mysql_token_cache_sql_mode = null; + + /** + * Token stream for the most recently tokenized MySQL query. + * + * @var WP_MySQL_Token[] + */ + private $mysql_token_cache_tokens = array(); + + /** + * FOUND_ROWS() value for the last SQL_CALC_FOUND_ROWS query. + * + * @var int + */ + private $last_found_rows = 0; + + /** + * MySQL-compatible insert ID for the last successful insert-like query. + * + * @var int|string + */ + private $last_insert_id = 0; + + /** + * LAST_INSERT_ID(expr) value staged while translating a standalone SELECT. + * + * @var int|null + */ + private $mysql_last_insert_id_assignment_value = null; + + /** + * Whether LAST_INSERT_ID(expr) translation is enabled for the current SELECT. + * + * @var bool + */ + private $mysql_last_insert_id_assignment_translation_enabled = false; + + /** + * MySQL-compatible ROW_COUNT() value preserved while per-query state resets. + * + * @var int + */ + private $last_row_count = 0; + + /** + * MySQL-compatible session SQL mode state. + * + * @var string[] + */ + private $active_sql_modes = self::DEFAULT_MYSQL_SQL_MODES; + + /** + * MySQL-compatible session character set state. + * + * @var string + */ + private $charset = self::DEFAULT_MYSQL_CHARSET; + + /** + * MySQL-compatible session collation state. + * + * @var string + */ + private $collation = self::DEFAULT_MYSQL_COLLATION; + + /** + * MySQL-compatible session variable overrides. + * + * @var array + */ + private $mysql_session_variable_values = array(); + + /** + * MySQL-compatible global variable overrides. + * + * @var array + */ + private $mysql_global_variable_values = array(); + + /** + * MySQL-compatible user variables. + * + * @var array + */ + private $mysql_user_variables = array(); + + /** + * Narrow in-memory procedure registry for WordPress mysqli compatibility tests. + * + * @var array + */ + private $procedures = array(); + + /** + * Constructor. + * + * @param WP_PostgreSQL_Connection $connection PostgreSQL connection. + * @param string $database MySQL-facing database name. + * @param int $mysql_version MySQL version to emulate. + */ + public function __construct( + WP_PostgreSQL_Connection $connection, + string $database, + int $mysql_version = 80038 + ) { + $this->connection = $connection; + $this->main_db_name = $database; + $this->db_name = $database; + $this->mysql_version = $mysql_version; + $this->client_info = $this->read_server_version(); + + $connection->get_pdo()->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + } + + /** + * Get the PostgreSQL connection instance. + * + * @return WP_PostgreSQL_Connection + */ + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } + + /** + * Get the PostgreSQL server version. + * + * @return string + */ + public function get_postgresql_version(): string { + return $this->client_info; + } + + /** + * Get the last executed MySQL query. + * + * @return string|null + */ + public function get_last_mysql_query(): ?string { + return $this->last_mysql_query; + } + + /** + * Get backend queries executed for the last MySQL query. + * + * @return array + */ + public function get_last_postgresql_queries(): array { + return $this->last_postgresql_queries; + } + + /** + * Get the auto-increment value generated for the last query. + * + * @return int|string + */ + public function get_insert_id() { + return is_numeric( $this->last_insert_id ) ? (int) $this->last_insert_id : $this->last_insert_id; + } + + /** + * Set the emulated MySQL session SQL mode. + * + * @param string $sql_mode Comma-separated SQL mode string. + */ + public function set_sql_mode( string $sql_mode ): void { + $this->active_sql_modes = $this->normalize_mysql_sql_modes( $sql_mode ); + unset( $this->mysql_session_variable_values['sql_mode'] ); + $this->clear_mysql_token_cache(); + $this->clear_mysql_query_translation_caches(); + } + + /** + * Get the emulated MySQL session SQL mode. + * + * @return string Comma-separated SQL mode string. + */ + public function get_sql_mode(): string { + return implode( ',', $this->active_sql_modes ); + } + + /** + * Check if a specific SQL mode is active. + * + * @param string $mode SQL mode name. + * @return bool Whether the mode is active. + */ + public function is_sql_mode_active( string $mode ): bool { + return in_array( strtoupper( $mode ), $this->active_sql_modes, true ); + } + + /** + * Normalize a MySQL SQL mode assignment to a canonical mode list. + * + * @param string $sql_mode Comma-separated SQL mode assignment value. + * @return string[] Normalized mode names. + */ + private function normalize_mysql_sql_modes( string $sql_mode ): array { + $sql_mode = trim( $sql_mode, "'\"` \t\n\r\0\x0B" ); + if ( '' === $sql_mode || '0' === $sql_mode ) { + return array(); + } + + if ( 'DEFAULT' === strtoupper( $sql_mode ) ) { + return self::DEFAULT_MYSQL_SQL_MODES; + } + + $normalized = array(); + foreach ( explode( ',', $sql_mode ) as $mode ) { + $mode = strtoupper( trim( $mode, "'\"` \t\n\r\0\x0B" ) ); + if ( '' === $mode ) { + continue; + } + + $modes = self::MYSQL_SQL_MODE_COMPOSITES[ $mode ] ?? array( $mode ); + foreach ( $modes as $expanded_mode ) { + if ( ! in_array( $expanded_mode, $normalized, true ) ) { + $normalized[] = $expanded_mode; + } + } + } + + return $normalized; + } + + /** + * Set the emulated MySQL session charset/collation. + * + * @param string $charset MySQL charset. + * @param string|null $collation Optional MySQL collation. + */ + public function set_charset( string $charset, ?string $collation = null ): void { + if ( 'default' === $this->normalize_mysql_charset_name( $charset ) ) { + $this->charset = self::DEFAULT_MYSQL_CHARSET; + $this->collation = self::DEFAULT_MYSQL_COLLATION; + $this->sync_mysql_charset_session_variables(); + return; + } + + $this->charset = $this->normalize_mysql_charset_name( $charset ); + $this->collation = null === $collation || '' === $collation + ? $this->get_default_mysql_collation_for_charset( $this->charset ) + : $this->normalize_mysql_collation_name( $collation ); + $this->sync_mysql_charset_session_variables(); + } + + /** + * Get the emulated MySQL session charset. + * + * @return string MySQL charset. + */ + public function get_charset(): string { + return $this->charset; + } + + /** + * Get the emulated MySQL session collation. + * + * @return string MySQL collation. + */ + public function get_collation(): string { + return $this->collation; + } + + /** + * Execute a query. + * + * @param string $query Full SQL statement string. + * @param int $fetch_mode PDO fetch mode. Default is PDO::FETCH_OBJ. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed Return value, depending on the query type. + */ + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->last_row_count = $this->get_mysql_row_count_from_last_result(); + $this->reset_query_state(); + $this->last_result = -1; + $this->last_mysql_query = $query; + + $runtime_setting_result = $this->execute_mysql_runtime_setting_query( $query ); + if ( null !== $runtime_setting_result ) { + return $runtime_setting_result; + } + + $use_database_name = $this->get_mysql_use_database_name( $query ); + if ( null !== $use_database_name ) { + return $this->execute_mysql_use_statement( $use_database_name ); + } + + $transaction_control_query = $this->get_mysql_transaction_control_query( $query ); + if ( null !== $transaction_control_query ) { + return $this->execute_mysql_transaction_control_query( $transaction_control_query ); + } + + $savepoint_query = $this->get_mysql_savepoint_query( $query ); + if ( null !== $savepoint_query ) { + return $this->execute_mysql_savepoint_query( $savepoint_query ); + } + + $procedure_result = $this->handle_mysql_procedure_query( $query, $fetch_mode, ...$fetch_mode_args ); + if ( null !== $procedure_result ) { + return $procedure_result; + } + + $mysql_variable_select_query = $this->get_mysql_variable_select_query( $query ); + if ( null !== $mysql_variable_select_query ) { + return $this->execute_mysql_variable_select_query( + $mysql_variable_select_query, + $fetch_mode, + ...$fetch_mode_args + ); + } + + $database_function_column = $this->get_mysql_database_function_select_column( $query ); + if ( null !== $database_function_column ) { + return $this->set_mysql_static_show_result( + array( $database_function_column ), + array( array( $database_function_column => $this->db_name ) ), + $fetch_mode, + ...$fetch_mode_args + ); + } + + $show_variables_query = $this->get_show_variables_query( $query ); + if ( null !== $show_variables_query ) { + return $this->execute_show_variables_query( $show_variables_query, $fetch_mode, ...$fetch_mode_args ); + } + + $show_character_set_query = $this->get_show_character_set_query( $query ); + if ( null !== $show_character_set_query ) { + return $this->execute_show_character_set_query( $show_character_set_query, $fetch_mode, ...$fetch_mode_args ); + } + + $show_collation_query = $this->get_show_collation_query( $query ); + if ( null !== $show_collation_query ) { + return $this->execute_show_collation_query( $show_collation_query, $fetch_mode, ...$fetch_mode_args ); + } + + $show_databases_query = $this->get_show_databases_query( $query ); + if ( null !== $show_databases_query ) { + return $this->execute_show_databases_query( $show_databases_query, $fetch_mode, ...$fetch_mode_args ); + } + + $show_create_database_query = $this->get_show_create_database_query( $query ); + if ( null !== $show_create_database_query ) { + return $this->execute_show_create_database_query( $show_create_database_query, $fetch_mode, ...$fetch_mode_args ); + } + + $show_engines_query = $this->get_show_engines_query( $query ); + if ( null !== $show_engines_query ) { + return $this->execute_show_engines_query( $show_engines_query, $fetch_mode, ...$fetch_mode_args ); + } + + $show_plugins_query = $this->get_show_plugins_query( $query ); + if ( null !== $show_plugins_query ) { + return $this->execute_show_plugins_query( $show_plugins_query, $fetch_mode, ...$fetch_mode_args ); + } + + $show_grants_query = $this->get_show_grants_query( $query ); + if ( null !== $show_grants_query ) { + return $this->execute_show_grants_query( $fetch_mode, ...$fetch_mode_args ); + } + + $show_status_query = $this->get_show_status_query( $query ); + if ( null !== $show_status_query ) { + return $this->execute_show_status_query( $show_status_query, $fetch_mode, ...$fetch_mode_args ); + } + + $show_warnings_query = $this->get_show_diagnostics_query( $query, WP_MySQL_Lexer::WARNINGS_SYMBOL, 'warnings' ); + if ( null !== $show_warnings_query ) { + return $this->execute_show_diagnostics_query( $show_warnings_query, $fetch_mode, ...$fetch_mode_args ); + } + + $show_errors_query = $this->get_show_diagnostics_query( $query, WP_MySQL_Lexer::ERRORS_SYMBOL, 'errors' ); + if ( null !== $show_errors_query ) { + return $this->execute_show_diagnostics_query( $show_errors_query, $fetch_mode, ...$fetch_mode_args ); + } + + $show_processlist_query = $this->get_show_processlist_query( $query ); + if ( null !== $show_processlist_query ) { + return $this->execute_show_processlist_query( $show_processlist_query, $fetch_mode, ...$fetch_mode_args ); + } + + if ( $this->contains_unsupported_mysql_group_concat_function_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL runtime function form.' ); + } + + if ( $this->contains_unsupported_mysql_fulltext_search_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL full-text search syntax.' ); + } + + $direct_information_schema_cte_translated = false; + $direct_information_schema_cte_query = $this->translate_direct_information_schema_cte_select_query( $query ); + if ( null !== $direct_information_schema_cte_query ) { + $query = $direct_information_schema_cte_query; + $direct_information_schema_cte_translated = true; + } + + if ( ! $direct_information_schema_cte_translated && $this->should_reject_information_schema_backend_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + $lock_tables_query = $this->get_mysql_lock_tables_query( $query ); + if ( null !== $lock_tables_query ) { + return $this->execute_mysql_lock_tables_query( $lock_tables_query ); + } + + $flush_query = $this->get_mysql_flush_query( $query ); + if ( null !== $flush_query ) { + return $this->execute_mysql_admin_noop_query(); + } + + $truncate_table_query = $this->get_mysql_truncate_table_query( $query ); + if ( null !== $truncate_table_query ) { + return $this->execute_mysql_truncate_table_query( $truncate_table_query ); + } + + if ( $this->is_found_rows_query( $query ) ) { + $this->last_result = array( (object) array( 'FOUND_ROWS()' => (string) $this->last_found_rows ) ); + $this->last_column_meta = array( + array( + 'name' => 'FOUND_ROWS()', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'FOUND_ROWS()', + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => 63, + 'mysqli:flags' => 0, + 'mysqli:type' => 8, + 'len' => 20, + 'precision' => 0, + 'native_type' => 'integer', + ), + ); + return $this->last_result; + } + + $create_table_select_query = $this->translate_mysql_create_table_select_query( $query ); + if ( null !== $create_table_select_query ) { + if ( $create_table_select_query['noop'] ) { + return $this->execute_mysql_admin_noop_query(); + } + + $result = $this->execute_postgresql_statements( $create_table_select_query['statements'] ); + $metadata_schema = $create_table_select_query['temporary'] + ? $this->get_temporary_schema_for_metadata_table( $create_table_select_query['table'] ) + : $create_table_select_query['schema']; + if ( null !== $create_table_select_query['metadata_query'] ) { + $this->store_mysql_schema_metadata_for_schema( + $create_table_select_query['metadata_query'], + $create_table_select_query['temporary'] + ? array( $this, 'get_temporary_schema_for_metadata_table' ) + : $metadata_schema + ); + } else { + $this->store_mysql_create_table_select_metadata( + $metadata_schema, + $create_table_select_query['table'], + $create_table_select_query['table_comment'] + ); + } + return $result; + } + + $create_table_like_query = $this->translate_mysql_create_table_like_query( $query ); + if ( null !== $create_table_like_query ) { + if ( $create_table_like_query['noop'] ) { + return $this->execute_mysql_admin_noop_query(); + } + + $result = $this->execute_postgresql_statements( $create_table_like_query['statements'] ); + if ( $create_table_like_query['temporary'] ) { + $this->store_mysql_schema_metadata_for_schema( + $create_table_like_query['metadata_query'], + array( $this, 'get_temporary_schema_for_metadata_table' ) + ); + } else { + $this->store_mysql_schema_metadata_for_schema( + $create_table_like_query['metadata_query'], + $create_table_like_query['schema'] + ); + $this->sync_mysql_on_update_current_timestamp_triggers_for_create_query( $create_table_like_query['metadata_query'] ); + } + return $result; + } + + if ( $this->contains_unsupported_mysql_create_table_column_attribute_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE column attribute.' ); + } + + if ( $this->is_create_table_query( $query ) ) { + $this->validate_mysql_create_table_target_database( $query ); + if ( $this->mysql_create_table_if_not_exists_target_exists( $query ) ) { + return $this->execute_mysql_admin_noop_query(); + } + + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ); + $result = $this->execute_postgresql_statements( $translator->translate_schema( $query ) ); + if ( $this->is_temporary_create_table_query( $query ) ) { + $this->store_mysql_temporary_schema_metadata( $query ); + } else { + $this->store_mysql_schema_metadata( $query ); + $this->sync_mysql_on_update_current_timestamp_triggers_for_create_query( $query ); + } + return $result; + } + + $create_view_query = $this->translate_mysql_create_view_query( $query ); + if ( null !== $create_view_query ) { + return $this->execute_postgresql_statements( $create_view_query['statements'] ); + } + + $create_index_query = $this->translate_mysql_create_index_query( $query ); + if ( null !== $create_index_query ) { + $this->execute_postgresql_statements( $create_index_query['statements'] ); + $this->apply_mysql_create_index_metadata( $create_index_query['metadata'] ); + $this->last_result = 0; + return $this->last_result; + } + $unsupported_create_statement = $this->get_unsupported_mysql_create_statement_message( $query ); + if ( null !== $unsupported_create_statement ) { + throw new InvalidArgumentException( $unsupported_create_statement ); + } + + $alter_query = $this->translate_mysql_dbdelta_alter_table_query( $query ); + if ( null !== $alter_query ) { + $result = $this->execute_postgresql_statements( $alter_query['statements'] ); + $this->apply_mysql_dbdelta_alter_metadata( $alter_query['metadata'] ); + if ( + $this->mysql_dbdelta_alter_metadata_has_operation( $alter_query['metadata'], 'drop_index' ) + || $this->mysql_dbdelta_alter_metadata_has_operation( $alter_query['metadata'], 'rename_table' ) + || $this->mysql_dbdelta_alter_metadata_has_operation( $alter_query['metadata'], 'set_auto_increment' ) + ) { + $this->last_result = 0; + return $this->last_result; + } + return $result; + } + if ( $this->is_mysql_alter_table_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $alter_view_query = $this->translate_mysql_alter_view_query( $query ); + if ( null !== $alter_view_query ) { + return $this->execute_postgresql_statements( $alter_view_query['statements'] ); + } + + $drop_query = $this->translate_mysql_drop_table_query( $query ); + if ( null !== $drop_query ) { + $this->execute_postgresql_statements( $drop_query['statements'] ); + $this->maybe_clear_mysql_schema_metadata_table_state( $drop_query['tables'] ); + $this->delete_mysql_schema_metadata_for_table_targets( $drop_query['metadata_targets'] ); + $this->last_result = 0; + return $this->last_result; + } + + $drop_view_query = $this->translate_mysql_drop_view_query( $query ); + if ( null !== $drop_view_query ) { + $this->execute_postgresql_statements( $drop_view_query['statements'] ); + $this->last_result = 0; + return $this->last_result; + } + + $drop_index_query = $this->translate_mysql_drop_index_query( $query ); + if ( null !== $drop_index_query ) { + $this->execute_postgresql_statements( $drop_index_query['statements'] ); + $this->apply_mysql_drop_index_metadata( $drop_index_query['metadata'] ); + $this->last_result = 0; + return $this->last_result; + } + $unsupported_drop_statement = $this->get_unsupported_mysql_drop_statement_message( $query ); + if ( null !== $unsupported_drop_statement ) { + throw new InvalidArgumentException( $unsupported_drop_statement ); + } + + $rename_table_query = $this->translate_mysql_rename_table_query( $query ); + if ( null !== $rename_table_query ) { + $this->execute_postgresql_statements( $rename_table_query['statements'] ); + $this->apply_mysql_rename_table_metadata( $rename_table_query['metadata'] ); + $this->last_result = 0; + return $this->last_result; + } + if ( $this->is_mysql_rename_table_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + + $describe_table_reference = $this->get_describe_table_reference( $query ); + if ( null !== $describe_table_reference ) { + return $this->execute_describe_query( + $describe_table_reference['schema'], + $describe_table_reference['table'], + $fetch_mode, + ...$fetch_mode_args + ); + } + + $show_tables_query = $this->get_show_tables_query( $query ); + if ( null !== $show_tables_query ) { + return $this->execute_show_tables_query( + $show_tables_query['full'], + $show_tables_query['schema'], + $show_tables_query['database'], + $show_tables_query['like'], + $show_tables_query['where'], + $fetch_mode, + ...$fetch_mode_args + ); + } + + $show_table_status_query = $this->get_show_table_status_query( $query ); + if ( null !== $show_table_status_query ) { + return $this->execute_show_table_status_query( + $show_table_status_query, + $fetch_mode, + ...$fetch_mode_args + ); + } + + $show_create_table_query = $this->get_show_create_table_query( $query ); + if ( null !== $show_create_table_query ) { + return $this->execute_show_create_table_query( + $show_create_table_query, + $fetch_mode, + ...$fetch_mode_args + ); + } + + $show_columns_query = $this->get_show_columns_query( $query ); + if ( null !== $show_columns_query ) { + return $this->execute_show_columns_query( + $show_columns_query['schema'], + $show_columns_query['table'], + $show_columns_query['full'], + $show_columns_query['like'], + $show_columns_query['where'], + $fetch_mode, + ...$fetch_mode_args + ); + } + + $show_index_query = $this->get_show_index_query( $query ); + if ( null !== $show_index_query ) { + return $this->execute_show_index_query( + $show_index_query['schema'], + $show_index_query['table'], + $show_index_query['where'], + $fetch_mode, + ...$fetch_mode_args + ); + } + + $table_administration_query = $this->get_mysql_table_administration_query( $query ); + if ( null !== $table_administration_query ) { + return $this->execute_mysql_table_administration_query( + $table_administration_query, + $fetch_mode, + ...$fetch_mode_args + ); + } + + $translated_for_postgresql = $direct_information_schema_cte_translated; + $dml_identity_repair_query = null; + $last_insert_id_after_success = null; + $mysql_update_ignore_query = $this->is_mysql_update_ignore_query( $query ); + + $translated_query = $this->translate_wordpress_options_regexp_delete_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + + $translated_query = $this->translate_wordpress_expired_transients_delete_query( $query ); + if ( null !== $translated_query ) { + return $this->execute_postgresql_statements( array( $translated_query ) ); + } + + $translated_query = $this->translate_mysql_left_join_orphan_delete_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + + $multi_target_delete_query = $this->translate_mysql_multi_target_delete_query( $query ); + if ( null !== $multi_target_delete_query ) { + return $this->execute_mysql_multi_target_delete_query( $multi_target_delete_query ); + } + + $translated_query = $this->translate_mysql_single_target_join_delete_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + + $translated_query = $this->translate_simple_mysql_delete_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + if ( ! $translated_for_postgresql && $this->is_unsupported_mysql_delete_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported DELETE statement.' ); + } + + $upsert_query = $this->translate_mysql_on_duplicate_key_update_query( $query ); + if ( null !== $upsert_query ) { + if ( ! empty( $upsert_query['upsert_select_materialized'] ) ) { + return $this->execute_materialized_mysql_upsert_select_statements( $upsert_query ); + } + if ( isset( $upsert_query['statements'] ) && is_array( $upsert_query['statements'] ) ) { + return $this->execute_translated_dml_statements( $upsert_query ); + } + $query = $upsert_query['sql']; + $dml_identity_repair_query = $upsert_query; + $translated_for_postgresql = true; + } elseif ( $this->is_unsupported_mysql_on_duplicate_key_update_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported ON DUPLICATE KEY UPDATE statement.' ); + } + + $replace_return_value = null; + $replace_query = $this->translate_simple_mysql_replace_query( $query ); + if ( null !== $replace_query ) { + if ( null !== $replace_query['conflict_column'] && empty( $replace_query['replace_select_materialized'] ) ) { + $replace_return_value = $this->get_mysql_replace_return_value( $replace_query ); + } + if ( isset( $replace_query['statements'] ) && is_array( $replace_query['statements'] ) ) { + if ( ! empty( $replace_query['replace_select_materialized'] ) ) { + return $this->execute_materialized_mysql_replace_select_statements( $replace_query ); + } + return $this->execute_translated_dml_statements( $replace_query, $replace_return_value ); + } + $query = $replace_query['sql']; + $dml_identity_repair_query = $replace_query; + $translated_for_postgresql = true; + } elseif ( $this->is_mysql_replace_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported REPLACE statement.' ); + } + + $insert_query = $this->translate_simple_mysql_insert_query( $query ); + if ( null !== $insert_query ) { + $query = $insert_query['sql']; + $dml_identity_repair_query = $insert_query; + $translated_for_postgresql = true; + } elseif ( ! $translated_for_postgresql && $this->is_unsupported_mysql_insert_set_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported INSERT statement.' ); + } + + $insert_select_query = $this->translate_simple_mysql_insert_select_query( $query ); + if ( null !== $insert_select_query ) { + $query = $insert_select_query['sql']; + $dml_identity_repair_query = $insert_select_query; + $translated_for_postgresql = true; + } elseif ( ! $translated_for_postgresql && $this->is_unsupported_mysql_insert_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported INSERT statement.' ); + } + + $cte_update_query = $this->translate_mysql_cte_prefixed_update_query( $query ); + if ( null !== $cte_update_query ) { + $query = $cte_update_query; + $translated_for_postgresql = true; + } + + $multi_target_update_query = $this->translate_mysql_multi_target_update_query( $query ); + if ( null !== $multi_target_update_query ) { + if ( $mysql_update_ignore_query ) { + return $this->execute_mysql_update_ignore_query( $multi_target_update_query, true ); + } + return $this->execute_mysql_multi_target_update_query( $multi_target_update_query ); + } + + $translated_query = $this->translate_simple_mysql_update_query( $query ); + if ( null !== $translated_query ) { + if ( $mysql_update_ignore_query ) { + return $this->execute_mysql_update_ignore_query( $translated_query ); + } + $query = $translated_query; + $translated_for_postgresql = true; + } elseif ( + ! $translated_for_postgresql + && ( + $this->is_unsupported_mysql_update_query( $query ) + || $this->is_unsupported_mysql_cte_prefixed_update_query( $query ) + ) + ) { + throw new InvalidArgumentException( 'Unsupported UPDATE statement.' ); + } + + $is_sql_calc_found_rows_query = $this->is_sql_calc_found_rows_select_query( $query ); + $sql_calc_found_rows_query = $is_sql_calc_found_rows_query ? $query : null; + $sql_calc_found_rows_window = false; + + if ( ! $translated_for_postgresql ) { + $translated_query = null !== $sql_calc_found_rows_query && $this->is_sql_calc_found_rows_window_fetch_mode( $fetch_mode ) + ? $this->translate_sql_calc_found_rows_window_select_query( $query ) + : null; + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + $sql_calc_found_rows_window = true; + } elseif ( $this->is_mysql_select_translation_cacheable_query( $query ) ) { + $select_translation = $this->get_mysql_select_query_translation( $query ); + $query = $select_translation['sql']; + $translated_for_postgresql = $select_translation['translated']; + if ( array_key_exists( 'last_insert_id', $select_translation ) ) { + $last_insert_id_after_success = $select_translation['last_insert_id']; + } + } elseif ( $this->is_mysql_top_level_select_query( $query ) ) { + $select_translation = $this->translate_mysql_select_query_for_postgresql( $query ); + $query = $select_translation['sql']; + $translated_for_postgresql = $select_translation['translated']; + if ( array_key_exists( 'last_insert_id', $select_translation ) ) { + $last_insert_id_after_success = $select_translation['last_insert_id']; + } + } else { + $translated_query = $this->translate_mysql_compatible_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + } + } + } + + if ( $this->contains_mysql_index_hint_syntax( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL index hint syntax.' ); + } + + if ( $this->contains_unsupported_mysql_date_arithmetic_function_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL date arithmetic statement.' ); + } + + if ( $this->contains_unsupported_mysql_date_format_function_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL runtime function form.' ); + } + + if ( $this->contains_unsupported_mysql_rand_function_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL runtime function form.' ); + } + + if ( $this->contains_unsupported_mysql_convert_function_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL runtime function form.' ); + } + + if ( $this->contains_unsupported_mysql_fulltext_search_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL full-text search syntax.' ); + } + + if ( $this->contains_unsupported_mysql_common_function_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL runtime function form.' ); + } + + if ( $this->contains_unsupported_mysql_group_concat_function_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL runtime function form.' ); + } + + if ( $this->contains_unsupported_mysql_week_function_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported MySQL WEEK() mode.' ); + } + + $unsupported_mysql_administration_statement = $this->get_unsupported_mysql_administration_statement_message( $query ); + if ( null !== $unsupported_mysql_administration_statement ) { + throw new InvalidArgumentException( $unsupported_mysql_administration_statement ); + } + + $this->ensure_postgresql_runtime_helpers_for_query( $query ); + $stmt = $this->connection->query( $query ); + $this->last_postgresql_queries[] = array( + 'sql' => $query, + 'params' => array(), + ); + + $affected_rows = $stmt->rowCount(); + + $column_count = $stmt->columnCount(); + if ( $column_count > 0 ) { + $this->set_lazy_last_column_meta( $stmt, $column_count ); + $this->last_result = $this->decode_postgresql_text_for_mysql_in_result( + $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ) + ); + if ( $sql_calc_found_rows_window ) { + $found_rows = $this->extract_sql_calc_found_rows_window_result( $this->last_result ); + $this->remove_sql_calc_found_rows_window_column_meta(); + $this->last_found_rows = null === $found_rows && null !== $sql_calc_found_rows_query + ? $this->execute_sql_calc_found_rows_count_query( $sql_calc_found_rows_query ) + : (int) $found_rows; + } elseif ( null !== $sql_calc_found_rows_query ) { + $this->last_found_rows = $this->execute_sql_calc_found_rows_count_query( $sql_calc_found_rows_query ); + } + } else { + $this->clear_last_column_meta(); + $this->last_result = $affected_rows; + if ( null !== $replace_return_value ) { + $this->last_result = $replace_return_value; + } + } + + if ( null !== $last_insert_id_after_success ) { + $this->last_insert_id = $last_insert_id_after_success; + } + + if ( null !== $dml_identity_repair_query ) { + $this->set_last_insert_id_after_dml_success( $dml_identity_repair_query, $affected_rows ); + $this->repair_dml_identity_sequences_after_success( $dml_identity_repair_query, $affected_rows ); + } + + return $this->last_result; + } + + /** + * Get an explicit unsupported error for unclaimed MySQL administration SQL. + * + * Supported SHOW/table-administration forms are dispatched before this guard. + * If one of these MySQL-only statement families reaches the backend fallback, + * fail closed rather than letting PostgreSQL parse incompatible SQL. + * + * @param string $query MySQL query. + * @return string|null Unsupported error message, or null when not guarded. + */ + private function get_unsupported_mysql_administration_statement_message( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + switch ( $tokens[0]->id ) { + case WP_MySQL_Lexer::SHOW_SYMBOL: + return 'Unsupported SHOW statement.'; + + case WP_MySQL_Lexer::ANALYZE_SYMBOL: + case WP_MySQL_Lexer::CHECK_SYMBOL: + case WP_MySQL_Lexer::OPTIMIZE_SYMBOL: + case WP_MySQL_Lexer::REPAIR_SYMBOL: + return 'Unsupported table administration statement.'; + + case WP_MySQL_Lexer::CHECKSUM_SYMBOL: + return 'Unsupported CHECKSUM TABLE statement.'; + + case WP_MySQL_Lexer::FLUSH_SYMBOL: + return 'Unsupported FLUSH statement.'; + + case WP_MySQL_Lexer::KILL_SYMBOL: + return 'Unsupported KILL statement.'; + + case WP_MySQL_Lexer::CACHE_SYMBOL: + return 'Unsupported CACHE INDEX statement.'; + + case WP_MySQL_Lexer::LOAD_SYMBOL: + return 'Unsupported LOAD statement.'; + + case WP_MySQL_Lexer::BINLOG_SYMBOL: + return 'Unsupported BINLOG statement.'; + + case WP_MySQL_Lexer::SHUTDOWN_SYMBOL: + return 'Unsupported SHUTDOWN statement.'; + + case WP_MySQL_Lexer::GRANT_SYMBOL: + return 'Unsupported GRANT statement.'; + + case WP_MySQL_Lexer::REVOKE_SYMBOL: + return 'Unsupported REVOKE statement.'; + + case WP_MySQL_Lexer::RESET_SYMBOL: + return 'Unsupported RESET statement.'; + + case WP_MySQL_Lexer::PURGE_SYMBOL: + return 'Unsupported PURGE statement.'; + + case WP_MySQL_Lexer::INSTALL_SYMBOL: + return 'Unsupported INSTALL statement.'; + + case WP_MySQL_Lexer::UNINSTALL_SYMBOL: + return 'Unsupported UNINSTALL statement.'; + + case WP_MySQL_Lexer::ALTER_SYMBOL: + switch ( $tokens[1]->id ?? null ) { + case WP_MySQL_Lexer::TABLE_SYMBOL: + return null; + + case WP_MySQL_Lexer::DATABASE_SYMBOL: + return 'Unsupported ALTER DATABASE statement.'; + + case WP_MySQL_Lexer::EVENT_SYMBOL: + return 'Unsupported ALTER EVENT statement.'; + + case WP_MySQL_Lexer::LOGFILE_SYMBOL: + return 'Unsupported ALTER LOGFILE statement.'; + + case WP_MySQL_Lexer::SERVER_SYMBOL: + return 'Unsupported ALTER SERVER statement.'; + + case WP_MySQL_Lexer::TABLESPACE_SYMBOL: + return 'Unsupported ALTER TABLESPACE statement.'; + + case WP_MySQL_Lexer::UNDO_SYMBOL: + return 'Unsupported ALTER UNDO TABLESPACE statement.'; + + case WP_MySQL_Lexer::USER_SYMBOL: + return 'Unsupported ALTER USER statement.'; + + case WP_MySQL_Lexer::VIEW_SYMBOL: + return 'Unsupported ALTER VIEW statement.'; + } + return null; + + case WP_MySQL_Lexer::RENAME_SYMBOL: + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::USER_SYMBOL === $tokens[1]->id ) { + return 'Unsupported RENAME USER statement.'; + } + return null; + } + + return null; + } + + /** + * Get an explicit unsupported error for unclaimed MySQL CREATE statements. + * + * Supported CREATE TABLE/CREATE INDEX forms are dispatched before this guard. + * Plain PostgreSQL-compatible CREATE TABLE statements may still fall through, + * but MySQL-only CREATE constructs should not reach the backend parser. + * + * @param string $query MySQL query. + * @return string|null Unsupported error message, or null when not guarded. + */ + private function get_unsupported_mysql_create_statement_message( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return 'Unsupported CREATE statement.'; + } + + $position = 1; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::REPLACE_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id ) { + return $this->get_unsupported_mysql_create_table_statement_message( $tokens, $position + 1 ); + } + + $statement_token = $tokens[ $position ] ?? null; + if ( + null !== $statement_token + && WP_MySQL_Lexer::SPATIAL_SYMBOL === $statement_token->id + && isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::REFERENCE_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::SYSTEM_SYMBOL === $tokens[ $position + 2 ]->id + ) { + return 'Unsupported CREATE SPATIAL REFERENCE SYSTEM statement.'; + } + + switch ( $statement_token->id ?? null ) { + case WP_MySQL_Lexer::DATABASE_SYMBOL: + case WP_MySQL_Lexer::SCHEMA_SYMBOL: + return 'Unsupported CREATE DATABASE statement.'; + + case WP_MySQL_Lexer::VIEW_SYMBOL: + return 'Unsupported CREATE VIEW statement.'; + + case WP_MySQL_Lexer::INDEX_SYMBOL: + case WP_MySQL_Lexer::UNIQUE_SYMBOL: + case WP_MySQL_Lexer::FULLTEXT_SYMBOL: + case WP_MySQL_Lexer::SPATIAL_SYMBOL: + return 'Unsupported CREATE INDEX statement.'; + + case WP_MySQL_Lexer::PROCEDURE_SYMBOL: + return 'Unsupported CREATE PROCEDURE statement.'; + + case WP_MySQL_Lexer::FUNCTION_SYMBOL: + return 'Unsupported CREATE FUNCTION statement.'; + + case WP_MySQL_Lexer::TRIGGER_SYMBOL: + return 'Unsupported CREATE TRIGGER statement.'; + + case WP_MySQL_Lexer::EVENT_SYMBOL: + return 'Unsupported CREATE EVENT statement.'; + + case WP_MySQL_Lexer::USER_SYMBOL: + return 'Unsupported CREATE USER statement.'; + + case WP_MySQL_Lexer::ROLE_SYMBOL: + return 'Unsupported CREATE ROLE statement.'; + + case WP_MySQL_Lexer::SERVER_SYMBOL: + return 'Unsupported CREATE SERVER statement.'; + + case WP_MySQL_Lexer::LOGFILE_SYMBOL: + return 'Unsupported CREATE LOGFILE statement.'; + + case WP_MySQL_Lexer::TABLESPACE_SYMBOL: + return 'Unsupported CREATE TABLESPACE statement.'; + } + + return 'Unsupported CREATE statement.'; + } + + /** + * Get an explicit unsupported error for MySQL CREATE TABLE forms not handled by supported translators. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Position immediately after TABLE. + * @return string|null Unsupported error message, or null for backend-compatible plain CREATE TABLE. + */ + private function get_unsupported_mysql_create_table_statement_message( array $tokens, int $position ): ?string { + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return 'Unsupported CREATE TABLE statement.'; + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position += 3; + } + + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $table_reference ) { + return 'Unsupported CREATE TABLE statement.'; + } + + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return 'Unsupported CREATE TABLE statement.'; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) { + return 'Unsupported CREATE TABLE statement.'; + } + + $has_as = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + $has_as = true; + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position ]->id ) { + return 'Unsupported CREATE TABLE statement.'; + } + + if ( $has_as && isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + return 'Unsupported CREATE TABLE statement.'; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return 'Unsupported CREATE TABLE statement.'; + } + + $definition_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $definition_end ) { + return 'Unsupported CREATE TABLE statement.'; + } + + $position = $definition_end; + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id + ) + ) { + return 'Unsupported CREATE TABLE statement.'; + } + + return 'Unsupported CREATE TABLE statement.'; + } + + /** + * Check whether CREATE TABLE has unsupported MySQL column attributes. + * + * @param string $query MySQL query. + * @return bool Whether the statement should fail before backend execution. + */ + private function contains_unsupported_mysql_create_table_column_attribute_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! $this->is_mysql_create_table_statement_prefix( $tokens ) ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( + null === $statement_end + || ! $this->contains_unsupported_mysql_column_attribute_tokens( $tokens, 0, $statement_end ) + ) { + return false; + } + + try { + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ); + $translator->translate_schema( $query ); + } catch ( InvalidArgumentException $e ) { + return 'Unsupported CREATE TABLE column attribute.' === $e->getMessage(); + } + + return false; + } + + /** + * Check whether a token stream starts with CREATE [TEMPORARY] TABLE. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether this is a CREATE TABLE statement. + */ + private function is_mysql_create_table_statement_prefix( array $tokens ): bool { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + return isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id; + } + + /** + * Check whether a query can use the exact SELECT translation cache. + * + * This intentionally uses a cheap prefix check. Queries with leading comments + * or parenthesized SELECTs keep the uncached fallback path rather than paying + * lexer cost just to decide cacheability. + * + * @param string $query MySQL query. + * @return bool Whether the query is cacheable by exact SQL text. + */ + private function is_mysql_select_translation_cacheable_query( string $query ): bool { + return 1 === preg_match( '/\A\s*SELECT\b/i', $query ) + && ! $this->contains_uncacheable_mysql_runtime_function_query( $query ); + } + + /** + * Check whether a SELECT contains session-state runtime functions. + * + * @param string $query MySQL query. + * @return bool Whether exact SQL text caching should be skipped. + */ + private function contains_uncacheable_mysql_runtime_function_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return false; + } + + for ( $i = 1; $i < $statement_end; $i++ ) { + if ( + WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $i ]->id + || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $tokens[ $i ]->id + ) { + return true; + } + + if ( null !== $this->get_mysql_group_concat_function_bounds( $tokens, $i, $statement_end ) ) { + return true; + } + + $bounds = $this->get_mysql_common_function_bounds( $tokens, $i, $statement_end ); + if ( null !== $bounds && in_array( $bounds['function'], array( 'last_insert_id', 'row_count' ), true ) ) { + return true; + } + } + + return false; + } + + /** + * Get the PostgreSQL execution SQL for a MySQL SELECT query. + * + * @param string $query MySQL SELECT query. + * @return array{sql: string, translated: bool, last_insert_id?: int} PostgreSQL SQL and translation flag. + */ + private function get_mysql_select_query_translation( string $query ): array { + $cached_translation = $this->get_mysql_select_translation_cache_entry( $query ); + if ( null !== $cached_translation ) { + return array( + 'sql' => $cached_translation['sql'], + 'translated' => $cached_translation['translated'], + ); + } + + $translation = $this->translate_mysql_select_query_for_postgresql( $query ); + $this->set_mysql_select_translation_cache_entry( $query, $translation ); + + return $translation; + } + + /** + * Check whether a query is a top-level MySQL SELECT after lexer normalization. + * + * @param string $query MySQL query. + * @return bool Whether the lexer sees a complete SELECT statement. + */ + private function is_mysql_top_level_select_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + return isset( $tokens[0] ) + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[0]->id + && null !== $this->get_mysql_statement_end_position( $tokens, 1 ); + } + + /** + * Translate a MySQL SELECT query using the existing ordered translator chain. + * + * @param string $query MySQL SELECT query. + * @return array{sql: string, translated: bool, last_insert_id?: int} PostgreSQL SQL and translation flag. + */ + private function translate_mysql_select_query_for_postgresql( string $query ): array { + $translated_query = $this->translate_information_schema_tables_site_health_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_mysql_select_row_locking_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_direct_information_schema_select_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + if ( $this->should_reject_unsupported_direct_information_schema_select_query( $query ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + $translated_query = $this->translate_strict_aggregate_grouped_order_by_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_grouped_having_alias_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_wordpress_available_post_mime_types_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_wordpress_term_cache_priming_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_wordpress_approved_comments_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $last_insert_id_assignment_query = $this->translate_mysql_last_insert_id_assignment_select_query( $query ); + if ( null !== $last_insert_id_assignment_query ) { + return array( + 'sql' => $last_insert_id_assignment_query['sql'], + 'translated' => true, + 'last_insert_id' => $last_insert_id_assignment_query['last_insert_id'], + ); + } + + $translated_query = $this->translate_mysql_version_function_select_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_wordpress_postmeta_distinct_meta_key_having_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_simple_mysql_select_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_information_schema_main_database_select_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_distinct_order_by_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_sql_calc_found_rows_select_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + $translated_query = $this->translate_mysql_compatible_query( $query ); + if ( null !== $translated_query ) { + return array( + 'sql' => $translated_query, + 'translated' => true, + ); + } + + return array( + 'sql' => $query, + 'translated' => false, + ); + } + + /** + * Get a cached exact SELECT translation. + * + * @param string $query MySQL SELECT query. + * @return array{query: string, sql: string, translated: bool}|null Cached translation. + */ + private function get_mysql_select_translation_cache_entry( string $query ): ?array { + $cache_key = $this->get_mysql_query_translation_cache_key( $query ); + if ( + ! isset( $this->mysql_select_translation_cache[ $cache_key ] ) + || $this->mysql_select_translation_cache[ $cache_key ]['query'] !== $query + ) { + return null; + } + + return $this->mysql_select_translation_cache[ $cache_key ]; + } + + /** + * Store a cached exact SELECT translation. + * + * @param string $query MySQL SELECT query. + * @param array{sql: string, translated: bool, last_insert_id?: int} $translation PostgreSQL translation. + */ + private function set_mysql_select_translation_cache_entry( string $query, array $translation ): void { + $cache_key = $this->get_mysql_query_translation_cache_key( $query ); + $this->mysql_select_translation_cache[ $cache_key ] = array( + 'query' => $query, + 'sql' => $translation['sql'], + 'translated' => $translation['translated'], + ); + + $this->limit_mysql_query_translation_cache( $this->mysql_select_translation_cache ); + } + + /** + * Get the cache key for an exact MySQL query translation. + * + * @param string $query MySQL query. + * @return string Cache key. + */ + private function get_mysql_query_translation_cache_key( string $query ): string { + return sha1( $query ); + } + + /** + * Keep an exact query translation cache bounded. + * + * @param array $cache Cache to trim. + */ + private function limit_mysql_query_translation_cache( array &$cache ): void { + while ( count( $cache ) > self::MYSQL_QUERY_TRANSLATION_CACHE_LIMIT ) { + reset( $cache ); + $first_key = key( $cache ); + if ( null === $first_key ) { + return; + } + unset( $cache[ $first_key ] ); + } + } + + /** + * Decode PostgreSQL-safe text envelopes in fetched result data. + * + * @param mixed $value Fetched result value. + * @return mixed MySQL-facing result value. + */ + private function decode_postgresql_text_for_mysql_in_result( $value ) { + if ( is_string( $value ) ) { + return self::decode_postgresql_text_for_mysql_value( $value ); + } + + if ( is_array( $value ) ) { + foreach ( $value as $key => $item ) { + $value[ $key ] = $this->decode_postgresql_text_for_mysql_in_result( $item ); + } + return $value; + } + + if ( is_object( $value ) ) { + foreach ( get_object_vars( $value ) as $key => $item ) { + $value->$key = $this->decode_postgresql_text_for_mysql_in_result( $item ); + } + } + + return $value; + } + + /** + * Decode MySQL text bytes previously encoded for PostgreSQL storage. + * + * @param string $value PostgreSQL text value. + * @return string MySQL-facing text value. + */ + private static function decode_postgresql_text_for_mysql_value( string $value ): string { + if ( 0 !== strpos( $value, self::MYSQL_TEXT_ENCODING_PREFIX ) ) { + return $value; + } + + $encoded = substr( $value, strlen( self::MYSQL_TEXT_ENCODING_PREFIX ) ); + $length_separator = strpos( $encoded, ':' ); + if ( false === $length_separator ) { + return $value; + } + + $length = substr( $encoded, 0, $length_separator ); + if ( ! self::is_canonical_decimal_string( $length ) ) { + return $value; + } + + $encoded = substr( $encoded, $length_separator + 1 ); + $hash_separator = strpos( $encoded, ':' ); + if ( false === $hash_separator ) { + return $value; + } + + $hash = substr( $encoded, 0, $hash_separator ); + $hex = substr( $encoded, $hash_separator + 1 ); + if ( + 1 !== preg_match( '/\A[0-9a-f]{64}\z/', $hash ) + || 0 !== strlen( $hex ) % 2 + || ! ctype_xdigit( $hex ) + ) { + return $value; + } + + $decoded = hex2bin( $hex ); + if ( + false === $decoded + || (string) strlen( $decoded ) !== $length + || ! hash_equals( $hash, hash( 'sha256', self::MYSQL_TEXT_ENCODING_HASH_CONTEXT . $decoded ) ) + ) { + return $value; + } + + return $decoded; + } + + /** + * Check whether a string is a canonical decimal integer. + * + * @param string $value String value. + * @return bool Whether the value is canonical decimal. + */ + private static function is_canonical_decimal_string( string $value ): bool { + if ( '' === $value ) { + return false; + } + + if ( '0' === $value ) { + return true; + } + + return '0' !== $value[0] && ctype_digit( $value ); + } + + /** + * Execute the unbounded count query for a SQL_CALC_FOUND_ROWS SELECT. + * + * @param string $query MySQL query. + * @return int Total matching rows before LIMIT/OFFSET. + */ + private function execute_sql_calc_found_rows_count_query( string $query ): int { + $count_query = $this->get_sql_calc_found_rows_count_query( $query ); + if ( null === $count_query ) { + throw new PDOException( 'Unsupported SQL_CALC_FOUND_ROWS query shape for PostgreSQL FOUND_ROWS accounting.' ); + } + + $stmt = $this->connection->query( $count_query ); + $this->last_postgresql_queries[] = array( + 'sql' => $count_query, + 'params' => array(), + ); + + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( ! is_array( $row ) || ! array_key_exists( '__wp_pg_found_rows', $row ) ) { + throw new PDOException( 'Failed to read PostgreSQL FOUND_ROWS accounting result.' ); + } + + return (int) $row['__wp_pg_found_rows']; + } + + /** + * Build the PostgreSQL count query for a SQL_CALC_FOUND_ROWS SELECT. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL count query, or null when unsupported. + */ + private function get_sql_calc_found_rows_count_query( string $query ): ?string { + $cached_count_query = $this->get_mysql_sql_calc_found_rows_count_query_cache_entry( $query ); + if ( null !== $cached_count_query ) { + return $cached_count_query; + } + + $count_query = $this->get_sql_calc_found_rows_direct_count_query( $query ); + if ( null !== $count_query ) { + $this->set_mysql_sql_calc_found_rows_count_query_cache_entry( $query, $count_query ); + return $count_query; + } + + $select_query = $this->translate_sql_calc_found_rows_count_select_query( $query ); + if ( null === $select_query ) { + return null; + } + + $alias = $this->connection->quote_identifier( '__wp_pg_found_rows' ); + $count_query = sprintf( + 'SELECT COUNT(*) AS %1$s FROM (%2$s) AS %1$s', + $alias, + $select_query + ); + $this->set_mysql_sql_calc_found_rows_count_query_cache_entry( $query, $count_query ); + return $count_query; + } + + /** + * Get cached PostgreSQL SQL for a SQL_CALC_FOUND_ROWS count query. + * + * @param string $query MySQL SQL_CALC_FOUND_ROWS query. + * @return string|null Cached PostgreSQL count SQL. + */ + private function get_mysql_sql_calc_found_rows_count_query_cache_entry( string $query ): ?string { + $cache_key = $this->get_mysql_query_translation_cache_key( $query ); + if ( + ! isset( $this->mysql_sql_calc_found_rows_count_query_cache[ $cache_key ] ) + || $this->mysql_sql_calc_found_rows_count_query_cache[ $cache_key ]['query'] !== $query + ) { + return null; + } + + return $this->mysql_sql_calc_found_rows_count_query_cache[ $cache_key ]['sql']; + } + + /** + * Store cached PostgreSQL SQL for a SQL_CALC_FOUND_ROWS count query. + * + * @param string $query MySQL SQL_CALC_FOUND_ROWS query. + * @param string $count_query PostgreSQL count SQL. + */ + private function set_mysql_sql_calc_found_rows_count_query_cache_entry( string $query, string $count_query ): void { + $cache_key = $this->get_mysql_query_translation_cache_key( $query ); + $this->mysql_sql_calc_found_rows_count_query_cache[ $cache_key ] = array( + 'query' => $query, + 'sql' => $count_query, + ); + + $this->limit_mysql_query_translation_cache( $this->mysql_sql_calc_found_rows_count_query_cache ); + } + + /** + * Check whether a fetch mode can hide the internal FOUND_ROWS window column. + * + * @param int $fetch_mode PDO fetch mode. + * @return bool Whether the hidden window column can be removed safely. + */ + private function is_sql_calc_found_rows_window_fetch_mode( $fetch_mode ): bool { + return in_array( (int) $fetch_mode, array( PDO::FETCH_OBJ, PDO::FETCH_ASSOC ), true ); + } + + /** + * Translate a simple SQL_CALC_FOUND_ROWS SELECT using one PostgreSQL query. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query carrying a hidden FOUND_ROWS value, or null. + */ + private function translate_sql_calc_found_rows_window_select_query( string $query ): ?string { + if ( false !== stripos( $query, self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN ) ) { + return null; + } + + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $projection_start = 2; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $projection_start ); + if ( null === $statement_end ) { + return null; + } + + $limit_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::LIMIT_SYMBOL, + $projection_start, + $statement_end + ); + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $select_end = $limit_position ?? $statement_end; + if ( + $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $select_end + ); + if ( null === $from_position || $projection_start === $from_position ) { + return null; + } + + if ( $this->contains_mysql_aggregate_call( $tokens, $projection_start, $from_position ) ) { + return null; + } + + $replacements = $this->get_mysql_select_statement_contextual_replacements( + $tokens, + $projection_start, + $select_end + ) ?? array(); + + $sql = sprintf( + 'SELECT %s, COUNT(*) OVER() AS %s %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $from_position ), + $this->connection->quote_identifier( self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN ), + $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $from_position, + $select_end, + $replacements + ) + ); + + if ( null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Extract and remove the hidden FOUND_ROWS window column from result rows. + * + * @param mixed $rows Result rows. + * @return int|null FOUND_ROWS value, or null when the fallback count is needed. + */ + private function extract_sql_calc_found_rows_window_result( &$rows ): ?int { + if ( ! is_array( $rows ) || empty( $rows ) ) { + return null; + } + + $found_rows = null; + $column = self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN; + foreach ( $rows as &$row ) { + if ( is_object( $row ) ) { + if ( ! property_exists( $row, $column ) ) { + return null; + } + + $found_rows = (int) $row->{$column}; + unset( $row->{$column} ); + continue; + } + + if ( ! is_array( $row ) || ! array_key_exists( $column, $row ) ) { + return null; + } + + $found_rows = (int) $row[ $column ]; + unset( $row[ $column ] ); + } + unset( $row ); + + return $found_rows; + } + + /** + * Remove hidden FOUND_ROWS metadata before exposing column metadata to wpdb. + */ + private function remove_sql_calc_found_rows_window_column_meta(): void { + if ( null !== $this->last_column_meta_statement ) { + $this->last_column_meta_excluded_names[ self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN ] = true; + $this->last_column_count = max( 0, $this->last_column_count - 1 ); + return; + } + + foreach ( $this->last_column_meta as $index => $column_meta ) { + if ( self::SQL_CALC_FOUND_ROWS_WINDOW_COLUMN !== ( $column_meta['name'] ?? '' ) ) { + continue; + } + + unset( $this->last_column_meta[ $index ] ); + $this->last_column_meta = array_values( $this->last_column_meta ); + return; + } + } + + /** + * Build a direct PostgreSQL count query for simple SQL_CALC_FOUND_ROWS SELECTs. + * + * Non-DISTINCT, non-grouped, non-aggregate SELECTs have the same FOUND_ROWS + * cardinality as COUNT(*) over the FROM/WHERE source. DISTINCT, aggregate, + * GROUP BY, and HAVING shapes stay on the derived-table fallback because + * their projection determines the counted row set. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL count query, or null when the wrapped fallback is required. + */ + private function get_sql_calc_found_rows_direct_count_query( string $query ): ?string { + $query = $this->get_sql_calc_found_rows_count_source_query( $query ); + if ( null === $query ) { + return null; + } + + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $projection_start = 1; + if ( WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $tokens[ $projection_start ]->id ) { + ++$projection_start; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $projection_start ); + if ( null === $statement_end ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $statement_end + ); + if ( null === $from_position ) { + return null; + } + + if ( $this->contains_mysql_aggregate_call( $tokens, $projection_start, $from_position ) ) { + return null; + } + + return sprintf( + 'SELECT COUNT(*) AS %s %s', + $this->connection->quote_identifier( '__wp_pg_found_rows' ), + $this->translate_sql_calc_found_rows_direct_count_source_to_postgresql( $tokens, $from_position, $statement_end ) + ); + } + + /** + * Translate a direct FOUND_ROWS count source while preserving contextual predicate rewrites. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $from_position FROM token position. + * @param int $statement_end Final statement token position, exclusive. + * @return string PostgreSQL FROM/WHERE SQL. + */ + private function translate_sql_calc_found_rows_direct_count_source_to_postgresql( + array $tokens, + int $from_position, + int $statement_end + ): string { + $where_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::WHERE_SYMBOL, + $from_position + 1, + $statement_end + ); + if ( null === $where_position ) { + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ); + } + + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $where_position ); + if ( null === $scope ) { + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ); + } + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $statement_end, + $scope + ); + if ( ! $where_sql['changed'] ) { + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ); + } + + return $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $from_position, + $statement_end, + array( + array( + 'start' => $where_position + 1, + 'end' => $statement_end, + 'sql' => $where_sql['sql'], + ), + ) + ); + } + + /** + * Translate the unbounded SELECT used for SQL_CALC_FOUND_ROWS accounting. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL SELECT query, or null when unsupported. + */ + private function translate_sql_calc_found_rows_count_select_query( string $query ): ?string { + $query = $this->get_sql_calc_found_rows_count_source_query( $query ); + if ( null === $query ) { + return null; + } + + $translated_query = $this->translate_strict_aggregate_grouped_order_by_query( $query, false ); + if ( null !== $translated_query ) { + return $translated_query; + } + + $translated_query = $this->translate_distinct_order_by_query( $query, false ); + if ( null !== $translated_query ) { + return $translated_query; + } + + return $this->translate_sql_calc_found_rows_select_query( $query, false ); + } + + /** + * Build the unordered, unbounded MySQL SELECT used for FOUND_ROWS accounting. + * + * @param string $query MySQL query. + * @return string|null MySQL query without top-level ORDER BY or LIMIT clauses. + */ + private function get_sql_calc_found_rows_count_source_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 1, $statement_end ); + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $select_end = $limit_position ?? $statement_end; + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 1, $select_end ); + $count_end = $order_position ?? $select_end; + + return rtrim( substr( $query, 0, $tokens[ $count_end ]->start ) ); + } + + /** + * Execute translated PostgreSQL statements for a single MySQL-facing query. + * + * @param string[] $statements PostgreSQL SQL statements to execute. + * @return mixed Return value from the last executed statement. + */ + private function execute_postgresql_statements( array $statements ) { + if ( empty( $statements ) ) { + return 0; + } + + foreach ( $statements as $statement ) { + $this->ensure_postgresql_runtime_helpers_for_query( $statement ); + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $this->last_result = $stmt->rowCount(); + } + + $this->last_column_meta = array(); + return $this->last_result; + } + + /** + * Execute PostgreSQL side-effect statements without changing the query result. + * + * @param string[] $statements PostgreSQL SQL statements to execute. + */ + private function execute_postgresql_side_effect_statements( array $statements ): void { + foreach ( $statements as $statement ) { + $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + } + } + + /** + * Execute translated DML statements for a single MySQL-facing query. + * + * @param array $dml_query Translated DML query metadata. + * @param int|null $return_value Optional MySQL-compatible return value. + * @return int Number of affected rows. + */ + private function execute_translated_dml_statements( array $dml_query, ?int $return_value = null ): int { + if ( ! isset( $dml_query['statements'] ) || ! is_array( $dml_query['statements'] ) ) { + return 0; + } + + $affected_rows = 0; + foreach ( $dml_query['statements'] as $statement ) { + $statement = (string) $statement; + $this->ensure_postgresql_runtime_helpers_for_query( $statement ); + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $affected_rows += $stmt->rowCount(); + } + + $this->clear_last_column_meta(); + $this->last_result = $return_value ?? $affected_rows; + $this->set_last_insert_id_after_dml_success( $dml_query, $affected_rows ); + $this->repair_dml_identity_sequences_after_success( $dml_query, $affected_rows ); + + return (int) $this->last_result; + } + + /** + * Execute a materialized INSERT ... SELECT ... ON DUPLICATE KEY UPDATE flow. + * + * @param array $upsert_query Translated INSERT ... SELECT upsert metadata. + * @return int MySQL-compatible affected rows. + */ + private function execute_materialized_mysql_upsert_select_statements( array $upsert_query ): int { + $materialize_statements = isset( $upsert_query['materialize_statements'] ) && is_array( $upsert_query['materialize_statements'] ) + ? $upsert_query['materialize_statements'] + : array(); + $mutation_statements = isset( $upsert_query['mutation_statements'] ) && is_array( $upsert_query['mutation_statements'] ) + ? $upsert_query['mutation_statements'] + : array(); + $cleanup_statements = isset( $upsert_query['cleanup_statements'] ) && is_array( $upsert_query['cleanup_statements'] ) + ? $upsert_query['cleanup_statements'] + : array(); + + foreach ( $materialize_statements as $statement ) { + $statement = (string) $statement; + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $stmt->closeCursor(); + } + + try { + $upsert_query = $this->prepare_materialized_mysql_upsert_select_insert_id_metadata( $upsert_query ); + + if ( ! empty( $upsert_query['upsert_select_ambiguous_conflict_targets'] ) ) { + $affected_rows = $this->execute_materialized_mysql_upsert_select_rows_with_ambiguous_targets( $upsert_query ); + } elseif ( isset( $upsert_query['duplicate_conflict_rows_sql'] ) && is_string( $upsert_query['duplicate_conflict_rows_sql'] ) ) { + $stmt = $this->connection->query( $upsert_query['duplicate_conflict_rows_sql'] ); + $has_duplicate = false !== $stmt->fetchColumn(); + $stmt->closeCursor(); + } else { + $has_duplicate = false; + } + + if ( empty( $upsert_query['upsert_select_ambiguous_conflict_targets'] ) ) { + if ( $has_duplicate ) { + $affected_rows = $this->execute_materialized_mysql_upsert_select_rows_sequentially( $upsert_query ); + } else { + $affected_rows = 0; + foreach ( $mutation_statements as $statement ) { + $statement = (string) $statement; + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $affected_rows += $stmt->rowCount(); + } + } + } + } finally { + foreach ( $cleanup_statements as $statement ) { + $statement = (string) $statement; + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $stmt->closeCursor(); + } + } + + $this->clear_last_column_meta(); + $this->last_result = $affected_rows; + $this->set_last_insert_id_after_dml_success( $upsert_query, $affected_rows ); + $this->repair_dml_identity_sequences_after_success( $upsert_query, $affected_rows ); + + return (int) $this->last_result; + } + + /** + * Add MySQL-compatible insert-id metadata to a materialized upsert flow. + * + * @param array $upsert_query Translated materialized upsert metadata. + * @return array Updated upsert metadata. + */ + private function prepare_materialized_mysql_upsert_select_insert_id_metadata( array $upsert_query ): array { + if ( + ! isset( $upsert_query['table_name'], $upsert_query['columns'], $upsert_query['source_table_sql'] ) + || ! is_string( $upsert_query['table_name'] ) + || ! is_array( $upsert_query['columns'] ) + || ! is_string( $upsert_query['source_table_sql'] ) + ) { + return $upsert_query; + } + + if ( isset( $upsert_query['last_insert_id_column_on_duplicate_key_update'] ) ) { + $last_insert_id_row = $this->get_materialized_mysql_upsert_select_last_insert_id_row( + $upsert_query, + (string) $upsert_query['last_insert_id_column_on_duplicate_key_update'] + ); + if ( null === $last_insert_id_row ) { + throw new InvalidArgumentException( 'Unsupported INSERT SELECT ON DUPLICATE KEY UPDATE statement.' ); + } + + if ( $last_insert_id_row['found'] ) { + $upsert_query['last_insert_id_on_duplicate_key_update'] = $last_insert_id_row['value']; + } + } + + if ( empty( $upsert_query['insert_id_unknown'] ) ) { + return $upsert_query; + } + + $metadata_lookup = $this->get_mysql_dml_column_metadata_lookup( $upsert_query['table_name'] ); + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $metadata_lookup ); + if ( null === $auto_increment_column || ! $this->mysql_dml_column_list_contains_column( $upsert_query['columns'], $auto_increment_column ) ) { + return $upsert_query; + } + + $insert_id_value_rows = $this->get_materialized_mysql_upsert_select_insert_id_value_rows( + $upsert_query, + $auto_increment_column + ); + if ( null === $insert_id_value_rows ) { + return $upsert_query; + } + + $explicit_insert_id = $this->get_explicit_mysql_auto_increment_insert_id( + $auto_increment_column, + $upsert_query['columns'], + $insert_id_value_rows + ); + if ( null === $explicit_insert_id ) { + return $upsert_query; + } + + $upsert_query['insert_id_value_rows'] = $insert_id_value_rows; + $upsert_query['insert_id_unknown'] = false; + + return $upsert_query; + } + + /** + * Resolve LAST_INSERT_ID(column) for a materialized SELECT-sourced upsert. + * + * @param array $upsert_query Translated materialized upsert metadata. + * @param string $column_name Target column assigned through LAST_INSERT_ID(). + * @return array{found: bool, value: mixed}|null Last insert-id row metadata, or null when unsupported. + */ + private function get_materialized_mysql_upsert_select_last_insert_id_row( array $upsert_query, string $column_name ): ?array { + if ( + empty( $upsert_query['conflict_parts'] ) + || ! is_array( $upsert_query['conflict_parts'] ) + || ! isset( $upsert_query['table_name'], $upsert_query['source_table_sql'] ) + || ! is_string( $upsert_query['table_name'] ) + || ! is_string( $upsert_query['source_table_sql'] ) + ) { + return null; + } + + $total_rows = $this->get_materialized_mysql_upsert_select_source_row_count( $upsert_query['source_table_sql'] ); + if ( null === $total_rows ) { + return null; + } + + if ( 0 === $total_rows ) { + return array( + 'found' => false, + 'value' => null, + ); + } + + $target_alias = $this->connection->quote_identifier( '__wp_pg_upsert_target' ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_upsert_rows' ); + $source_alias = $this->connection->quote_identifier( '__wp_pg_upsert_source' ); + $ordinal = $this->connection->quote_identifier( '__wp_pg_upsert_insert_id_ordinal' ); + $predicate = $this->get_materialized_mysql_upsert_select_conflict_predicate_sql( + $target_alias, + $rows_alias, + $upsert_query['conflict_parts'] + ); + if ( null === $predicate ) { + return null; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT %1$s.%2$s FROM (SELECT ROW_NUMBER() OVER () AS %3$s, %4$s.* FROM %5$s AS %4$s) AS %6$s, %7$s AS %1$s WHERE %8$s ORDER BY %6$s.%3$s', + $target_alias, + $this->connection->quote_identifier( $column_name ), + $ordinal, + $source_alias, + $upsert_query['source_table_sql'], + $rows_alias, + $this->connection->quote_identifier( $upsert_query['table_name'] ), + $predicate + ) + ); + $values = $stmt->fetchAll( PDO::FETCH_COLUMN ); + $stmt->closeCursor(); + + if ( count( $values ) !== $total_rows ) { + return 0 === count( $values ) + ? array( + 'found' => false, + 'value' => null, + ) + : null; + } + + return array( + 'found' => true, + 'value' => $values[ count( $values ) - 1 ], + ); + } + + /** + * Count materialized SELECT source rows. + * + * @param string $source_table_sql Quoted materialized source table SQL. + * @return int|null Row count, or null when unavailable. + */ + private function get_materialized_mysql_upsert_select_source_row_count( string $source_table_sql ): ?int { + $stmt = $this->connection->query( sprintf( 'SELECT COUNT(*) FROM %s', $source_table_sql ) ); + $count = $stmt->fetchColumn(); + $stmt->closeCursor(); + + return is_numeric( $count ) ? (int) $count : null; + } + + /** + * Read materialized source values used for MySQL insert-id detection. + * + * @param array $upsert_query Translated materialized upsert metadata. + * @param string $auto_increment_column AUTO_INCREMENT column name. + * @return array[]|null Insert-id value rows, or null when unsupported. + */ + private function get_materialized_mysql_upsert_select_insert_id_value_rows( array $upsert_query, string $auto_increment_column ): ?array { + $auto_increment_index = null; + foreach ( $upsert_query['columns'] as $index => $column ) { + if ( 0 === strcasecmp( (string) $column, $auto_increment_column ) ) { + $auto_increment_index = $index; + break; + } + } + if ( null === $auto_increment_index ) { + return null; + } + + $source_alias = $this->connection->quote_identifier( '__wp_pg_upsert_source' ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_upsert_rows' ); + $ordinal = $this->connection->quote_identifier( '__wp_pg_upsert_insert_id_ordinal' ); + $stmt = $this->connection->query( + sprintf( + 'SELECT %1$s.%2$s FROM (SELECT ROW_NUMBER() OVER () AS %3$s, %4$s.* FROM %5$s AS %4$s) AS %1$s ORDER BY %1$s.%3$s', + $rows_alias, + $this->connection->quote_identifier( $auto_increment_column ), + $ordinal, + $source_alias, + $upsert_query['source_table_sql'] + ) + ); + $values = $stmt->fetchAll( PDO::FETCH_COLUMN ); + $stmt->closeCursor(); + + $value_rows = array(); + foreach ( $values as $value ) { + $row = array_fill( 0, count( $upsert_query['columns'] ), 'DEFAULT' ); + $row[ $auto_increment_index ] = null === $value ? 'NULL' : (string) $value; + $value_rows[] = $row; + } + + return $value_rows; + } + + /** + * Replay a materialized INSERT ... SELECT upsert with per-row conflict targets. + * + * @param array $upsert_query Translated INSERT ... SELECT upsert metadata. + * @return int MySQL-compatible affected rows. + */ + private function execute_materialized_mysql_upsert_select_rows_with_ambiguous_targets( array $upsert_query ): int { + if ( + ! isset( $upsert_query['table_name'], $upsert_query['columns'], $upsert_query['source_table_sql'], $upsert_query['ordinal_source_table_sql'], $upsert_query['conflict_targets'], $upsert_query['assignments'] ) + || ! is_string( $upsert_query['table_name'] ) + || ! is_array( $upsert_query['columns'] ) + || ! is_string( $upsert_query['source_table_sql'] ) + || ! is_string( $upsert_query['ordinal_source_table_sql'] ) + || ! is_array( $upsert_query['conflict_targets'] ) + || ! is_array( $upsert_query['assignments'] ) + ) { + throw new InvalidArgumentException( 'Unsupported INSERT SELECT ON DUPLICATE KEY UPDATE statement.' ); + } + + $ordinal_column = '__wp_pg_upsert_ordinal'; + $quoted_ordinal = $this->connection->quote_identifier( $ordinal_column ); + $source_alias = $this->connection->quote_identifier( '__wp_pg_upsert_source' ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_upsert_rows' ); + $quoted_target_table = $this->connection->quote_identifier( $upsert_query['table_name'] ); + $column_sql = implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $upsert_query['columns'] ) ); + + $insert_projection_sql = array(); + foreach ( $upsert_query['columns'] as $column ) { + $insert_projection_sql[] = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( (string) $column ) + ); + } + + $create_ordinal_table_sql = sprintf( + 'CREATE TEMPORARY TABLE %s AS SELECT ROW_NUMBER() OVER () AS %s, %s.* FROM %s AS %s', + $upsert_query['ordinal_source_table_sql'], + $quoted_ordinal, + $source_alias, + $upsert_query['source_table_sql'], + $source_alias + ); + $stmt = $this->connection->query( $create_ordinal_table_sql ); + $this->last_postgresql_queries[] = array( + 'sql' => $create_ordinal_table_sql, + 'params' => array(), + ); + $stmt->closeCursor(); + + $drop_ordinal_table_sql = sprintf( 'DROP TABLE IF EXISTS %s', $upsert_query['ordinal_source_table_sql'] ); + + try { + $stmt = $this->connection->query( + sprintf( + 'SELECT %1$s FROM %2$s ORDER BY %1$s', + $quoted_ordinal, + $upsert_query['ordinal_source_table_sql'] + ) + ); + $ordinals = $stmt->fetchAll( PDO::FETCH_COLUMN ); + $stmt->closeCursor(); + + $affected_rows = 0; + foreach ( $ordinals as $ordinal ) { + $ordinal_value = (int) $ordinal; + $conflict_target = $this->get_materialized_mysql_upsert_select_conflict_target_for_ordinal( $upsert_query, $ordinal_value ); + if ( null === $conflict_target ) { + throw new InvalidArgumentException( 'Unsupported INSERT SELECT ON DUPLICATE KEY UPDATE statement.' ); + } + + $conflict_sql = sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + implode( ', ', $conflict_target['sql'] ), + implode( ', ', $upsert_query['assignments'] ) + ); + $row_filter = sprintf( '%s.%s = %d', $rows_alias, $quoted_ordinal, $ordinal_value ); + $insert_sql = sprintf( + 'INSERT INTO %s (%s) SELECT %s FROM %s AS %s WHERE %s %s', + $quoted_target_table, + $column_sql, + implode( ', ', $insert_projection_sql ), + $upsert_query['ordinal_source_table_sql'], + $rows_alias, + $row_filter, + $conflict_sql + ); + + $stmt = $this->connection->query( $insert_sql ); + $this->last_postgresql_queries[] = array( + 'sql' => $insert_sql, + 'params' => array(), + ); + $affected_rows += $stmt->rowCount(); + } + + return $affected_rows; + } finally { + $stmt = $this->connection->query( $drop_ordinal_table_sql ); + $this->last_postgresql_queries[] = array( + 'sql' => $drop_ordinal_table_sql, + 'params' => array(), + ); + $stmt->closeCursor(); + } + } + + /** + * Resolve a materialized SELECT row's upsert conflict target. + * + * @param array $upsert_query Translated INSERT ... SELECT upsert metadata. + * @param int $ordinal Source row ordinal. + * @return array|null Conflict target, or null when ambiguous/unsupported. + */ + private function get_materialized_mysql_upsert_select_conflict_target_for_ordinal( array $upsert_query, int $ordinal ): ?array { + $conflict_targets = isset( $upsert_query['conflict_targets'] ) && is_array( $upsert_query['conflict_targets'] ) + ? $upsert_query['conflict_targets'] + : array(); + if ( empty( $conflict_targets ) ) { + return null; + } + + $target_alias = $this->connection->quote_identifier( '__wp_pg_upsert_target' ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_upsert_rows' ); + $ordinal_sql = sprintf( + '%s.%s = %d', + $rows_alias, + $this->connection->quote_identifier( '__wp_pg_upsert_ordinal' ), + $ordinal + ); + + $matching_targets = array(); + foreach ( $conflict_targets as $conflict_target ) { + $predicate_sql = $this->get_materialized_mysql_upsert_select_conflict_predicate_sql( + $target_alias, + $rows_alias, + $conflict_target['parts'] ?? array() + ); + if ( null === $predicate_sql ) { + return null; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s AS %s, %s AS %s WHERE %s AND %s LIMIT 1', + $this->connection->quote_identifier( (string) $upsert_query['table_name'] ), + $target_alias, + $upsert_query['ordinal_source_table_sql'], + $rows_alias, + $ordinal_sql, + $predicate_sql + ) + ); + $conflict_exists = false !== $stmt->fetchColumn(); + $stmt->closeCursor(); + if ( $conflict_exists ) { + $matching_targets[] = $conflict_target; + } + + if ( count( $matching_targets ) > 1 ) { + return null; + } + } + + return $matching_targets[0] ?? $conflict_targets[0]; + } + + /** + * Build a target/source predicate for a materialized upsert conflict key. + * + * @param string $target_alias Quoted target table alias. + * @param string $rows_alias Quoted source rows alias. + * @param array $conflict_parts Conflict target key parts. + * @return string|null Predicate SQL, or null when unsupported. + */ + private function get_materialized_mysql_upsert_select_conflict_predicate_sql( string $target_alias, string $rows_alias, array $conflict_parts ): ?string { + $where = array(); + foreach ( $conflict_parts as $conflict_part ) { + $column = (string) ( $conflict_part['column'] ?? '' ); + if ( '' === $column ) { + return null; + } + + $target_value = sprintf( + '%s.%s', + $target_alias, + $this->connection->quote_identifier( $column ) + ); + $incoming_value = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( $column ) + ); + if ( null !== ( $conflict_part['sub_part'] ?? null ) && '' !== (string) $conflict_part['sub_part'] ) { + $target_value = sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $target_value, + (int) $conflict_part['sub_part'] + ); + $incoming_value = sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $incoming_value, + (int) $conflict_part['sub_part'] + ); + } + + $where[] = sprintf( '%s = %s', $target_value, $incoming_value ); + } + + return empty( $where ) ? null : implode( ' AND ', $where ); + } + + /** + * Replay a materialized INSERT ... SELECT upsert source one row at a time. + * + * @param array $upsert_query Translated INSERT ... SELECT upsert metadata. + * @return int MySQL-compatible affected rows. + */ + private function execute_materialized_mysql_upsert_select_rows_sequentially( array $upsert_query ): int { + if ( + ! isset( $upsert_query['table_name'], $upsert_query['columns'], $upsert_query['source_table_sql'], $upsert_query['ordinal_source_table_sql'], $upsert_query['conflict_sql'] ) + || ! is_string( $upsert_query['table_name'] ) + || ! is_array( $upsert_query['columns'] ) + || ! is_string( $upsert_query['source_table_sql'] ) + || ! is_string( $upsert_query['ordinal_source_table_sql'] ) + || ! is_string( $upsert_query['conflict_sql'] ) + ) { + throw new InvalidArgumentException( 'Unsupported INSERT SELECT ON DUPLICATE KEY UPDATE statement.' ); + } + + $ordinal_column = '__wp_pg_upsert_ordinal'; + $quoted_ordinal = $this->connection->quote_identifier( $ordinal_column ); + $source_alias = $this->connection->quote_identifier( '__wp_pg_upsert_source' ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_upsert_rows' ); + $quoted_target_table = $this->connection->quote_identifier( $upsert_query['table_name'] ); + $column_sql = implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $upsert_query['columns'] ) ); + + $insert_projection_sql = array(); + foreach ( $upsert_query['columns'] as $column ) { + $insert_projection_sql[] = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( (string) $column ) + ); + } + + $create_ordinal_table_sql = sprintf( + 'CREATE TEMPORARY TABLE %s AS SELECT ROW_NUMBER() OVER () AS %s, %s.* FROM %s AS %s', + $upsert_query['ordinal_source_table_sql'], + $quoted_ordinal, + $source_alias, + $upsert_query['source_table_sql'], + $source_alias + ); + $stmt = $this->connection->query( $create_ordinal_table_sql ); + $this->last_postgresql_queries[] = array( + 'sql' => $create_ordinal_table_sql, + 'params' => array(), + ); + $stmt->closeCursor(); + + $drop_ordinal_table_sql = sprintf( 'DROP TABLE IF EXISTS %s', $upsert_query['ordinal_source_table_sql'] ); + + try { + $stmt = $this->connection->query( + sprintf( + 'SELECT %1$s FROM %2$s ORDER BY %1$s', + $quoted_ordinal, + $upsert_query['ordinal_source_table_sql'] + ) + ); + $ordinals = $stmt->fetchAll( PDO::FETCH_COLUMN ); + $stmt->closeCursor(); + + $affected_rows = 0; + foreach ( $ordinals as $ordinal ) { + $ordinal_value = (int) $ordinal; + $row_filter = sprintf( '%s.%s = %d', $rows_alias, $quoted_ordinal, $ordinal_value ); + $insert_sql = sprintf( + 'INSERT INTO %s (%s) SELECT %s FROM %s AS %s WHERE %s %s', + $quoted_target_table, + $column_sql, + implode( ', ', $insert_projection_sql ), + $upsert_query['ordinal_source_table_sql'], + $rows_alias, + $row_filter, + $upsert_query['conflict_sql'] + ); + + $stmt = $this->connection->query( $insert_sql ); + $this->last_postgresql_queries[] = array( + 'sql' => $insert_sql, + 'params' => array(), + ); + $affected_rows += $stmt->rowCount(); + } + + return $affected_rows; + } finally { + $stmt = $this->connection->query( $drop_ordinal_table_sql ); + $this->last_postgresql_queries[] = array( + 'sql' => $drop_ordinal_table_sql, + 'params' => array(), + ); + $stmt->closeCursor(); + } + } + + /** + * Execute a materialized REPLACE ... SELECT delete-then-insert flow. + * + * @param array $replace_query Translated REPLACE ... SELECT metadata. + * @return int MySQL-compatible affected rows. + */ + private function execute_materialized_mysql_replace_select_statements( array $replace_query ): int { + $materialize_statements = isset( $replace_query['materialize_statements'] ) && is_array( $replace_query['materialize_statements'] ) + ? $replace_query['materialize_statements'] + : array(); + $mutation_statements = isset( $replace_query['mutation_statements'] ) && is_array( $replace_query['mutation_statements'] ) + ? $replace_query['mutation_statements'] + : array(); + $cleanup_statements = isset( $replace_query['cleanup_statements'] ) && is_array( $replace_query['cleanup_statements'] ) + ? $replace_query['cleanup_statements'] + : array(); + + foreach ( $materialize_statements as $statement ) { + $statement = (string) $statement; + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $stmt->closeCursor(); + } + + try { + if ( isset( $replace_query['duplicate_conflict_rows_sql'] ) && is_string( $replace_query['duplicate_conflict_rows_sql'] ) ) { + $stmt = $this->connection->query( $replace_query['duplicate_conflict_rows_sql'] ); + $has_duplicate = false !== $stmt->fetchColumn(); + $stmt->closeCursor(); + } else { + $has_duplicate = false; + } + + $return_value = null; + if ( $has_duplicate ) { + $affected_rows = $this->execute_materialized_mysql_replace_select_rows_sequentially( $replace_query ); + $return_value = $affected_rows; + $replace_query['inserted_new_row'] = $affected_rows > 0; + } elseif ( isset( $replace_query['replace_select_affected_rows_sql'] ) && is_string( $replace_query['replace_select_affected_rows_sql'] ) ) { + $stmt = $this->connection->query( $replace_query['replace_select_affected_rows_sql'] ); + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + $stmt->closeCursor(); + if ( is_array( $row ) && isset( $row['affected_rows'] ) ) { + $return_value = (int) $row['affected_rows']; + $replace_query['inserted_new_row'] = isset( $row['inserted_rows'] ) && (int) $row['inserted_rows'] > 0; + } + } + + if ( ! $has_duplicate ) { + $affected_rows = 0; + foreach ( $mutation_statements as $statement ) { + $statement = (string) $statement; + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $affected_rows += $stmt->rowCount(); + } + } + } finally { + foreach ( $cleanup_statements as $statement ) { + $statement = (string) $statement; + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $stmt->closeCursor(); + } + } + + $this->clear_last_column_meta(); + $this->last_result = $return_value ?? $affected_rows; + $this->set_last_insert_id_after_dml_success( $replace_query, $affected_rows ); + $this->repair_dml_identity_sequences_after_success( $replace_query, $affected_rows ); + + return (int) $this->last_result; + } + + /** + * Replay a materialized REPLACE ... SELECT source one row at a time. + * + * Duplicate source conflict keys need MySQL's row-by-row REPLACE semantics: + * each later row may delete the row inserted by an earlier source row. + * + * @param array $replace_query Translated REPLACE ... SELECT metadata. + * @return int MySQL-compatible affected rows. + */ + private function execute_materialized_mysql_replace_select_rows_sequentially( array $replace_query ): int { + if ( + ! isset( $replace_query['table_name'], $replace_query['columns'], $replace_query['source_table_sql'], $replace_query['ordinal_source_table_sql'], $replace_query['conflict_index_groups'] ) + || ! is_string( $replace_query['table_name'] ) + || ! is_array( $replace_query['columns'] ) + || ! is_string( $replace_query['source_table_sql'] ) + || ! is_string( $replace_query['ordinal_source_table_sql'] ) + || ! is_array( $replace_query['conflict_index_groups'] ) + ) { + throw new InvalidArgumentException( 'Unsupported REPLACE SELECT statement.' ); + } + + $ordinal_column = '__wp_pg_replace_ordinal'; + $quoted_ordinal = $this->connection->quote_identifier( $ordinal_column ); + $source_alias = $this->connection->quote_identifier( '__wp_pg_replace_source' ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_replace_rows' ); + $target_alias = $this->connection->quote_identifier( '__wp_pg_replace_target' ); + $quoted_target_table = $this->connection->quote_identifier( $replace_query['table_name'] ); + $column_sql = implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $replace_query['columns'] ) ); + + $insert_projection_sql = array(); + foreach ( $replace_query['columns'] as $column ) { + $insert_projection_sql[] = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( (string) $column ) + ); + } + + $delete_predicate_sql = $this->get_mysql_replace_select_delete_predicate_sql( + $target_alias, + $rows_alias, + $replace_query['conflict_index_groups'] + ); + if ( null === $delete_predicate_sql ) { + throw new InvalidArgumentException( 'Unsupported REPLACE SELECT statement.' ); + } + + $create_ordinal_table_sql = sprintf( + 'CREATE TEMPORARY TABLE %s AS SELECT ROW_NUMBER() OVER () AS %s, %s.* FROM %s AS %s', + $replace_query['ordinal_source_table_sql'], + $quoted_ordinal, + $source_alias, + $replace_query['source_table_sql'], + $source_alias + ); + $stmt = $this->connection->query( $create_ordinal_table_sql ); + $this->last_postgresql_queries[] = array( + 'sql' => $create_ordinal_table_sql, + 'params' => array(), + ); + $stmt->closeCursor(); + + $drop_ordinal_table_sql = sprintf( 'DROP TABLE IF EXISTS %s', $replace_query['ordinal_source_table_sql'] ); + + try { + $stmt = $this->connection->query( + sprintf( + 'SELECT %1$s FROM %2$s ORDER BY %1$s', + $quoted_ordinal, + $replace_query['ordinal_source_table_sql'] + ) + ); + $ordinals = $stmt->fetchAll( PDO::FETCH_COLUMN ); + $stmt->closeCursor(); + + $affected_rows = 0; + foreach ( $ordinals as $ordinal ) { + $ordinal_value = (int) $ordinal; + $row_filter = sprintf( '%s.%s = %d', $rows_alias, $quoted_ordinal, $ordinal_value ); + $delete_sql = sprintf( + 'DELETE FROM %s AS %s WHERE EXISTS (SELECT 1 FROM %s AS %s WHERE %s AND %s)', + $quoted_target_table, + $target_alias, + $replace_query['ordinal_source_table_sql'], + $rows_alias, + $row_filter, + $delete_predicate_sql + ); + $insert_sql = sprintf( + 'INSERT INTO %s (%s) SELECT %s FROM %s AS %s WHERE %s', + $quoted_target_table, + $column_sql, + implode( ', ', $insert_projection_sql ), + $replace_query['ordinal_source_table_sql'], + $rows_alias, + $row_filter + ); + + foreach ( array( $delete_sql, $insert_sql ) as $statement ) { + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $affected_rows += $stmt->rowCount(); + } + } + + return $affected_rows; + } finally { + $stmt = $this->connection->query( $drop_ordinal_table_sql ); + $this->last_postgresql_queries[] = array( + 'sql' => $drop_ordinal_table_sql, + 'params' => array(), + ); + $stmt->closeCursor(); + } + } + + /** + * Execute a translated MySQL multi-target DELETE query. + * + * @param string $statement PostgreSQL writable-CTE statement. + * @return int Total rows deleted from all target tables. + */ + private function execute_mysql_multi_target_delete_query( string $statement ): int { + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + $this->clear_last_column_meta(); + $this->last_result = isset( $row['affected_rows'] ) ? (int) $row['affected_rows'] : 0; + + return $this->last_result; + } + + /** + * Execute a translated MySQL multi-target UPDATE query. + * + * @param string $statement PostgreSQL writable-CTE statement. + * @return int Total rows updated across all target tables. + */ + private function execute_mysql_multi_target_update_query( string $statement ): int { + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + $this->clear_last_column_meta(); + $this->last_result = isset( $row['affected_rows'] ) ? (int) $row['affected_rows'] : 0; + + return $this->last_result; + } + + /** + * Execute a translated UPDATE IGNORE statement. + * + * MySQL skips rows that would raise data-integrity constraint errors under + * UPDATE IGNORE. PostgreSQL has no UPDATE-level conflict action, so preserve + * the visible MySQL behavior for translated statements by converting only + * constraint-class failures into a zero-row result. + * + * @param string $statement PostgreSQL statement. + * @param bool $expects_affected_rows_result Whether the statement returns an affected_rows row. + * @return int MySQL-compatible affected row count. + */ + private function execute_mysql_update_ignore_query( string $statement, bool $expects_affected_rows_result = false ): int { + try { + $this->ensure_postgresql_runtime_helpers_for_query( $statement ); + $stmt = $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + } catch ( PDOException $e ) { + if ( ! $this->is_mysql_update_ignore_constraint_exception( $e ) ) { + throw $e; + } + + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $this->clear_last_column_meta(); + $this->last_result = 0; + return $this->last_result; + } + + if ( $expects_affected_rows_result ) { + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + $this->clear_last_column_meta(); + $this->last_result = isset( $row['affected_rows'] ) ? (int) $row['affected_rows'] : 0; + return $this->last_result; + } + + $this->clear_last_column_meta(); + $this->last_result = $stmt->rowCount(); + return $this->last_result; + } + + /** + * Execute a simple MySQL transaction-control statement in PostgreSQL. + * + * @param string $statement Canonical PostgreSQL transaction statement. + * @return int Number of affected rows. + */ + private function execute_mysql_transaction_control_query( string $statement ): int { + $pdo = $this->connection->get_pdo(); + $in_transaction = $pdo->inTransaction(); + + if ( 'BEGIN' === $statement ) { + if ( $in_transaction ) { + $pdo->commit(); + $this->connection->reset_statement_savepoint_state(); + $this->last_postgresql_queries[] = array( + 'sql' => 'COMMIT', + 'params' => array(), + ); + } + $pdo->beginTransaction(); + $this->connection->reset_statement_savepoint_state(); + $this->last_postgresql_queries[] = array( + 'sql' => 'BEGIN', + 'params' => array(), + ); + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; + } + + if ( ! $in_transaction ) { + $this->connection->reset_statement_savepoint_state(); + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; + } + + if ( 'COMMIT' === $statement ) { + $pdo->commit(); + } else { + $pdo->rollBack(); + } + $this->connection->reset_statement_savepoint_state(); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; + } + + /** + * Execute a public MySQL savepoint statement. + * + * @param string $statement Canonical PostgreSQL savepoint statement. + * @return int Number of affected rows. + */ + private function execute_mysql_savepoint_query( string $statement ): int { + $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + $this->last_result = 0; + $this->clear_last_column_meta(); + + return $this->last_result; + } + + /** + * Execute a supported MySQL runtime SET statement from emulated session state. + * + * @param string $query MySQL query. + * @return int|null Query result for handled SET statements, or null when this is not SET. + */ + private function execute_mysql_runtime_setting_query( string $query ): ?int { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[0]->id ) { + return null; + } + + if ( $this->apply_mysql_set_names_tokens( $tokens ) || $this->apply_mysql_set_charset_tokens( $tokens ) ) { + $this->last_result = 0; + $this->clear_last_column_meta(); + return $this->last_result; + } + + $operations = $this->get_mysql_set_assignment_operations( $tokens ); + if ( null === $operations ) { + throw new InvalidArgumentException( 'Unsupported SET statement.' ); + } + + foreach ( $operations as $operation ) { + if ( 'user' === $operation['target_type'] ) { + $this->mysql_user_variables[ $operation['name'] ] = $operation['value']; + continue; + } + + if ( 'global' === ( $operation['scope'] ?? null ) ) { + $this->set_mysql_global_variable_value( $operation['name'], $operation['value'] ); + continue; + } + + $this->set_mysql_session_variable_value( $operation['name'], $operation['value'] ); + } + + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; + } + + /** + * Apply a supported MySQL SET NAMES statement to the emulated session. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the query was handled. + */ + private function apply_mysql_set_names_tokens( array $tokens ): bool { + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2] ) + || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::NAMES_SYMBOL !== $tokens[1]->id + || ! $this->is_mysql_charset_token( $tokens[2] ) + ) { + return false; + } + + $charset = $this->get_mysql_charset_token_value( $tokens[2] ); + $collation = null; + $position = 3; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLLATE_SYMBOL === $tokens[ $position ]->id ) { + if ( ! isset( $tokens[ $position + 1 ] ) || ! $this->is_mysql_charset_token( $tokens[ $position + 1 ] ) ) { + return false; + } + + $collation = $this->get_mysql_charset_token_value( $tokens[ $position + 1 ] ); + $position += 2; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return false; + } + + $this->set_charset( $charset, $collation ); + return true; + } + + /** + * Apply supported MySQL SET CHARSET and SET CHARACTER SET statements. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the query was handled. + */ + private function apply_mysql_set_charset_tokens( array $tokens ): bool { + if ( + isset( $tokens[0], $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[0]->id + && $this->is_mysql_token_value( $tokens[1], 'charset' ) + && $this->is_mysql_charset_token( $tokens[2] ) + && $this->is_at_mysql_query_end( $tokens, 3 ) + ) { + $this->set_charset( $this->get_mysql_charset_token_value( $tokens[2] ) ); + return true; + } + + if ( + isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3] ) + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[0]->id + && $this->is_mysql_token_value( $tokens[1], 'character' ) + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[2]->id + && $this->is_mysql_charset_token( $tokens[3] ) + && $this->is_at_mysql_query_end( $tokens, 4 ) + ) { + $this->set_charset( $this->get_mysql_charset_token_value( $tokens[3] ) ); + return true; + } + + return false; + } + + /** + * Parse supported MySQL SET assignment operations. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return array|null Assignment operations, or null when unsupported. + */ + private function get_mysql_set_assignment_operations( array $tokens ): ?array { + $position = 1; + $statement_scope = $this->get_mysql_set_statement_scope( $tokens, $position ); + $ops = array(); + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + $target = $this->parse_mysql_set_assignment_target( $tokens, $position ); + if ( null === $target ) { + return null; + } + if ( 'system' === $target['type'] && null === ( $target['scope'] ?? null ) ) { + $target['scope'] = $statement_scope; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $operation = $this->parse_mysql_set_assignment_operation( $tokens, $position, $target ); + if ( null === $operation ) { + return null; + } + + $ops[] = $operation; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + if ( array() === $ops || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + return $ops; + } + + /** + * Get the statement-level SET scope, if present. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return string|null SET scope. + */ + private function get_mysql_set_statement_scope( array $tokens, int &$position ): ?string { + if ( + isset( $tokens[ $position ] ) + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::GLOBAL_SYMBOL, + WP_MySQL_Lexer::LOCAL_SYMBOL, + WP_MySQL_Lexer::SESSION_SYMBOL, + ), + true + ) + ) { + return strtolower( $tokens[ $position++ ]->get_value() ); + } + + return null; + } + + /** + * Parse a SET assignment target. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return array{type: string, name: string, scope?: string|null}|null Assignment target. + */ + private function parse_mysql_set_assignment_target( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + return array( + 'type' => 'user', + 'name' => $this->normalize_mysql_user_variable_name( $tokens[ $position++ ]->get_value() ), + ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $tokens[ $position ]->id ) { + $display = null; + $scope = null; + $name = $this->parse_mysql_system_variable_reference( $tokens, $position, $display, $scope ); + if ( null === $name || ! $this->is_supported_mysql_system_variable( $name ) ) { + return null; + } + + return array( + 'type' => 'system', + 'name' => $name, + 'scope' => $scope, + ); + } + + $name = strtolower( $tokens[ $position ]->get_value() ); + if ( ! $this->is_supported_mysql_system_variable( $name ) ) { + return null; + } + + ++$position; + return array( + 'type' => 'system', + 'name' => $name, + 'scope' => null, + ); + } + + /** + * Parse a SET assignment operation. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param array $target Parsed assignment target. + * @return array{target_type: string, name: string, value: string|null, scope?: string|null}|null Assignment operation. + */ + private function parse_mysql_set_assignment_operation( array $tokens, int &$position, array $target ): ?array { + $value = $this->parse_mysql_set_assignment_value( $tokens, $position, $target ); + if ( null === $value ) { + return null; + } + + if ( 'system' === $target['type'] ) { + $value = $this->normalize_mysql_system_variable_assignment_value( + $target['name'], + $value, + $target['scope'] ?? null + ); + if ( null === $value ) { + return null; + } + } + + return array( + 'target_type' => $target['type'], + 'name' => $target['name'], + 'value' => $value, + 'scope' => $target['scope'] ?? null, + ); + } + + /** + * Parse a supported SET assignment value. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param array $target Parsed assignment target. + * @return string|null Assignment value, or null when unsupported. + */ + private function parse_mysql_set_assignment_value( array $tokens, int &$position, array $target ): ?string { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + $start = $position; + $end = $this->get_mysql_set_assignment_value_end( $tokens, $start ); + if ( null === $end || $start === $end ) { + return null; + } + + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + $user_variable_name = $this->normalize_mysql_user_variable_name( $tokens[ $position++ ]->get_value() ); + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $position ]->id ) { + return $this->parse_mysql_user_variable_increment_value( + $tokens, + $position, + $target, + $user_variable_name + ); + } + + if ( $position !== $end ) { + return null; + } + + return $this->get_mysql_user_variable_value( $user_variable_name ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $tokens[ $position ]->id ) { + $display = null; + $scope = null; + $system_variable_name = $this->parse_mysql_system_variable_reference( $tokens, $position, $display, $scope ); + if ( null === $system_variable_name || $position !== $end ) { + return null; + } + + return $this->get_mysql_system_variable_value( $system_variable_name, $scope ); + } + + if ( 'system' === $target['type'] && 'sql_mode' === $target['name'] ) { + $value = $this->evaluate_mysql_sql_mode_set_expression( $tokens, $start, $end ); + if ( null !== $value ) { + $position = $end; + return $value; + } + } + + if ( $start + 1 !== $end ) { + return null; + } + + $value = $this->get_mysql_set_literal_token_value( $tokens[ $start ] ); + if ( null !== $value ) { + $position = $end; + } + + return $value; + } + + /** + * Find the end of one SET assignment value. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position First value token position. + * @return int|null Final value token position, exclusive, or null when malformed. + */ + private function get_mysql_set_assignment_value_end( array $tokens, int $position ): ?int { + $depth = 0; + for ( $i = $position; isset( $tokens[ $i ] ); $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + + continue; + } + + if ( + 0 === $depth + && in_array( + $tokens[ $i ]->id, + array( + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::EOF, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + ), + true + ) + ) { + return $i; + } + } + + return null; + } + + /** + * Evaluate bounded SQL-mode SET expressions SQLite can handle dynamically. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @return string|null Evaluated SQL mode string, or null when unsupported. + */ + private function evaluate_mysql_sql_mode_set_expression( array $tokens, int $start, int $end ): ?string { + while ( + $start < $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start ]->id + ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $start, $end ); + if ( $after_close !== $end ) { + break; + } + + if ( isset( $tokens[ $start + 1 ] ) && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $start + 1 ]->id ) { + return $this->evaluate_mysql_sql_mode_set_select_expression( $tokens, $start + 1, $end - 1 ); + } + + ++$start; + --$end; + } + + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return null; + } + + if ( $start + 1 === $end ) { + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $start ]->id ) { + return $this->get_mysql_user_variable_value( $this->normalize_mysql_user_variable_name( $tokens[ $start ]->get_value() ) ); + } + + return $this->get_mysql_set_literal_token_value( $tokens[ $start ] ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $tokens[ $start ]->id ) { + $position = $start; + $display = null; + $scope = null; + $name = $this->parse_mysql_system_variable_reference( $tokens, $position, $display, $scope ); + if ( null !== $name && $position === $end ) { + return $this->get_mysql_system_variable_value( $name, $scope ); + } + } + + return $this->evaluate_mysql_sql_mode_set_function_expression( $tokens, $start, $end ); + } + + /** + * Evaluate a scalar SELECT wrapper used in SQL-mode SET expressions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start SELECT token position. + * @param int $end Final SELECT token position, exclusive. + * @return string|null Evaluated value, or null when unsupported. + */ + private function evaluate_mysql_sql_mode_set_select_expression( array $tokens, int $start, int $end ): ?string { + if ( ! isset( $tokens[ $start ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $start ]->id ) { + return null; + } + + $projection_start = $start + 1; + $projection_end = $end; + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $projection_start, $end ); + if ( null !== $from_position ) { + if ( + ! isset( $tokens[ $from_position + 1 ] ) + || WP_MySQL_Lexer::DUAL_SYMBOL !== $tokens[ $from_position + 1 ]->id + || $from_position + 2 !== $end + ) { + return null; + } + + $projection_end = $from_position; + } + + return $this->evaluate_mysql_sql_mode_set_expression( $tokens, $projection_start, $projection_end ); + } + + /** + * Evaluate supported SQL-mode string functions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start Function token position. + * @param int $end Final function token position, exclusive. + * @return string|null Evaluated value, or null when unsupported. + */ + private function evaluate_mysql_sql_mode_set_function_expression( array $tokens, int $start, int $end ): ?string { + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $start + 1, $end ); + if ( $after_close !== $end ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $start + 2, $end - 1 ); + if ( null === $arguments ) { + return null; + } + + $function = strtolower( $tokens[ $start ]->get_value() ); + if ( in_array( $function, array( 'lcase', 'lower', 'ucase', 'upper' ), true ) ) { + if ( 1 !== count( $arguments ) ) { + return null; + } + + $value = $this->evaluate_mysql_sql_mode_set_expression( $tokens, $arguments[0]['start'], $arguments[0]['end'] ); + if ( null === $value ) { + return null; + } + + return in_array( $function, array( 'lcase', 'lower' ), true ) + ? strtolower( $value ) + : strtoupper( $value ); + } + + if ( 'concat' === $function ) { + $values = array(); + foreach ( $arguments as $argument ) { + $value = $this->evaluate_mysql_sql_mode_set_expression( $tokens, $argument['start'], $argument['end'] ); + if ( null === $value ) { + return null; + } + + $values[] = $value; + } + + return implode( '', $values ); + } + + if ( in_array( $function, array( 'replace', 'regexp_replace' ), true ) ) { + if ( 3 !== count( $arguments ) ) { + return null; + } + + $subject = $this->evaluate_mysql_sql_mode_set_expression( $tokens, $arguments[0]['start'], $arguments[0]['end'] ); + $search = $this->evaluate_mysql_sql_mode_set_expression( $tokens, $arguments[1]['start'], $arguments[1]['end'] ); + $replacement = $this->evaluate_mysql_sql_mode_set_expression( $tokens, $arguments[2]['start'], $arguments[2]['end'] ); + if ( null === $subject || null === $search || null === $replacement ) { + return null; + } + + if ( 'replace' === $function ) { + return str_replace( $search, $replacement, $subject ); + } + + $result = @preg_replace( '/' . str_replace( '/', '\/', $search ) . '/', $replacement, $subject ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Unsupported regular expressions should evaluate to null, not emit warnings. + return null === $result ? null : $result; + } + + return null; + } + + /** + * Parse @name = @name + integer assignment values. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param array $target Parsed assignment target. + * @param string $source_variable_name Source user variable name. + * @return string|null Incremented value, or null when unsupported. + */ + private function parse_mysql_user_variable_increment_value( + array $tokens, + int &$position, + array $target, + string $source_variable_name + ): ?string { + if ( + 'user' !== $target['type'] + || $target['name'] !== $source_variable_name + || ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::PLUS_OPERATOR !== $tokens[ $position ]->id + || ! $this->is_mysql_unsigned_integer_token( $tokens[ $position + 1 ] ) + ) { + return null; + } + + $current_value = $this->get_mysql_user_variable_value( $source_variable_name ); + if ( null === $current_value || ! preg_match( '/\A[0-9]+\z/', $current_value ) ) { + return null; + } + + $increment = $tokens[ $position + 1 ]->get_value(); + $position += 2; + return (string) ( (int) $current_value + (int) $increment ); + } + + /** + * Get a simple literal token value allowed in supported SET assignments. + * + * @param WP_MySQL_Token $token MySQL token. + * @return string|null Literal value, or null when unsupported. + */ + private function get_mysql_set_literal_token_value( WP_MySQL_Token $token ): ?string { + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL, + WP_MySQL_Lexer::AT_SIGN_SYMBOL, + WP_MySQL_Lexer::AT_TEXT_SUFFIX, + WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::EOF, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::MINUS_OPERATOR, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::PLUS_OPERATOR, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + ), + true + ) + ) { + return null; + } + + return $token->get_value(); + } + + /** + * Get the canonical PostgreSQL transaction statement for a simple MySQL query. + * + * @param string $query MySQL query. + * @return string|null Canonical PostgreSQL statement, or null when unsupported. + */ + private function get_mysql_transaction_control_query( string $query ): ?string { + $statement = trim( $query ); + $statement = preg_replace( '/;\s*\z/', '', $statement ); + if ( null === $statement ) { + return null; + } + + $normalized_statement = preg_replace( '/\s+/', ' ', $statement ); + if ( null === $normalized_statement ) { + return null; + } + + $statement = trim( $normalized_statement ); + if ( '' === $statement ) { + return null; + } + + if ( 1 === preg_match( '/\A(?:START TRANSACTION|BEGIN(?: WORK)?)\z/i', $statement ) ) { + return 'BEGIN'; + } + + if ( 1 === preg_match( '/\ACOMMIT(?: WORK)?\z/i', $statement ) ) { + return 'COMMIT'; + } + + if ( 1 === preg_match( '/\AROLLBACK(?: WORK)?\z/i', $statement ) ) { + return 'ROLLBACK'; + } + + return null; + } + + /** + * Get the canonical PostgreSQL statement for a public MySQL savepoint query. + * + * @param string $query MySQL query. + * @return string|null Canonical PostgreSQL savepoint statement, or null when this is not SAVEPOINT SQL. + */ + private function get_mysql_savepoint_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $tokens[0]->id ) { + $position = 1; + $name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $name || ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + return 'SAVEPOINT ' . $this->connection->quote_identifier( $name ); + } + + if ( WP_MySQL_Lexer::ROLLBACK_SYMBOL === $tokens[0]->id ) { + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WORK_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TO_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SAVEPOINT_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $name || ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + return 'ROLLBACK TO SAVEPOINT ' . $this->connection->quote_identifier( $name ); + } + + if ( WP_MySQL_Lexer::RELEASE_SYMBOL === $tokens[0]->id ) { + $position = 1; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SAVEPOINT_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + ++$position; + $name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $name || ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + return 'RELEASE SAVEPOINT ' . $this->connection->quote_identifier( $name ); + } + + return null; + } + + /** + * Execute a supported MySQL TRUNCATE TABLE statement. + * + * @param array{schema: string|null, table: string} $truncate_table_query Parsed truncate query. + * @return int Number of affected rows. + */ + private function execute_mysql_truncate_table_query( array $truncate_table_query ): int { + $requested_schema = $truncate_table_query['schema']; + $table_name = $truncate_table_query['table']; + + if ( + ( null === $requested_schema && 0 === strcasecmp( $this->db_name, 'information_schema' ) ) + || ( null !== $requested_schema && 0 !== strcasecmp( $requested_schema, $this->main_db_name ) ) + ) { + throw new InvalidArgumentException( 'Unsupported TRUNCATE TABLE statement.' ); + } + + $statement = 'TRUNCATE TABLE ' . $this->connection->quote_identifier( $table_name ) . ' RESTART IDENTITY'; + if ( 'sqlite' === (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ) ) { + $statement = 'DELETE FROM ' . $this->connection->quote_identifier( $table_name ); + } + + $this->connection->query( $statement ); + $this->last_postgresql_queries[] = array( + 'sql' => $statement, + 'params' => array(), + ); + + if ( 'sqlite' === (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ) ) { + $this->reset_sqlite_autoincrement_sequence( $table_name ); + } + + $this->mysql_introspection_result_cache = array(); + $this->last_result = 0; + $this->clear_last_column_meta(); + + return $this->last_result; + } + + /** + * Reset a SQLite AUTOINCREMENT sequence after TRUNCATE emulation. + * + * @param string $table_name Table name. + */ + private function reset_sqlite_autoincrement_sequence( string $table_name ): void { + try { + $this->connection->query( + 'DELETE FROM sqlite_sequence WHERE name = ?', + array( $table_name ) + ); + } catch ( PDOException $e ) { + if ( false === strpos( $e->getMessage(), 'no such table' ) ) { + throw $e; + } + } + } + + /** + * Create the MySQL schema metadata tables used by dbDelta emulation. + */ + private function ensure_mysql_schema_metadata_tables(): void { + if ( $this->mysql_schema_metadata_tables_ensured ) { + return; + } + + $this->connection->query( + sprintf( + 'CREATE TABLE IF NOT EXISTS %s ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + table_comment TEXT NOT NULL DEFAULT \'\', + PRIMARY KEY (table_schema, table_name) + )', + $this->connection->quote_identifier( self::MYSQL_TABLE_METADATA_TABLE ) + ) + ); + + $this->connection->query( + sprintf( + 'CREATE TABLE IF NOT EXISTS %s ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + ordinal_position INTEGER NOT NULL, + column_type TEXT NOT NULL, + character_set_name TEXT, + collation_name TEXT, + is_nullable TEXT NOT NULL, + column_default TEXT, + extra TEXT NOT NULL, + column_comment TEXT NOT NULL DEFAULT \'\', + PRIMARY KEY (table_schema, table_name, column_name) + )', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ) + ); + + $this->ensure_mysql_column_metadata_column( 'character_set_name', 'TEXT' ); + $this->ensure_mysql_column_metadata_column( 'collation_name', 'TEXT' ); + $this->ensure_mysql_column_metadata_column( 'column_comment', 'TEXT NOT NULL DEFAULT \'\'' ); + + $this->connection->query( + sprintf( + 'CREATE TABLE IF NOT EXISTS %s ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + key_name TEXT NOT NULL, + index_ordinal INTEGER NOT NULL, + seq_in_index INTEGER NOT NULL, + column_name TEXT NOT NULL, + non_unique TEXT NOT NULL, + index_type TEXT NOT NULL, + "collation" TEXT, + sub_part TEXT, + nullable TEXT NOT NULL, + index_comment TEXT NOT NULL DEFAULT \'\', + PRIMARY KEY (table_schema, table_name, key_name, seq_in_index) + )', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ) + ); + $this->ensure_mysql_index_metadata_column( 'index_comment', 'TEXT NOT NULL DEFAULT \'\'' ); + $this->ensure_mysql_index_metadata_column( 'collation', 'TEXT' ); + + $this->connection->query( + sprintf( + 'CREATE TABLE IF NOT EXISTS %s ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + constraint_name TEXT NOT NULL, + constraint_ordinal INTEGER NOT NULL, + seq_in_index INTEGER NOT NULL, + column_name TEXT NOT NULL, + referenced_table_schema TEXT NOT NULL, + referenced_table_name TEXT NOT NULL, + referenced_column_name TEXT NOT NULL, + update_rule TEXT NOT NULL, + delete_rule TEXT NOT NULL, + PRIMARY KEY (table_schema, table_name, constraint_name, seq_in_index) + )', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ) + ); + + $this->connection->query( + sprintf( + 'CREATE TABLE IF NOT EXISTS %s ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + constraint_name TEXT NOT NULL, + constraint_ordinal INTEGER NOT NULL, + check_clause TEXT NOT NULL, + enforced TEXT NOT NULL, + PRIMARY KEY (table_schema, table_name, constraint_name) + )', + $this->connection->quote_identifier( self::MYSQL_CHECK_METADATA_TABLE ) + ) + ); + + $this->mysql_schema_metadata_tables_ensured = true; + } + + /** + * Clear all cached MySQL metadata derived from side tables. + */ + private function clear_mysql_metadata_caches(): void { + $this->mysql_table_schema_introspection_cache = array(); + $this->mysql_dml_column_metadata_cache = array(); + $this->mysql_dml_identity_column_metadata_cache = array(); + $this->mysql_table_column_type_cache = array(); + $this->mysql_table_column_collation_cache = array(); + $this->mysql_table_has_column_metadata_cache = array(); + $this->mysql_table_column_name_cache = array(); + $this->mysql_upsert_conflict_target_cache = array(); + $this->mysql_introspection_result_cache = array(); + $this->clear_mysql_query_translation_caches(); + } + + /** + * Clear cached MySQL metadata for one table. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + */ + private function clear_mysql_metadata_cache_for_table( string $table_schema, string $table_name ): void { + $cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + unset( + $this->mysql_dml_column_metadata_cache[ $cache_key ], + $this->mysql_dml_identity_column_metadata_cache[ $cache_key ], + $this->mysql_table_column_type_cache[ $cache_key ], + $this->mysql_table_column_collation_cache[ $cache_key ], + $this->mysql_table_has_column_metadata_cache[ $cache_key ], + $this->mysql_table_column_name_cache[ $cache_key ] + ); + $this->mysql_upsert_conflict_target_cache = array(); + $this->mysql_introspection_result_cache = array(); + $this->clear_mysql_query_translation_caches(); + + /* + * Temporary table creation/drop can change which backend schema an + * unqualified MySQL table resolves to, so clear all schema resolutions. + */ + $this->mysql_table_schema_introspection_cache = array(); + } + + /** + * Get a cache key for metadata keyed by backend schema and table name. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @return string Cache key. + */ + private function get_mysql_metadata_cache_key( string $table_schema, string $table_name ): string { + return $table_schema . "\0" . $table_name; + } + + /** + * Clear exact query translation caches derived from metadata-sensitive rewrites. + */ + private function clear_mysql_query_translation_caches(): void { + $this->mysql_select_translation_cache = array(); + $this->mysql_sql_calc_found_rows_count_query_cache = array(); + } + + /** + * Clear the mode-sensitive MySQL token cache. + */ + private function clear_mysql_token_cache(): void { + $this->mysql_token_cache_query = null; + $this->mysql_token_cache_sql_mode = null; + $this->mysql_token_cache_tokens = array(); + } + + /** + * Reset metadata side-table state if a query drops the side tables directly. + * + * @param string[] $table_names Dropped table names. + */ + private function maybe_clear_mysql_schema_metadata_table_state( array $table_names ): void { + foreach ( $table_names as $table_name ) { + if ( ! $this->is_mysql_schema_metadata_table_name( (string) $table_name ) ) { + continue; + } + + $this->mysql_schema_metadata_tables_ensured = false; + $this->clear_mysql_metadata_caches(); + return; + } + } + + /** + * Check whether a table name belongs to the driver's metadata side tables. + * + * @param string $table_name Table name. + * @return bool Whether this is a metadata side table. + */ + private function is_mysql_schema_metadata_table_name( string $table_name ): bool { + return in_array( + $table_name, + array( + self::MYSQL_COLUMN_METADATA_TABLE, + self::MYSQL_INDEX_METADATA_TABLE, + self::MYSQL_FOREIGN_KEY_METADATA_TABLE, + self::MYSQL_CHECK_METADATA_TABLE, + self::MYSQL_TABLE_METADATA_TABLE, + ), + true + ); + } + + /** + * Add a MySQL column metadata field when upgrading an existing side table. + * + * @param string $column_name Column name. + * @param string $column_type Column type SQL. + */ + private function ensure_mysql_column_metadata_column( string $column_name, string $column_type ): void { + $this->ensure_mysql_metadata_column( self::MYSQL_COLUMN_METADATA_TABLE, $column_name, $column_type ); + } + + /** + * Add a MySQL index metadata field when upgrading an existing side table. + * + * @param string $column_name Column name. + * @param string $column_type Column type SQL. + */ + private function ensure_mysql_index_metadata_column( string $column_name, string $column_type ): void { + $this->ensure_mysql_metadata_column( self::MYSQL_INDEX_METADATA_TABLE, $column_name, $column_type ); + } + + /** + * Add a metadata field when upgrading an existing side table. + * + * @param string $metadata_table Metadata side-table name. + * @param string $column_name Column name. + * @param string $column_type Column type SQL. + */ + private function ensure_mysql_metadata_column( string $metadata_table, string $column_name, string $column_type ): void { + if ( $this->mysql_metadata_column_exists( $metadata_table, $column_name ) ) { + return; + } + + $this->connection->query( + sprintf( + 'ALTER TABLE %s ADD COLUMN %s %s', + $this->connection->quote_identifier( $metadata_table ), + $this->connection->quote_identifier( $column_name ), + $column_type + ) + ); + } + + /** + * Check whether a metadata side-table field exists. + * + * @param string $metadata_table Metadata side-table name. + * @param string $column_name Column name. + * @return bool Whether the column exists. + */ + private function mysql_metadata_column_exists( string $metadata_table, string $column_name ): bool { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + if ( 'sqlite' === $driver_name ) { + $stmt = $this->connection->query( + sprintf( + 'PRAGMA table_info(%s)', + $this->connection->quote_identifier( $metadata_table ) + ) + ); + + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $column ) { + if ( isset( $column['name'] ) && $column_name === $column['name'] ) { + return true; + } + } + + return false; + } + + $stmt = $this->connection->query( + 'SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = ? + AND column_name = ? + )', + array( $metadata_table, $column_name ) + ); + + return (bool) $stmt->fetchColumn(); + } + + /** + * Store MySQL-facing schema metadata for translated CREATE TABLE statements. + * + * @param string $query MySQL CREATE TABLE query. + */ + public function store_mysql_schema_metadata( string $query ): void { + if ( $this->is_temporary_create_table_query( $query ) ) { + return; + } + + $this->store_mysql_schema_metadata_for_schema( $query, 'public' ); + } + + /** + * Store MySQL-facing schema metadata for translated CREATE TEMPORARY TABLE statements. + * + * @param string $query MySQL CREATE TEMPORARY TABLE query. + */ + private function store_mysql_temporary_schema_metadata( string $query ): void { + $this->store_mysql_schema_metadata_for_schema( + $query, + array( $this, 'get_temporary_schema_for_metadata_table' ) + ); + } + + /** + * Store MySQL-facing schema metadata for translated CREATE TABLE statements in one backend schema. + * + * @param string $query MySQL CREATE TABLE query. + * @param string|callable $table_schema Metadata schema, or resolver receiving the table name. + */ + private function store_mysql_schema_metadata_for_schema( string $query, $table_schema ): void { + $this->ensure_mysql_schema_metadata_tables(); + + $metadata_tables = ( new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ) )->extract_schema_metadata( $query, true ); + foreach ( $metadata_tables as $metadata ) { + $schema_name = is_callable( $table_schema ) + ? (string) call_user_func( $table_schema, $metadata['table_name'] ) + : (string) $table_schema; + $table_name = $metadata['table_name']; + + $this->delete_mysql_schema_metadata_for_tables( array( $table_name ), $schema_name ); + $this->clear_mysql_metadata_cache_for_table( $schema_name, $table_name ); + $this->insert_mysql_table_metadata( $schema_name, $table_name, $metadata ); + + $column_nullable = array(); + foreach ( $metadata['columns'] as $column ) { + $this->insert_mysql_column_metadata( $schema_name, $table_name, $column ); + $column_nullable[ strtolower( $column['name'] ) ] = $column['nullable'] ?? 'YES'; + } + + foreach ( $metadata['indexes'] ?? array() as $index ) { + $this->insert_mysql_index_metadata( $schema_name, $table_name, $index, $column_nullable ); + } + + foreach ( $metadata['foreign_keys'] ?? array() as $foreign_key ) { + $foreign_key['referenced_schema'] = $foreign_key['referenced_schema'] ?? $schema_name; + $this->insert_mysql_foreign_key_metadata( $schema_name, $table_name, $foreign_key ); + } + + foreach ( $metadata['checks'] ?? array() as $check ) { + $this->insert_mysql_check_metadata( $schema_name, $table_name, $check ); + } + } + } + + /** + * Create PostgreSQL trigger side effects for CREATE TABLE ON UPDATE columns. + * + * @param string $query MySQL CREATE TABLE query. + */ + private function sync_mysql_on_update_current_timestamp_triggers_for_create_query( string $query ): void { + if ( 'pgsql' !== $this->connection->get_driver_name() ) { + return; + } + + $metadata_tables = ( new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ) )->extract_schema_metadata( $query, true ); + foreach ( $metadata_tables as $metadata ) { + $table_name = (string) $metadata['table_name']; + foreach ( $metadata['columns'] as $column ) { + if ( $this->mysql_column_extra_has_on_update_current_timestamp( $column['extra'] ?? '' ) ) { + $this->execute_postgresql_side_effect_statements( + $this->get_postgresql_on_update_current_timestamp_create_statements( 'public', $table_name, (string) $column['name'] ) + ); + } + } + } + } + + /** + * Check whether MySQL column extra metadata contains ON UPDATE CURRENT_TIMESTAMP. + * + * @param string|null $extra Extra metadata. + * @return bool Whether the column needs an ON UPDATE trigger. + */ + private function mysql_column_extra_has_on_update_current_timestamp( ?string $extra ): bool { + return null !== $extra && false !== stripos( $extra, 'on update CURRENT_TIMESTAMP' ); + } + + /** + * Check whether MySQL column extra metadata contains DEFAULT_GENERATED. + * + * @param string|null $extra Extra metadata. + * @return bool Whether the default is generated. + */ + private function mysql_column_extra_has_default_generated( ?string $extra ): bool { + return null !== $extra && false !== stripos( $extra, 'DEFAULT_GENERATED' ); + } + + /** + * Get CREATE/replace trigger statements for a MySQL ON UPDATE CURRENT_TIMESTAMP column. + * + * @param string $table_schema Backend schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string[] PostgreSQL statements. + */ + private function get_postgresql_on_update_current_timestamp_create_statements( string $table_schema, string $table_name, string $column_name ): array { + if ( 'pgsql' !== $this->connection->get_driver_name() ) { + return array(); + } + + $trigger_name = $this->get_postgresql_on_update_current_timestamp_trigger_name( $table_schema, $table_name, $column_name ); + $function_name = $this->get_postgresql_on_update_current_timestamp_function_name( $table_schema, $table_name, $column_name ); + $table_sql = $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + $function_sql = $this->get_postgresql_schema_identifier( $table_schema, $function_name ); + $column_sql = $this->connection->quote_identifier( $column_name ); + $column_value = $this->connection->quote( $column_name ); + + return array( + sprintf( + 'CREATE OR REPLACE FUNCTION %s() +RETURNS trigger +LANGUAGE plpgsql +AS $wp_mysql_on_update$ +BEGIN + IF NEW.%s IS NOT DISTINCT FROM OLD.%s + AND to_jsonb(NEW) - %s IS DISTINCT FROM to_jsonb(OLD) - %s THEN + NEW.%s = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\'); + END IF; + RETURN NEW; +END; +$wp_mysql_on_update$', + $function_sql, + $column_sql, + $column_sql, + $column_value, + $column_value, + $column_sql + ), + sprintf( + 'DROP TRIGGER IF EXISTS %s ON %s', + $this->connection->quote_identifier( $trigger_name ), + $table_sql + ), + sprintf( + 'CREATE TRIGGER %s BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION %s()', + $this->connection->quote_identifier( $trigger_name ), + $table_sql, + $function_sql + ), + ); + } + + /** + * Get drop trigger/function statements for a MySQL ON UPDATE CURRENT_TIMESTAMP column. + * + * @param string $table_schema Backend schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string[] PostgreSQL statements. + */ + private function get_postgresql_on_update_current_timestamp_drop_statements( string $table_schema, string $table_name, string $column_name ): array { + if ( 'pgsql' !== $this->connection->get_driver_name() ) { + return array(); + } + + $trigger_name = $this->get_postgresql_on_update_current_timestamp_trigger_name( $table_schema, $table_name, $column_name ); + $function_name = $this->get_postgresql_on_update_current_timestamp_function_name( $table_schema, $table_name, $column_name ); + + return array( + sprintf( + 'DROP TRIGGER IF EXISTS %s ON %s', + $this->connection->quote_identifier( $trigger_name ), + $this->get_postgresql_schema_identifier( $table_schema, $table_name ) + ), + sprintf( + 'DROP FUNCTION IF EXISTS %s()', + $this->get_postgresql_schema_identifier( $table_schema, $function_name ) + ), + ); + } + + /** + * Get a stable PostgreSQL trigger name for an ON UPDATE column. + * + * @param string $table_schema Backend schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string Trigger name. + */ + private function get_postgresql_on_update_current_timestamp_trigger_name( string $table_schema, string $table_name, string $column_name ): string { + return '__wp_pg_on_update_' . substr( sha1( $table_schema . "\0" . $table_name . "\0" . $column_name ), 0, 32 ); + } + + /** + * Get a stable PostgreSQL function name for an ON UPDATE column. + * + * @param string $table_schema Backend schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string Function name. + */ + private function get_postgresql_on_update_current_timestamp_function_name( string $table_schema, string $table_name, string $column_name ): string { + return '__wp_pg_on_update_fn_' . substr( sha1( $table_schema . "\0" . $table_name . "\0" . $column_name ), 0, 29 ); + } + + /** + * Get the metadata schema name for an active temporary table. + * + * @param string $table_name Table name. + * @return string Metadata schema name. + */ + private function get_temporary_schema_for_metadata_table( string $table_name ): string { + $schema_name = $this->get_active_temporary_table_schema( $table_name ); + if ( null !== $schema_name ) { + return $schema_name; + } + + return $this->get_temporary_drop_table_schema_name(); + } + + /** + * Delete stored MySQL schema metadata for dropped tables. + * + * @param string[] $table_names Table names. + * @param string $table_schema Metadata schema name. + */ + private function delete_mysql_schema_metadata_for_tables( array $table_names, string $table_schema = 'public' ): void { + if ( empty( $table_names ) ) { + return; + } + + $this->ensure_mysql_schema_metadata_tables(); + + foreach ( $table_names as $table_name ) { + $params = array( $table_schema, $table_name ); + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_TABLE_METADATA_TABLE ) + ), + $params + ); + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + $params + ); + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + $params + ); + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + $params + ); + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_CHECK_METADATA_TABLE ) + ), + $params + ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + } + + /** + * Delete stored MySQL schema metadata for concrete schema/table targets. + * + * @param array[] $targets Metadata targets. + */ + private function delete_mysql_schema_metadata_for_table_targets( array $targets ): void { + foreach ( $targets as $target ) { + $this->delete_mysql_schema_metadata_for_tables( + array( $target['table'] ), + $target['schema'] + ); + } + } + + /** + * Apply metadata changes for a translated dbDelta ALTER TABLE statement. + * + * @param array $metadata ALTER metadata. + */ + private function apply_mysql_dbdelta_alter_metadata( array $metadata ): void { + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = $metadata['schema'] ?? 'public'; + $table_name = $metadata['table']; + + if ( 'operations' === $metadata['operation'] ) { + foreach ( $metadata['operations'] as $operation_metadata ) { + $operation_metadata['schema'] = $table_schema; + $operation_metadata['table'] = $table_name; + $this->apply_mysql_dbdelta_alter_metadata( $operation_metadata ); + } + return; + } + + if ( 'noop' === $metadata['operation'] ) { + return; + } + + if ( 'set_auto_increment' === $metadata['operation'] ) { + return; + } + + if ( 'set_table_comment' === $metadata['operation'] ) { + $this->update_mysql_table_comment_metadata( + $table_schema, + $table_name, + (string) ( $metadata['comment'] ?? '' ) + ); + return; + } + + if ( 'rename_table' === $metadata['operation'] ) { + $this->apply_mysql_rename_table_metadata( + array( + 'schema' => $table_schema, + 'old_table' => $table_name, + 'new_table' => $metadata['new_table'], + ) + ); + return; + } + + if ( 'add_column' === $metadata['operation'] ) { + $column = $metadata['column']; + $column['ordinal'] = $this->get_next_mysql_column_ordinal( $table_schema, $table_name ); + $column_nullable = array( strtolower( $column['name'] ) => $column['nullable'] ?? 'YES' ); + $this->insert_mysql_column_metadata( $table_schema, $table_name, $column ); + if ( $this->mysql_column_extra_has_on_update_current_timestamp( $column['extra'] ?? '' ) ) { + $this->execute_postgresql_side_effect_statements( + $this->get_postgresql_on_update_current_timestamp_create_statements( $table_schema, $table_name, $column['name'] ) + ); + } + $index_ordinal = $this->get_next_mysql_index_ordinal( $table_schema, $table_name ); + foreach ( $metadata['indexes'] ?? array() as $index ) { + $index['ordinal'] = $index_ordinal; + $this->insert_mysql_index_metadata( $table_schema, $table_name, $index, $column_nullable ); + ++$index_ordinal; + } + foreach ( $metadata['foreign_keys'] ?? array() as $foreign_key ) { + $foreign_key['referenced_schema'] = $foreign_key['referenced_schema'] ?? $table_schema; + $this->insert_mysql_foreign_key_metadata( $table_schema, $table_name, $foreign_key ); + } + foreach ( $metadata['checks'] ?? array() as $check ) { + $this->insert_mysql_check_metadata( $table_schema, $table_name, $check ); + } + return; + } + + if ( 'change_column' === $metadata['operation'] ) { + $old_extra = $this->get_mysql_column_extra_metadata( $table_schema, $table_name, $metadata['old_column'] ); + $column = $metadata['column']; + $column['ordinal'] = $this->get_existing_mysql_column_ordinal( + $table_schema, + $table_name, + $metadata['old_column'] + ) ?? $this->get_next_mysql_column_ordinal( $table_schema, $table_name ); + + $this->delete_mysql_column_metadata( $table_schema, $table_name, $metadata['old_column'] ); + $this->insert_mysql_column_metadata( $table_schema, $table_name, $column ); + $column_nullable = array( strtolower( $column['name'] ) => $column['nullable'] ?? 'YES' ); + if ( $this->mysql_column_extra_has_on_update_current_timestamp( $old_extra ) ) { + $this->execute_postgresql_side_effect_statements( + $this->get_postgresql_on_update_current_timestamp_drop_statements( $table_schema, $table_name, $metadata['old_column'] ) + ); + } + if ( $this->mysql_column_extra_has_on_update_current_timestamp( $column['extra'] ?? '' ) ) { + $this->execute_postgresql_side_effect_statements( + $this->get_postgresql_on_update_current_timestamp_create_statements( $table_schema, $table_name, $column['name'] ) + ); + } + $this->rename_mysql_index_column_metadata( + $table_schema, + $table_name, + $metadata['old_column'], + $column['name'] + ); + $this->rename_mysql_foreign_key_column_metadata( + $table_schema, + $table_name, + $metadata['old_column'], + $column['name'] + ); + $this->rename_mysql_referenced_foreign_key_column_metadata( + $table_schema, + $table_name, + $metadata['old_column'], + $column['name'] + ); + $index_ordinal = $this->get_next_mysql_index_ordinal( $table_schema, $table_name ); + foreach ( $metadata['indexes'] ?? array() as $index ) { + $index['ordinal'] = $index_ordinal; + $this->delete_mysql_index_metadata( $table_schema, $table_name, $index['name'] ); + $this->insert_mysql_index_metadata( $table_schema, $table_name, $index, $column_nullable ); + ++$index_ordinal; + } + return; + } + + if ( 'change_columns' === $metadata['operation'] ) { + foreach ( $metadata['columns'] as $column_metadata ) { + $column_metadata['operation'] = 'change_column'; + $column_metadata['table'] = $table_name; + $this->apply_mysql_dbdelta_alter_metadata( $column_metadata ); + } + return; + } + + if ( 'rename_column' === $metadata['operation'] ) { + $old_extra = $this->get_mysql_column_extra_metadata( $table_schema, $table_name, $metadata['old_column'] ); + $this->rename_mysql_column_metadata( + $table_schema, + $table_name, + $metadata['old_column'], + $metadata['new_column'] + ); + $this->rename_mysql_index_column_metadata( + $table_schema, + $table_name, + $metadata['old_column'], + $metadata['new_column'] + ); + $this->rename_mysql_foreign_key_column_metadata( + $table_schema, + $table_name, + $metadata['old_column'], + $metadata['new_column'] + ); + $this->rename_mysql_referenced_foreign_key_column_metadata( + $table_schema, + $table_name, + $metadata['old_column'], + $metadata['new_column'] + ); + if ( $this->mysql_column_extra_has_on_update_current_timestamp( $old_extra ) ) { + $this->execute_postgresql_side_effect_statements( + array_merge( + $this->get_postgresql_on_update_current_timestamp_drop_statements( $table_schema, $table_name, $metadata['old_column'] ), + $this->get_postgresql_on_update_current_timestamp_create_statements( $table_schema, $table_name, $metadata['new_column'] ) + ) + ); + } + return; + } + + if ( 'drop_column' === $metadata['operation'] ) { + $old_extra = $this->get_mysql_column_extra_metadata( $table_schema, $table_name, $metadata['column'] ); + if ( $this->mysql_column_extra_has_on_update_current_timestamp( $old_extra ) ) { + $this->execute_postgresql_side_effect_statements( + $this->get_postgresql_on_update_current_timestamp_drop_statements( $table_schema, $table_name, $metadata['column'] ) + ); + } + $this->delete_mysql_index_metadata_for_column( $table_schema, $table_name, $metadata['column'] ); + $this->delete_mysql_foreign_key_metadata_for_column( $table_schema, $table_name, $metadata['column'] ); + $this->delete_mysql_column_metadata( $table_schema, $table_name, $metadata['column'] ); + return; + } + + if ( 'add_index' === $metadata['operation'] ) { + $this->delete_mysql_index_metadata( $table_schema, $table_name, $metadata['index']['name'] ); + $this->insert_mysql_index_metadata( $table_schema, $table_name, $metadata['index'] ); + return; + } + + if ( 'drop_index' === $metadata['operation'] ) { + $this->apply_mysql_drop_index_metadata( $metadata ); + return; + } + + if ( 'rename_index' === $metadata['operation'] ) { + $this->rename_mysql_index_metadata( + $table_schema, + $table_name, + $metadata['old_index'], + $metadata['new_index'] + ); + return; + } + + if ( 'add_foreign_key' === $metadata['operation'] ) { + $this->insert_mysql_foreign_key_metadata( $table_schema, $table_name, $metadata['foreign_key'] ); + return; + } + + if ( 'add_check' === $metadata['operation'] ) { + $this->insert_mysql_check_metadata( $table_schema, $table_name, $metadata['check'] ); + return; + } + + if ( 'drop_foreign_key' === $metadata['operation'] ) { + $this->delete_mysql_foreign_key_metadata( $table_schema, $table_name, $metadata['constraint'] ); + return; + } + + if ( 'drop_check' === $metadata['operation'] ) { + $this->delete_mysql_check_metadata( $table_schema, $table_name, $metadata['constraint'] ); + return; + } + + if ( 'set_default' === $metadata['operation'] ) { + $this->connection->query( + sprintf( + 'UPDATE %s SET column_default = ? WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $metadata['default'], $table_schema, $table_name, $metadata['column'] ) + ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + return; + } + + if ( 'drop_default' === $metadata['operation'] ) { + $this->connection->query( + sprintf( + 'UPDATE %s SET column_default = NULL WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $metadata['column'] ) + ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + } + + /** + * Apply MySQL-facing metadata changes after a table rename. + * + * @param array{schema?: string, old_table?: string, new_table?: string, renames?: array} $metadata Rename metadata. + */ + private function apply_mysql_rename_table_metadata( array $metadata ): void { + if ( isset( $metadata['renames'] ) && is_array( $metadata['renames'] ) ) { + foreach ( $metadata['renames'] as $rename_metadata ) { + $this->apply_mysql_rename_table_metadata( $rename_metadata ); + } + return; + } + + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = $metadata['schema']; + $old_table_name = $metadata['old_table']; + $new_table_name = $metadata['new_table']; + if ( $old_table_name === $new_table_name ) { + return; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT DISTINCT table_schema, table_name FROM %s WHERE referenced_table_schema = ? AND referenced_table_name = ?', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( $table_schema, $old_table_name ) + ); + $referencing_tables = $stmt->fetchAll( PDO::FETCH_ASSOC ); + + foreach ( array( self::MYSQL_TABLE_METADATA_TABLE, self::MYSQL_COLUMN_METADATA_TABLE, self::MYSQL_INDEX_METADATA_TABLE, self::MYSQL_FOREIGN_KEY_METADATA_TABLE, self::MYSQL_CHECK_METADATA_TABLE ) as $metadata_table ) { + $this->connection->query( + sprintf( + 'UPDATE %s SET table_name = ? WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( $metadata_table ) + ), + array( $new_table_name, $table_schema, $old_table_name ) + ); + } + + $this->connection->query( + sprintf( + 'UPDATE %s SET referenced_table_name = ? WHERE referenced_table_schema = ? AND referenced_table_name = ?', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( $new_table_name, $table_schema, $old_table_name ) + ); + + $this->clear_mysql_metadata_cache_for_table( $table_schema, $old_table_name ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $new_table_name ); + foreach ( $referencing_tables as $referencing_table ) { + $this->clear_mysql_metadata_cache_for_table( + (string) $referencing_table['table_schema'], + (string) $referencing_table['table_name'] + ); + } + } + + /** + * Check whether ALTER metadata contains an operation. + * + * @param array $metadata ALTER metadata. + * @param string $operation Operation name. + * @return bool Whether the operation is present. + */ + private function mysql_dbdelta_alter_metadata_has_operation( array $metadata, string $operation ): bool { + if ( ( $metadata['operation'] ?? '' ) === $operation ) { + return true; + } + + if ( 'operations' !== ( $metadata['operation'] ?? '' ) ) { + return false; + } + + foreach ( $metadata['operations'] ?? array() as $operation_metadata ) { + if ( is_array( $operation_metadata ) && $this->mysql_dbdelta_alter_metadata_has_operation( $operation_metadata, $operation ) ) { + return true; + } + } + + return false; + } + + /** + * Store MySQL metadata for a standalone CREATE INDEX statement. + * + * @param array $metadata CREATE INDEX metadata. + */ + private function apply_mysql_create_index_metadata( array $metadata ): void { + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = $metadata['schema']; + $table_name = $metadata['table']; + $index = $metadata['index']; + + $this->delete_mysql_index_metadata( $table_schema, $table_name, $index['name'] ); + $index['ordinal'] = $this->get_next_mysql_index_ordinal( $table_schema, $table_name ); + $this->insert_mysql_index_metadata( $table_schema, $table_name, $index ); + } + + /** + * Remove MySQL metadata for a standalone DROP INDEX statement. + * + * @param array $metadata DROP INDEX metadata. + */ + private function apply_mysql_drop_index_metadata( array $metadata ): void { + $this->ensure_mysql_schema_metadata_tables(); + + $this->delete_mysql_index_metadata( + $metadata['schema'], + $metadata['table'], + $metadata['index'] + ); + } + + /** + * Insert or replace table metadata. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param array $metadata Table metadata. + */ + private function insert_mysql_table_metadata( string $table_schema, string $table_name, array $metadata ): void { + $this->connection->query( + sprintf( + 'INSERT INTO %s + (table_schema, table_name, table_comment) + VALUES (?, ?, ?)', + $this->connection->quote_identifier( self::MYSQL_TABLE_METADATA_TABLE ) + ), + array( + $table_schema, + $table_name, + $metadata['comment'] ?? '', + ) + ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Update MySQL-facing table comment metadata. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $table_comment Table comment. + */ + private function update_mysql_table_comment_metadata( string $table_schema, string $table_name, string $table_comment ): void { + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_TABLE_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + $this->insert_mysql_table_metadata( + $table_schema, + $table_name, + array( + 'comment' => $table_comment, + ) + ); + } + + /** + * Insert or replace column metadata. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param array $column Column metadata. + */ + private function insert_mysql_column_metadata( string $table_schema, string $table_name, array $column ): void { + $this->delete_mysql_column_metadata( $table_schema, $table_name, $column['name'] ); + $this->connection->query( + sprintf( + 'INSERT INTO %s + (table_schema, table_name, column_name, ordinal_position, column_type, character_set_name, collation_name, is_nullable, column_default, extra, column_comment) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( + $table_schema, + $table_name, + $column['name'], + $column['ordinal'], + $column['type'], + $column['charset'] ?? null, + $column['collation'] ?? null, + $column['nullable'] ?? 'YES', + $column['default'] ?? null, + $column['extra'] ?? '', + $column['comment'] ?? '', + ) + ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Delete metadata for one column. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + */ + private function delete_mysql_column_metadata( string $table_schema, string $table_name, string $column_name ): void { + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Rename stored MySQL column metadata. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $old_column_name Old column name. + * @param string $new_column_name New column name. + */ + private function rename_mysql_column_metadata( + string $table_schema, + string $table_name, + string $old_column_name, + string $new_column_name + ): void { + if ( $old_column_name === $new_column_name ) { + return; + } + + $this->connection->query( + sprintf( + 'UPDATE %s SET column_name = ? WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $new_column_name, $table_schema, $table_name, $old_column_name ) + ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Insert MySQL SHOW INDEX metadata rows for an index. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param array $index Index metadata. + * @param array|null $column_nullable Optional nullable metadata keyed by lowercase column. + */ + private function insert_mysql_index_metadata( + string $table_schema, + string $table_name, + array $index, + ?array $column_nullable = null + ): void { + $column_nullable = $column_nullable ?? array(); + + foreach ( $index['columns'] as $column ) { + $is_nullable = $column_nullable[ strtolower( $column['column_name'] ) ] + ?? $this->get_mysql_column_nullable( $table_schema, $table_name, $column['column_name'] ); + + $this->connection->query( + sprintf( + 'INSERT INTO %s + (table_schema, table_name, key_name, index_ordinal, seq_in_index, column_name, non_unique, index_type, "collation", sub_part, nullable, index_comment) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( + $table_schema, + $table_name, + $index['name'], + $index['ordinal'], + $column['seq_in_index'], + $column['column_name'], + $index['non_unique'], + $index['index_type'], + strtoupper( (string) $index['index_type'] ) === 'FULLTEXT' ? null : ( $column['collation'] ?? 'A' ), + null === $column['sub_part'] ? null : (string) $column['sub_part'], + 'NO' === $is_nullable ? '' : 'YES', + $index['comment'] ?? '', + ) + ); + } + + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Delete metadata rows for one index. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $index_name Index name. + */ + private function delete_mysql_index_metadata( string $table_schema, string $table_name, string $index_name ): void { + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ? AND LOWER(key_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $index_name ) + ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Rename stored MySQL index metadata. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $old_index_name Old index name. + * @param string $new_index_name New index name. + */ + private function rename_mysql_index_metadata( + string $table_schema, + string $table_name, + string $old_index_name, + string $new_index_name + ): void { + if ( $old_index_name === $new_index_name ) { + return; + } + + $this->connection->query( + sprintf( + 'UPDATE %s SET key_name = ? WHERE table_schema = ? AND table_name = ? AND LOWER(key_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $new_index_name, $table_schema, $table_name, $old_index_name ) + ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Check whether stored MySQL metadata has an index with the given name. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $index_name Index name. + * @return bool Whether the index metadata exists. + */ + private function mysql_index_metadata_exists( string $table_schema, string $table_name, string $index_name ): bool { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ? AND LOWER(key_name) = LOWER(?) LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $index_name ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Check whether stored MySQL metadata has a unique index with the given name. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $index_name Index name. + * @return bool Whether the unique index metadata exists. + */ + private function mysql_unique_index_metadata_exists( string $table_schema, string $table_name, string $index_name ): bool { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ? AND LOWER(key_name) = LOWER(?) AND non_unique = \'0\' LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $index_name ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Check whether stored MySQL metadata has any indexes for the given table. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @return bool Whether index metadata exists. + */ + private function mysql_index_metadata_has_rows( string $table_schema, string $table_name ): bool { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ? LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Insert MySQL foreign key metadata rows for a constraint. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param array $foreign_key Foreign key metadata. + */ + private function insert_mysql_foreign_key_metadata( string $table_schema, string $table_name, array $foreign_key ): void { + $this->delete_mysql_foreign_key_metadata( $table_schema, $table_name, $foreign_key['name'] ); + + $ordinal = $this->get_next_mysql_foreign_key_ordinal( $table_schema, $table_name ); + foreach ( $foreign_key['columns'] as $index => $column_name ) { + $this->connection->query( + sprintf( + 'INSERT INTO %s + (table_schema, table_name, constraint_name, constraint_ordinal, seq_in_index, column_name, referenced_table_schema, referenced_table_name, referenced_column_name, update_rule, delete_rule) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( + $table_schema, + $table_name, + $foreign_key['name'], + $ordinal, + $index + 1, + $column_name, + $foreign_key['referenced_schema'], + $foreign_key['referenced_table'], + $foreign_key['referenced_columns'][ $index ], + $foreign_key['update_rule'], + $foreign_key['delete_rule'], + ) + ); + } + + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Insert MySQL CHECK constraint metadata. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param array $check CHECK constraint metadata. + */ + private function insert_mysql_check_metadata( string $table_schema, string $table_name, array $check ): void { + $this->delete_mysql_check_metadata( $table_schema, $table_name, $check['name'] ); + + $this->connection->query( + sprintf( + 'INSERT INTO %s + (table_schema, table_name, constraint_name, constraint_ordinal, check_clause, enforced) + VALUES (?, ?, ?, ?, ?, ?)', + $this->connection->quote_identifier( self::MYSQL_CHECK_METADATA_TABLE ) + ), + array( + $table_schema, + $table_name, + $check['name'], + $this->get_next_mysql_check_metadata_ordinal( $table_schema, $table_name ), + $check['check_clause'], + $check['enforced'] ?? 'YES', + ) + ); + + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Delete metadata rows for one CHECK constraint. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $constraint_name Constraint name. + */ + private function delete_mysql_check_metadata( string $table_schema, string $table_name, string $constraint_name ): void { + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ? AND LOWER(constraint_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_CHECK_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $constraint_name ) + ); + + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Get stored metadata for one CHECK constraint. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $constraint_name Constraint name. + * @return array|null CHECK metadata row, or null when absent. + */ + private function get_mysql_check_metadata( string $table_schema, string $table_name, string $constraint_name ): ?array { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT constraint_name, check_clause, enforced + FROM %s + WHERE table_schema = ? AND table_name = ? AND LOWER(constraint_name) = LOWER(?) + LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_CHECK_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $constraint_name ) + ); + + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + return false === $row ? null : $row; + } + + /** + * Get the next CHECK metadata ordinal for a table. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @return int Next ordinal. + */ + private function get_next_mysql_check_metadata_ordinal( string $table_schema, string $table_name ): int { + $stmt = $this->connection->query( + sprintf( + 'SELECT COALESCE(MAX(constraint_ordinal), 0) + 1 FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_CHECK_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + return (int) $stmt->fetchColumn(); + } + + /** + * Delete metadata rows for one foreign key. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $constraint_name Constraint name. + */ + private function delete_mysql_foreign_key_metadata( string $table_schema, string $table_name, string $constraint_name ): void { + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ? AND LOWER(constraint_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $constraint_name ) + ); + + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Delete metadata for foreign keys that reference one local column. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $column_name Dropped column name. + */ + private function delete_mysql_foreign_key_metadata_for_column( string $table_schema, string $table_name, string $column_name ): void { + $stmt = $this->connection->query( + sprintf( + 'SELECT DISTINCT constraint_name FROM %s WHERE table_schema = ? AND table_name = ? AND LOWER(column_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN, 0 ) as $constraint_name ) { + $this->delete_mysql_foreign_key_metadata( $table_schema, $table_name, (string) $constraint_name ); + } + + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Rename local foreign key column metadata after ALTER TABLE CHANGE COLUMN. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $old_column_name Old column name. + * @param string $new_column_name New column name. + */ + private function rename_mysql_foreign_key_column_metadata( + string $table_schema, + string $table_name, + string $old_column_name, + string $new_column_name + ): void { + if ( $old_column_name === $new_column_name ) { + return; + } + + $this->connection->query( + sprintf( + 'UPDATE %s SET column_name = ? WHERE table_schema = ? AND table_name = ? AND LOWER(column_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( $new_column_name, $table_schema, $table_name, $old_column_name ) + ); + + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Rename referenced foreign key column metadata after a referenced column rename. + * + * @param string $table_schema Referenced table schema. + * @param string $table_name Referenced table name. + * @param string $old_column_name Old referenced column name. + * @param string $new_column_name New referenced column name. + */ + private function rename_mysql_referenced_foreign_key_column_metadata( + string $table_schema, + string $table_name, + string $old_column_name, + string $new_column_name + ): void { + if ( $old_column_name === $new_column_name ) { + return; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT DISTINCT table_schema, table_name FROM %s WHERE referenced_table_schema = ? AND referenced_table_name = ? AND LOWER(referenced_column_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $old_column_name ) + ); + $referencing_tables = $stmt->fetchAll( PDO::FETCH_ASSOC ); + + $this->connection->query( + sprintf( + 'UPDATE %s SET referenced_column_name = ? WHERE referenced_table_schema = ? AND referenced_table_name = ? AND LOWER(referenced_column_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( $new_column_name, $table_schema, $table_name, $old_column_name ) + ); + + foreach ( $referencing_tables as $referencing_table ) { + $this->clear_mysql_metadata_cache_for_table( + (string) $referencing_table['table_schema'], + (string) $referencing_table['table_name'] + ); + } + } + + /** + * Get the next stored foreign key ordinal for a table. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @return int Next ordinal. + */ + private function get_next_mysql_foreign_key_ordinal( string $table_schema, string $table_name ): int { + $stmt = $this->connection->query( + sprintf( + 'SELECT COALESCE(MAX(constraint_ordinal), 0) + 1 FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + return (int) $stmt->fetchColumn(); + } + + /** + * Generate the next MySQL-style unnamed foreign key constraint name. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string[] $reserved Names already generated for this statement. + * @return string Constraint name. + */ + private function get_next_mysql_foreign_key_constraint_name( string $table_schema, string $table_name, array $reserved = array() ): string { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT DISTINCT constraint_name FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + $max_suffix = 0; + $pattern = '/^' . preg_quote( $table_name, '/' ) . '_ibfk_(\d+)$/i'; + foreach ( array_merge( $stmt->fetchAll( PDO::FETCH_COLUMN, 0 ), $reserved ) as $constraint_name ) { + if ( 1 === preg_match( $pattern, (string) $constraint_name, $matches ) ) { + $max_suffix = max( $max_suffix, (int) $matches[1] ); + } + } + + return sprintf( '%s_ibfk_%d', $table_name, $max_suffix + 1 ); + } + + /** + * Check whether stored MySQL metadata has a foreign key with the given name. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $constraint_name Constraint name. + * @return bool Whether the foreign key metadata exists. + */ + private function mysql_foreign_key_metadata_exists( string $table_schema, string $table_name, string $constraint_name ): bool { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ? AND LOWER(constraint_name) = LOWER(?) LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $constraint_name ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Delete metadata key parts for one dropped column and renumber surviving parts. + * + * MySQL removes a dropped column from every index that contains it. Indexes + * remain visible when at least one key part survives. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $column_name Dropped column name. + */ + private function delete_mysql_index_metadata_for_column( string $table_schema, string $table_name, string $column_name ): void { + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + + $this->connection->query( + sprintf( + 'DELETE FROM %s WHERE table_schema = ? AND table_name = ? AND LOWER(column_name) = LOWER(?)', + $index_metadata_table + ), + array( $table_schema, $table_name, $column_name ) + ); + + $this->connection->query( + sprintf( + 'WITH renumbered AS ( + SELECT + table_schema, + table_name, + key_name, + seq_in_index AS old_seq_in_index, + row_number() OVER (PARTITION BY key_name ORDER BY seq_in_index) AS new_seq_in_index + FROM %1$s + WHERE table_schema = ? + AND table_name = ? + ) + UPDATE %1$s AS im + SET seq_in_index = -( + SELECT new_seq_in_index + FROM renumbered + WHERE renumbered.table_schema = im.table_schema + AND renumbered.table_name = im.table_name + AND renumbered.key_name = im.key_name + AND renumbered.old_seq_in_index = im.seq_in_index + ) + WHERE im.table_schema = ? + AND im.table_name = ? + AND EXISTS ( + SELECT 1 + FROM renumbered + WHERE renumbered.table_schema = im.table_schema + AND renumbered.table_name = im.table_name + AND renumbered.key_name = im.key_name + AND renumbered.old_seq_in_index = im.seq_in_index + )', + $index_metadata_table + ), + array( $table_schema, $table_name, $table_schema, $table_name ) + ); + + $this->connection->query( + sprintf( + 'UPDATE %s SET seq_in_index = -seq_in_index WHERE table_schema = ? AND table_name = ? AND seq_in_index < 0', + $index_metadata_table + ), + array( $table_schema, $table_name ) + ); + + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Get the backend primary key constraint name for a MySQL table. + * + * @param string $table_schema Backend schema. + * @param string $table_name Table name. + * @return string PostgreSQL primary key constraint name. + */ + private function get_postgresql_primary_key_constraint_name( string $table_schema, string $table_name ): string { + try { + $stmt = $this->connection->query( + "SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_schema = ? + AND table_name = ? + AND constraint_type = 'PRIMARY KEY' + ORDER BY constraint_name + LIMIT 1", + array( $table_schema, $table_name ) + ); + + $constraint_name = $stmt->fetchColumn(); + if ( false !== $constraint_name && '' !== (string) $constraint_name ) { + return (string) $constraint_name; + } + } catch ( PDOException $e ) { + // Test fixtures and SQLite-backed connections may not expose PostgreSQL catalogs. + } + + return $table_name . '_pkey'; + } + + /** + * Generate the next MySQL-compatible CHECK constraint name for a table. + * + * @param string $table_schema Backend schema. + * @param string $table_name Table name. + * @param array $reserved Constraint names already reserved in the current statement. + * @return string Generated CHECK constraint name. + */ + private function get_next_mysql_check_constraint_name( string $table_schema, string $table_name, array $reserved = array() ): string { + $prefix = $table_name . '_chk_'; + $max = 0; + + try { + $stmt = $this->connection->query( + "SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_schema = ? + AND table_name = ? + AND constraint_type = 'CHECK'", + array( $table_schema, $table_name ) + ); + + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN, 0 ) as $constraint_name ) { + if ( 1 === preg_match( '/^' . preg_quote( $prefix, '/' ) . '([1-9][0-9]*)$/', (string) $constraint_name, $matches ) ) { + $max = max( $max, (int) $matches[1] ); + } + } + } catch ( PDOException $e ) { + // Test fixtures and SQLite-backed connections may not expose PostgreSQL catalogs. + } + + try { + $this->ensure_mysql_schema_metadata_tables(); + $stmt = $this->connection->query( + sprintf( + 'SELECT constraint_name + FROM %s + WHERE table_schema = ? + AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_CHECK_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN, 0 ) as $constraint_name ) { + if ( 1 === preg_match( '/^' . preg_quote( $prefix, '/' ) . '([1-9][0-9]*)$/', (string) $constraint_name, $matches ) ) { + $max = max( $max, (int) $matches[1] ); + } + } + } catch ( PDOException $e ) { + // Test fixtures and SQLite-backed connections may not expose metadata side tables yet. + } + + foreach ( $reserved as $constraint_name ) { + if ( 1 === preg_match( '/^' . preg_quote( $prefix, '/' ) . '([1-9][0-9]*)$/', (string) $constraint_name, $matches ) ) { + $max = max( $max, (int) $matches[1] ); + } + } + + return $prefix . ( $max + 1 ); + } + + /** + * Rename index column metadata after ALTER TABLE CHANGE COLUMN. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $old_column_name Old column name. + * @param string $new_column_name New column name. + */ + private function rename_mysql_index_column_metadata( + string $table_schema, + string $table_name, + string $old_column_name, + string $new_column_name + ): void { + if ( $old_column_name === $new_column_name ) { + return; + } + + $this->connection->query( + sprintf( + 'UPDATE %s SET column_name = ? WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $new_column_name, $table_schema, $table_name, $old_column_name ) + ); + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Get the next stored column ordinal for a table. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @return int Next ordinal. + */ + private function get_next_mysql_column_ordinal( string $table_schema, string $table_name ): int { + $stmt = $this->connection->query( + sprintf( + 'SELECT COALESCE(MAX(ordinal_position), 0) + 1 FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + return (int) $stmt->fetchColumn(); + } + + /** + * Get an existing stored column ordinal. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return int|null Existing ordinal, or null. + */ + private function get_existing_mysql_column_ordinal( string $table_schema, string $table_name, string $column_name ): ?int { + $stmt = $this->connection->query( + sprintf( + 'SELECT ordinal_position FROM %s WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $ordinal = $stmt->fetchColumn(); + return false === $ordinal ? null : (int) $ordinal; + } + + /** + * Get the next stored index ordinal for a table. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @return int Next ordinal. + */ + private function get_next_mysql_index_ordinal( string $table_schema, string $table_name ): int { + $stmt = $this->connection->query( + sprintf( + 'SELECT COALESCE(MAX(index_ordinal), 0) + 1 FROM %s WHERE table_schema = ? AND table_name = ?', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + return (int) $stmt->fetchColumn(); + } + + /** + * Get stored nullable metadata for an index column. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string MySQL nullable value. + */ + private function get_mysql_column_nullable( string $table_schema, string $table_name, string $column_name ): string { + $stmt = $this->connection->query( + sprintf( + 'SELECT is_nullable FROM %s WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $nullable = $stmt->fetchColumn(); + return false === $nullable ? 'YES' : (string) $nullable; + } + + /** + * Get stored MySQL extra metadata for a table column. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string Extra metadata. + */ + private function get_mysql_column_extra_metadata( string $table_schema, string $table_name, string $column_name ): string { + $stmt = $this->connection->query( + sprintf( + 'SELECT extra FROM %s WHERE table_schema = ? AND table_name = ? AND column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $extra = $stmt->fetchColumn(); + return false === $extra ? '' : (string) $extra; + } + + /** + * Get the stored MySQL type for a table column. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string|null MySQL column type, or null when unavailable. + */ + private function get_mysql_table_column_type( + string $table_schema, + string $table_name, + string $column_name + ): ?string { + $this->ensure_mysql_schema_metadata_tables(); + + $table_cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + $column_cache_key = strtolower( $column_name ); + if ( + isset( $this->mysql_table_column_type_cache[ $table_cache_key ] ) + && array_key_exists( $column_cache_key, $this->mysql_table_column_type_cache[ $table_cache_key ] ) + ) { + return $this->mysql_table_column_type_cache[ $table_cache_key ][ $column_cache_key ]; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT column_type FROM %s + WHERE table_schema = ? + AND table_name = ? + AND LOWER(column_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $column_type = $stmt->fetchColumn(); + $this->mysql_table_column_type_cache[ $table_cache_key ][ $column_cache_key ] = false === $column_type + ? null + : (string) $column_type; + + return $this->mysql_table_column_type_cache[ $table_cache_key ][ $column_cache_key ]; + } + + /** + * Get the stored MySQL collation for a table column. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return string|null MySQL collation, or null when unavailable. + */ + private function get_mysql_table_column_collation( + string $table_schema, + string $table_name, + string $column_name + ): ?string { + $this->ensure_mysql_schema_metadata_tables(); + + $table_cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + $column_cache_key = strtolower( $column_name ); + if ( + isset( $this->mysql_table_column_collation_cache[ $table_cache_key ] ) + && array_key_exists( $column_cache_key, $this->mysql_table_column_collation_cache[ $table_cache_key ] ) + ) { + return $this->mysql_table_column_collation_cache[ $table_cache_key ][ $column_cache_key ]; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT collation_name FROM %s + WHERE table_schema = ? + AND table_name = ? + AND LOWER(column_name) = LOWER(?)', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $collation = $stmt->fetchColumn(); + $this->mysql_table_column_collation_cache[ $table_cache_key ][ $column_cache_key ] = false === $collation || null === $collation + ? null + : (string) $collation; + + return $this->mysql_table_column_collation_cache[ $table_cache_key ][ $column_cache_key ]; + } + + /** + * Check whether stored MySQL metadata exists for a table. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @return bool Whether metadata exists. + */ + private function mysql_table_has_column_metadata( string $table_schema, string $table_name ): bool { + $this->ensure_mysql_schema_metadata_tables(); + + $cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + if ( array_key_exists( $cache_key, $this->mysql_table_has_column_metadata_cache ) ) { + return $this->mysql_table_has_column_metadata_cache[ $cache_key ]; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s WHERE table_schema = ? AND table_name = ? LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + $this->mysql_table_has_column_metadata_cache[ $cache_key ] = false !== $stmt->fetchColumn(); + return $this->mysql_table_has_column_metadata_cache[ $cache_key ]; + } + + /** + * Translate supported MySQL CREATE TABLE ... [AS] SELECT statements to PostgreSQL. + * + * @param string $query MySQL CREATE TABLE ... SELECT query. + * @return array{statements: string[], schema: string, table: string, temporary: bool, metadata_query: string|null, table_comment: string, noop: bool}|null Translation, or null when this is not CTAS. + */ + private function translate_mysql_create_table_select_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + $position = 1; + $is_temporary = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + $is_temporary = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $if_not_exists = false; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $if_not_exists = true; + $position += 3; + } + + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $table_reference ) { + return null; + } + + $definition_start = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $parenthesized_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $parenthesized_end ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + $is_parenthesized_select = $parenthesized_end === $statement_end + && isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id; + if ( ! $is_parenthesized_select ) { + $definition_start = $position; + $position = $parenthesized_end; + } + } + + $table_comment = ''; + if ( ! $this->consume_mysql_create_table_select_options( $tokens, $position, $statement_end, $table_comment ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + $metadata_end = $position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $select_start = $position; + $select_end = $statement_end; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $parenthesized_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( + null !== $parenthesized_end + && $parenthesized_end === $statement_end + && isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $select_start = $position + 1; + $select_end = $parenthesized_end - 1; + } + } + + if ( ! isset( $tokens[ $select_start ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $select_start ]->id ) { + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $definition_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null !== $definition_end && isset( $tokens[ $definition_end ] ) ) { + $after_definition = $definition_end; + if ( WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $after_definition ]->id ) { + ++$after_definition; + } + + if ( isset( $tokens[ $after_definition ] ) && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $after_definition ]->id ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + } + } + + if ( null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::SELECT_SYMBOL, $position, $statement_end ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + return null; + } + + $schema_name = $this->get_mysql_create_table_select_backend_schema( $table_reference, $is_temporary ); + if ( + $if_not_exists + && $this->mysql_create_table_target_exists( $schema_name, $table_reference['table'], $is_temporary ) + ) { + return array( + 'statements' => array(), + 'schema' => $schema_name, + 'table' => $table_reference['table'], + 'temporary' => $is_temporary, + 'metadata_query' => null, + 'table_comment' => $table_comment, + 'noop' => true, + ); + } + + $select_sql = $this->get_mysql_token_range_bytes( $query, $tokens, $select_start, $select_end ); + if ( '' === trim( $select_sql ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + $select_translation = $this->translate_mysql_select_query_for_postgresql( $select_sql ); + $table_identifier = $is_temporary + ? $this->connection->quote_identifier( $table_reference['table'] ) + : $this->get_postgresql_schema_identifier( $schema_name, $table_reference['table'] ); + + if ( null !== $definition_start ) { + $metadata_query = trim( $this->get_mysql_token_range_bytes( $query, $tokens, 0, $metadata_end ) ); + if ( '' === $metadata_query ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ); + $metadata_tables = $translator->extract_schema_metadata( $metadata_query, true ); + if ( 1 !== count( $metadata_tables ) || empty( $metadata_tables[0]['columns'] ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + $column_identifiers = array(); + foreach ( $metadata_tables[0]['columns'] as $column ) { + $column_identifiers[] = $this->connection->quote_identifier( (string) $column['name'] ); + } + + $statements = $translator->translate_schema( $metadata_query ); + $statements[] = sprintf( + 'INSERT INTO %s (%s) %s', + $table_identifier, + implode( ', ', $column_identifiers ), + $select_translation['sql'] + ); + + return array( + 'statements' => $statements, + 'schema' => $schema_name, + 'table' => $table_reference['table'], + 'temporary' => $is_temporary, + 'metadata_query' => $metadata_query, + 'table_comment' => $table_comment, + 'noop' => false, + ); + } + + return array( + 'statements' => array( + sprintf( + 'CREATE %sTABLE %s%s AS %s', + $is_temporary ? 'TEMPORARY ' : '', + $if_not_exists ? 'IF NOT EXISTS ' : '', + $table_identifier, + $select_translation['sql'] + ), + ), + 'schema' => $schema_name, + 'table' => $table_reference['table'], + 'temporary' => $is_temporary, + 'metadata_query' => null, + 'table_comment' => $table_comment, + 'noop' => false, + ); + } + + /** + * Consume MySQL table options that are no-ops for CREATE TABLE ... SELECT. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether all option-like tokens consumed successfully. + */ + private function consume_mysql_create_table_select_options( array $tokens, int &$position, int $statement_end, string &$table_comment = '' ): bool { + while ( $position < $statement_end && isset( $tokens[ $position ] ) ) { + if ( $this->is_mysql_create_table_select_boundary_token( $tokens[ $position ] ) ) { + return true; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + $before = $position; + if ( + $this->consume_mysql_create_table_select_comment_option( $tokens, $position, $statement_end, $table_comment ) + || $this->consume_mysql_create_table_select_charset_option( $tokens, $position, $statement_end ) + || $this->consume_mysql_create_table_select_assignment_option( $tokens, $position, $statement_end ) + || $this->consume_mysql_create_table_select_directory_option( $tokens, $position, $statement_end ) + || $this->consume_mysql_create_table_select_tablespace_option( $tokens, $position, $statement_end ) + || $this->consume_mysql_create_table_select_union_option( $tokens, $position, $statement_end ) + ) { + continue; + } + + return $position === $before; + } + + return true; + } + + /** + * Check whether a token starts the SELECT half of CREATE TABLE ... SELECT. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token should stop table-option parsing. + */ + private function is_mysql_create_table_select_boundary_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::AS_SYMBOL, + WP_MySQL_Lexer::LIKE_SYMBOL, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + ), + true + ); + } + + /** + * Consume a supported CREATE TABLE ... SELECT COMMENT option. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @param string $table_comment Parsed table comment. + * @return bool Whether an option was consumed. + */ + private function consume_mysql_create_table_select_comment_option( array $tokens, int &$position, int $statement_end, string &$table_comment ): bool { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMENT_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || $position >= $statement_end || ! $this->is_mysql_quoted_text_token( $tokens[ $position ] ) ) { + return false; + } + + $table_comment = $tokens[ $position ]->get_value(); + ++$position; + return true; + } + + /** + * Consume a supported CREATE TABLE ... SELECT charset/collation option. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether an option was consumed. + */ + private function consume_mysql_create_table_select_charset_option( array $tokens, int &$position, int $statement_end ): bool { + $next_position = $position; + if ( isset( $tokens[ $next_position ] ) && WP_MySQL_Lexer::DEFAULT_SYMBOL === $tokens[ $next_position ]->id ) { + ++$next_position; + } + + if ( isset( $tokens[ $next_position ] ) && WP_MySQL_Lexer::CHARSET_SYMBOL === $tokens[ $next_position ]->id ) { + ++$next_position; + } elseif ( $this->is_mysql_create_table_charset_set_marker( $tokens, $next_position ) ) { + $next_position += 2; + } elseif ( isset( $tokens[ $next_position ] ) && WP_MySQL_Lexer::COLLATE_SYMBOL === $tokens[ $next_position ]->id ) { + ++$next_position; + } else { + return false; + } + + if ( isset( $tokens[ $next_position ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $next_position ]->id ) { + ++$next_position; + } + + if ( ! isset( $tokens[ $next_position ] ) || $next_position >= $statement_end || ! $this->is_mysql_charset_token( $tokens[ $next_position ] ) ) { + $position = $next_position; + return false; + } + + $position = $next_position + 1; + return true; + } + + /** + * Consume a supported CREATE TABLE ... SELECT storage option with one value. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether an option was consumed. + */ + private function consume_mysql_create_table_select_assignment_option( array $tokens, int &$position, int $statement_end ): bool { + if ( ! isset( $tokens[ $position ] ) || ! $this->is_mysql_create_table_select_assignment_option_token( $tokens[ $position ] ) ) { + return false; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position ]->id ) { + ++$position; + } + + return $this->consume_mysql_create_table_select_option_value( $tokens, $position, $statement_end ); + } + + /** + * Check whether a token is a supported assignment-style CTAS table option. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token starts a supported option. + */ + private function is_mysql_create_table_select_assignment_option_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::AUTOEXTEND_SIZE_SYMBOL, + WP_MySQL_Lexer::AVG_ROW_LENGTH_SYMBOL, + WP_MySQL_Lexer::CHECKSUM_SYMBOL, + WP_MySQL_Lexer::COMPRESSION_SYMBOL, + WP_MySQL_Lexer::CONNECTION_SYMBOL, + WP_MySQL_Lexer::DELAY_KEY_WRITE_SYMBOL, + WP_MySQL_Lexer::ENCRYPTION_SYMBOL, + WP_MySQL_Lexer::ENGINE_SYMBOL, + WP_MySQL_Lexer::ENGINE_ATTRIBUTE_SYMBOL, + WP_MySQL_Lexer::INSERT_METHOD_SYMBOL, + WP_MySQL_Lexer::KEY_BLOCK_SIZE_SYMBOL, + WP_MySQL_Lexer::MAX_ROWS_SYMBOL, + WP_MySQL_Lexer::MIN_ROWS_SYMBOL, + WP_MySQL_Lexer::PACK_KEYS_SYMBOL, + WP_MySQL_Lexer::PASSWORD_SYMBOL, + WP_MySQL_Lexer::ROW_FORMAT_SYMBOL, + WP_MySQL_Lexer::SECONDARY_ENGINE_SYMBOL, + WP_MySQL_Lexer::SECONDARY_ENGINE_ATTRIBUTE_SYMBOL, + WP_MySQL_Lexer::STATS_AUTO_RECALC_SYMBOL, + WP_MySQL_Lexer::STATS_PERSISTENT_SYMBOL, + WP_MySQL_Lexer::STATS_SAMPLE_PAGES_SYMBOL, + ), + true + ); + } + + /** + * Consume a supported CREATE TABLE ... SELECT DATA/INDEX DIRECTORY option. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether an option was consumed. + */ + private function consume_mysql_create_table_select_directory_option( array $tokens, int &$position, int $statement_end ): bool { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || ! in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::DATA_SYMBOL, WP_MySQL_Lexer::INDEX_SYMBOL ), true ) + || WP_MySQL_Lexer::DIRECTORY_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return false; + } + + $position += 2; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position ]->id ) { + ++$position; + } + + return $this->consume_mysql_create_table_select_option_value( $tokens, $position, $statement_end ); + } + + /** + * Consume a supported CREATE TABLE ... SELECT TABLESPACE option. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether an option was consumed. + */ + private function consume_mysql_create_table_select_tablespace_option( array $tokens, int &$position, int $statement_end ): bool { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLESPACE_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + if ( ! $this->consume_mysql_create_table_select_option_value( $tokens, $position, $statement_end ) ) { + return false; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::STORAGE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + return $this->consume_mysql_create_table_select_option_value( $tokens, $position, $statement_end ); + } + + return true; + } + + /** + * Consume a supported CREATE TABLE ... SELECT UNION table option. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether an option was consumed. + */ + private function consume_mysql_create_table_select_union_option( array $tokens, int &$position, int $statement_end ): bool { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::UNION_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position ]->id ) { + ++$position; + } + + $after_union = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $after_union ) { + return false; + } + + $position = $after_union; + return true; + } + + /** + * Consume a single CREATE TABLE ... SELECT table-option value. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether a value was consumed. + */ + private function consume_mysql_create_table_select_option_value( array $tokens, int &$position, int $statement_end ): bool { + if ( ! isset( $tokens[ $position ] ) || $position >= $statement_end ) { + return false; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_value = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $after_value ) { + return false; + } + + $position = $after_value; + return true; + } + + if ( + in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::AS_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::EOF, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::LIKE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + ), + true + ) + || $this->is_mysql_create_table_select_option_start_token( $tokens[ $position ] ) + ) { + return false; + } + + ++$position; + return true; + } + + /** + * Check whether a token starts a supported CTAS no-op table option. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token starts a supported option. + */ + private function is_mysql_create_table_select_option_start_token( WP_MySQL_Token $token ): bool { + return $this->is_mysql_create_table_select_assignment_option_token( $token ) + || in_array( + $token->id, + array( + WP_MySQL_Lexer::CHARACTER_SYMBOL, + WP_MySQL_Lexer::CHARSET_SYMBOL, + WP_MySQL_Lexer::CHAR_SYMBOL, + WP_MySQL_Lexer::COLLATE_SYMBOL, + WP_MySQL_Lexer::COMMENT_SYMBOL, + WP_MySQL_Lexer::DATA_SYMBOL, + WP_MySQL_Lexer::INDEX_SYMBOL, + WP_MySQL_Lexer::TABLESPACE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ), + true + ); + } + + /** + * Resolve the backend schema for a CREATE TABLE ... SELECT target. + * + * @param array $table_reference Parsed table reference. + * @param bool $is_temporary Whether the target table is temporary. + * @return string Backend schema name for metadata. + */ + private function get_mysql_create_table_select_backend_schema( array $table_reference, bool $is_temporary ): string { + $requested_schema = $table_reference['schema']; + if ( null !== $requested_schema && 0 === strcasecmp( $requested_schema, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + if ( $is_temporary ) { + if ( + null !== $requested_schema + && 0 !== strcasecmp( $requested_schema, $this->main_db_name ) + && 0 !== strcasecmp( $requested_schema, 'public' ) + ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + return $this->get_temporary_drop_table_schema_name(); + } + + if ( null === $requested_schema ) { + if ( 0 === strcasecmp( $this->db_name, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + return 'public'; + } + + return $this->get_mysql_writable_table_backend_schema( $table_reference, 'CREATE TABLE' ); + } + + /** + * Store MySQL-facing column metadata for a CREATE TABLE ... SELECT result. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + */ + private function store_mysql_create_table_select_metadata( string $table_schema, string $table_name, string $table_comment = '' ): void { + $this->ensure_mysql_schema_metadata_tables(); + $this->delete_mysql_schema_metadata_for_tables( array( $table_name ), $table_schema ); + + if ( '' !== $table_comment ) { + $this->insert_mysql_table_metadata( + $table_schema, + $table_name, + array( + 'comment' => $table_comment, + ) + ); + } + + foreach ( $this->get_mysql_create_table_select_column_metadata( $table_schema, $table_name ) as $column ) { + $this->insert_mysql_column_metadata( $table_schema, $table_name, $column ); + } + + $this->clear_mysql_metadata_cache_for_table( $table_schema, $table_name ); + } + + /** + * Translate supported MySQL CREATE TABLE ... LIKE statements to PostgreSQL. + * + * @param string $query MySQL CREATE TABLE ... LIKE query. + * @return array{statements: string[], metadata_query: string, schema: string, table: string, temporary: bool, noop: bool}|null Translation, or null when this is not CREATE TABLE ... LIKE. + */ + private function translate_mysql_create_table_like_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + $position = 1; + $is_temporary = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + $is_temporary = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $if_not_exists = false; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $if_not_exists = true; + $position += 3; + } + + $target_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $target_reference ) { + return null; + } + + $parenthesized_like_end = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $parenthesized_like_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( + null === $parenthesized_like_end + || $parenthesized_like_end !== $statement_end + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::LIKE_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + ++$position; + } elseif ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::LIKE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $source_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + $expected_end = null === $parenthesized_like_end ? $statement_end : $parenthesized_like_end - 1; + if ( null === $source_reference || $position !== $expected_end ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + $target_schema = $this->get_mysql_create_table_select_backend_schema( $target_reference, $is_temporary ); + if ( + $if_not_exists + && $this->mysql_create_table_target_exists( $target_schema, $target_reference['table'], $is_temporary ) + ) { + return array( + 'statements' => array(), + 'metadata_query' => '', + 'schema' => $target_schema, + 'table' => $target_reference['table'], + 'temporary' => $is_temporary, + 'noop' => true, + ); + } + + $metadata_query = $this->get_mysql_create_table_like_metadata_query( + $target_reference['table'], + $source_reference, + $is_temporary, + $if_not_exists + ); + + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ); + return array( + 'statements' => $translator->translate_schema( $metadata_query ), + 'metadata_query' => $metadata_query, + 'schema' => $target_schema, + 'table' => $target_reference['table'], + 'temporary' => $is_temporary, + 'noop' => false, + ); + } + + /** + * Build a MySQL CREATE TABLE definition for a CREATE TABLE ... LIKE target. + * + * @param string $target_table Destination table name. + * @param array $source_reference Source table reference. + * @param bool $is_temporary Whether the destination table is temporary. + * @param bool $if_not_exists Whether IF NOT EXISTS was present. + * @return string MySQL CREATE TABLE statement used for translation and metadata. + */ + private function get_mysql_create_table_like_metadata_query( string $target_table, array $source_reference, bool $is_temporary, bool $if_not_exists ): string { + $this->ensure_mysql_schema_metadata_tables(); + + $source_schema = $this->get_mysql_read_table_backend_schema( $source_reference['schema'] ); + if ( 0 === strcasecmp( $source_schema, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + $source_schema = $this->resolve_mysql_table_schema_for_introspection( $source_schema, $source_reference['table'] ); + + $logged_queries = $this->last_postgresql_queries; + try { + $columns = $this->get_show_create_table_column_metadata_rows( $source_schema, $source_reference['table'] ); + $indexes = $this->get_show_create_table_index_metadata_rows( $source_schema, $source_reference['table'] ); + $checks = $this->get_show_create_table_check_constraint_metadata_rows( $source_schema, $source_reference['table'] ); + $table_comment = $this->get_show_create_table_table_comment_metadata( $source_schema, $source_reference['table'] ); + } finally { + $this->last_postgresql_queries = $logged_queries; + } + + if ( empty( $columns ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + + $metadata_query = $this->get_mysql_create_table_statement_from_metadata( + $target_table, + $columns, + $indexes, + array(), + $checks, + $table_comment, + $is_temporary + ); + + if ( ! $if_not_exists ) { + return $metadata_query; + } + + $prefix = $is_temporary ? 'CREATE TEMPORARY TABLE ' : 'CREATE TABLE '; + $replacement = $is_temporary ? 'CREATE TEMPORARY TABLE IF NOT EXISTS ' : 'CREATE TABLE IF NOT EXISTS '; + if ( 0 === strpos( $metadata_query, $prefix ) ) { + return $replacement . substr( $metadata_query, strlen( $prefix ) ); + } + + return $metadata_query; + } + + /** + * Get MySQL-facing column metadata for a backend CTAS table. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @return array[] Column metadata rows. + */ + private function get_mysql_create_table_select_column_metadata( string $table_schema, string $table_name ): array { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + if ( 'sqlite' === $driver_name ) { + return $this->get_sqlite_create_table_select_column_metadata( $table_schema, $table_name ); + } + + $stmt = $this->connection->query( + 'SELECT column_name, ordinal_position, data_type, character_maximum_length, numeric_precision, numeric_scale, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = ? AND table_name = ? + ORDER BY ordinal_position', + array( $table_schema, $table_name ) + ); + + $columns = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $column ) { + $column_type = $this->get_mysql_column_type_from_backend_metadata( $column ); + $columns[] = array( + 'name' => (string) $column['column_name'], + 'ordinal' => (int) $column['ordinal_position'], + 'type' => $column_type, + 'charset' => $this->mysql_column_type_uses_charset( $column_type ) ? $this->charset : null, + 'collation' => $this->mysql_column_type_uses_charset( $column_type ) ? $this->collation : null, + 'nullable' => (string) $column['is_nullable'], + 'default' => null === $column['column_default'] ? null : (string) $column['column_default'], + 'extra' => '', + ); + } + + return $columns; + } + + /** + * Get MySQL-facing column metadata for a SQLite-backed CTAS table. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @return array[] Column metadata rows. + */ + private function get_sqlite_create_table_select_column_metadata( string $table_schema, string $table_name ): array { + $schema_prefix = 'temp' === $table_schema ? 'temp.' : ''; + $stmt = $this->connection->query( + sprintf( + 'PRAGMA %stable_info(%s)', + $schema_prefix, + $this->connection->quote_identifier( $table_name ) + ) + ); + + $columns = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $column ) { + $column_type = $this->get_mysql_column_type_from_sqlite_type( (string) ( $column['type'] ?? '' ) ); + $columns[] = array( + 'name' => (string) $column['name'], + 'ordinal' => (int) $column['cid'] + 1, + 'type' => $column_type, + 'charset' => $this->mysql_column_type_uses_charset( $column_type ) ? $this->charset : null, + 'collation' => $this->mysql_column_type_uses_charset( $column_type ) ? $this->collation : null, + 'nullable' => ! empty( $column['notnull'] ) ? 'NO' : 'YES', + 'default' => null === $column['dflt_value'] ? null : (string) $column['dflt_value'], + 'extra' => '', + ); + } + + return $columns; + } + + /** + * Convert backend information_schema type metadata into a MySQL-facing type. + * + * @param array $column Backend column metadata. + * @return string MySQL-facing column type. + */ + private function get_mysql_column_type_from_backend_metadata( array $column ): string { + $data_type = strtolower( (string) $column['data_type'] ); + switch ( $data_type ) { + case 'character varying': + return null === $column['character_maximum_length'] ? 'varchar' : 'varchar(' . (int) $column['character_maximum_length'] . ')'; + case 'character': + return null === $column['character_maximum_length'] ? 'char' : 'char(' . (int) $column['character_maximum_length'] . ')'; + case 'integer': + return 'int'; + case 'boolean': + return 'tinyint(1)'; + case 'timestamp without time zone': + return 'datetime'; + case 'timestamp with time zone': + return 'timestamp'; + case 'numeric': + case 'decimal': + if ( null !== $column['numeric_precision'] && null !== $column['numeric_scale'] ) { + return 'decimal(' . (int) $column['numeric_precision'] . ',' . (int) $column['numeric_scale'] . ')'; + } + if ( null !== $column['numeric_precision'] ) { + return 'decimal(' . (int) $column['numeric_precision'] . ')'; + } + return 'decimal'; + case 'double precision': + return 'double'; + case 'real': + return 'float'; + default: + return '' === $data_type ? 'text' : $data_type; + } + } + + /** + * Convert a SQLite declared type into a MySQL-facing type. + * + * @param string $sqlite_type SQLite declared type. + * @return string MySQL-facing column type. + */ + private function get_mysql_column_type_from_sqlite_type( string $sqlite_type ): string { + $type = strtoupper( $sqlite_type ); + if ( '' === $type ) { + return 'text'; + } + + if ( false !== strpos( $type, 'INT' ) ) { + return 'int'; + } + + if ( false !== strpos( $type, 'CHAR' ) || false !== strpos( $type, 'CLOB' ) || false !== strpos( $type, 'TEXT' ) ) { + return 'text'; + } + + if ( false !== strpos( $type, 'BLOB' ) ) { + return 'blob'; + } + + if ( false !== strpos( $type, 'REAL' ) || false !== strpos( $type, 'FLOA' ) || false !== strpos( $type, 'DOUB' ) ) { + return 'double'; + } + + if ( false !== strpos( $type, 'NUM' ) || false !== strpos( $type, 'DEC' ) ) { + return 'decimal'; + } + + return strtolower( $sqlite_type ); + } + + /** + * Check whether a MySQL-facing column type uses character metadata. + * + * @param string $column_type MySQL-facing column type. + * @return bool Whether charset and collation metadata apply. + */ + private function mysql_column_type_uses_charset( string $column_type ): bool { + $column_type = strtolower( $column_type ); + return 0 === strpos( $column_type, 'char' ) + || 0 === strpos( $column_type, 'varchar' ) + || false !== strpos( $column_type, 'text' ); + } + + /** + * Check whether a MySQL-facing column type is spatial. + * + * @param string $column_type MySQL-facing column type. + * @return bool Whether the type is spatial. + */ + private function is_mysql_spatial_column_type( string $column_type ): bool { + $column_type = strtolower( trim( $column_type ) ); + $length_position = strpos( $column_type, '(' ); + if ( false !== $length_position ) { + $column_type = substr( $column_type, 0, $length_position ); + } + $space_position = strpos( $column_type, ' ' ); + if ( false !== $space_position ) { + $column_type = substr( $column_type, 0, $space_position ); + } + + return in_array( + $column_type, + array( + 'geometry', + 'point', + 'linestring', + 'polygon', + 'multipoint', + 'multilinestring', + 'multipolygon', + 'geometrycollection', + 'geomcollection', + ), + true + ); + } + + /** + * Translate supported standalone MySQL CREATE INDEX statements to PostgreSQL. + * + * @param string $query MySQL CREATE INDEX query. + * @return array{statements: string[], metadata: array}|null Translation, or null when this is not CREATE INDEX. + */ + private function translate_mysql_create_index_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $position = 1; + $is_unique = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::UNIQUE_SYMBOL === $tokens[ $position ]->id ) { + $is_unique = true; + ++$position; + } + + $index_type = 'BTREE'; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FULLTEXT_SYMBOL === $tokens[ $position ]->id ) { + if ( $is_unique ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + $index_type = 'FULLTEXT'; + ++$position; + } elseif ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SPATIAL_SYMBOL === $tokens[ $position ]->id ) { + if ( $is_unique ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + $index_type = 'SPATIAL'; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $if_not_exists = false; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $if_not_exists = true; + $position += 3; + } + + $index_name_token = $tokens[ $position ] ?? null; + $index_name = $this->get_mysql_identifier_token_value( $index_name_token, true ); + if ( null === $index_name ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + ++$position; + if ( ! $this->consume_mysql_supported_create_index_type( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + ++$position; + $table_reference_is_quoted = isset( $tokens[ $position ] ) && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[ $position ]->id; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, 'CREATE INDEX' ); + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $key_list_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $key_list_end ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $key_parts = $this->parse_mysql_create_index_key_parts( + $tokens, + $position + 1, + $key_list_end - 1, + $is_unique + ); + if ( null === $key_parts ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + if ( 'BTREE' === $index_type && $this->is_mysql_spatial_index_key_parts( $table_schema, $table_reference['table'], $key_parts['metadata'] ) ) { + $index_type = 'SPATIAL'; + } + if ( 'SPATIAL' === $index_type ) { + $key_parts['metadata'] = $this->apply_mysql_spatial_index_sub_parts( $key_parts['metadata'] ); + } + + $position = $key_list_end; + $index_comment = ''; + if ( ! $this->consume_mysql_supported_create_index_options( $tokens, $position, $statement_end, $index_type, $index_comment ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + $table_name = $table_reference['table']; + $metadata_index_name = $index_name; + $postgresql_index_name = $table_name . '__' . $index_name; + if ( + WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $index_name_token->id + && $table_reference_is_quoted + && 0 === strpos( $index_name, $table_name . '__' ) + ) { + $metadata_index_name = substr( $index_name, strlen( $table_name ) + 2 ); + $postgresql_index_name = $index_name; + if ( '' === $metadata_index_name ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + } + + $postgresql_index = $this->connection->quote_identifier( $postgresql_index_name ); + $postgresql_table = $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + + $statements = array(); + if ( ! $this->is_mysql_metadata_only_index_type( $index_type ) ) { + $statements[] = sprintf( + 'CREATE %sINDEX %s%s ON %s (%s)', + $is_unique ? 'UNIQUE ' : '', + $if_not_exists ? 'IF NOT EXISTS ' : '', + $postgresql_index, + $postgresql_table, + implode( ', ', $key_parts['sql'] ) + ); + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'schema' => $table_schema, + 'table' => $table_name, + 'index' => array( + 'name' => $metadata_index_name, + 'non_unique' => $is_unique ? '0' : '1', + 'index_type' => $index_type, + 'comment' => $index_comment, + 'columns' => $key_parts['metadata'], + ), + ), + ); + } + + /** + * Check whether a MySQL index type is stored only as MySQL metadata. + * + * @param string $index_type MySQL index type. + * @return bool Whether no PostgreSQL physical index should be created. + */ + private function is_mysql_metadata_only_index_type( string $index_type ): bool { + return in_array( strtoupper( $index_type ), array( 'FULLTEXT', 'SPATIAL' ), true ); + } + + /** + * Translate supported MySQL CREATE VIEW statements to PostgreSQL. + * + * @param string $query MySQL CREATE VIEW query. + * @return array{statements: string[]}|null Translation, or null when this is not CREATE VIEW. + */ + private function translate_mysql_create_view_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + if ( $this->contains_mysql_view_token_after_position( $tokens, 1 ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE VIEW statement.' ); + } + return null; + } + + $position = 1; + $or_replace = false; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::REPLACE_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $or_replace = true; + $position += 2; + } + + if ( $this->contains_mysql_unsupported_view_prefix_clause( $tokens, $position, $statement_end ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE VIEW statement.' ); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VIEW_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + return $this->translate_mysql_view_definition_query( + $query, + $tokens, + $position, + $statement_end, + $or_replace, + 'CREATE VIEW' + ); + } + + /** + * Translate supported MySQL ALTER VIEW statements to PostgreSQL CREATE OR REPLACE VIEW. + * + * @param string $query MySQL ALTER VIEW query. + * @return array{statements: string[]}|null Translation, or null when this is not ALTER VIEW. + */ + private function translate_mysql_alter_view_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::ALTER_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + if ( $this->contains_mysql_view_token_after_position( $tokens, 1 ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER VIEW statement.' ); + } + return null; + } + + $position = 1; + if ( $this->contains_mysql_unsupported_view_prefix_clause( $tokens, $position, $statement_end ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER VIEW statement.' ); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VIEW_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + return $this->translate_mysql_view_definition_query( + $query, + $tokens, + $position, + $statement_end, + true, + 'ALTER VIEW' + ); + } + + /** + * Translate a supported CREATE/ALTER VIEW definition. + * + * @param string $query MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Position of VIEW token. + * @param int $statement_end Final statement token position, exclusive. + * @param bool $or_replace Whether to emit CREATE OR REPLACE VIEW. + * @param string $statement_type Statement type for fail-closed error messages. + * @return array{statements: string[]} Translation. + */ + private function translate_mysql_view_definition_query( + string $query, + array $tokens, + int $position, + int $statement_end, + bool $or_replace, + string $statement_type + ): array { + ++$position; + $view_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $view_reference ) { + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + + $view_schema = $this->get_mysql_writable_table_backend_schema( $view_reference, $statement_type ); + $view_identifier = null === $view_reference['schema'] + ? $this->connection->quote_identifier( $view_reference['table'] ) + : $this->get_postgresql_schema_identifier( $view_schema, $view_reference['table'] ); + + $columns_sql = ''; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $columns_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $columns_end ) { + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + + $columns = $this->parse_mysql_view_column_list( $tokens, $position + 1, $columns_end - 1 ); + if ( null === $columns ) { + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + + $columns_sql = ' (' . implode( ', ', $columns ) . ')'; + $position = $columns_end; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AS_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + + if ( $this->contains_mysql_unsupported_view_trailing_clause( $tokens, $position, $statement_end ) ) { + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + + $select_sql = $this->get_mysql_token_range_bytes( $query, $tokens, $position, $statement_end ); + $this->validate_mysql_view_select_query( $select_sql, $statement_type ); + $select_translation = $this->translate_mysql_select_query_for_postgresql( $select_sql ); + $select_sql = $select_translation['sql']; + $this->validate_mysql_view_select_query( $select_sql, $statement_type ); + + return array( + 'statements' => array( + sprintf( + 'CREATE %sVIEW %s%s AS %s', + $or_replace ? 'OR REPLACE ' : '', + $view_identifier, + $columns_sql, + $select_sql + ), + ), + ); + } + + /** + * Parse a CREATE/ALTER VIEW column list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First column token position. + * @param int $end Final column token position, exclusive. + * @return string[]|null PostgreSQL-quoted column identifiers, or null when unsupported. + */ + private function parse_mysql_view_column_list( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $columns = array(); + $position = $start; + while ( $position < $end ) { + $column_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $column_name ) { + return null; + } + + $columns[] = $this->connection->quote_identifier( $column_name ); + ++$position; + + if ( $position === $end ) { + break; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( $position === $end ) { + return null; + } + } + + return $columns; + } + + /** + * Check whether a CREATE/ALTER VIEW prefix uses unsupported MySQL-only clauses. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Position immediately after CREATE/ALTER modifiers. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether an unsupported view prefix clause appears before VIEW. + */ + private function contains_mysql_unsupported_view_prefix_clause( array $tokens, int $position, int $statement_end ): bool { + $unsupported = false; + for ( $i = $position; $i < $statement_end; $i++ ) { + if ( WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[ $i ]->id ) { + return $unsupported; + } + + if ( + in_array( + $tokens[ $i ]->id, + array( + WP_MySQL_Lexer::ALGORITHM_SYMBOL, + WP_MySQL_Lexer::DEFINER_SYMBOL, + WP_MySQL_Lexer::SECURITY_SYMBOL, + ), + true + ) + ) { + $unsupported = true; + continue; + } + + if ( + WP_MySQL_Lexer::SQL_SYMBOL === $tokens[ $i ]->id + && isset( $tokens[ $i + 1 ] ) + && WP_MySQL_Lexer::SECURITY_SYMBOL === $tokens[ $i + 1 ]->id + ) { + $unsupported = true; + } + } + + return false; + } + + /** + * Check whether a token stream contains VIEW after a position. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position First token position to inspect. + * @return bool Whether VIEW appears before EOF. + */ + private function contains_mysql_view_token_after_position( array $tokens, int $position ): bool { + for ( $i = $position; isset( $tokens[ $i ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $i ]->id; $i++ ) { + if ( WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[ $i ]->id ) { + return true; + } + } + + return false; + } + + /** + * Check whether a VIEW SELECT has unsupported MySQL trailing clauses. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First SELECT token position. + * @param int $end Final SELECT token position, exclusive. + * @return bool Whether an unsupported trailing clause is present. + */ + private function contains_mysql_unsupported_view_trailing_clause( array $tokens, int $start, int $end ): bool { + $depth = 0; + for ( $i = $start; $i < $end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return true; + } + continue; + } + + if ( 0 !== $depth || WP_MySQL_Lexer::WITH_SYMBOL !== $tokens[ $i ]->id ) { + continue; + } + + $next_token = $tokens[ $i + 1 ] ?? null; + if ( + null !== $next_token + && in_array( + $next_token->id, + array( + WP_MySQL_Lexer::CASCADED_SYMBOL, + WP_MySQL_Lexer::CHECK_SYMBOL, + WP_MySQL_Lexer::LOCAL_SYMBOL, + ), + true + ) + ) { + return true; + } + } + + return 0 !== $depth; + } + + /** + * Validate a view SELECT against MySQL constructs the driver must reject. + * + * @param string $select_sql MySQL or translated SELECT SQL. + * @param string $statement_type Statement type for fail-closed error messages. + */ + private function validate_mysql_view_select_query( string $select_sql, string $statement_type ): void { + if ( + $this->contains_mysql_index_hint_syntax( $select_sql ) + || $this->contains_unsupported_mysql_date_arithmetic_function_query( $select_sql ) + || $this->contains_unsupported_mysql_fulltext_search_query( $select_sql ) + || $this->contains_unsupported_mysql_common_function_query( $select_sql ) + || $this->contains_unsupported_mysql_group_concat_function_query( $select_sql ) + || $this->contains_unsupported_mysql_week_function_query( $select_sql ) + ) { + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + } + + /** + * Check whether parsed key parts should be exposed as a MySQL SPATIAL index. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param array $key_parts Parsed key-part metadata. + * @return bool Whether the first key part targets a spatial column. + */ + private function is_mysql_spatial_index_key_parts( string $table_schema, string $table_name, array $key_parts ): bool { + if ( ! isset( $key_parts[0]['column_name'] ) ) { + return false; + } + + $column_type = $this->get_mysql_table_column_type( $table_schema, $table_name, (string) $key_parts[0]['column_name'] ); + return is_string( $column_type ) && $this->is_mysql_spatial_column_type( $column_type ); + } + + /** + * Apply MySQL's implicit SPATIAL key-part prefix length. + * + * @param array $key_parts Parsed key-part metadata. + * @return array Key-part metadata with spatial sub-parts. + */ + private function apply_mysql_spatial_index_sub_parts( array $key_parts ): array { + foreach ( $key_parts as $position => $key_part ) { + if ( null === $key_part['sub_part'] ) { + $key_parts[ $position ]['sub_part'] = 32; + } + } + + return $key_parts; + } + + /** + * Consume a supported MySQL CREATE INDEX USING clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return bool Whether the optional index type is supported. + */ + private function consume_mysql_supported_create_index_type( array $tokens, int &$position ): bool { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::USING_SYMBOL !== $tokens[ $position ]->id ) { + return true; + } + + if ( + ! isset( $tokens[ $position + 1 ] ) + || ( + ! $this->is_mysql_token_value( $tokens[ $position + 1 ], 'btree' ) + && ! $this->is_mysql_token_value( $tokens[ $position + 1 ], 'hash' ) + ) + ) { + return false; + } + + $position += 2; + return true; + } + + /** + * Parse standalone CREATE INDEX key parts. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First key-part token position. + * @param int $end Final key-part token position, exclusive. + * @param bool $is_unique Whether the index is unique. + * @return array{sql: string[], metadata: array[]}|null Key part SQL and metadata, or null when unsupported. + */ + private function parse_mysql_create_index_key_parts( array $tokens, int $start, int $end, bool $is_unique ): ?array { + $key_part_ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $key_part_ranges || array() === $key_part_ranges ) { + return null; + } + + $sql_parts = array(); + $metadata_parts = array(); + foreach ( $key_part_ranges as $key_part_range ) { + $position = $key_part_range['start']; + $column_name = $this->get_mysql_index_identifier_token_value( $tokens[ $position ] ?? null, true ); + if ( null === $column_name ) { + return null; + } + + ++$position; + $sub_part = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + || ! $this->is_mysql_unsigned_integer_token( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position + 2 ]->id + ) { + return null; + } + + $sub_part = $tokens[ $position + 1 ]->get_value(); + $position += 3; + } + + $direction = ''; + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $position ]->id + ) + ) { + $direction = ' ' . strtoupper( $tokens[ $position ]->get_value() ); + ++$position; + } + + if ( $position !== $key_part_range['end'] ) { + return null; + } + + $sql_parts[] = $this->get_mysql_index_key_part_sql( $column_name, $is_unique ? $sub_part : null ) . $direction; + $metadata_parts[] = array( + 'column_name' => $column_name, + 'seq_in_index' => count( $metadata_parts ) + 1, + 'collation' => ' DESC' === $direction ? 'D' : 'A', + 'sub_part' => $sub_part, + ); + } + + return array( + 'sql' => $sql_parts, + 'metadata' => $metadata_parts, + ); + } + + /** + * Get a column identifier token value from a CREATE INDEX key part. + * + * MySQL permits some unquoted keyword-like names, such as "value" and + * "name", in key parts. Keep this fallback local to index column parsing so + * statement structure keywords are still handled explicitly by the parser. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @param bool $allow_double_quoted Whether to accept double-quoted text as an identifier. + * @return string|null Identifier value, or null when unsupported. + */ + private function get_mysql_index_identifier_token_value( ?WP_MySQL_Token $token, bool $allow_double_quoted = false ): ?string { + $identifier = $this->get_mysql_identifier_token_value( $token, $allow_double_quoted ); + if ( null !== $identifier ) { + return $identifier; + } + + if ( null === $token ) { + return null; + } + + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::ASC_SYMBOL, + WP_MySQL_Lexer::COMMENT_SYMBOL, + WP_MySQL_Lexer::DESC_SYMBOL, + WP_MySQL_Lexer::INDEX_SYMBOL, + WP_MySQL_Lexer::ON_SYMBOL, + WP_MySQL_Lexer::USING_SYMBOL, + ), + true + ) + ) { + return null; + } + + $value = $token->get_value(); + if ( 1 === preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $value ) ) { + return $value; + } + + return null; + } + + /** + * Get PostgreSQL SQL for a MySQL index key part. + * + * @param string $column_name Column name. + * @param int|string|null $sub_part Optional prefix length. + * @return string PostgreSQL key-part SQL. + */ + private function get_mysql_index_key_part_sql( string $column_name, $sub_part ): string { + if ( null !== $sub_part && '' !== (string) $sub_part ) { + return sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $this->connection->quote_identifier( $column_name ), + (int) $sub_part + ); + } + + return $this->connection->quote_identifier( $column_name ); + } + + /** + * Consume supported MySQL CREATE INDEX options. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @param string $index_type MySQL index type. + * @param string $index_comment Index comment, updated on success. + * @return bool Whether all remaining options are supported. + */ + private function consume_mysql_supported_create_index_options( array $tokens, int &$position, int $statement_end, string $index_type, string &$index_comment ): bool { + $allow_btree_options = ! $this->is_mysql_metadata_only_index_type( $index_type ); + if ( ! $allow_btree_options ) { + return $position === $statement_end; + } + + while ( $position < $statement_end ) { + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::COMMENT_SYMBOL === $tokens[ $position ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 1 ] ) + ) { + $index_comment = $tokens[ $position + 1 ]->get_value(); + $position += 2; + continue; + } + + $before_type_position = $position; + if ( ! $this->consume_mysql_supported_create_index_type( $tokens, $position ) ) { + return false; + } + if ( $position !== $before_type_position ) { + continue; + } + + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::VISIBLE_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::INVISIBLE_SYMBOL === $tokens[ $position ]->id + ) + ) { + ++$position; + continue; + } + + if ( $this->consume_mysql_supported_index_key_block_size_option( $tokens, $position, $statement_end ) ) { + continue; + } + + if ( $this->consume_mysql_supported_index_lock_and_algorithm_options( $tokens, $position, $statement_end ) ) { + continue; + } + + return false; + } + + return true; + } + + /** + * Consume a supported MySQL index KEY_BLOCK_SIZE option. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether a supported KEY_BLOCK_SIZE option was consumed. + */ + private function consume_mysql_supported_index_key_block_size_option( array $tokens, int &$position, int $statement_end ): bool { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::KEY_BLOCK_SIZE_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + $next_position = $position + 1; + if ( isset( $tokens[ $next_position ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $next_position ]->id ) { + ++$next_position; + } + + if ( $next_position >= $statement_end || ! isset( $tokens[ $next_position ] ) || ! $this->is_mysql_unsigned_integer_token( $tokens[ $next_position ] ) ) { + return false; + } + + $position = $next_position + 1; + return true; + } + + /** + * Consume supported MySQL index ALGORITHM and LOCK options. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether a supported option was consumed. + */ + private function consume_mysql_supported_index_lock_and_algorithm_options( array $tokens, int &$position, int $statement_end ): bool { + if ( ! isset( $tokens[ $position ] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::ALGORITHM_SYMBOL === $tokens[ $position ]->id ) { + return $this->consume_mysql_supported_index_option_value( + $tokens, + $position, + $statement_end, + array( 'default', 'inplace', 'copy' ) + ); + } + + if ( WP_MySQL_Lexer::LOCK_SYMBOL === $tokens[ $position ]->id ) { + return $this->consume_mysql_supported_index_option_value( + $tokens, + $position, + $statement_end, + array( 'default', 'none', 'shared', 'exclusive' ) + ); + } + + return false; + } + + /** + * Consume a MySQL index option with an optional equals sign and a bounded value. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $statement_end Final statement token position, exclusive. + * @param string[] $supported_values Supported option values. + * @return bool Whether a supported option value was consumed. + */ + private function consume_mysql_supported_index_option_value( array $tokens, int &$position, int $statement_end, array $supported_values ): bool { + $value_position = $position + 1; + if ( isset( $tokens[ $value_position ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $value_position ]->id ) { + ++$value_position; + } + + if ( $value_position >= $statement_end || ! isset( $tokens[ $value_position ] ) ) { + return false; + } + + foreach ( $supported_values as $supported_value ) { + if ( $this->is_mysql_token_value( $tokens[ $value_position ], $supported_value ) ) { + $position = $value_position + 1; + return true; + } + } + + return false; + } + + /** + * Resolve the backend schema for a writable MySQL table reference. + * + * @param array $table_reference Parsed table reference. + * @param string $statement_type Statement type for error messages. + * @return string Backend schema name. + */ + private function get_mysql_writable_table_backend_schema( array $table_reference, string $statement_type ): string { + $requested_schema = $table_reference['schema']; + $table_name = $table_reference['table']; + + if ( null === $requested_schema ) { + if ( 0 === strcasecmp( $this->db_name, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + return $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + } + + if ( 0 === strcasecmp( $requested_schema, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + if ( + 0 === strcasecmp( $requested_schema, $this->main_db_name ) + || 0 === strcasecmp( $requested_schema, 'public' ) + ) { + return 'public'; + } + + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + + /** + * Resolve the backend schema for a read-only MySQL table reference. + * + * @param string|null $requested_schema Requested MySQL-facing schema, or null for the current database. + * @return string Backend schema name. + */ + private function get_mysql_read_table_backend_schema( ?string $requested_schema ): string { + if ( null === $requested_schema ) { + return 0 === strcasecmp( $this->db_name, 'information_schema' ) ? 'information_schema' : 'public'; + } + + if ( + 0 === strcasecmp( $requested_schema, $this->main_db_name ) + || 0 === strcasecmp( $requested_schema, 'public' ) + ) { + return 'public'; + } + + if ( 0 === strcasecmp( $requested_schema, 'information_schema' ) ) { + return 'information_schema'; + } + + return $requested_schema; + } + + /** + * Build a backend identifier in a specific schema when the test backend supports it. + * + * @param string $schema_name Backend schema name. + * @param string $object_name Object name. + * @return string Backend SQL identifier. + */ + private function get_postgresql_schema_identifier( string $schema_name, string $object_name ): string { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + if ( + 'sqlite' === $driver_name + && ( + 'main' === $schema_name + || ( 'public' === $schema_name && ! $this->sqlite_database_schema_exists( 'public' ) ) + ) + ) { + return $this->connection->quote_identifier( $object_name ); + } + + return $this->connection->quote_identifier( $schema_name ) . '.' . $this->connection->quote_identifier( $object_name ); + } + + /** + * Translate supported dbDelta ALTER TABLE statements to PostgreSQL. + * + * @param string $query MySQL ALTER TABLE query. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_alter_table_query( string $query ): ?array { + $query_tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $query_tokens[0], $query_tokens[1] ) + || WP_MySQL_Lexer::ALTER_SYMBOL !== $query_tokens[0]->id + || WP_MySQL_Lexer::TABLE_SYMBOL !== $query_tokens[1]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $query_tokens, 2 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $position = 2; + $table_reference = $this->get_mysql_dbdelta_alter_table_target_reference( $query_tokens, $position ); + if ( null === $table_reference || $position >= $statement_end ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $table_name = $table_reference['table']; + $clause = $this->trim_mysql_statement_fragment( + $this->get_mysql_token_range_bytes( $query, $query_tokens, $position, $statement_end ) + ); + + $tokens = $this->get_mysql_tokens( $clause ); + $statement_end = $this->get_mysql_statement_end_position( $tokens, 0 ); + if ( null === $statement_end ) { + return null; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, 0, $statement_end ); + if ( null === $ranges || array() === $ranges ) { + return null; + } + + $ranges = $this->merge_mysql_dbdelta_order_by_alter_ranges( $tokens, $ranges ); + + if ( $this->contains_unsupported_mysql_column_attribute_alter_actions( $tokens, $ranges ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, 'ALTER TABLE' ); + + $statements = array(); + $metadata_operations = array(); + $has_online_ddl_options = false; + $check_names = array(); + $foreign_key_names = array(); + foreach ( $ranges as $range ) { + $translation = $this->translate_mysql_dbdelta_alter_table_action( + $table_schema, + $table_name, + $clause, + $tokens, + $range['start'], + $range['end'], + $check_names, + $foreign_key_names + ); + if ( null === $translation ) { + return null; + } + + $statements = array_merge( $statements, $translation['statements'] ); + if ( 'noop' === ( $translation['metadata']['operation'] ?? '' ) && 'online_ddl_option' === ( $translation['metadata']['option'] ?? '' ) ) { + $has_online_ddl_options = true; + } + if ( 'noop' !== ( $translation['metadata']['operation'] ?? '' ) ) { + $metadata_operations[] = $translation['metadata']; + } + } + + if ( $has_online_ddl_options && $this->has_mysql_dbdelta_metadata_only_index_operation( $metadata_operations ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $this->preflight_mysql_dbdelta_alter_table_metadata_operations( $table_schema, $table_name, $metadata_operations ); + + if ( 1 === count( $metadata_operations ) ) { + $metadata = $metadata_operations[0]; + $metadata['schema'] = $table_schema; + $metadata['table'] = $table_name; + } elseif ( count( $metadata_operations ) > 1 ) { + $metadata = array( + 'operation' => 'operations', + 'schema' => $table_schema, + 'table' => $table_name, + 'operations' => $metadata_operations, + ); + } else { + $metadata = array( + 'operation' => 'noop', + 'schema' => $table_schema, + 'table' => $table_name, + ); + } + + return array( + 'statements' => $statements, + 'metadata' => $metadata, + ); + } + + /** + * Validate ALTER TABLE metadata operations before backend DDL executes. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param array[] $metadata_operations ALTER metadata operations. + */ + private function preflight_mysql_dbdelta_alter_table_metadata_operations( string $table_schema, string $table_name, array $metadata_operations ): void { + $metadata_operations = $this->flatten_mysql_dbdelta_alter_table_metadata_operations( $metadata_operations ); + $added_columns = array(); + $added_indexes = array(); + $dropped_columns = array(); + $dropped_indexes = array(); + + foreach ( $metadata_operations as $metadata ) { + if ( 'drop_column' === ( $metadata['operation'] ?? '' ) ) { + $column_name = (string) ( $metadata['column'] ?? '' ); + if ( '' !== $column_name ) { + $column_key = strtolower( $column_name ); + unset( $added_columns[ $column_key ] ); + $dropped_columns[ $column_key ] = true; + foreach ( + $this->get_mysql_index_names_removed_by_dropped_columns( + $table_schema, + $table_name, + array_keys( $dropped_columns ) + ) as $index_name + ) { + $dropped_indexes[ strtolower( $index_name ) ] = true; + } + } + continue; + } + + if ( 'drop_index' === ( $metadata['operation'] ?? '' ) ) { + $index_name = (string) ( $metadata['index'] ?? '' ); + if ( '' !== $index_name ) { + $index_key = strtolower( $index_name ); + unset( $added_indexes[ $index_key ] ); + $dropped_indexes[ $index_key ] = true; + } + continue; + } + + if ( 'add_column' === ( $metadata['operation'] ?? '' ) ) { + $column_name = (string) ( $metadata['column']['name'] ?? '' ); + if ( '' !== $column_name ) { + $column_key = strtolower( $column_name ); + if ( + isset( $added_columns[ $column_key ] ) + || ( + ! isset( $dropped_columns[ $column_key ] ) + && $this->mysql_table_has_column_for_translation( $table_schema, $table_name, $column_name ) + ) + ) { + throw new InvalidArgumentException( sprintf( "Duplicate column name '%s'.", $column_name ) ); + } + $added_columns[ $column_key ] = true; + unset( $dropped_columns[ $column_key ] ); + } + + foreach ( $metadata['indexes'] ?? array() as $index ) { + $this->preflight_mysql_dbdelta_alter_table_add_index_metadata( + $table_schema, + $table_name, + $index, + $added_indexes, + $dropped_indexes + ); + } + continue; + } + + if ( 'change_column' === ( $metadata['operation'] ?? '' ) || 'rename_column' === ( $metadata['operation'] ?? '' ) ) { + $old_column_name = (string) ( $metadata['old_column'] ?? '' ); + $new_column_name = ( 'change_column' === ( $metadata['operation'] ?? '' ) ) + ? (string) ( $metadata['column']['name'] ?? '' ) + : (string) ( $metadata['new_column'] ?? '' ); + if ( '' === $old_column_name || '' === $new_column_name ) { + continue; + } + + $old_column_key = strtolower( $old_column_name ); + $new_column_key = strtolower( $new_column_name ); + if ( $old_column_key === $new_column_key ) { + continue; + } + + if ( + isset( $added_columns[ $new_column_key ] ) + || ( + ! isset( $dropped_columns[ $new_column_key ] ) + && $this->mysql_table_has_column_for_translation( $table_schema, $table_name, $new_column_name ) + ) + ) { + throw new InvalidArgumentException( sprintf( "Duplicate column name '%s'.", $new_column_name ) ); + } + + unset( $added_columns[ $old_column_key ] ); + $dropped_columns[ $old_column_key ] = true; + $added_columns[ $new_column_key ] = true; + unset( $dropped_columns[ $new_column_key ] ); + continue; + } + + if ( 'add_index' === ( $metadata['operation'] ?? '' ) ) { + $this->preflight_mysql_dbdelta_alter_table_add_index_metadata( + $table_schema, + $table_name, + $metadata['index'] ?? array(), + $added_indexes, + $dropped_indexes + ); + } + } + } + + /** + * Get indexes that would be fully removed by the columns already dropped in this ALTER statement. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param string[] $dropped_column_keys Lowercase column names dropped before the current action. + * @return string[] MySQL index names whose key parts are all dropped. + */ + private function get_mysql_index_names_removed_by_dropped_columns( string $table_schema, string $table_name, array $dropped_column_keys ): array { + if ( array() === $dropped_column_keys ) { + return array(); + } + + $this->ensure_mysql_schema_metadata_tables(); + + $dropped_column_lookup = array_fill_keys( $dropped_column_keys, true ); + $stmt = $this->connection->query( + sprintf( + 'SELECT key_name, column_name + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY key_name, seq_in_index', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + $indexes = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $index_key = strtolower( (string) $row['key_name'] ); + if ( ! isset( $indexes[ $index_key ] ) ) { + $indexes[ $index_key ] = array( + 'name' => (string) $row['key_name'], + 'columns' => array(), + ); + } + + $indexes[ $index_key ]['columns'][] = strtolower( (string) $row['column_name'] ); + } + + $removed_indexes = array(); + foreach ( $indexes as $index ) { + if ( array() === $index['columns'] ) { + continue; + } + + $all_columns_dropped = true; + foreach ( $index['columns'] as $column_key ) { + if ( ! isset( $dropped_column_lookup[ $column_key ] ) ) { + $all_columns_dropped = false; + break; + } + } + + if ( $all_columns_dropped ) { + $removed_indexes[] = $index['name']; + } + } + + return $removed_indexes; + } + + /** + * Check whether ALTER TABLE metadata operations contain FULLTEXT/SPATIAL indexes. + * + * @param array[] $metadata_operations ALTER metadata operations. + * @return bool Whether any operation targets a metadata-only index type. + */ + private function has_mysql_dbdelta_metadata_only_index_operation( array $metadata_operations ): bool { + foreach ( $this->flatten_mysql_dbdelta_alter_table_metadata_operations( $metadata_operations ) as $metadata ) { + if ( + 'add_index' === ( $metadata['operation'] ?? '' ) + && $this->is_mysql_metadata_only_index_type( (string) ( $metadata['index']['index_type'] ?? '' ) ) + ) { + return true; + } + + if ( 'add_column' !== ( $metadata['operation'] ?? '' ) ) { + continue; + } + + foreach ( $metadata['indexes'] ?? array() as $index ) { + if ( $this->is_mysql_metadata_only_index_type( (string) ( $index['index_type'] ?? '' ) ) ) { + return true; + } + } + } + + return false; + } + + /** + * Flatten nested ALTER TABLE metadata operations for validation. + * + * @param array[] $metadata_operations ALTER metadata operations. + * @return array[] Flat operation list. + */ + private function flatten_mysql_dbdelta_alter_table_metadata_operations( array $metadata_operations ): array { + $flat_operations = array(); + foreach ( $metadata_operations as $metadata ) { + if ( 'operations' === ( $metadata['operation'] ?? '' ) ) { + $flat_operations = array_merge( + $flat_operations, + $this->flatten_mysql_dbdelta_alter_table_metadata_operations( $metadata['operations'] ?? array() ) + ); + continue; + } + + $flat_operations[] = $metadata; + } + + return $flat_operations; + } + + /** + * Validate one ALTER TABLE ADD INDEX metadata operation. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param array $index Index metadata. + * @param array $added_indexes Index names already added by this ALTER statement. + * @param array $dropped_indexes Index names already dropped by this ALTER statement. + */ + private function preflight_mysql_dbdelta_alter_table_add_index_metadata( + string $table_schema, + string $table_name, + array $index, + array &$added_indexes, + array $dropped_indexes + ): void { + $index_name = (string) ( $index['name'] ?? '' ); + if ( '' === $index_name ) { + return; + } + + $index_key = strtolower( $index_name ); + if ( + isset( $added_indexes[ $index_key ] ) + || ( + ! isset( $dropped_indexes[ $index_key ] ) + && $this->mysql_index_metadata_exists( $table_schema, $table_name, $index_name ) + ) + ) { + throw new InvalidArgumentException( sprintf( "Duplicate key name '%s'.", $index_name ) ); + } + + $added_indexes[ $index_key ] = true; + } + + /** + * Parse an ALTER TABLE target reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return array{schema: string|null, table: string}|null Parsed table reference, or null when unsupported. + */ + private function get_mysql_dbdelta_alter_table_target_reference( array $tokens, int &$position ): ?array { + $first_identifier = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position ]->id ) { + return array( + 'schema' => null, + 'table' => $first_identifier, + ); + } + + $table_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $table_name ) { + return null; + } + + $position += 2; + return array( + 'schema' => $first_identifier, + 'table' => $table_name, + ); + } + + /** + * Resolve an ALTER TABLE target to the supported backend table name. + * + * @param array{schema: string|null, table: string} $table_reference Parsed table reference. + * @return string Table name. + */ + private function get_mysql_dbdelta_alter_table_target_name( array $table_reference ): string { + $this->get_mysql_writable_table_backend_schema( $table_reference, 'ALTER TABLE' ); + return $table_reference['table']; + } + + /** + * Check whether ALTER actions contain unsupported MySQL column attributes. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param array $ranges Top-level action ranges. + * @return bool Whether an unsupported column attribute is present. + */ + private function contains_unsupported_mysql_column_attribute_alter_actions( array $tokens, array $ranges ): bool { + foreach ( $ranges as $range ) { + if ( $this->contains_unsupported_mysql_column_attribute_alter_action( $tokens, $range['start'], $range['end'] ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether one ALTER action contains unsupported MySQL column attributes. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return bool Whether an unsupported column attribute is present. + */ + private function contains_unsupported_mysql_column_attribute_alter_action( array $tokens, int $start, int $end ): bool { + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::ADD_SYMBOL === $tokens[ $start ]->id ) { + if ( + $this->is_mysql_dbdelta_add_index_action( $tokens, $start, $end ) + || $this->is_mysql_dbdelta_add_constraint_action( $tokens, $start, $end ) + ) { + return false; + } + + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $parenthesized_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( $parenthesized_end !== $end ) { + return false; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, $position + 1, $end - 1 ); + if ( null === $ranges ) { + return false; + } + + foreach ( $ranges as $range ) { + if ( + ! $this->is_mysql_dbdelta_add_index_definition_action( $tokens, $range['start'], $range['end'] ) + && ! $this->is_mysql_dbdelta_add_constraint_definition_action( $tokens, $range['start'], $range['end'] ) + && $this->contains_unsupported_mysql_column_attribute_definition_tokens( $tokens, $range['start'], $range['end'] ) + ) { + return true; + } + } + + return false; + } + + return $this->contains_unsupported_mysql_column_attribute_definition_tokens( $tokens, $position, $end ); + } + + if ( WP_MySQL_Lexer::MODIFY_SYMBOL === $tokens[ $start ]->id ) { + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + return $this->contains_unsupported_mysql_column_attribute_definition_tokens( $tokens, $position, $end ); + } + + if ( WP_MySQL_Lexer::CHANGE_SYMBOL === $tokens[ $start ]->id ) { + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + if ( null === $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ) ) { + return false; + } + ++$position; + if ( null === $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ) ) { + return false; + } + ++$position; + + return $this->contains_unsupported_mysql_column_attribute_tokens( $tokens, $position, $end ); + } + + return false; + } + + /** + * Check whether a column definition range contains unsupported MySQL attributes. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First definition token. + * @param int $end Final definition token, exclusive. + * @return bool Whether an unsupported column attribute is present. + */ + private function contains_unsupported_mysql_column_attribute_definition_tokens( array $tokens, int $start, int $end ): bool { + if ( $start >= $end ) { + return false; + } + + return $this->contains_unsupported_mysql_column_attribute_tokens( $tokens, $start + 1, $end ); + } + + /** + * Check whether a token range contains unsupported MySQL column attributes. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an unsupported column attribute token is present. + */ + private function contains_unsupported_mysql_column_attribute_tokens( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( + isset( $tokens[ $i ] ) + && in_array( + $tokens[ $i ]->id, + array( + WP_MySQL_Lexer::GENERATED_SYMBOL, + WP_MySQL_Lexer::COLUMN_FORMAT_SYMBOL, + WP_MySQL_Lexer::STORAGE_SYMBOL, + WP_MySQL_Lexer::VISIBLE_SYMBOL, + WP_MySQL_Lexer::INVISIBLE_SYMBOL, + ), + true + ) + ) { + return true; + } + } + + return false; + } + + /** + * Translate one ALTER TABLE action. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param string $clause Full ALTER clause string. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @param string[] $check_names CHECK names generated for this ALTER TABLE statement. + * @param string[] $foreign_key_names Foreign key names generated for this ALTER TABLE statement. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_alter_table_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end, array &$check_names, array &$foreign_key_names ): ?array { + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return null; + } + + switch ( $tokens[ $start ]->id ) { + case WP_MySQL_Lexer::CHANGE_SYMBOL: + return $this->translate_mysql_dbdelta_change_column_alter_action( $table_schema, $table_name, $clause, $tokens, $start, $end ); + + case WP_MySQL_Lexer::MODIFY_SYMBOL: + return $this->translate_mysql_dbdelta_modify_column_alter_action( $table_schema, $table_name, $clause, $tokens, $start, $end ); + + case WP_MySQL_Lexer::ADD_SYMBOL: + if ( $this->is_mysql_dbdelta_add_constraint_action( $tokens, $start, $end ) ) { + return $this->translate_mysql_dbdelta_add_constraint_alter_action( $table_schema, $table_name, $clause, $tokens, $start, $end, $check_names, $foreign_key_names ); + } + if ( $this->is_mysql_dbdelta_add_index_action( $tokens, $start, $end ) ) { + return $this->translate_mysql_dbdelta_add_index_alter_action( $table_schema, $table_name, $clause, $tokens, $start, $end ); + } + return $this->translate_mysql_dbdelta_add_column_alter_action( $table_schema, $table_name, $clause, $tokens, $start, $end, $check_names, $foreign_key_names ); + + case WP_MySQL_Lexer::DROP_SYMBOL: + if ( + isset( $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::PRIMARY_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[ $start + 2 ]->id + && $start + 3 === $end + ) { + return $this->translate_mysql_dbdelta_drop_primary_key_alter_action( $table_schema, $table_name ); + } + if ( + isset( $tokens[ $start + 2 ] ) + && in_array( $tokens[ $start + 1 ]->id, array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL ), true ) + && 'PRIMARY' === strtoupper( (string) $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 2 ] ?? null ) ) + && $start + 3 === $end + ) { + return $this->translate_mysql_dbdelta_drop_primary_key_alter_action( $table_schema, $table_name ); + } + if ( + isset( $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[ $start + 1 ]->id + && $start + 3 === $end + ) { + return $this->translate_mysql_dbdelta_drop_constraint_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + } + if ( + isset( $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[ $start + 1 ]->id + && $start + 3 === $end + ) { + return $this->translate_mysql_dbdelta_drop_check_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + } + if ( $this->is_mysql_dbdelta_drop_foreign_key_action( $tokens, $start, $end ) ) { + return $this->translate_mysql_dbdelta_drop_foreign_key_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + } + if ( isset( $tokens[ $start + 1 ] ) && in_array( $tokens[ $start + 1 ]->id, array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL ), true ) ) { + return $this->translate_mysql_dbdelta_drop_index_alter_action( $table_name, $tokens, $start, $end ); + } + return $this->translate_mysql_dbdelta_drop_column_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + + case WP_MySQL_Lexer::ALTER_SYMBOL: + return $this->translate_mysql_dbdelta_alter_column_default_action( $table_schema, $table_name, $clause, $tokens, $start, $end ); + + case WP_MySQL_Lexer::RENAME_SYMBOL: + $table_rename = $this->translate_mysql_dbdelta_rename_table_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + if ( null !== $table_rename ) { + return $table_rename; + } + if ( isset( $tokens[ $start + 1 ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $start + 1 ]->id ) { + return $this->translate_mysql_dbdelta_rename_column_alter_action( $table_schema, $table_name, $tokens, $start, $end ); + } + if ( isset( $tokens[ $start + 1 ] ) && in_array( $tokens[ $start + 1 ]->id, array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL ), true ) ) { + return $this->translate_mysql_dbdelta_rename_index_alter_action( $table_name, $tokens, $start, $end ); + } + return null; + + case WP_MySQL_Lexer::ORDER_SYMBOL: + if ( $this->is_supported_mysql_dbdelta_order_by_alter_action( $table_name, $tokens, $start, $end ) ) { + return array( + 'statements' => array(), + 'metadata' => array( + 'operation' => 'noop', + ), + ); + } + return null; + } + + $auto_increment_value = $this->get_mysql_dbdelta_auto_increment_alter_value( $tokens, $start, $end ); + if ( null !== $auto_increment_value ) { + return $this->translate_mysql_dbdelta_auto_increment_alter_action( $table_name, $auto_increment_value ); + } + + if ( $this->is_supported_mysql_dbdelta_keys_alter_action( $tokens, $start, $end ) ) { + return array( + 'statements' => array(), + 'metadata' => array( + 'operation' => 'noop', + ), + ); + } + + if ( $this->is_supported_mysql_dbdelta_online_ddl_option_alter_action( $tokens, $start, $end ) ) { + return array( + 'statements' => array(), + 'metadata' => array( + 'operation' => 'noop', + 'option' => 'online_ddl_option', + ), + ); + } + + $table_comment = $this->get_mysql_dbdelta_table_comment_alter_value( $tokens, $start, $end ); + if ( null !== $table_comment ) { + return array( + 'statements' => array(), + 'metadata' => array( + 'operation' => 'set_table_comment', + 'comment' => $table_comment, + ), + ); + } + + if ( $this->is_supported_mysql_dbdelta_table_option_alter_action( $clause, $tokens, $start, $end ) ) { + return array( + 'statements' => array(), + 'metadata' => array( + 'operation' => 'noop', + ), + ); + } + + return null; + } + + /** + * Translate an ALTER TABLE RENAME [TO|AS] table action. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_rename_table_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + $new_table_position = $start + 1; + if ( + isset( $tokens[ $new_table_position ] ) + && in_array( $tokens[ $new_table_position ]->id, array( WP_MySQL_Lexer::TO_SYMBOL, WP_MySQL_Lexer::AS_SYMBOL ), true ) + ) { + ++$new_table_position; + } + + $position = $new_table_position; + $new_table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $new_table_reference || $position !== $end ) { + return null; + } + + $new_table_schema = $this->get_mysql_rename_table_target_backend_schema( $new_table_reference, $table_schema, 'ALTER TABLE' ); + if ( $new_table_schema !== $table_schema ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $new_table_name = $new_table_reference['table']; + + return array( + 'statements' => $this->get_mysql_rename_table_statements( $table_schema, $table_name, $new_table_name ), + 'metadata' => array( + 'operation' => 'rename_table', + 'new_table' => $new_table_name, + ), + ); + } + + /** + * Translate an ALTER TABLE RENAME COLUMN action. + * + * @param string $table_name Table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_rename_column_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + if ( + ! isset( $tokens[ $start + 4 ] ) + || WP_MySQL_Lexer::COLUMN_SYMBOL !== $tokens[ $start + 1 ]->id + || WP_MySQL_Lexer::TO_SYMBOL !== $tokens[ $start + 3 ]->id + || $start + 5 !== $end + ) { + return null; + } + + $old_column_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + $new_column_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 4 ] ?? null ); + if ( null === $old_column_name || null === $new_column_name ) { + return null; + } + $old_column_name = $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $old_column_name ); + + return array( + 'statements' => array( + sprintf( + 'ALTER TABLE %s RENAME COLUMN %s TO %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $old_column_name ), + $this->connection->quote_identifier( $new_column_name ) + ), + ), + 'metadata' => array( + 'operation' => 'rename_column', + 'old_column' => $old_column_name, + 'new_column' => $new_column_name, + ), + ); + } + + /** + * Translate an ALTER TABLE RENAME INDEX/KEY action. + * + * @param string $table_name Table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_rename_index_alter_action( string $table_name, array $tokens, int $start, int $end ): ?array { + if ( + ! isset( $tokens[ $start + 4 ] ) + || ! in_array( $tokens[ $start + 1 ]->id, array( WP_MySQL_Lexer::INDEX_SYMBOL, WP_MySQL_Lexer::KEY_SYMBOL ), true ) + || WP_MySQL_Lexer::TO_SYMBOL !== $tokens[ $start + 3 ]->id + || $start + 5 !== $end + ) { + return null; + } + + $old_index_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + $new_index_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 4 ] ?? null ); + if ( + null === $old_index_name + || null === $new_index_name + || 'PRIMARY' === strtoupper( $old_index_name ) + || 'PRIMARY' === strtoupper( $new_index_name ) + ) { + return null; + } + + $table_reference = array( + 'schema' => null, + 'table' => $table_name, + ); + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, 'ALTER TABLE' ); + + if ( $this->mysql_index_metadata_has_rows( $table_schema, $table_name ) ) { + if ( + ! $this->mysql_index_metadata_exists( $table_schema, $table_name, $old_index_name ) + || $this->mysql_index_metadata_exists( $table_schema, $table_name, $new_index_name ) + ) { + return null; + } + } + + $index_type = $this->get_stored_mysql_index_type( $table_schema, $table_name, $old_index_name ); + + $statements = array(); + if ( null === $index_type || ! $this->is_mysql_metadata_only_index_type( $index_type ) ) { + $statements[] = sprintf( + 'ALTER INDEX %s RENAME TO %s', + $this->get_postgresql_schema_identifier( $table_schema, $table_name . '__' . $old_index_name ), + $this->connection->quote_identifier( $table_name . '__' . $new_index_name ) + ); + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'operation' => 'rename_index', + 'old_index' => $old_index_name, + 'new_index' => $new_index_name, + ), + ); + } + + /** + * Parse an ALTER TABLE AUTO_INCREMENT = N table option. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return int|null Requested next AUTO_INCREMENT value, or null when not this option. + */ + private function get_mysql_dbdelta_auto_increment_alter_value( array $tokens, int $start, int $end ): ?int { + if ( ! isset( $tokens[ $start ] ) || WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL !== $tokens[ $start ]->id ) { + return null; + } + + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position ]->id ) { + ++$position; + } + + if ( + $position + 1 !== $end + || ! isset( $tokens[ $position ] ) + || ! in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::INT_NUMBER, WP_MySQL_Lexer::LONG_NUMBER, WP_MySQL_Lexer::ULONGLONG_NUMBER ), true ) + ) { + return null; + } + + $value = $tokens[ $position ]->get_value(); + if ( ! preg_match( '/^[0-9]+$/', $value ) ) { + return null; + } + + $value = (int) $value; + return $value > 0 ? $value : null; + } + + /** + * Translate ALTER TABLE AUTO_INCREMENT = N into backend sequence adjustment. + * + * @param string $table_name Target table name. + * @param int $value Requested next AUTO_INCREMENT value. + * @return array{statements: string[], metadata: array} Translation. + */ + private function translate_mysql_dbdelta_auto_increment_alter_action( string $table_name, int $value ): array { + $metadata_lookup = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $metadata_lookup ); + if ( null === $auto_increment_column ) { + return array( + 'statements' => array(), + 'metadata' => array( + 'operation' => 'noop', + ), + ); + } + + $minimum_sequence_value = max( 0, $value - 1 ); + $statements = array(); + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + + $statements = $this->get_postgresql_auto_increment_alter_statements( $table_name, $auto_increment_column, $minimum_sequence_value ); + if ( empty( $statements ) && 'sqlite' === $this->connection->get_driver_name() ) { + $statements = $this->get_sqlite_auto_increment_alter_statements( $table_schema, $table_name, $auto_increment_column, $minimum_sequence_value ); + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'operation' => 'set_auto_increment', + ), + ); + } + + /** + * Translate an ALTER TABLE CHANGE COLUMN action. + * + * @param string $table_name Table name. + * @param string $clause Full ALTER clause string. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_change_column_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end ): ?array { + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $old_column = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $old_column ) { + return null; + } + $old_column = $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $old_column ); + + ++$position; + $definition_end = $this->get_mysql_alter_column_definition_end_without_placement( $tokens, $position, $end ); + if ( null === $definition_end || $position >= $definition_end ) { + return null; + } + + try { + $column = $this->translate_mysql_column_definition_fragment( + $this->get_mysql_token_range_bytes( $clause, $tokens, $position, $definition_end ) + ); + } catch ( InvalidArgumentException $e ) { + return null; + } + if ( null === $column ) { + return null; + } + + return array( + 'statements' => $this->get_mysql_dbdelta_change_column_statements( $table_schema, $table_name, $old_column, $column ), + 'metadata' => array( + 'operation' => 'change_column', + 'old_column' => $old_column, + 'column' => $column['metadata'], + 'indexes' => $column['indexes'], + ), + ); + } + + /** + * Translate an ALTER TABLE MODIFY COLUMN action. + * + * @param string $table_name Table name. + * @param string $clause Full ALTER clause string. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_modify_column_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end ): ?array { + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $definition_end = $this->get_mysql_alter_column_definition_end_without_placement( $tokens, $position, $end ); + if ( null === $definition_end || $position >= $definition_end ) { + return null; + } + + try { + $column = $this->translate_mysql_column_definition_fragment( + $this->get_mysql_token_range_bytes( $clause, $tokens, $position, $definition_end ) + ); + } catch ( InvalidArgumentException $e ) { + return null; + } + if ( null === $column ) { + return null; + } + + $column_name = $column['metadata']['name']; + $old_column = $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $column_name ); + return array( + 'statements' => $this->get_mysql_dbdelta_change_column_statements( $table_schema, $table_name, $old_column, $column ), + 'metadata' => array( + 'operation' => 'change_column', + 'old_column' => $old_column, + 'column' => $column['metadata'], + 'indexes' => $column['indexes'], + ), + ); + } + + /** + * Translate an ALTER TABLE ADD COLUMN action. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param string $clause Full ALTER clause string. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @param string[] $check_names CHECK names generated for this ALTER TABLE statement. + * @param string[] $foreign_key_names Foreign key names generated for this ALTER TABLE statement. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_add_column_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end, array &$check_names, array &$foreign_key_names ): ?array { + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + return $this->translate_mysql_dbdelta_add_parenthesized_columns_alter_action( + $table_schema, + $table_name, + $clause, + $tokens, + $position, + $end, + $check_names, + $foreign_key_names + ); + } + + $definition_end = $this->get_mysql_alter_column_definition_end_without_placement( $tokens, $position, $end ); + if ( null === $definition_end || $position >= $definition_end ) { + return null; + } + + return $this->translate_mysql_dbdelta_add_column_definition_alter_action( + $table_schema, + $table_name, + $this->get_mysql_token_range_bytes( $clause, $tokens, $position, $definition_end ), + $foreign_key_names + ); + } + + /** + * Translate an ALTER TABLE ADD (definition, definition) action. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param string $clause Full ALTER clause string. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $position Opening parenthesis token. + * @param int $end Final action token, exclusive. + * @param string[] $check_names CHECK names generated for this ALTER TABLE statement. + * @param string[] $foreign_key_names Foreign key names generated for this ALTER TABLE statement. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_add_parenthesized_columns_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $position, int $end, array &$check_names, array &$foreign_key_names ): ?array { + $parenthesized_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( $parenthesized_end !== $end ) { + return null; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, $position + 1, $end - 1 ); + if ( null === $ranges || array() === $ranges ) { + return null; + } + + $statements = array(); + $metadata_operations = array(); + foreach ( $ranges as $range ) { + if ( $this->is_mysql_dbdelta_add_index_definition_action( $tokens, $range['start'], $range['end'] ) ) { + $translation = $this->translate_mysql_dbdelta_add_index_definition_alter_action( + $table_schema, + $table_name, + $clause, + $tokens, + $range['start'], + $range['end'] + ); + } elseif ( $this->is_mysql_dbdelta_add_constraint_definition_action( $tokens, $range['start'], $range['end'] ) ) { + $translation = $this->translate_mysql_dbdelta_add_constraint_definition_alter_action( + $table_schema, + $table_name, + $clause, + $tokens, + $range['start'], + $range['end'], + $check_names, + $foreign_key_names + ); + } else { + $definition_end = $this->get_mysql_alter_column_definition_end_without_placement( $tokens, $range['start'], $range['end'] ); + if ( null === $definition_end || $range['start'] >= $definition_end ) { + return null; + } + + $translation = $this->translate_mysql_dbdelta_add_column_definition_alter_action( + $table_schema, + $table_name, + $this->get_mysql_token_range_bytes( $clause, $tokens, $range['start'], $definition_end ), + $foreign_key_names + ); + } + + if ( null === $translation ) { + return null; + } + + $statements = array_merge( $statements, $translation['statements'] ); + $metadata_operations[] = $translation['metadata']; + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'operation' => 'operations', + 'operations' => $metadata_operations, + ), + ); + } + + /** + * Translate one ALTER TABLE ADD column definition. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param string $definition Column definition fragment. + * @param string[] $foreign_key_names Foreign key names generated for this ALTER TABLE statement. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_add_column_definition_alter_action( string $table_schema, string $table_name, string $definition, array &$foreign_key_names ): ?array { + try { + $column = $this->translate_mysql_column_definition_fragment( + $definition, + $table_name + ); + } catch ( InvalidArgumentException $e ) { + return null; + } + if ( null === $column ) { + return null; + } + + foreach ( $column['foreign_keys'] as &$foreign_key ) { + $constraint_name = $this->get_next_mysql_foreign_key_constraint_name( $table_schema, $table_name, $foreign_key_names ); + $column['sql'] = $this->replace_mysql_column_fragment_constraint_name( + $column['sql'], + $foreign_key['name'], + $constraint_name + ); + + $foreign_key['name'] = $constraint_name; + $foreign_key['referenced_schema'] = $foreign_key['referenced_schema'] ?? $table_schema; + $foreign_key_names[] = $constraint_name; + } + unset( $foreign_key ); + + return array( + 'statements' => array( + sprintf( + 'ALTER TABLE %s ADD COLUMN %s', + $this->connection->quote_identifier( $table_name ), + $column['sql'] + ), + ), + 'metadata' => array( + 'operation' => 'add_column', + 'column' => $column['metadata'], + 'indexes' => $column['indexes'], + 'foreign_keys' => $column['foreign_keys'], + 'checks' => $column['checks'], + ), + ); + } + + /** + * Translate an ALTER TABLE ADD INDEX action. + * + * @param string $table_name Table name. + * @param string $clause Full ALTER clause string. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_add_index_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end ): ?array { + $definition_start = $start + 1; + if ( $definition_start >= $end ) { + return null; + } + + return $this->translate_mysql_dbdelta_add_index_definition_alter_action( + $table_schema, + $table_name, + $clause, + $tokens, + $definition_start, + $end + ); + } + + /** + * Translate an ALTER TABLE ADD index definition without the ADD keyword. + * + * @param string $table_name Table name. + * @param string $clause Full ALTER clause string. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $definition_start First index-definition token. + * @param int $end Final index-definition token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_add_index_definition_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $definition_start, int $end ): ?array { + $index = $this->translate_mysql_index_definition_fragment( + $table_name, + $this->get_mysql_token_range_bytes( $clause, $tokens, $definition_start, $end ), + $table_schema + ); + if ( null === $index ) { + return null; + } + + return array( + 'statements' => $index['statements'], + 'metadata' => array( + 'operation' => 'add_index', + 'index' => $index['metadata'], + ), + ); + } + + /** + * Translate an ALTER TABLE ADD CONSTRAINT or ADD CHECK action. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param string $clause Full ALTER clause string. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @param string[] $check_names CHECK names generated for this ALTER TABLE statement. + * @param string[] $foreign_key_names Foreign key names generated for this ALTER TABLE statement. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_add_constraint_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end, array &$check_names, array &$foreign_key_names ): ?array { + return $this->translate_mysql_dbdelta_add_constraint_definition_alter_action( + $table_schema, + $table_name, + $clause, + $tokens, + $start + 1, + $end, + $check_names, + $foreign_key_names + ); + } + + /** + * Translate an ALTER TABLE ADD constraint definition without the ADD keyword. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param string $clause Full ALTER clause string. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $position First constraint-definition token. + * @param int $end Final constraint-definition token, exclusive. + * @param string[] $check_names CHECK names generated for this ALTER TABLE statement. + * @param string[] $foreign_key_names Foreign key names generated for this ALTER TABLE statement. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_add_constraint_definition_alter_action( string $table_schema, string $table_name, string $clause, array $tokens, int $position, int $end, array &$check_names, array &$foreign_key_names ): ?array { + $constraint_name = null; + $definition_start = $position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::CONSTRAINT_SYMBOL === $tokens[ $position ]->id ) { + $constraint_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $constraint_name ) { + return null; + } + $position += 2; + } + + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( + WP_MySQL_Lexer::PRIMARY_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::UNIQUE_SYMBOL === $tokens[ $position ]->id + ) { + $is_unique_constraint = WP_MySQL_Lexer::UNIQUE_SYMBOL === $tokens[ $position ]->id; + $index = $this->translate_mysql_index_definition_fragment( + $table_name, + $this->get_mysql_token_range_bytes( $clause, $tokens, $definition_start, $end ), + $table_schema + ); + if ( null === $index ) { + return null; + } + + if ( + $is_unique_constraint + && null !== $constraint_name + && isset( $index['metadata']['columns'][0]['column_name'] ) + && $index['metadata']['name'] === $index['metadata']['columns'][0]['column_name'] + ) { + $index['metadata']['name'] = $constraint_name; + $columns = array(); + foreach ( $index['metadata']['columns'] as $column ) { + $columns[] = $this->connection->quote_identifier( $column['column_name'] ); + } + + $index['statements'] = array( + sprintf( + 'CREATE UNIQUE INDEX %s ON %s (%s)', + $this->connection->quote_identifier( $table_name . '__' . $constraint_name ), + $this->connection->quote_identifier( $table_name ), + implode( ', ', $columns ) + ), + ); + } + + return array( + 'statements' => $index['statements'], + 'metadata' => array( + 'operation' => 'add_index', + 'index' => $index['metadata'], + ), + ); + } + + if ( WP_MySQL_Lexer::CHECK_SYMBOL === $tokens[ $position ]->id ) { + return $this->translate_mysql_dbdelta_add_check_alter_action( + $table_name, + $tokens, + $position, + $end, + $constraint_name, + $check_names + ); + } + + if ( WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[ $position ]->id ) { + return $this->translate_mysql_dbdelta_add_foreign_key_alter_action( + $table_schema, + $table_name, + $tokens, + $position, + $end, + $constraint_name, + $foreign_key_names + ); + } + + return null; + } + + /** + * Translate an ALTER TABLE ADD CHECK action. + * + * @param string $table_name Table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $check_position CHECK token position. + * @param int $end Final action token, exclusive. + * @param string|null $constraint_name Optional MySQL constraint name. + * @param string[] $check_names CHECK names generated for this ALTER TABLE statement. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_add_check_alter_action( string $table_name, array $tokens, int $check_position, int $end, ?string $constraint_name, array &$check_names ): ?array { + if ( ! isset( $tokens[ $check_position + 1 ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $check_position + 1 ]->id ) { + return null; + } + + $check_end = $this->get_mysql_parenthesized_sequence_end( $tokens, $check_position + 1, $end ); + if ( null === $check_end || $check_position + 2 >= $check_end - 1 ) { + return null; + } + + $enforced = 'YES'; + if ( $check_end < $end ) { + if ( $check_end + 1 === $end && WP_MySQL_Lexer::ENFORCED_SYMBOL === ( $tokens[ $check_end ]->id ?? null ) ) { + $enforced = 'YES'; + } elseif ( + $check_end + 2 === $end + && WP_MySQL_Lexer::NOT_SYMBOL === ( $tokens[ $check_end ]->id ?? null ) + && WP_MySQL_Lexer::ENFORCED_SYMBOL === ( $tokens[ $check_end + 1 ]->id ?? null ) + ) { + $enforced = 'NO'; + } else { + return null; + } + } + + if ( null === $constraint_name ) { + $constraint_name = $this->get_next_mysql_check_constraint_name( 'public', $table_name, $check_names ); + $check_names[] = $constraint_name; + } + + $postgresql_expression = $this->translate_mysql_check_constraint_expression_to_postgresql( + $tokens, + $check_position + 2, + $check_end - 1 + ); + $mysql_expression = $this->render_mysql_check_constraint_metadata_expression( + $tokens, + $check_position + 2, + $check_end - 1 + ); + + $statements = array(); + if ( 'YES' === $enforced ) { + $statements[] = sprintf( + 'ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s)', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $constraint_name ), + $postgresql_expression + ); + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'operation' => 'add_check', + 'check' => array( + 'name' => $constraint_name, + 'check_clause' => $mysql_expression, + 'enforced' => $enforced, + ), + ), + ); + } + + /** + * Translate a MySQL CHECK expression to backend PostgreSQL SQL. + * + * Most CHECK expressions can use the shared expression renderer. JSON_VALID() + * is special because the runtime-compatible translation returns 1/0/NULL, + * while PostgreSQL CHECK constraints require a boolean expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return string PostgreSQL CHECK expression SQL. + */ + private function translate_mysql_check_constraint_expression_to_postgresql( array $tokens, int $start, int $end ): string { + $sql = ''; + $segment_start = $start; + + for ( $position = $start; $position < $end; ++$position ) { + $json_valid = $this->translate_mysql_json_valid_check_constraint_function( $tokens, $position, $end ); + if ( null === $json_valid ) { + continue; + } + + $sql = $this->append_mysql_check_constraint_sql_fragment( + $sql, + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $position ) + ); + $sql = $this->append_mysql_check_constraint_sql_fragment( $sql, $json_valid['sql'] ); + + $position = $json_valid['position']; + $segment_start = $position + 1; + } + + $sql = $this->append_mysql_check_constraint_sql_fragment( + $sql, + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $end ) + ); + + if ( '' === $sql ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + + return $sql; + } + + /** + * Render a MySQL-facing CHECK expression for metadata. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return string MySQL-facing CHECK expression SQL. + */ + private function render_mysql_check_constraint_metadata_expression( array $tokens, int $start, int $end ): string { + $sql = ''; + $previous_token = null; + + for ( $position = $start; $position < $end; ++$position ) { + $token = $tokens[ $position ]; + $fragment = $this->translate_mysql_token_to_postgresql( $token, $tokens[ $position + 1 ] ?? null ); + if ( '' === $fragment ) { + continue; + } + + if ( '' === $sql ) { + $sql = $fragment; + } elseif ( $this->should_join_mysql_check_constraint_metadata_tokens_without_space( $previous_token, $token ) ) { + $sql .= $fragment; + } else { + $sql .= ' ' . $fragment; + } + + $previous_token = $token; + } + + if ( '' === $sql ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + + return $sql; + } + + /** + * Decide whether two CHECK metadata tokens should be joined without whitespace. + * + * @param WP_MySQL_Token|null $previous Previous token, or null. + * @param WP_MySQL_Token $current Current token. + * @return bool Whether no separator should be added. + */ + private function should_join_mysql_check_constraint_metadata_tokens_without_space( ?WP_MySQL_Token $previous, WP_MySQL_Token $current ): bool { + if ( null === $previous ) { + return false; + } + + if ( $this->should_join_mysql_tokens_without_space( $previous->id, $current->id ) ) { + return true; + } + + return WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $current->id + && null !== $this->get_mysql_identifier_token_value( $previous ); + } + + /** + * Translate one JSON_VALID() call in a CHECK expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @param int $end Final expression token, exclusive. + * @return array{sql: string, position: int}|null Translation data, or null when not JSON_VALID(). + */ + private function translate_mysql_json_valid_check_constraint_function( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || ! $this->is_mysql_json_valid_identifier_token( $tokens[ $position ] ) + ) { + return null; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $position + 2, $after_close - 1 ); + if ( null === $arguments || 1 !== count( $arguments ) || $arguments[0]['start'] >= $arguments[0]['end'] ) { + throw new InvalidArgumentException( 'Unsupported CHECK constraint expression.' ); + } + + $argument_sql = $this->translate_mysql_check_constraint_expression_to_postgresql( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + + return array( + 'sql' => sprintf( '(CASE WHEN %1$s IS NULL THEN NULL ELSE (CAST(%1$s AS jsonb) IS NOT NULL) END)', $argument_sql ), + 'position' => $after_close - 1, + ); + } + + /** + * Check whether a token names JSON_VALID. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether this token is a JSON_VALID identifier. + */ + private function is_mysql_json_valid_identifier_token( WP_MySQL_Token $token ): bool { + $identifier = $this->get_mysql_identifier_token_value( $token ); + return null !== $identifier && 'json_valid' === strtolower( $identifier ); + } + + /** + * Append one CHECK expression fragment with bounded spacing. + * + * @param string $sql SQL accumulated so far. + * @param string $fragment Fragment to append. + * @return string Combined SQL. + */ + private function append_mysql_check_constraint_sql_fragment( string $sql, string $fragment ): string { + $fragment = trim( $fragment ); + if ( '' === $fragment ) { + return $sql; + } + + if ( '' === $sql ) { + return $fragment; + } + + $last_character = substr( $sql, -1 ); + $first_character = $fragment[0]; + if ( '(' === $last_character || ')' === $first_character || ',' === $first_character || '.' === $last_character || '.' === $first_character ) { + return $sql . $fragment; + } + + return $sql . ' ' . $fragment; + } + + /** + * Translate an ALTER TABLE ADD FOREIGN KEY action. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $foreign_position FOREIGN token position. + * @param int $end Final action token, exclusive. + * @param string|null $constraint_name Optional MySQL constraint name. + * @param string[] $foreign_key_names Foreign key names generated for this ALTER TABLE statement. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_add_foreign_key_alter_action( string $table_schema, string $table_name, array $tokens, int $foreign_position, int $end, ?string $constraint_name, array &$foreign_key_names ): ?array { + if ( ! isset( $tokens[ $foreign_position + 1 ] ) || WP_MySQL_Lexer::KEY_SYMBOL !== $tokens[ $foreign_position + 1 ]->id ) { + return null; + } + + $position = $foreign_position + 2; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + $index_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ); + if ( null === $index_name ) { + return null; + } + ++$position; + } + + $columns = $this->parse_mysql_alter_identifier_list( $tokens, $position ); + if ( null === $columns || empty( $columns ) ) { + return null; + } + $columns = array_map( + function ( string $column_name ) use ( $table_schema, $table_name ): string { + return $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $column_name ); + }, + $columns + ); + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::REFERENCES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $referenced_table = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $referenced_table ) { + return null; + } + + $referenced_schema = $this->get_mysql_writable_table_backend_schema( $referenced_table, 'ALTER TABLE' ); + $referenced_columns = $this->parse_mysql_alter_identifier_list( $tokens, $position ); + if ( null === $referenced_columns || count( $referenced_columns ) !== count( $columns ) ) { + return null; + } + $referenced_columns = array_map( + function ( string $column_name ) use ( $referenced_schema, $referenced_table ): string { + return $this->resolve_mysql_existing_alter_column_name( $referenced_schema, $referenced_table['table'], $column_name ); + }, + $referenced_columns + ); + + $rules = $this->parse_mysql_foreign_key_rules( $tokens, $position, $end ); + if ( null === $rules ) { + return null; + } + + if ( null === $constraint_name ) { + $constraint_name = $this->get_next_mysql_foreign_key_constraint_name( $table_schema, $table_name, $foreign_key_names ); + $foreign_key_names[] = $constraint_name; + } else { + $foreign_key_names[] = $constraint_name; + } + + $foreign_key_sql = sprintf( + 'ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)%s%s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $constraint_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + null === $referenced_table['schema'] + ? $this->connection->quote_identifier( $referenced_table['table'] ) + : $this->get_postgresql_schema_identifier( $referenced_schema, $referenced_table['table'] ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $referenced_columns ) ), + 'NO ACTION' === $rules['delete_rule'] ? '' : ' ON DELETE ' . $rules['delete_rule'], + 'NO ACTION' === $rules['update_rule'] ? '' : ' ON UPDATE ' . $rules['update_rule'] + ); + + return array( + 'statements' => array( $foreign_key_sql ), + 'metadata' => array( + 'operation' => 'add_foreign_key', + 'foreign_key' => array( + 'name' => $constraint_name, + 'columns' => $columns, + 'referenced_schema' => $referenced_schema, + 'referenced_table' => $referenced_table['table'], + 'referenced_columns' => $referenced_columns, + 'update_rule' => $rules['update_rule'], + 'delete_rule' => $rules['delete_rule'], + ), + ), + ); + } + + /** + * Parse a parenthesized identifier list in ALTER TABLE contexts. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $position Current token position, updated on success. + * @return string[]|null Identifier values, or null when unsupported. + */ + private function parse_mysql_alter_identifier_list( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $identifiers = array(); + while ( isset( $tokens[ $position ] ) ) { + $identifier = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ); + if ( null === $identifier ) { + return null; + } + + $identifiers[] = $identifier; + ++$position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + return $identifiers; + } + + return null; + } + + return null; + } + + /** + * Parse optional foreign key ON UPDATE/ON DELETE rules. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $position Current token position, updated on success. + * @param int $end Final action token, exclusive. + * @return array{update_rule: string, delete_rule: string}|null Parsed rules, or null when unsupported. + */ + private function parse_mysql_foreign_key_rules( array $tokens, int &$position, int $end ): ?array { + $rules = array( + 'update_rule' => 'NO ACTION', + 'delete_rule' => 'NO ACTION', + ); + $seen = array(); + + while ( $position < $end ) { + if ( ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + if ( WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $position + 1 ]->id ) { + $rule_key = 'update_rule'; + } elseif ( WP_MySQL_Lexer::DELETE_SYMBOL === $tokens[ $position + 1 ]->id ) { + $rule_key = 'delete_rule'; + } else { + return null; + } + + if ( isset( $seen[ $rule_key ] ) ) { + return null; + } + + $position += 2; + $rule = $this->parse_mysql_foreign_key_reference_option( $tokens, $position, $end ); + if ( null === $rule ) { + return null; + } + + $rules[ $rule_key ] = $rule; + $seen[ $rule_key ] = true; + } + + return $rules; + } + + /** + * Parse one foreign key reference option. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $position Current token position, updated on success. + * @param int $end Final action token, exclusive. + * @return string|null Reference option, or null when unsupported. + */ + private function parse_mysql_foreign_key_reference_option( array $tokens, int &$position, int $end ): ?string { + if ( $position >= $end || ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::CASCADE_SYMBOL, WP_MySQL_Lexer::RESTRICT_SYMBOL ), true ) ) { + $rule = strtoupper( $tokens[ $position ]->get_value() ); + ++$position; + return $rule; + } + + if ( WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position ]->id ) { + if ( ! isset( $tokens[ $position + 1 ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $position + 1 ]->id ) { + $position += 2; + return 'SET NULL'; + } + + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $tokens[ $position + 1 ]->id ) { + $position += 2; + return 'SET DEFAULT'; + } + + return null; + } + + if ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::NO_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::ACTION_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + return 'NO ACTION'; + } + + return null; + } + + /** + * Translate an ALTER TABLE DROP INDEX action. + * + * @param string $table_name Table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_drop_index_alter_action( string $table_name, array $tokens, int $start, int $end ): ?array { + if ( $start + 3 !== $end ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $index_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + if ( null === $index_name ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + $drop_index_query = $this->get_mysql_drop_index_translation( + array( + 'schema' => null, + 'table' => $table_name, + ), + $index_name, + 'ALTER TABLE' + ); + + $drop_index_query['metadata']['operation'] = 'drop_index'; + return $drop_index_query; + } + + /** + * Translate an ALTER TABLE DROP PRIMARY KEY action. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @return array{statements: string[], metadata: array} Drop primary key translation. + */ + private function translate_mysql_dbdelta_drop_primary_key_alter_action( string $table_schema, string $table_name ): array { + return array( + 'statements' => array( + sprintf( + 'ALTER TABLE %s DROP CONSTRAINT %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( + $this->get_postgresql_primary_key_constraint_name( $table_schema, $table_name ) + ) + ), + ), + 'metadata' => array( + 'operation' => 'drop_index', + 'schema' => $table_schema, + 'table' => $table_name, + 'index' => 'PRIMARY', + ), + ); + } + + /** + * Translate an ALTER TABLE DROP CONSTRAINT action. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_drop_constraint_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + $constraint_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + if ( null === $constraint_name ) { + return null; + } + + if ( 'PRIMARY' === strtoupper( $constraint_name ) ) { + return $this->translate_mysql_dbdelta_drop_primary_key_alter_action( $table_schema, $table_name ); + } + + $matching_constraint_types = array(); + if ( $this->mysql_unique_index_metadata_exists( $table_schema, $table_name, $constraint_name ) ) { + $matching_constraint_types[] = 'unique'; + } + if ( $this->mysql_foreign_key_metadata_exists( $table_schema, $table_name, $constraint_name ) ) { + $matching_constraint_types[] = 'foreign_key'; + } + $check_metadata = $this->get_mysql_check_metadata( $table_schema, $table_name, $constraint_name ); + if ( null !== $check_metadata ) { + $matching_constraint_types[] = 'check'; + } + + if ( 1 !== count( $matching_constraint_types ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + if ( 'unique' === $matching_constraint_types[0] ) { + return array( + 'statements' => array( + 'DROP INDEX ' . $this->get_postgresql_schema_identifier( $table_schema, $table_name . '__' . $constraint_name ), + ), + 'metadata' => array( + 'operation' => 'drop_index', + 'schema' => $table_schema, + 'table' => $table_name, + 'index' => $constraint_name, + ), + ); + } + + if ( 'foreign_key' === $matching_constraint_types[0] ) { + return $this->get_mysql_dbdelta_drop_foreign_key_translation( $table_name, $constraint_name ); + } + + return $this->get_mysql_dbdelta_drop_check_translation( + $table_name, + $constraint_name, + strtoupper( (string) $check_metadata['enforced'] ) !== 'NO' + ); + } + + /** + * Translate an ALTER TABLE DROP FOREIGN KEY action. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_drop_foreign_key_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + if ( $start + 4 !== $end ) { + return null; + } + + $constraint_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 3 ] ?? null ); + if ( null === $constraint_name ) { + return null; + } + + if ( ! $this->mysql_foreign_key_metadata_exists( $table_schema, $table_name, $constraint_name ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + return $this->get_mysql_dbdelta_drop_foreign_key_translation( $table_name, $constraint_name ); + } + + /** + * Build a DROP FOREIGN KEY translation for a named constraint. + * + * @param string $table_name Table name. + * @param string $constraint_name Constraint name. + * @return array{statements: string[], metadata: array} Drop foreign key translation. + */ + private function get_mysql_dbdelta_drop_foreign_key_translation( string $table_name, string $constraint_name ): array { + return array( + 'statements' => array( + sprintf( + 'ALTER TABLE %s DROP CONSTRAINT %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $constraint_name ) + ), + ), + 'metadata' => array( + 'operation' => 'drop_foreign_key', + 'constraint' => $constraint_name, + ), + ); + } + + /** + * Translate an ALTER TABLE DROP CHECK action. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_drop_check_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + $constraint_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + if ( null === $constraint_name ) { + return null; + } + + $check_metadata = $this->get_mysql_check_metadata( $table_schema, $table_name, $constraint_name ); + if ( null === $check_metadata ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + return $this->get_mysql_dbdelta_drop_check_translation( + $table_name, + $constraint_name, + strtoupper( (string) $check_metadata['enforced'] ) !== 'NO' + ); + } + + /** + * Build a DROP CHECK translation for a named constraint. + * + * @param string $table_name Table name. + * @param string $constraint_name Constraint name. + * @param bool $drop_backend_constraint Whether a backend constraint should be dropped. + * @return array{statements: string[], metadata: array} Drop CHECK translation. + */ + private function get_mysql_dbdelta_drop_check_translation( string $table_name, string $constraint_name, bool $drop_backend_constraint ): array { + $statements = array(); + if ( $drop_backend_constraint ) { + $statements[] = sprintf( + 'ALTER TABLE %s DROP CONSTRAINT %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $constraint_name ) + ); + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'operation' => 'drop_check', + 'constraint' => $constraint_name, + ), + ); + } + + /** + * Translate an ALTER TABLE DROP COLUMN action. + * + * @param string $table_name Table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_drop_column_alter_action( string $table_schema, string $table_name, array $tokens, int $start, int $end ): ?array { + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( + $position + 2 === $end + && isset( $tokens[ $position + 1 ] ) + && in_array( $tokens[ $position + 1 ]->id, array( WP_MySQL_Lexer::CASCADE_SYMBOL, WP_MySQL_Lexer::RESTRICT_SYMBOL ), true ) + ) { + --$end; + } + + if ( $position + 1 !== $end ) { + return null; + } + + $column_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $column_name ) { + return null; + } + $column_name = $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $column_name ); + + return array( + 'statements' => array( + sprintf( + 'ALTER TABLE %s DROP COLUMN %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ) + ), + ), + 'metadata' => array( + 'operation' => 'drop_column', + 'column' => $column_name, + ), + ); + } + + /** + * Translate ALTER TABLE ALTER COLUMN default actions. + * + * @param string $table_name Table name. + * @param string $clause Full ALTER clause string. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_alter_column_default_action( string $table_schema, string $table_name, string $clause, array $tokens, int $start, int $end ): ?array { + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $column_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $column_name || ! isset( $tokens[ $position + 1 ] ) ) { + return null; + } + $column_name = $this->resolve_mysql_existing_alter_column_name( $table_schema, $table_name, $column_name ); + + $position += 1; + if ( WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position ]->id ) { + if ( ! isset( $tokens[ $position + 1 ] ) || WP_MySQL_Lexer::DEFAULT_SYMBOL !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $default_start = $position + 2; + if ( $default_start >= $end ) { + return null; + } + + $default = $this->translate_mysql_default_fragment( + $this->get_mysql_token_range_bytes( $clause, $tokens, $default_start, $end ) + ); + if ( null === $default ) { + return null; + } + + return array( + 'statements' => array( + sprintf( + 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ), + $default['sql'] + ), + ), + 'metadata' => array( + 'operation' => 'set_default', + 'column' => $column_name, + 'default' => $default['metadata'], + ), + ); + } + + if ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DROP_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::DEFAULT_SYMBOL === $tokens[ $position + 1 ]->id + && $position + 2 === $end + ) { + return array( + 'statements' => array( + sprintf( + 'ALTER TABLE %s ALTER COLUMN %s DROP DEFAULT', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ) + ), + ), + 'metadata' => array( + 'operation' => 'drop_default', + 'column' => $column_name, + ), + ); + } + + return null; + } + + /** + * Build PostgreSQL statements for a CHANGE/MODIFY column operation. + * + * @param string $table_name Table name. + * @param string $old_column Existing column name. + * @param array $column Translated column definition data. + * @return string[] PostgreSQL ALTER statements. + */ + private function get_mysql_dbdelta_change_column_statements( string $table_schema, string $table_name, string $old_column, array $column ): array { + $new_column = $column['metadata']['name']; + $statements = array(); + if ( $old_column !== $new_column ) { + $statements[] = sprintf( + 'ALTER TABLE %s RENAME COLUMN %s TO %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $old_column ), + $this->connection->quote_identifier( $new_column ) + ); + } + + $column_type = $this->get_translated_column_type_from_definition_line( $column['sql'] ); + $preserve_existing_identity = $this->should_preserve_existing_identity_integer_column_change( + $table_schema, + $table_name, + $old_column, + $column['metadata'] + ); + if ( '' !== $column_type && ! $preserve_existing_identity ) { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s TYPE %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $new_column ), + $column_type + ); + } + + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s %s NOT NULL', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $new_column ), + 'NO' === ( $column['metadata']['nullable'] ?? 'YES' ) ? 'SET' : 'DROP' + ); + + $default_sql = $this->get_translated_column_default_from_definition_line( $column['sql'] ); + if ( ! $preserve_existing_identity ) { + if ( null !== $default_sql ) { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $new_column ), + $default_sql + ); + } else { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s DROP DEFAULT', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $new_column ) + ); + } + } + + if ( $this->should_add_identity_for_auto_increment_column_change( $table_schema, $table_name, $old_column, $column['metadata'] ) ) { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s ADD GENERATED BY DEFAULT AS IDENTITY', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $new_column ) + ); + } + + return array_merge( + $statements, + $this->get_mysql_dbdelta_inline_index_statements( $table_name, $column['indexes'] ?? array() ) + ); + } + + /** + * Build PostgreSQL statements for inline indexes parsed from a column definition. + * + * @param string $table_name Table name. + * @param array $indexes MySQL index metadata rows. + * @return string[] PostgreSQL ALTER/CREATE INDEX statements. + */ + private function get_mysql_dbdelta_inline_index_statements( string $table_name, array $indexes ): array { + $statements = array(); + foreach ( $indexes as $index ) { + $columns = array(); + foreach ( $index['columns'] as $column ) { + $column_sql = $this->get_mysql_index_key_part_sql( + (string) $column['column_name'], + '0' === (string) $index['non_unique'] ? $column['sub_part'] : null + ); + if ( 'D' === strtoupper( (string) ( $column['collation'] ?? '' ) ) ) { + $column_sql .= ' DESC'; + } + + $columns[] = $column_sql; + } + + if ( 'PRIMARY' === strtoupper( (string) $index['name'] ) ) { + $statements[] = sprintf( + 'ALTER TABLE %s ADD PRIMARY KEY (%s)', + $this->connection->quote_identifier( $table_name ), + implode( ', ', $columns ) + ); + continue; + } + + if ( $this->is_mysql_metadata_only_index_type( (string) $index['index_type'] ) ) { + continue; + } + + $statements[] = sprintf( + 'CREATE %sINDEX %s ON %s (%s)', + '0' === (string) $index['non_unique'] ? 'UNIQUE ' : '', + $this->connection->quote_identifier( $table_name . '__' . $index['name'] ), + $this->connection->quote_identifier( $table_name ), + implode( ', ', $columns ) + ); + } + + return $statements; + } + + /** + * Check whether an ADD action adds an index rather than a column. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return bool Whether the action is an ADD INDEX form. + */ + private function is_mysql_dbdelta_add_index_action( array $tokens, int $start, int $end ): bool { + if ( $start + 1 >= $end || ! isset( $tokens[ $start + 1 ] ) ) { + return false; + } + + return $this->is_mysql_dbdelta_add_index_definition_action( $tokens, $start + 1, $end ); + } + + /** + * Check whether a token range is an ADD index definition without the ADD keyword. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First definition token. + * @param int $end Final definition token, exclusive. + * @return bool Whether the range is an index definition. + */ + private function is_mysql_dbdelta_add_index_definition_action( array $tokens, int $start, int $end ): bool { + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return false; + } + + return in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::FULLTEXT_SYMBOL, + WP_MySQL_Lexer::INDEX_SYMBOL, + WP_MySQL_Lexer::KEY_SYMBOL, + WP_MySQL_Lexer::PRIMARY_SYMBOL, + WP_MySQL_Lexer::SPATIAL_SYMBOL, + WP_MySQL_Lexer::UNIQUE_SYMBOL, + ), + true + ); + } + + /** + * Check whether an ADD action adds a table constraint. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return bool Whether the action is an ADD CONSTRAINT or ADD CHECK form. + */ + private function is_mysql_dbdelta_add_constraint_action( array $tokens, int $start, int $end ): bool { + if ( $start + 1 >= $end || ! isset( $tokens[ $start + 1 ] ) ) { + return false; + } + + return $this->is_mysql_dbdelta_add_constraint_definition_action( $tokens, $start + 1, $end ); + } + + /** + * Check whether a token range is an ADD constraint definition without the ADD keyword. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First definition token. + * @param int $end Final definition token, exclusive. + * @return bool Whether the range is an ADD CONSTRAINT or ADD CHECK definition. + */ + private function is_mysql_dbdelta_add_constraint_definition_action( array $tokens, int $start, int $end ): bool { + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return false; + } + + return in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::CHECK_SYMBOL, + WP_MySQL_Lexer::CONSTRAINT_SYMBOL, + WP_MySQL_Lexer::FOREIGN_SYMBOL, + ), + true + ); + } + + /** + * Check whether a DROP action removes a foreign key. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return bool Whether the action is DROP FOREIGN KEY. + */ + private function is_mysql_dbdelta_drop_foreign_key_action( array $tokens, int $start, int $end ): bool { + return $start + 4 === $end + && isset( $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[ $start + 2 ]->id; + } + + /** + * Get the end of a column definition after removing MySQL placement syntax. + * + * PostgreSQL cannot preserve MySQL physical column placement. SQLite ignores + * it too, so PostgreSQL translation strips FIRST/AFTER while preserving the + * column definition and metadata. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First definition token. + * @param int $end Final definition token, exclusive. + * @return int|null Definition end, exclusive, or null when placement is malformed. + */ + private function get_mysql_alter_column_definition_end_without_placement( array $tokens, int $start, int $end ): ?int { + if ( $start >= $end ) { + return null; + } + + if ( isset( $tokens[ $end - 1 ] ) && WP_MySQL_Lexer::FIRST_SYMBOL === $tokens[ $end - 1 ]->id ) { + return $end - 1; + } + + if ( + $end - 2 >= $start + && isset( $tokens[ $end - 2 ], $tokens[ $end - 1 ] ) + && WP_MySQL_Lexer::AFTER_SYMBOL === $tokens[ $end - 2 ]->id + && null !== $this->get_mysql_alter_identifier_token_value( $tokens[ $end - 1 ] ) + ) { + return $end - 2; + } + + if ( + $end - 1 >= $start + && isset( $tokens[ $end - 1 ] ) + && WP_MySQL_Lexer::AFTER_SYMBOL === $tokens[ $end - 1 ]->id + ) { + return null; + } + + return $end; + } + + /** + * Get an identifier value in ALTER TABLE action contexts. + * + * MySQL permits unquoted keyword-like column names such as "status". + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Identifier value, or null when unsupported. + */ + private function get_mysql_alter_identifier_token_value( ?WP_MySQL_Token $token ): ?string { + return $this->get_mysql_index_identifier_token_value( $token ); + } + + /** + * Resolve an existing ALTER column reference to the stored MySQL column name. + * + * MySQL column identifiers are case-insensitive. The PostgreSQL DDL we emit is + * quoted and therefore case-sensitive, so existing columns must use the stored + * casing when a plugin supplies a different spelling. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param string $column_name User-supplied column name. + * @return string Stored column name when known, otherwise the original name. + */ + private function resolve_mysql_existing_alter_column_name( string $table_schema, string $table_name, string $column_name ): string { + return $this->get_mysql_table_column_name( $table_schema, $table_name, $column_name ) ?? $column_name; + } + + /** + * Check whether an ALTER action is a supported MySQL table option no-op. + * + * @param string $clause Full ALTER clause string. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return bool Whether this table option can be safely ignored. + */ + private function is_supported_mysql_dbdelta_table_option_alter_action( string $clause, array $tokens, int $start, int $end ): bool { + $fragment = strtoupper( preg_replace( '/\s+/', ' ', trim( $this->get_mysql_token_range_bytes( $clause, $tokens, $start, $end ) ) ) ); + + $optional_assignment_option = '(?:ENGINE|ROW_FORMAT|KEY_BLOCK_SIZE|MAX_ROWS|MIN_ROWS|AVG_ROW_LENGTH|CHECKSUM|DELAY_KEY_WRITE|PACK_KEYS|STATS_PERSISTENT|STATS_AUTO_RECALC|STATS_SAMPLE_PAGES|COMPRESSION|ENCRYPTION|CONNECTION|PASSWORD|INSERT_METHOD|SECONDARY_ENGINE|AUTOEXTEND_SIZE|ENGINE_ATTRIBUTE|SECONDARY_ENGINE_ATTRIBUTE)(?:\s*=\s*|\s+)\S'; + $character_set_option = '(?:DEFAULT\s+)?(?:CHARACTER\s+SET|CHAR\s+SET|CHARSET|COLLATE)(?:\s*=\s*|\s+)\S'; + + return 1 === preg_match( + '/^(?:' . $optional_assignment_option . '|' . $character_set_option . '|CONVERT\s+TO\s+(?:CHARACTER\s+SET|CHAR\s+SET|CHARSET)\b|(?:DATA|INDEX)\s+DIRECTORY\b|TABLESPACE\b|UNION\s*=)/', + $fragment + ); + } + + /** + * Parse ALTER TABLE COMMENT[=]'...' table options. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return string|null Table comment, or null when not a supported comment option. + */ + private function get_mysql_dbdelta_table_comment_alter_value( array $tokens, int $start, int $end ): ?string { + if ( ! isset( $tokens[ $start ] ) || WP_MySQL_Lexer::COMMENT_SYMBOL !== $tokens[ $start ]->id ) { + return null; + } + + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position ]->id ) { + ++$position; + } + + if ( $position + 1 !== $end || ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( + ! in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, + WP_MySQL_Lexer::NCHAR_TEXT, + ), + true + ) + ) { + return null; + } + + return $tokens[ $position ]->get_value(); + } + + /** + * Check whether an ALTER action is a supported MySQL online-DDL no-op. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return bool Whether this option can be safely ignored. + */ + private function is_supported_mysql_dbdelta_online_ddl_option_alter_action( array $tokens, int $start, int $end ): bool { + $position = $start; + return $this->consume_mysql_supported_index_lock_and_algorithm_options( $tokens, $position, $end ) + && $position === $end; + } + + /** + * Check whether an ALTER action is a supported MySQL key-maintenance no-op. + * + * MySQL accepts these clauses around bulk data loads. PostgreSQL has no + * equivalent table-level index toggle, so the compatible behavior is to + * accept them explicitly without backend DDL. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return bool Whether this key-maintenance clause can be safely ignored. + */ + private function is_supported_mysql_dbdelta_keys_alter_action( array $tokens, int $start, int $end ): bool { + return $start + 2 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + && in_array( $tokens[ $start ]->id, array( WP_MySQL_Lexer::DISABLE_SYMBOL, WP_MySQL_Lexer::ENABLE_SYMBOL ), true ) + && WP_MySQL_Lexer::KEYS_SYMBOL === $tokens[ $start + 1 ]->id; + } + + /** + * Merge ALTER TABLE ORDER BY ranges split at order-list commas. + * + * ORDER BY is a MySQL physical row-ordering hint. PostgreSQL and SQLite do + * not preserve it, but the SQLite backend accepts it as a schema no-op. The + * generic top-level comma splitter cannot know that ORDER BY owns following + * comma-separated order terms, so merge it to the end of the ALTER action + * list and let the ORDER BY validator decide whether the full range is valid. + * + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param array $ranges Top-level action ranges. + * @return array Normalized ranges. + */ + private function merge_mysql_dbdelta_order_by_alter_ranges( array $tokens, array $ranges ): array { + $normalized_ranges = array(); + $range_count = count( $ranges ); + + for ( $i = 0; $i < $range_count; ++$i ) { + $range = $ranges[ $i ]; + if ( ! isset( $tokens[ $range['start'] ] ) || WP_MySQL_Lexer::ORDER_SYMBOL !== $tokens[ $range['start'] ]->id ) { + $normalized_ranges[] = $range; + continue; + } + + for ( $j = $i + 1; $j < $range_count; ++$j ) { + $range['end'] = $ranges[ $j ]['end']; + } + + $normalized_ranges[] = $range; + return $normalized_ranges; + } + + return $normalized_ranges; + } + + /** + * Check whether an ALTER TABLE ORDER BY clause can be accepted as a no-op. + * + * @param string $table_name Target table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $start First action token. + * @param int $end Final action token, exclusive. + * @return bool Whether this ORDER BY clause is syntactically supported. + */ + private function is_supported_mysql_dbdelta_order_by_alter_action( string $table_name, array $tokens, int $start, int $end ): bool { + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::ORDER_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return false; + } + + $position = $start + 2; + if ( $position >= $end ) { + return false; + } + + while ( $position < $end ) { + if ( ! $this->consume_mysql_dbdelta_order_by_alter_key_part( $table_name, $tokens, $position, $end ) ) { + return false; + } + + if ( $position === $end ) { + return true; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + if ( $position >= $end ) { + return false; + } + } + + return false; + } + + /** + * Consume one ALTER TABLE ORDER BY key part. + * + * @param string $table_name Target table name. + * @param WP_MySQL_Token[] $tokens Clause token stream. + * @param int $position Current token position, updated on success. + * @param int $end Final action token, exclusive. + * @return bool Whether a valid key part was consumed. + */ + private function consume_mysql_dbdelta_order_by_alter_key_part( string $table_name, array $tokens, int &$position, int $end ): bool { + $identifier = $this->get_mysql_alter_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $identifier ) { + return false; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id ) { + $column_name = $this->get_mysql_alter_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $column_name || 0 !== strcasecmp( $identifier, $table_name ) ) { + return false; + } + + $position += 2; + } + + if ( + $position < $end + && isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::ASC_SYMBOL, WP_MySQL_Lexer::DESC_SYMBOL ), true ) + ) { + ++$position; + } + + return true; + } + + /** + * Check whether a query starts with ALTER TABLE. + * + * @param string $query SQL query. + * @return bool Whether this is an ALTER TABLE statement. + */ + private function is_mysql_alter_table_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + return isset( $tokens[0], $tokens[1] ) + && WP_MySQL_Lexer::ALTER_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[1]->id; + } + + /** + * Check whether a query starts with RENAME TABLE. + * + * @param string $query SQL query. + * @return bool Whether this is a RENAME TABLE statement. + */ + private function is_mysql_rename_table_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + return isset( $tokens[0], $tokens[1] ) + && WP_MySQL_Lexer::RENAME_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[1]->id; + } + + /** + * Translate ALTER TABLE MODIFY COLUMN clauses. + * + * Action Scheduler emits comma-separated MODIFY COLUMN clauses for datetime + * null/default adjustments. Treat each one like CHANGE COLUMN without a + * rename and keep unsupported ALTER fragments visible by returning null. + * + * @param string $table_name Table name. + * @param string $clause ALTER TABLE clause fragment. + * @return array{statements: string[], metadata: array}|null Translation, or null when unsupported. + */ + private function translate_mysql_dbdelta_modify_column_alter_query( string $table_name, string $clause ): ?array { + $tokens = $this->get_mysql_tokens( $clause ); + $statement_end = $this->get_mysql_statement_end_position( $tokens, 0 ); + if ( null === $statement_end ) { + return null; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, 0, $statement_end ); + if ( null === $ranges || array() === $ranges ) { + return null; + } + + $statements = array(); + $columns = array(); + foreach ( $ranges as $range ) { + $position = $range['start']; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::MODIFY_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COLUMN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( $position >= $range['end'] ) { + return null; + } + + $definition = $this->get_mysql_token_range_bytes( $clause, $tokens, $position, $range['end'] ); + try { + $column = $this->translate_mysql_column_definition_fragment( $definition ); + } catch ( InvalidArgumentException $e ) { + return null; + } + if ( null === $column ) { + return null; + } + + $column_name = $column['metadata']['name']; + $column_type = $this->get_translated_column_type_from_definition_line( $column['sql'] ); + $preserve_existing_identity = $this->should_preserve_existing_identity_integer_column_change( + 'public', + $table_name, + $column_name, + $column['metadata'] + ); + if ( '' !== $column_type && ! $preserve_existing_identity ) { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s TYPE %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ), + $column_type + ); + } + + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s %s NOT NULL', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ), + 'NO' === ( $column['metadata']['nullable'] ?? 'YES' ) ? 'SET' : 'DROP' + ); + + $default_sql = $this->get_translated_column_default_from_definition_line( $column['sql'] ); + if ( ! $preserve_existing_identity ) { + if ( null !== $default_sql ) { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ), + $default_sql + ); + } else { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s DROP DEFAULT', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ) + ); + } + } + + if ( $this->should_add_identity_for_auto_increment_column_change( 'public', $table_name, $column_name, $column['metadata'] ) ) { + $statements[] = sprintf( + 'ALTER TABLE %s ALTER COLUMN %s ADD GENERATED BY DEFAULT AS IDENTITY', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column_name ) + ); + } + + $columns[] = array( + 'old_column' => $column_name, + 'column' => $column['metadata'], + ); + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'operation' => 'change_columns', + 'table' => $table_name, + 'columns' => $columns, + ), + ); + } + + /** + * Check whether an AUTO_INCREMENT CHANGE COLUMN should leave PostgreSQL identity DDL untouched. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $old_column Existing column name. + * @param array $column Replacement MySQL metadata. + * @return bool Whether the physical type/default changes should be metadata-only. + */ + private function should_preserve_existing_identity_integer_column_change( + string $table_schema, + string $table_name, + string $old_column, + array $column + ): bool { + if ( 'auto_increment' !== strtolower( (string) ( $column['extra'] ?? '' ) ) ) { + return false; + } + + if ( ! $this->is_mysql_integer_family_column_type( (string) ( $column['type'] ?? '' ) ) ) { + return false; + } + + $existing = $this->get_existing_dbdelta_column_identity_metadata( $table_schema, $table_name, $old_column ); + if ( null === $existing || ! $this->is_existing_dbdelta_column_backend_identity( $existing ) ) { + return false; + } + + $existing_mysql_type = (string) ( $existing['mysql_column_type'] ?? '' ); + if ( '' !== $existing_mysql_type ) { + return $this->is_mysql_integer_family_column_type( $existing_mysql_type ); + } + + return $this->is_postgresql_integer_family_data_type( (string) ( $existing['data_type'] ?? '' ) ); + } + + /** + * Check whether an AUTO_INCREMENT CHANGE/MODIFY COLUMN should add PostgreSQL identity. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $old_column Existing column name. + * @param array $column Replacement MySQL metadata. + * @return bool Whether identity DDL should be added. + */ + private function should_add_identity_for_auto_increment_column_change( + string $table_schema, + string $table_name, + string $old_column, + array $column + ): bool { + if ( 'auto_increment' !== strtolower( (string) ( $column['extra'] ?? '' ) ) ) { + return false; + } + + if ( ! $this->is_mysql_integer_family_column_type( (string) ( $column['type'] ?? '' ) ) ) { + return false; + } + + $existing = $this->get_existing_dbdelta_column_identity_metadata( $table_schema, $table_name, $old_column ); + if ( null === $existing ) { + return false; + } + + return ! $this->is_existing_dbdelta_column_backend_identity( $existing ); + } + + /** + * Get catalog and MySQL metadata for an existing dbDelta column. + * + * @param string $table_schema Table schema. + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return array|null Existing column metadata, or null. + */ + private function get_existing_dbdelta_column_identity_metadata( string $table_schema, string $table_name, string $column_name ): ?array { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT + c.data_type, + c.is_identity, + c.column_default, + cm.column_type AS mysql_column_type, + cm.extra AS mysql_extra + FROM information_schema.columns c + LEFT JOIN %s cm + ON cm.table_schema = c.table_schema + AND cm.table_name = c.table_name + AND cm.column_name = c.column_name + WHERE c.table_schema = ? + AND c.table_name = ? + AND c.column_name = ?', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + return false === $row ? null : $row; + } + + /** + * Check whether existing metadata describes a PostgreSQL identity column. + * + * @param array $metadata Existing column metadata. + * @return bool Whether the column is identity/auto_increment. + */ + private function is_existing_dbdelta_column_identity( array $metadata ): bool { + if ( 'auto_increment' === strtolower( (string) ( $metadata['mysql_extra'] ?? '' ) ) ) { + return true; + } + + return $this->is_existing_dbdelta_column_backend_identity( $metadata ); + } + + /** + * Check whether existing backend metadata describes an identity/serial-like column. + * + * @param array $metadata Existing column metadata. + * @return bool Whether the backend column is identity/serial-like. + */ + private function is_existing_dbdelta_column_backend_identity( array $metadata ): bool { + if ( 'YES' === strtoupper( (string) ( $metadata['is_identity'] ?? '' ) ) ) { + return true; + } + + $column_default = ltrim( (string) ( $metadata['column_default'] ?? '' ) ); + return 0 === stripos( $column_default, 'nextval(' ); + } + + /** + * Check whether a MySQL column type is part of the integer family. + * + * @param string $column_type MySQL column type. + * @return bool Whether the type is integer-like. + */ + private function is_mysql_integer_family_column_type( string $column_type ): bool { + $column_type = strtolower( trim( $column_type ) ); + $column_type = preg_replace( '/\s+unsigned\b/i', '', $column_type ); + $column_type = trim( (string) $column_type ); + + return (bool) preg_match( '/^(?:bigint|int|integer|mediumint|smallint|tinyint)(?:\(\d+\))?$/', $column_type ); + } + + /** + * Check whether a PostgreSQL catalog data type is integer-like. + * + * @param string $data_type PostgreSQL information_schema data_type. + * @return bool Whether the type is integer-like. + */ + private function is_postgresql_integer_family_data_type( string $data_type ): bool { + return in_array( strtolower( trim( $data_type ) ), array( 'bigint', 'integer', 'smallint' ), true ); + } + + /** + * Translate supported DROP TABLE statements and expose dropped table names. + * + * @param string $query MySQL DROP TABLE query. + * @return array{statements: string[], tables: string[], metadata_targets: array[]}|null Translation, or null when unsupported. + */ + private function translate_mysql_drop_table_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + + $position = 1; + $temporary = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + $temporary = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $if_exists = false; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $if_exists = true; + $position += 2; + } + + $table_names = array(); + $table_identifiers = array(); + $metadata_targets = array(); + while ( $position < $statement_end ) { + $reference_start = $position; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + + $table_name = $table_reference['table']; + $table_names[] = $table_name; + + if ( $temporary ) { + if ( null !== $table_reference['schema'] ) { + $this->get_mysql_writable_table_backend_schema( $table_reference, 'DROP TABLE' ); + } + + $table_identifiers[] = $this->get_temporary_drop_table_identifier( $table_name ); + foreach ( $this->get_mysql_schema_metadata_drop_targets( array( $table_name ), true ) as $target ) { + $metadata_targets[] = $target; + } + } else { + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, 'DROP TABLE' ); + $table_identifiers[] = null === $table_reference['schema'] + ? $this->connection->quote_identifier( $table_name ) + : $this->get_postgresql_schema_identifier( $table_schema, $table_name ); + $metadata_targets[] = array( + 'schema' => $table_schema, + 'table' => $table_name, + ); + } + + if ( $position === $reference_start ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + + if ( $position === $statement_end ) { + break; + } + + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::RESTRICT_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::CASCADE_SYMBOL === $tokens[ $position ]->id + ) + && $position + 1 === $statement_end + ) { + break; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + + ++$position; + if ( $position === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + } + + if ( array() === $table_names ) { + throw new InvalidArgumentException( 'Unsupported DROP TABLE statement.' ); + } + + $statements = array(); + foreach ( $table_identifiers as $table_identifier ) { + $statements[] = sprintf( + 'DROP TABLE %s%s', + $if_exists ? 'IF EXISTS ' : '', + $table_identifier + ); + } + + return array( + 'statements' => $statements, + 'tables' => $table_names, + 'metadata_targets' => $metadata_targets, + ); + } + + /** + * Translate supported MySQL DROP VIEW statements to PostgreSQL. + * + * @param string $query MySQL DROP VIEW query. + * @return array{statements: string[]}|null Translation, or null when this is not DROP VIEW. + */ + private function translate_mysql_drop_view_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return null; + } + + if ( WP_MySQL_Lexer::VIEW_SYMBOL !== $tokens[1]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported DROP VIEW statement.' ); + } + + $position = 2; + $if_exists = false; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $if_exists = true; + $position += 2; + } + + $view_identifiers = array(); + while ( $position < $statement_end ) { + $reference_start = $position; + $view_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $view_reference || $position === $reference_start ) { + throw new InvalidArgumentException( 'Unsupported DROP VIEW statement.' ); + } + + $view_schema = $this->get_mysql_writable_table_backend_schema( $view_reference, 'DROP VIEW' ); + $view_identifiers[] = null === $view_reference['schema'] + ? $this->connection->quote_identifier( $view_reference['table'] ) + : $this->get_postgresql_schema_identifier( $view_schema, $view_reference['table'] ); + + if ( $position === $statement_end ) { + break; + } + + if ( + isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::CASCADE_SYMBOL, WP_MySQL_Lexer::RESTRICT_SYMBOL ), true ) + ) { + ++$position; + if ( $position !== $statement_end ) { + throw new InvalidArgumentException( 'Unsupported DROP VIEW statement.' ); + } + break; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported DROP VIEW statement.' ); + } + + ++$position; + if ( $position === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported DROP VIEW statement.' ); + } + } + + if ( array() === $view_identifiers ) { + throw new InvalidArgumentException( 'Unsupported DROP VIEW statement.' ); + } + + $statements = array(); + foreach ( $view_identifiers as $view_identifier ) { + $statements[] = sprintf( + 'DROP VIEW %s%s', + $if_exists ? 'IF EXISTS ' : '', + $view_identifier + ); + } + + return array( + 'statements' => $statements, + ); + } + + /** + * Translate supported standalone MySQL DROP INDEX statements to PostgreSQL. + * + * @param string $query MySQL DROP INDEX query. + * @return array{statements: string[], metadata: array}|null Translation, or null when this is not DROP INDEX. + */ + private function translate_mysql_drop_index_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return null; + } + + if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[1]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + + $position = 2; + $index_name = $this->get_mysql_index_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $index_name ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + + ++$position; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + + while ( $position < $statement_end ) { + if ( ! $this->consume_mysql_supported_index_lock_and_algorithm_options( $tokens, $position, $statement_end ) ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + } + + if ( 'PRIMARY' === strtoupper( $index_name ) ) { + return $this->get_mysql_drop_primary_key_index_translation( $table_reference, 'DROP INDEX' ); + } + + return $this->get_mysql_drop_index_translation( $table_reference, $index_name, 'DROP INDEX' ); + } + + /** + * Get an explicit unsupported error for unclaimed MySQL DROP statements. + * + * Supported DROP TABLE/DROP INDEX forms and the narrow procedure shim are + * dispatched before this guard. + * + * @param string $query MySQL query. + * @return string|null Unsupported error message, or null when not guarded. + */ + private function get_unsupported_mysql_drop_statement_message( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + switch ( $tokens[ $position ]->id ?? null ) { + case WP_MySQL_Lexer::TABLE_SYMBOL: + case WP_MySQL_Lexer::INDEX_SYMBOL: + return null; + + case WP_MySQL_Lexer::DATABASE_SYMBOL: + case WP_MySQL_Lexer::SCHEMA_SYMBOL: + return 'Unsupported DROP DATABASE statement.'; + + case WP_MySQL_Lexer::VIEW_SYMBOL: + return 'Unsupported DROP VIEW statement.'; + + case WP_MySQL_Lexer::PROCEDURE_SYMBOL: + return 'Unsupported DROP PROCEDURE statement.'; + + case WP_MySQL_Lexer::FUNCTION_SYMBOL: + return 'Unsupported DROP FUNCTION statement.'; + + case WP_MySQL_Lexer::TRIGGER_SYMBOL: + return 'Unsupported DROP TRIGGER statement.'; + + case WP_MySQL_Lexer::EVENT_SYMBOL: + return 'Unsupported DROP EVENT statement.'; + + case WP_MySQL_Lexer::USER_SYMBOL: + return 'Unsupported DROP USER statement.'; + + case WP_MySQL_Lexer::ROLE_SYMBOL: + return 'Unsupported DROP ROLE statement.'; + + case WP_MySQL_Lexer::SPATIAL_SYMBOL: + if ( + isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::REFERENCE_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::SYSTEM_SYMBOL === $tokens[ $position + 2 ]->id + ) { + return 'Unsupported DROP SPATIAL REFERENCE SYSTEM statement.'; + } + return null; + + case WP_MySQL_Lexer::TABLESPACE_SYMBOL: + return 'Unsupported DROP TABLESPACE statement.'; + + case WP_MySQL_Lexer::UNDO_SYMBOL: + return 'Unsupported DROP UNDO TABLESPACE statement.'; + + case WP_MySQL_Lexer::SERVER_SYMBOL: + return 'Unsupported DROP SERVER statement.'; + + case WP_MySQL_Lexer::LOGFILE_SYMBOL: + return 'Unsupported DROP LOGFILE statement.'; + } + + return null; + } + + /** + * Build PostgreSQL primary-key DROP SQL and metadata cleanup target. + * + * @param array{schema: string|null, table: string} $table_reference MySQL table reference. + * @param string $statement_type Statement type for fail-closed error messages. + * @return array{statements: string[], metadata: array} Drop primary-key translation. + */ + private function get_mysql_drop_primary_key_index_translation( array $table_reference, string $statement_type ): array { + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, $statement_type ); + $table_name = $table_reference['table']; + + return array( + 'statements' => array( + sprintf( + 'ALTER TABLE %s DROP CONSTRAINT %s', + null === $table_reference['schema'] + ? $this->connection->quote_identifier( $table_name ) + : $this->get_postgresql_schema_identifier( $table_schema, $table_name ), + $this->connection->quote_identifier( + $this->get_postgresql_primary_key_constraint_name( $table_schema, $table_name ) + ) + ), + ), + 'metadata' => array( + 'schema' => $table_schema, + 'table' => $table_name, + 'index' => 'PRIMARY', + ), + ); + } + + /** + * Build PostgreSQL DROP INDEX SQL and metadata cleanup target. + * + * @param array{schema: string|null, table: string} $table_reference MySQL table reference. + * @param string $index_name MySQL index name. + * @param string $statement_type Statement type for fail-closed error messages. + * @return array{statements: string[], metadata: array} Drop index translation. + */ + private function get_mysql_drop_index_translation( array $table_reference, string $index_name, string $statement_type ): array { + if ( 'PRIMARY' === strtoupper( $index_name ) ) { + throw new InvalidArgumentException( 'Unsupported ' . $statement_type . ' statement.' ); + } + + $table_schema = $this->get_mysql_writable_table_backend_schema( $table_reference, $statement_type ); + $table_name = $table_reference['table']; + $index_type = $this->get_stored_mysql_index_type( $table_schema, $table_name, $index_name ); + + $statements = array(); + if ( null === $index_type || ! $this->is_mysql_metadata_only_index_type( $index_type ) ) { + $statements[] = 'DROP INDEX ' . $this->get_postgresql_schema_identifier( $table_schema, $table_name . '__' . $index_name ); + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'schema' => $table_schema, + 'table' => $table_name, + 'index' => $index_name, + ), + ); + } + + /** + * Translate supported MySQL RENAME TABLE statements to PostgreSQL. + * + * @param string $query MySQL query. + * @return array{statements: string[], metadata: array{schema?: string, old_table?: string, new_table?: string, renames?: array}}|null Translation, or null when this is not RENAME TABLE. + */ + private function translate_mysql_rename_table_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::RENAME_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + + $position = 2; + $statements = array(); + $renames = array(); + $metadata_source_names = array(); + while ( $position < $statement_end ) { + $old_table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $old_table_reference ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TO_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + + ++$position; + $new_table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $new_table_reference ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + + $table_schema = $this->get_mysql_writable_table_backend_schema( $old_table_reference, 'RENAME TABLE' ); + $new_table_schema = $this->get_mysql_rename_table_target_backend_schema( $new_table_reference, $table_schema, 'RENAME TABLE' ); + if ( $new_table_schema !== $table_schema ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + + $old_table_name = $old_table_reference['table']; + $new_table_name = $new_table_reference['table']; + $old_metadata_key = $this->get_mysql_rename_table_metadata_key( $table_schema, $old_table_name ); + $new_metadata_key = $this->get_mysql_rename_table_metadata_key( $table_schema, $new_table_name ); + $metadata_source_name = $metadata_source_names[ $old_metadata_key ] ?? $old_table_name; + + $statements = array_merge( + $statements, + $this->get_mysql_rename_table_statements( $table_schema, $old_table_name, $new_table_name, $metadata_source_name ) + ); + $renames[] = array( + 'schema' => $table_schema, + 'old_table' => $old_table_name, + 'new_table' => $new_table_name, + ); + + unset( $metadata_source_names[ $old_metadata_key ] ); + $metadata_source_names[ $new_metadata_key ] = $metadata_source_name; + + if ( $position === $statement_end ) { + break; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + ++$position; + } + + if ( empty( $renames ) ) { + throw new InvalidArgumentException( 'Unsupported RENAME TABLE statement.' ); + } + + if ( 1 === count( $renames ) ) { + return array( + 'statements' => $statements, + 'metadata' => $renames[0], + ); + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'renames' => $renames, + ), + ); + } + + /** + * Get a virtual rename metadata key for an in-flight RENAME TABLE sequence. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Current table name. + * @return string Metadata key. + */ + private function get_mysql_rename_table_metadata_key( string $table_schema, string $table_name ): string { + return strtolower( $table_schema ) . "\0" . strtolower( $table_name ); + } + + /** + * Resolve the backend schema for the new side of a table rename. + * + * @param array $table_reference Parsed target table reference. + * @param string $default_schema Schema of the source table. + * @param string $statement_type Statement type for error messages. + * @return string Backend schema name. + */ + private function get_mysql_rename_table_target_backend_schema( array $table_reference, string $default_schema, string $statement_type ): string { + $requested_schema = $table_reference['schema']; + if ( null === $requested_schema ) { + return $default_schema; + } + + if ( 0 === strcasecmp( $requested_schema, 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + if ( + 0 === strcasecmp( $requested_schema, $this->main_db_name ) + || 0 === strcasecmp( $requested_schema, 'public' ) + ) { + return 'public'; + } + + throw new InvalidArgumentException( sprintf( 'Unsupported %s statement.', $statement_type ) ); + } + + /** + * Build PostgreSQL statements for a MySQL table rename. + * + * @param string $table_schema Backend schema name. + * @param string $old_table_name Old table name. + * @param string $new_table_name New table name. + * @return string[] PostgreSQL statements. + */ + private function get_mysql_rename_table_statements( string $table_schema, string $old_table_name, string $new_table_name, ?string $metadata_table_name = null ): array { + $statements = array( + sprintf( + 'ALTER TABLE %s RENAME TO %s', + $this->get_postgresql_schema_identifier( $table_schema, $old_table_name ), + $this->connection->quote_identifier( $new_table_name ) + ), + ); + + return array_merge( + $statements, + $this->get_mysql_rename_table_index_statements( $table_schema, $old_table_name, $new_table_name, $metadata_table_name ?? $old_table_name ) + ); + } + + /** + * Build PostgreSQL index rename statements for indexes whose physical names include the table name. + * + * @param string $table_schema Backend schema name. + * @param string $old_table_name Old table name. + * @param string $new_table_name New table name. + * @return string[] PostgreSQL ALTER INDEX statements. + */ + private function get_mysql_rename_table_index_statements( string $table_schema, string $old_table_name, string $new_table_name, string $metadata_table_name ): array { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT DISTINCT key_name, index_type + FROM %s + WHERE table_schema = ? AND table_name = ? AND UPPER(key_name) <> \'PRIMARY\' + ORDER BY key_name', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $metadata_table_name ) + ); + + $statements = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $index_type = (string) $row['index_type']; + if ( $this->is_mysql_metadata_only_index_type( $index_type ) ) { + continue; + } + + $key_name = (string) $row['key_name']; + $statements[] = sprintf( + 'ALTER INDEX %s RENAME TO %s', + $this->get_postgresql_schema_identifier( $table_schema, $old_table_name . '__' . $key_name ), + $this->connection->quote_identifier( $new_table_name . '__' . $key_name ) + ); + } + + return $statements; + } + + /** + * Get a stored MySQL index type from side metadata. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $index_name Index name. + * @return string|null Stored index type, or null when unavailable. + */ + private function get_stored_mysql_index_type( string $table_schema, string $table_name, string $index_name ): ?string { + $this->ensure_mysql_schema_metadata_tables(); + + $stmt = $this->connection->query( + sprintf( + 'SELECT index_type FROM %s WHERE table_schema = ? AND table_name = ? AND LOWER(key_name) = LOWER(?) ORDER BY seq_in_index LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $index_name ) + ); + + $index_type = $stmt->fetchColumn(); + return false === $index_type ? null : (string) $index_type; + } + + /** + * Get the MySQL metadata rows that should be removed after a DROP TABLE. + * + * @param string[] $table_names Table names. + * @param bool $temporary Whether the DROP TABLE explicitly targets temporary tables. + * @return array[] Metadata targets. + */ + private function get_mysql_schema_metadata_drop_targets( array $table_names, bool $temporary ): array { + $targets = array(); + + foreach ( $table_names as $table_name ) { + $temporary_schema = $this->get_active_temporary_table_schema( $table_name ); + if ( null !== $temporary_schema ) { + $targets[] = array( + 'schema' => $temporary_schema, + 'table' => $table_name, + ); + continue; + } + + if ( ! $temporary ) { + $targets[] = array( + 'schema' => 'public', + 'table' => $table_name, + ); + } + } + + return $targets; + } + + /** + * Get the backend table identifier for a MySQL DROP TEMPORARY TABLE target. + * + * @param string $table_name MySQL table identifier value. + * @return string PostgreSQL table identifier constrained to the temporary schema. + */ + private function get_temporary_drop_table_identifier( string $table_name ): string { + return $this->get_temporary_drop_table_schema_name() . '.' . $this->connection->quote_identifier( $table_name ); + } + + /** + * Get the backend temporary schema name. + * + * @return string Backend temporary schema name. + */ + private function get_temporary_drop_table_schema_name(): string { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + + if ( 'sqlite' === $driver_name ) { + return 'temp'; + } + + return 'pg_temp'; + } + + /** + * Extract original bytes for a bounded MySQL token range. + * + * @param string $query Original MySQL query fragment. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @return string Original query bytes for the token range. + */ + private function get_mysql_token_range_bytes( string $query, array $tokens, int $start, int $end ): string { + if ( $start >= $end || ! isset( $tokens[ $start ], $tokens[ $end - 1 ] ) ) { + return ''; + } + + $range_start = $tokens[ $start ]->start; + $range_end = $tokens[ $end - 1 ]->start + $tokens[ $end - 1 ]->length; + return substr( $query, $range_start, $range_end - $range_start ); + } + + /** + * Translate a MySQL column definition fragment via the CREATE TABLE translator. + * + * @param string $definition MySQL column definition. + * @param string|null $table_name Table name for inline foreign key names. + * @return array{sql: string, metadata: array, indexes: array, foreign_keys: array, checks: array}|null Translated column, or null when unsupported. + */ + private function translate_mysql_column_definition_fragment( string $definition, ?string $table_name = null ): ?array { + $definition = $this->trim_mysql_statement_fragment( $definition ); + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes ); + $wrapper_table = $table_name ?? '__wp_dbdelta_column'; + $wrapper = sprintf( 'CREATE TABLE %s (%s)', $this->quote_mysql_identifier( $wrapper_table ), $definition ); + + $statements = $translator->translate_schema( $wrapper ); + $metadata = $translator->extract_schema_metadata( $wrapper, true ); + if ( ! isset( $metadata[0]['columns'][0] ) ) { + return null; + } + + return array( + 'sql' => $this->get_first_translated_create_table_definition( $statements[0] ), + 'metadata' => $metadata[0]['columns'][0], + 'indexes' => $metadata[0]['indexes'] ?? array(), + 'foreign_keys' => $metadata[0]['foreign_keys'] ?? array(), + 'checks' => $metadata[0]['checks'] ?? array(), + ); + } + + /** + * Replace a translated column fragment constraint name. + * + * @param string $sql Column definition SQL. + * @param string $old_name Existing constraint name. + * @param string $new_name Replacement constraint name. + * @return string Updated column definition SQL. + */ + private function replace_mysql_column_fragment_constraint_name( string $sql, string $old_name, string $new_name ): string { + $old_sql = 'CONSTRAINT ' . $this->connection->quote_identifier( $old_name ); + $new_sql = 'CONSTRAINT ' . $this->connection->quote_identifier( $new_name ); + + if ( false === strpos( $sql, $old_sql ) ) { + throw new InvalidArgumentException( 'Translated column definition has an unexpected constraint name.' ); + } + + return str_replace( $old_sql, $new_sql, $sql ); + } + + /** + * Translate a MySQL index definition fragment via the CREATE TABLE translator. + * + * @param string $table_name Table name receiving the index. + * @param string $definition MySQL index definition. + * @param string|null $table_schema Optional backend schema for resolving MySQL's case-insensitive column names. + * @return array{statements: string[], metadata: array}|null Translated index, or null when unsupported. + */ + private function translate_mysql_index_definition_fragment( string $table_name, string $definition, ?string $table_schema = null ): ?array { + $definition = $this->trim_mysql_statement_fragment( $definition ); + $translator = new WP_PostgreSQL_Create_Table_Translator( $this->active_sql_modes, true ); + $wrapper = 'CREATE TABLE __wp_dbdelta_index (__wp_dummy int, ' . $definition . ')'; + + $metadata = $translator->extract_schema_metadata( $wrapper, true ); + if ( ! isset( $metadata[0]['indexes'][0] ) ) { + return null; + } + + $index = $metadata[0]['indexes'][0]; + if ( null !== $table_schema ) { + foreach ( $index['columns'] as &$column ) { + $column['column_name'] = $this->resolve_mysql_existing_alter_column_name( + $table_schema, + $table_name, + (string) $column['column_name'] + ); + } + unset( $column ); + } + + $columns = array(); + foreach ( $index['columns'] as $column ) { + $column_sql = $this->get_mysql_index_key_part_sql( + (string) $column['column_name'], + '0' === (string) $index['non_unique'] ? $column['sub_part'] : null + ); + if ( 'D' === strtoupper( (string) ( $column['collation'] ?? '' ) ) ) { + $column_sql .= ' DESC'; + } + + $columns[] = $column_sql; + } + + if ( 'PRIMARY' === strtoupper( $index['name'] ) ) { + $statement = sprintf( + 'ALTER TABLE %s ADD PRIMARY KEY (%s)', + $this->connection->quote_identifier( $table_name ), + implode( ', ', $columns ) + ); + $statements = array( $statement ); + } elseif ( $this->is_mysql_metadata_only_index_type( $index['index_type'] ) ) { + $statements = array(); + } else { + $statement = sprintf( + 'CREATE %sINDEX %s ON %s (%s)', + '0' === $index['non_unique'] ? 'UNIQUE ' : '', + $this->connection->quote_identifier( $table_name . '__' . $index['name'] ), + $this->connection->quote_identifier( $table_name ), + implode( ', ', $columns ) + ); + $statements = array( $statement ); + } + + return array( + 'statements' => $statements, + 'metadata' => $index, + ); + } + + /** + * Extract the first definition from a translated CREATE TABLE statement. + * + * @param string $create_table_sql Translated CREATE TABLE statement. + * @return string First definition line. + */ + private function get_first_translated_create_table_definition( string $create_table_sql ): string { + if ( ! preg_match( "/\\(\\n (?P.*)\\n\\)\\z/s", $create_table_sql, $matches ) ) { + throw new InvalidArgumentException( 'Translated CREATE TABLE statement has an unexpected shape.' ); + } + + $definitions = explode( ",\n ", $matches['definitions'] ); + return $definitions[0]; + } + + /** + * Extract PostgreSQL type SQL from a translated column definition line. + * + * @param string $definition_line Translated column definition line. + * @return string PostgreSQL type SQL. + */ + private function get_translated_column_type_from_definition_line( string $definition_line ): string { + if ( ! preg_match( '/^"(?:""|[^"])+"\s+(?P.+)$/s', $definition_line, $matches ) ) { + return ''; + } + + $definition = $this->remove_translated_inline_key_constraints_from_column_definition( $matches['definition'] ); + $stop_at = strlen( $definition ); + foreach ( array( ' GENERATED ', ' NOT NULL', ' DEFAULT ' ) as $marker ) { + $position = stripos( $definition, $marker ); + if ( false !== $position && $position < $stop_at ) { + $stop_at = $position; + } + } + + return trim( substr( $definition, 0, $stop_at ) ); + } + + /** + * Extract PostgreSQL DEFAULT SQL from a translated column definition line. + * + * @param string $definition_line Translated column definition line. + * @return string|null Default SQL, or null when absent. + */ + private function get_translated_column_default_from_definition_line( string $definition_line ): ?string { + $definition_line = preg_replace( + '/\s+GENERATED\s+BY\s+DEFAULT\s+AS\s+IDENTITY\b/i', + '', + $definition_line + ); + $definition_line = $this->remove_translated_inline_key_constraints_from_column_definition( $definition_line ); + + if ( ! preg_match( '/\sDEFAULT\s+(?P.+)$/is', $definition_line, $matches ) ) { + return null; + } + + return trim( $matches['default'] ); + } + + /** + * Remove inline key fragments from translated column SQL. + * + * @param string $definition Translated column definition or definition tail. + * @return string Definition without inline PRIMARY/UNIQUE key fragments. + */ + private function remove_translated_inline_key_constraints_from_column_definition( string $definition ): string { + return preg_replace( + array( + '/\s+PRIMARY\s+KEY\b/i', + '/\s+UNIQUE\b/i', + ), + '', + $definition + ); + } + + /** + * Translate a simple MySQL DEFAULT fragment. + * + * @param string $fragment Default expression fragment. + * @return array{sql: string, metadata: string|null}|null Translated default, or null when unsupported. + */ + private function translate_mysql_default_fragment( string $fragment ): ?array { + $fragment = $this->trim_mysql_statement_fragment( $fragment ); + $tokens = $this->get_mysql_tokens( $fragment ); + $end = $this->get_mysql_statement_end_position( $tokens, 0 ); + + $current_timestamp_default = $this->get_mysql_current_timestamp_default_fragment_data( $tokens, $end ); + if ( null !== $current_timestamp_default ) { + return array( + 'sql' => $this->get_postgresql_mysql_current_timestamp_sql( $current_timestamp_default['fsp'] ), + 'metadata' => $current_timestamp_default['metadata'], + ); + } + + if ( 1 !== $end ) { + return null; + } + + $token = $tokens[0]; + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id ) { + return array( + 'sql' => 'NULL', + 'metadata' => null, + ); + } + + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::INT_NUMBER === $token->id + || WP_MySQL_Lexer::LONG_NUMBER === $token->id + || WP_MySQL_Lexer::ULONGLONG_NUMBER === $token->id + || WP_MySQL_Lexer::DECIMAL_NUMBER === $token->id + || WP_MySQL_Lexer::FLOAT_NUMBER === $token->id + ) { + return array( + 'sql' => $this->translate_mysql_token_to_postgresql( $token ), + 'metadata' => $token->get_value(), + ); + } + + return null; + } + + /** + * Get data for a current timestamp DEFAULT fragment. + * + * @param WP_MySQL_Token[] $tokens Default fragment tokens. + * @param int|null $end Statement end. + * @return array{metadata: string, fsp: int}|null Metadata and fractional precision, or null. + */ + private function get_mysql_current_timestamp_default_fragment_data( array $tokens, ?int $end ): ?array { + if ( null === $end ) { + return null; + } + + if ( + 1 === $end + && isset( $tokens[0] ) + && $this->is_mysql_current_timestamp_token( $tokens[0] ) + ) { + return array( + 'metadata' => 'CURRENT_TIMESTAMP', + 'fsp' => 0, + ); + } + + if ( + 3 === $end + && isset( $tokens[0], $tokens[1], $tokens[2] ) + && $this->is_mysql_current_timestamp_token( $tokens[0] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[2]->id + ) { + return array( + 'metadata' => 'CURRENT_TIMESTAMP', + 'fsp' => 0, + ); + } + + if ( + 3 === $end + && isset( $tokens[0], $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::NOW_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[2]->id + ) { + return array( + 'metadata' => 'now()', + 'fsp' => 0, + ); + } + + if ( + 4 === $end + && isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3] ) + && $this->is_mysql_current_timestamp_token( $tokens[0] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[3]->id + ) { + $fsp = $this->get_mysql_fractional_seconds_precision_token_value( $tokens[2] ); + if ( null === $fsp ) { + return null; + } + + return array( + 'metadata' => sprintf( 'CURRENT_TIMESTAMP(%d)', $fsp ), + 'fsp' => $fsp, + ); + } + + if ( + 4 === $end + && isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3] ) + && WP_MySQL_Lexer::NOW_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[3]->id + ) { + $fsp = $this->get_mysql_fractional_seconds_precision_token_value( $tokens[2] ); + if ( null === $fsp ) { + return null; + } + + return array( + 'metadata' => sprintf( 'now(%d)', $fsp ), + 'fsp' => $fsp, + ); + } + + return null; + } + + /** + * Check whether a token represents CURRENT_TIMESTAMP. + * + * @param WP_MySQL_Token $token Token. + * @return bool Whether the token is CURRENT_TIMESTAMP. + */ + private function is_mysql_current_timestamp_token( WP_MySQL_Token $token ): bool { + return WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL === $token->id + || ( + WP_MySQL_Lexer::NOW_SYMBOL === $token->id + && 'CURRENT_TIMESTAMP' === strtoupper( $token->get_value() ) + ); + } + + /** + * Parse a comma-separated list of simple MySQL identifiers. + * + * @param string $identifiers Identifier list. + * @return string[]|null Identifier values, or null when unsupported. + */ + private function parse_mysql_identifier_csv( string $identifiers ): ?array { + $values = array(); + + foreach ( explode( ',', $identifiers ) as $identifier ) { + $identifier = trim( $identifier ); + if ( preg_match( '/^`([^`]+)`$/', $identifier, $matches ) ) { + $values[] = $matches[1]; + continue; + } + + if ( preg_match( '/^[A-Za-z0-9_]+$/', $identifier ) ) { + $values[] = $identifier; + continue; + } + + return null; + } + + return $values; + } + + /** + * Trim a MySQL statement fragment. + * + * @param string $fragment SQL fragment. + * @return string Trimmed fragment. + */ + private function trim_mysql_statement_fragment( string $fragment ): string { + return rtrim( trim( $fragment ), "; \t\n\r\0\x0B" ); + } + + /** + * Emulate the narrow stored procedure surface used by WordPress tests. + * + * @param string $query MySQL query. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed|null Query result, or null when the query is not a supported procedure statement. + */ + private function handle_mysql_procedure_query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + if ( preg_match( '/^\s*DROP\s+PROCEDURE\s+IF\s+EXISTS\s+`?([A-Za-z0-9_]+)`?\s*;?\s*$/i', $query, $matches ) ) { + unset( $this->procedures[ strtolower( $matches[1] ) ] ); + $this->last_result = 0; + $this->clear_last_column_meta(); + return $this->last_result; + } + + if ( preg_match( '/^\s*CREATE\s+PROCEDURE\s+`?([A-Za-z0-9_]+)`?\s*\(\s*\)\s+BEGIN\s+(.*?)\s*;\s*END\s*;?\s*$/is', $query, $matches ) ) { + $this->procedures[ strtolower( $matches[1] ) ] = trim( $matches[2] ); + $this->last_result = 0; + $this->last_column_meta = array(); + return $this->last_result; + } + + if ( preg_match( '/^\s*SHOW\s+CREATE\s+PROCEDURE\s+`?([A-Za-z0-9_]+)`?\s*;?\s*$/i', $query, $matches ) ) { + $name = strtolower( $matches[1] ); + if ( ! isset( $this->procedures[ $name ] ) ) { + $this->last_result = array(); + $this->last_column_meta = array(); + return $this->last_result; + } + + $this->last_result = array( + (object) array( + 'Procedure' => $matches[1], + 'sql_mode' => $this->get_sql_mode(), + 'Create Procedure' => 'CREATE PROCEDURE `' . $matches[1] . '`() BEGIN ' . $this->procedures[ $name ] . '; END', + ), + ); + $this->last_column_meta = array(); + return $this->last_result; + } + + if ( preg_match( '/^\s*CALL\s+`?([A-Za-z0-9_]+)`?\s*(?:\(\s*\))?\s*;?\s*$/i', $query, $matches ) ) { + $name = strtolower( $matches[1] ); + if ( ! isset( $this->procedures[ $name ] ) ) { + return null; + } + + return $this->query( $this->procedures[ $name ], $fetch_mode, ...$fetch_mode_args ); + } + + return null; + } + + /** + * Get the target database from a supported MySQL USE statement. + * + * @param string $query MySQL query. + * @return string|null Target database name, or null when this is not USE. + */ + private function get_mysql_use_database_name( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::USE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $database_name = $this->get_mysql_identifier_token_value( $tokens[1] ?? null ); + if ( null === $database_name || ! $this->is_at_mysql_query_end( $tokens, 2 ) ) { + throw new InvalidArgumentException( 'Unsupported USE statement.' ); + } + + return $database_name; + } + + /** + * Get the table reference from a supported MySQL DESCRIBE/DESC statement. + * + * @param string $query MySQL query. + * @return array{schema: string, table: string}|null Table reference, or null when this is not DESCRIBE/DESC. + */ + private function get_describe_table_reference( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0] ) + || ( + WP_MySQL_Lexer::DESCRIBE_SYMBOL !== $tokens[0]->id + && WP_MySQL_Lexer::DESC_SYMBOL !== $tokens[0]->id + ) + ) { + return null; + } + + $position = 1; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported DESCRIBE statement.' ); + } + + $requested_schema = $table_reference['schema']; + $table_name = $table_reference['table']; + if ( + ( null === $requested_schema && 0 === strcasecmp( $this->db_name, 'information_schema' ) ) + || ( null !== $requested_schema && 0 === strcasecmp( $requested_schema, 'information_schema' ) ) + ) { + if ( null === $this->get_direct_information_schema_relation_columns( $table_name ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + return array( + 'schema' => 'information_schema', + 'table' => $table_name, + ); + } + + return array( + 'schema' => $this->get_mysql_writable_table_backend_schema( $table_reference, 'DESCRIBE' ), + 'table' => $table_name, + ); + } + + /** + * Parse a supported MySQL SHOW TABLES statement. + * + * @param string $query MySQL query. + * @return array{full: bool, schema: string, database: string, like: string|null, where: array|null}|null SHOW TABLES options, or null when unsupported. + */ + private function get_show_tables_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + $is_full = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $position ]->id ) { + $is_full = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $schema_name = 'public'; + $database_name = $this->db_name; + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $position ]->id + ) + ) { + $database_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $database_name ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + + if ( + 0 !== strcasecmp( $database_name, $this->main_db_name ) + && 0 !== strcasecmp( $database_name, 'public' ) + && 0 !== strcasecmp( $database_name, 'information_schema' ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + + $schema_name = 0 === strcasecmp( $database_name, 'information_schema' ) ? 'information_schema' : 'public'; + $position += 2; + } + + $like = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position + 1 ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id + ) + ) { + return null; + } + + $like = $tokens[ $position + 1 ]->get_value(); + $position += 2; + } + + $where = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { + if ( null !== $like ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + + $allowed_columns = array( + strtolower( 'Tables_in_' . $database_name ) => 'Tables_in_' . $database_name, + ); + if ( $is_full ) { + $allowed_columns['table_type'] = 'Table_type'; + } + + $where = $this->get_mysql_show_where_filters( $tokens, $position, $allowed_columns ); + if ( null === $where ) { + $where = $this->get_mysql_show_where_expression_filter( $tokens, $position, $allowed_columns ); + } + if ( null === $where ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + + $position = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $position ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + return array( + 'full' => $is_full, + 'schema' => $schema_name, + 'database' => $database_name, + 'like' => $like, + 'where' => $where, + ); + } + + /** + * Parse a supported MySQL SHOW TABLE STATUS statement. + * + * @param string $query MySQL query. + * @return array{database: string, schema: string, filter_type: string, filter_column: string|null, filter_pattern: string|null, filter_threshold: string|null, conditions?: array}|null SHOW TABLE STATUS options, or null when this is not SHOW TABLE STATUS. + */ + private function get_show_table_status_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id + || WP_MySQL_Lexer::STATUS_SYMBOL !== $tokens[2]->id + ) { + return null; + } + + $position = 3; + $database_name = $this->db_name; + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $position ]->id + ) + ) { + $database_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $database_name ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLE STATUS statement.' ); + } + + $position += 2; + } + + if ( + 0 !== strcasecmp( $database_name, $this->main_db_name ) + && 0 !== strcasecmp( $database_name, 'public' ) + && 0 !== strcasecmp( $database_name, 'information_schema' ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLE STATUS statement.' ); + } + + $schema_name = 0 === strcasecmp( $database_name, 'information_schema' ) ? 'information_schema' : 'public'; + + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return array( + 'database' => $database_name, + 'schema' => $schema_name, + 'filter_type' => 'all', + 'filter_column' => null, + 'filter_pattern' => null, + 'filter_threshold' => null, + ); + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 1 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 2 ) + ) { + return array( + 'database' => $database_name, + 'schema' => $schema_name, + 'filter_type' => 'like', + 'filter_column' => 'Name', + 'filter_pattern' => $tokens[ $position + 1 ]->get_value(), + 'filter_threshold' => null, + ); + } + + if ( + isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id + ) { + $filter = $this->get_show_table_status_where_filter( $tokens, $position ); + if ( null !== $filter ) { + $filter['database'] = $database_name; + $filter['schema'] = $schema_name; + return $filter; + } + } + + throw new InvalidArgumentException( 'Unsupported SHOW TABLE STATUS statement.' ); + } + + /** + * Parse a supported SHOW TABLE STATUS WHERE clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position WHERE token position. + * @return array{filter_type: string, filter_column: string|null, filter_pattern: string|null, filter_threshold: string|null, conditions?: array, predicate?: array}|null Parsed filter, or null when unsupported. + */ + private function get_show_table_status_where_filter( array $tokens, int $position ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $predicate_position = $position + 1; + if ( ! isset( $tokens[ $predicate_position ] ) ) { + return null; + } + + $column = $this->get_mysql_show_output_column_name( + $tokens[ $predicate_position ], + array( 'auto_increment' => 'Auto_increment' ) + ); + if ( + 'Auto_increment' === $column + && isset( $tokens[ $predicate_position + 1 ] ) + && WP_MySQL_Lexer::GREATER_THAN_OPERATOR === $tokens[ $predicate_position + 1 ]->id + && isset( $tokens[ $predicate_position + 2 ] ) + && $this->is_mysql_unsigned_integer_token( $tokens[ $predicate_position + 2 ] ) + && $this->is_at_mysql_query_end( $tokens, $predicate_position + 3 ) + ) { + return array( + 'filter_type' => 'auto_increment_gt', + 'filter_column' => 'Auto_increment', + 'filter_pattern' => null, + 'filter_threshold' => $tokens[ $predicate_position + 2 ]->get_value(), + ); + } + + if ( + 'Auto_increment' === $column + && isset( $tokens[ $predicate_position + 1 ] ) + && WP_MySQL_Lexer::IS_SYMBOL === $tokens[ $predicate_position + 1 ]->id + && isset( $tokens[ $predicate_position + 2 ] ) + && WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $predicate_position + 2 ]->id + && $this->is_at_mysql_query_end( $tokens, $predicate_position + 3 ) + ) { + return array( + 'filter_type' => 'auto_increment_is_null', + 'filter_column' => 'Auto_increment', + 'filter_pattern' => null, + 'filter_threshold' => null, + ); + } + + $allowed_columns = array( + 'name' => 'Name', + 'engine' => 'Engine', + 'version' => 'Version', + 'row_format' => 'Row_format', + 'rows' => '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', + 'collation' => 'Collation', + 'checksum' => 'Checksum', + 'create_options' => 'Create_options', + 'comment' => 'Comment', + ); + $numeric_columns = array( + 'Version', + 'Rows', + 'Avg_row_length', + 'Data_length', + 'Max_data_length', + 'Index_length', + 'Data_free', + 'Auto_increment', + 'Checksum', + ); + $where_expression = $this->get_mysql_show_where_expression_filter( $tokens, $position, $allowed_columns, $numeric_columns ); + if ( null !== $where_expression ) { + return array( + 'filter_type' => 'where_expression', + 'filter_column' => null, + 'filter_pattern' => null, + 'filter_threshold' => null, + 'predicate' => $where_expression['predicate'], + ); + } + + $where_filter = $this->get_mysql_show_where_filters( + $tokens, + $position, + $allowed_columns, + $numeric_columns + ); + if ( null === $where_filter ) { + return null; + } + + return array( + 'filter_type' => 'where', + 'filter_column' => null, + 'filter_pattern' => null, + 'filter_threshold' => null, + 'conditions' => $where_filter, + ); + } + + /** + * Parse a supported MySQL SHOW CREATE TABLE statement. + * + * @param string $query MySQL query. + * @return array{schema: string, table: string}|null SHOW CREATE TABLE options, or null when this is not SHOW CREATE TABLE. + */ + private function get_show_create_table_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + if ( ! isset( $tokens[2] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[2]->id ) { + return null; + } + + $table_reference = $this->get_show_create_table_reference( $tokens, 3 ); + if ( null === $table_reference || ! $this->is_at_mysql_query_end( $tokens, $table_reference['position'] ) ) { + throw new InvalidArgumentException( 'Unsupported SHOW CREATE TABLE statement.' ); + } + + $schema_name = $this->get_mysql_read_table_backend_schema( $table_reference['schema'] ); + if ( + ! in_array( $schema_name, array( 'public', 'information_schema' ), true ) + && 0 !== strcasecmp( $schema_name, $this->main_db_name ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW CREATE TABLE statement.' ); + } + + return array( + 'schema' => $schema_name, + 'table' => $table_reference['table'], + ); + } + + /** + * Parse a SHOW CREATE TABLE table reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Table reference start position. + * @return array{schema: string|null, table: string, position: int}|null Parsed reference, or null when unsupported. + */ + private function get_show_create_table_reference( array $tokens, int $position ): ?array { + $first_identifier = $this->get_mysql_table_reference_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position ]->id ) { + return array( + 'schema' => null, + 'table' => $first_identifier, + 'position' => $position, + ); + } + + $table_name = $this->get_mysql_table_reference_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $table_name ) { + return null; + } + + return array( + 'schema' => $first_identifier, + 'table' => $table_name, + 'position' => $position + 2, + ); + } + + /** + * Check whether a token is an unsigned integer literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is an unsigned integer literal. + */ + private function is_mysql_unsigned_integer_token( WP_MySQL_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 + ); + } + + /** + * Parse a supported MySQL SHOW VARIABLES statement. + * + * @param string $query MySQL query. + * @return array{type: string, pattern: string|null, scope: string, column?: string, conditions?: array[]}|null SHOW VARIABLES options, or null when this is not SHOW VARIABLES. + */ + private function get_show_variables_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + $scope = 'session'; + if ( + WP_MySQL_Lexer::GLOBAL_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::SESSION_SYMBOL === $tokens[ $position ]->id + ) { + $scope = WP_MySQL_Lexer::GLOBAL_SYMBOL === $tokens[ $position ]->id ? 'global' : 'session'; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VARIABLES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return array( + 'type' => 'all', + 'scope' => $scope, + 'column' => null, + 'pattern' => null, + ); + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 1 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 2 ) + ) { + return array( + 'type' => 'like', + 'scope' => $scope, + 'column' => 'Variable_name', + 'pattern' => strtolower( $tokens[ $position + 1 ]->get_value() ), + ); + } + + $allowed_columns = array( + 'variable_name' => 'Variable_name', + 'value' => 'Value', + ); + $numeric_columns = array( 'Value' ); + $where_filters = $this->get_mysql_show_where_filters( $tokens, $position, $allowed_columns, $numeric_columns ); + if ( null !== $where_filters ) { + return array( + 'type' => 'where', + 'scope' => $scope, + 'column' => null, + 'pattern' => null, + 'conditions' => $where_filters, + ); + } + + $where_expression = $this->get_mysql_show_where_expression_filter( $tokens, $position, $allowed_columns, $numeric_columns ); + if ( null !== $where_expression ) { + $where_expression['scope'] = $scope; + return $where_expression; + } + + throw new InvalidArgumentException( 'Unsupported SHOW VARIABLES statement.' ); + } + + /** + * Parse a supported MySQL SHOW CHARACTER SET/SHOW CHARSET statement. + * + * @param string $query MySQL query. + * @return array{type: string, column: string|null, pattern: string|null}|null SHOW CHARACTER SET options, or null when this is not SHOW CHARACTER SET. + */ + private function get_show_character_set_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( WP_MySQL_Lexer::CHARSET_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } elseif ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::CHAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + } else { + return null; + } + + $allowed_columns = array( + 'charset' => 'Charset', + 'description' => 'Description', + 'default collation' => 'Default collation', + 'maxlen' => 'Maxlen', + ); + $filter = $this->get_show_static_result_filter( $tokens, $position, 'Charset', $allowed_columns ); + if ( null === $filter ) { + throw new InvalidArgumentException( 'Unsupported SHOW CHARACTER SET statement.' ); + } + + return $filter; + } + + /** + * Parse a supported MySQL SHOW COLLATION statement. + * + * @param string $query MySQL query. + * @return array{type: string, column: string|null, pattern: string|null}|null SHOW COLLATION options, or null when this is not SHOW COLLATION. + */ + private function get_show_collation_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::COLLATION_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $allowed_columns = array( + 'collation' => 'Collation', + 'charset' => 'Charset', + 'id' => 'Id', + 'default' => 'Default', + 'compiled' => 'Compiled', + 'sortlen' => 'Sortlen', + 'pad_attribute' => 'Pad_attribute', + ); + $filter = $this->get_show_static_result_filter( $tokens, 2, 'Collation', $allowed_columns ); + if ( null === $filter ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLLATION statement.' ); + } + + return $filter; + } + + /** + * Parse a supported MySQL SHOW DATABASES/SHOW SCHEMAS statement. + * + * @param string $query MySQL query. + * @return array{type: string, column: string|null, pattern: string|null}|null SHOW DATABASES options, or null when this is not SHOW DATABASES. + */ + private function get_show_databases_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || ( + WP_MySQL_Lexer::DATABASES_SYMBOL !== $tokens[1]->id + && WP_MySQL_Lexer::SCHEMAS_SYMBOL !== $tokens[1]->id + ) + ) { + return null; + } + + $filter = $this->get_show_static_result_filter( + $tokens, + 2, + 'Database', + array( 'database' => 'Database' ) + ); + if ( null === $filter ) { + throw new InvalidArgumentException( 'Unsupported SHOW DATABASES statement.' ); + } + + return $filter; + } + + /** + * Parse a supported MySQL SHOW CREATE DATABASE/SCHEMA statement. + * + * @param string $query MySQL query. + * @return array{database: string, if_not_exists: bool}|null SHOW CREATE DATABASE options, or null when this is not SHOW CREATE DATABASE. + */ + private function get_show_create_database_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[1]->id + || ( + WP_MySQL_Lexer::DATABASE_SYMBOL !== $tokens[2]->id + && WP_MySQL_Lexer::SCHEMA_SYMBOL !== $tokens[2]->id + ) + ) { + return null; + } + + $position = 3; + $if_not_exists = false; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $if_not_exists = true; + $position += 3; + } + + $database = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null, true ); + if ( null === $database || ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( 'Unsupported SHOW CREATE DATABASE statement.' ); + } + + return array( + 'database' => $database, + 'if_not_exists' => $if_not_exists, + ); + } + + /** + * Parse a supported MySQL SHOW ENGINES statement. + * + * @param string $query MySQL query. + * @return array{type: string, column: string|null, pattern: string|null}|null SHOW ENGINES options, or null when this is not SHOW ENGINES. + */ + private function get_show_engines_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::STORAGE_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::ENGINES_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + } elseif ( WP_MySQL_Lexer::ENGINES_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } else { + return null; + } + + $allowed_columns = array( + 'engine' => 'Engine', + 'support' => 'Support', + 'comment' => 'Comment', + 'transactions' => 'Transactions', + 'xa' => 'XA', + 'savepoints' => 'Savepoints', + ); + $filter = $this->get_show_static_result_filter( $tokens, $position, 'Engine', $allowed_columns ); + if ( null === $filter ) { + throw new InvalidArgumentException( 'Unsupported SHOW ENGINES statement.' ); + } + + return $filter; + } + + /** + * Parse a supported MySQL SHOW PLUGINS statement. + * + * @param string $query MySQL query. + * @return array{type: string, column: string|null, pattern: string|null}|null SHOW PLUGINS options, or null when this is not SHOW PLUGINS. + */ + private function get_show_plugins_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::PLUGINS_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $allowed_columns = array( + 'name' => 'Name', + 'status' => 'Status', + 'type' => 'Type', + 'library' => 'Library', + 'license' => 'License', + ); + $filter = $this->get_show_static_result_filter( $tokens, 2, 'Name', $allowed_columns ); + if ( null === $filter ) { + throw new InvalidArgumentException( 'Unsupported SHOW PLUGINS statement.' ); + } + + return $filter; + } + + /** + * Parse a supported MySQL SHOW GRANTS statement. + * + * @param string $query MySQL query. + * @return array{}|null Empty options array, or null when this is not SHOW GRANTS. + */ + private function get_show_grants_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::GRANTS_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + if ( $this->is_at_mysql_query_end( $tokens, 2 ) ) { + return array(); + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + + $position = 2; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->parse_supported_show_grants_principal( $tokens, $position + 1, $statement_end ); + if ( null === $position ) { + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::USING_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->parse_supported_show_grants_role_list( $tokens, $position + 1, $statement_end ); + if ( null === $position ) { + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + } + + if ( $position === $statement_end ) { + return array(); + } + + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + + /** + * Parse the optional principal in a SHOW GRANTS statement. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First principal token. + * @param int $end Final statement token position, exclusive. + * @return int|null Position after the principal, or null when unsupported. + */ + private function parse_supported_show_grants_principal( array $tokens, int $start, int $end ): ?int { + if ( ! isset( $tokens[ $start ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::CURRENT_USER_SYMBOL === $tokens[ $start ]->id ) { + $position = $start + 1; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + } + + return $position; + } + + return $this->parse_supported_show_grants_account_name( $tokens, $start, $end ); + } + + /** + * Parse a SHOW GRANTS role list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First role token. + * @param int $end Final statement token position, exclusive. + * @return int|null Position after the role list, or null when unsupported. + */ + private function parse_supported_show_grants_role_list( array $tokens, int $start, int $end ): ?int { + $position = $this->parse_supported_show_grants_account_name( $tokens, $start, $end ); + if ( null === $position ) { + return null; + } + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->parse_supported_show_grants_account_name( $tokens, $position + 1, $end ); + if ( null === $position ) { + return null; + } + } + + return $position; + } + + /** + * Parse a MySQL account-style name used by SHOW GRANTS. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First account token. + * @param int $end Final statement token position, exclusive. + * @return int|null Position after the account name, or null when unsupported. + */ + private function parse_supported_show_grants_account_name( array $tokens, int $start, int $end ): ?int { + if ( $start >= $end || ! $this->is_supported_show_grants_name_part( $tokens[ $start ] ?? null ) ) { + return null; + } + + $position = $start + 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + return $position + 1; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AT_SIGN_SYMBOL === $tokens[ $position ]->id ) { + if ( ! $this->is_supported_show_grants_name_part( $tokens[ $position + 1 ] ?? null ) ) { + return null; + } + + return $position + 2; + } + + return $position; + } + + /** + * Check whether a token is a supported SHOW GRANTS account/role part. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return bool Whether the token can name a user, host, or role part. + */ + private function is_supported_show_grants_name_part( ?WP_MySQL_Token $token ): bool { + return null !== $token + && ( + null !== $this->get_mysql_identifier_token_value( $token, true ) + || WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + ); + } + + /** + * Parse a supported MySQL SHOW STATUS statement. + * + * @param string $query MySQL query. + * @return array{type: string, column: string|null, pattern: string|null, conditions?: array[]}|null SHOW STATUS options, or null when this is not SHOW STATUS. + */ + private function get_show_status_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( + WP_MySQL_Lexer::GLOBAL_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::SESSION_SYMBOL === $tokens[ $position ]->id + ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::STATUS_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return array( + 'type' => 'all', + 'column' => null, + 'pattern' => null, + ); + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 1 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 2 ) + ) { + return array( + 'type' => 'like', + 'column' => 'Variable_name', + 'pattern' => $tokens[ $position + 1 ]->get_value(), + ); + } + + $allowed_columns = array( + 'variable_name' => 'Variable_name', + 'value' => 'Value', + ); + $where_filters = $this->get_mysql_show_where_filters( $tokens, $position, $allowed_columns ); + if ( null !== $where_filters ) { + return array( + 'type' => 'where', + 'column' => null, + 'pattern' => null, + 'conditions' => $where_filters, + ); + } + + $where_expression = $this->get_mysql_show_where_expression_filter( $tokens, $position, $allowed_columns ); + if ( null !== $where_expression ) { + return $where_expression; + } + + throw new InvalidArgumentException( 'Unsupported SHOW STATUS statement.' ); + } + + /** + * Parse a supported MySQL SHOW WARNINGS/ERRORS statement. + * + * PostgreSQL execution errors are surfaced directly as exceptions, so there + * is no MySQL diagnostics area to inspect. The supported forms return the + * compatible empty diagnostics shape, or a zero COUNT(*) row. + * + * @param string $query MySQL query. + * @param int $diagnostic_token WARNINGS_SYMBOL or ERRORS_SYMBOL. + * @param string $diagnostic_name Diagnostic type for error messages and count columns. + * @return array{type: string, count_column: string|null, statement: string}|null Diagnostics options, or null when this is not the requested SHOW statement. + */ + private function get_show_diagnostics_query( string $query, int $diagnostic_token, string $diagnostic_name ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( WP_MySQL_Lexer::COUNT_SYMBOL === $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ], $tokens[ $position + 4 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::MULT_OPERATOR !== $tokens[ $position + 2 ]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position + 3 ]->id + ) { + return null; + } + + $position += 4; + if ( WP_MySQL_Lexer::WARNINGS_SYMBOL !== $tokens[ $position ]->id && WP_MySQL_Lexer::ERRORS_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + if ( $diagnostic_token !== $tokens[ $position ]->id ) { + return null; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( sprintf( 'Unsupported SHOW %s statement.', strtoupper( $diagnostic_name ) ) ); + } + + return array( + 'type' => 'count', + 'count_column' => '@@session.' . ( 'warnings' === $diagnostic_name ? 'warning_count' : 'error_count' ), + 'statement' => $diagnostic_name, + ); + } + + if ( WP_MySQL_Lexer::WARNINGS_SYMBOL !== $tokens[ $position ]->id && WP_MySQL_Lexer::ERRORS_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + if ( $diagnostic_token !== $tokens[ $position ]->id ) { + return null; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position + 1 ) && ! $this->is_supported_show_diagnostics_limit_clause( $tokens, $position + 1 ) ) { + throw new InvalidArgumentException( sprintf( 'Unsupported SHOW %s statement.', strtoupper( $diagnostic_name ) ) ); + } + + return array( + 'type' => 'rows', + 'count_column' => null, + 'statement' => $diagnostic_name, + ); + } + + /** + * Check whether a SHOW WARNINGS/ERRORS LIMIT clause is supported. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position LIMIT token position. + * @return bool Whether the LIMIT clause is supported. + */ + private function is_supported_show_diagnostics_limit_clause( array $tokens, int $position ): bool { + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( + null === $statement_end + || ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $position ]->id + ) { + return false; + } + + if ( $position + 2 === $statement_end ) { + return $this->is_mysql_show_limit_number_token( $tokens[ $position + 1 ] ); + } + + if ( + $position + 4 === $statement_end + && isset( $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position + 2 ]->id + ) { + return $this->is_mysql_show_limit_number_token( $tokens[ $position + 1 ] ) + && $this->is_mysql_show_limit_number_token( $tokens[ $position + 3 ] ); + } + + if ( + $position + 4 === $statement_end + && isset( $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + && WP_MySQL_Lexer::OFFSET_SYMBOL === $tokens[ $position + 2 ]->id + ) { + return $this->is_mysql_show_limit_number_token( $tokens[ $position + 1 ] ) + && $this->is_mysql_show_limit_number_token( $tokens[ $position + 3 ] ); + } + + return false; + } + + /** + * Check whether a token is a supported SHOW LIMIT number. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a non-negative integer literal. + */ + private function is_mysql_show_limit_number_token( WP_MySQL_Token $token ): bool { + return $this->is_mysql_unsigned_integer_token( $token ) && ctype_digit( $token->get_value() ); + } + + /** + * Parse a MySQL SHOW LIMIT clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position LIMIT token position. + * @param int $end Final token position, exclusive. + * @return array{offset:int,count:int}|null Parsed LIMIT clause, or null when unsupported. + */ + private function get_mysql_show_limit_clause( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $position ]->id + || ! $this->is_mysql_show_limit_number_token( $tokens[ $position + 1 ] ) + ) { + return null; + } + + if ( $position + 2 === $end ) { + return array( + 'offset' => 0, + 'count' => (int) $tokens[ $position + 1 ]->get_value(), + ); + } + + if ( + $position + 4 !== $end + || ! isset( $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + || ! $this->is_mysql_show_limit_number_token( $tokens[ $position + 3 ] ) + ) { + return null; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position + 2 ]->id ) { + return array( + 'offset' => (int) $tokens[ $position + 1 ]->get_value(), + 'count' => (int) $tokens[ $position + 3 ]->get_value(), + ); + } + + if ( WP_MySQL_Lexer::OFFSET_SYMBOL === $tokens[ $position + 2 ]->id ) { + return array( + 'offset' => (int) $tokens[ $position + 3 ]->get_value(), + 'count' => (int) $tokens[ $position + 1 ]->get_value(), + ); + } + + return null; + } + + /** + * Parse a supported MySQL SHOW PROCESSLIST statement. + * + * @param string $query MySQL query. + * @return array{full: bool, where_filter?: array|null, limit?: array{offset:int,count:int}|null}|null SHOW PROCESSLIST options, or null when this is not SHOW PROCESSLIST. + */ + private function get_show_processlist_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( + ( + WP_MySQL_Lexer::GLOBAL_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::SESSION_SYMBOL === $tokens[ $position ]->id + ) + && isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::PROCESSLIST_SYMBOL === $tokens[ $position + 1 ]->id + ) { + throw new InvalidArgumentException( 'Unsupported SHOW PROCESSLIST statement.' ); + } + + $is_full = false; + if ( WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $position ]->id ) { + $is_full = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::PROCESSLIST_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position + 1 ); + if ( null === $statement_end ) { + throw new InvalidArgumentException( 'Unsupported SHOW PROCESSLIST statement.' ); + } + + ++$position; + $where_filter = null; + $limit = null; + $allowed_columns = array( + 'id' => 'Id', + 'user' => 'User', + 'host' => 'Host', + 'db' => 'db', + 'command' => 'Command', + 'time' => 'Time', + 'state' => 'State', + 'info' => 'Info', + ); + $numeric_columns = array( 'Id', 'Time' ); + + if ( $position < $statement_end && WP_MySQL_Lexer::WHERE_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $position + 1, $statement_end ); + $where_end = null === $limit_position ? $statement_end : $limit_position; + $where_filter = $this->get_mysql_show_where_expression_filter_until( + $tokens, + $position, + $where_end, + $allowed_columns, + $numeric_columns + ); + if ( null === $where_filter ) { + throw new InvalidArgumentException( 'Unsupported SHOW PROCESSLIST statement.' ); + } + $position = $where_end; + } + + if ( $position < $statement_end ) { + $limit = $this->get_mysql_show_limit_clause( $tokens, $position, $statement_end ); + if ( null === $limit ) { + throw new InvalidArgumentException( 'Unsupported SHOW PROCESSLIST statement.' ); + } + } + + return array( + 'full' => $is_full, + 'where_filter' => $where_filter, + 'limit' => $limit, + ); + } + + /** + * Parse optional LIKE or simple WHERE filters for static SHOW result sets. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @param string $like_column Output column filtered by LIKE. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @return array{type: string, column: string|null, pattern: string|null, predicate?: array}|null Parsed filter, or null when unsupported. + */ + private function get_show_static_result_filter( + array $tokens, + int $position, + string $like_column, + array $allowed_columns + ): ?array { + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return array( + 'type' => 'all', + 'column' => null, + 'pattern' => null, + ); + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 1 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 2 ) + ) { + return array( + 'type' => 'like', + 'column' => $like_column, + 'pattern' => $tokens[ $position + 1 ]->get_value(), + ); + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { + return $this->get_mysql_show_where_expression_filter( $tokens, $position, $allowed_columns ); + } + + return null; + } + + /** + * Parse a MySQL WHERE expression that can be evaluated against materialized SHOW rows. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position WHERE token position. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array{type: string, column: null, pattern: null, predicate: array}|null Parsed expression filter, or null when unsupported. + */ + private function get_mysql_show_where_expression_filter( array $tokens, int $position, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end || $position + 1 >= $statement_end ) { + return null; + } + + return $this->get_mysql_show_where_expression_filter_until( + $tokens, + $position, + $statement_end, + $allowed_columns, + $numeric_columns + ); + } + + /** + * Parse a MySQL WHERE expression against materialized SHOW rows up to a fixed end token. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position WHERE token position. + * @param int $end Final token position, exclusive. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array{type: string, column: null, pattern: null, predicate: array}|null Parsed expression filter, or null when unsupported. + */ + private function get_mysql_show_where_expression_filter_until( array $tokens, int $position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[ $position ]->id || $position + 1 >= $end ) { + return null; + } + + $expression_position = $position + 1; + $predicate = $this->parse_mysql_show_where_or_expression( $tokens, $expression_position, $end, $allowed_columns, $numeric_columns ); + if ( null === $predicate || $expression_position !== $end ) { + return null; + } + + return array( + 'type' => 'where_expression', + 'column' => null, + 'pattern' => null, + 'predicate' => $predicate, + ); + } + + /** + * Check whether a parsed SHOW WHERE filter should be evaluated against materialized rows. + * + * @param array|null $where_filter Parsed SHOW WHERE filter. + * @return bool Whether the filter is an expression filter. + */ + private function is_mysql_show_where_expression_filter( ?array $where_filter ): bool { + return is_array( $where_filter ) && 'where_expression' === ( $where_filter['type'] ?? null ); + } + + /** + * Parse OR-combined SHOW WHERE predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, advanced on success. + * @param int $end Final token position, exclusive. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array|null Predicate AST, or null when unsupported. + */ + private function parse_mysql_show_where_or_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + $left = $this->parse_mysql_show_where_and_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $left ) { + return null; + } + + while ( isset( $tokens[ $position ] ) && $position < $end && WP_MySQL_Lexer::OR_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $right = $this->parse_mysql_show_where_and_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $right ) { + return null; + } + + $left = array( + 'type' => 'or', + 'left' => $left, + 'right' => $right, + ); + } + + return $left; + } + + /** + * Parse AND-combined SHOW WHERE predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, advanced on success. + * @param int $end Final token position, exclusive. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array|null Predicate AST, or null when unsupported. + */ + private function parse_mysql_show_where_and_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + $left = $this->parse_mysql_show_where_not_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $left ) { + return null; + } + + while ( isset( $tokens[ $position ] ) && $position < $end && WP_MySQL_Lexer::AND_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $right = $this->parse_mysql_show_where_not_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $right ) { + return null; + } + + $left = array( + 'type' => 'and', + 'left' => $left, + 'right' => $right, + ); + } + + return $left; + } + + /** + * Parse optional NOT around SHOW WHERE predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, advanced on success. + * @param int $end Final token position, exclusive. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array|null Predicate AST, or null when unsupported. + */ + private function parse_mysql_show_where_not_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( isset( $tokens[ $position ] ) && $position < $end && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $expression = $this->parse_mysql_show_where_not_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $expression ) { + return null; + } + + return array( + 'type' => 'not', + 'expr' => $expression, + ); + } + + return $this->parse_mysql_show_where_boolean_primary( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + } + + /** + * Parse a SHOW WHERE parenthesized predicate or comparison. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, advanced on success. + * @param int $end Final token position, exclusive. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array|null Predicate AST, or null when unsupported. + */ + private function parse_mysql_show_where_boolean_primary( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null !== $after_close ) { + if ( + isset( $tokens[ $after_close ] ) + && $after_close < $end + && $this->is_mysql_show_where_comparison_continuation_token( $tokens[ $after_close ] ) + ) { + return $this->parse_mysql_show_where_comparison_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + } + + $inner_position = $position + 1; + $predicate = $this->parse_mysql_show_where_or_expression( $tokens, $inner_position, $after_close - 1, $allowed_columns, $numeric_columns ); + if ( null !== $predicate && $inner_position === $after_close - 1 ) { + $position = $after_close; + return $predicate; + } + } + } + + return $this->parse_mysql_show_where_comparison_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + } + + /** + * Check whether a token can continue a SHOW WHERE comparison after a value expression. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token continues a comparison predicate. + */ + private function is_mysql_show_where_comparison_continuation_token( WP_MySQL_Token $token ): bool { + return null !== $this->get_mysql_show_where_comparison_operator( $token ) + || in_array( + $token->id, + array( + WP_MySQL_Lexer::BETWEEN_SYMBOL, + WP_MySQL_Lexer::IN_SYMBOL, + WP_MySQL_Lexer::IS_SYMBOL, + WP_MySQL_Lexer::LIKE_SYMBOL, + WP_MySQL_Lexer::NOT_SYMBOL, + ), + true + ); + } + + /** + * Parse a SHOW WHERE comparison predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, advanced on success. + * @param int $end Final token position, exclusive. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array|null Predicate AST, or null when unsupported. + */ + private function parse_mysql_show_where_comparison_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + $left = $this->parse_mysql_show_where_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $left ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return array( + 'type' => 'truthy', + 'expr' => $left, + ); + } + + if ( WP_MySQL_Lexer::IS_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $is_not = false; + if ( isset( $tokens[ $position ] ) && $position < $end && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position ]->id ) { + $is_not = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || $position >= $end || WP_MySQL_Lexer::NULL_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + ++$position; + + return array( + 'type' => 'is_null', + 'expr' => $left, + 'not' => $is_not, + ); + } + + $operator = $this->get_mysql_show_where_comparison_operator( $tokens[ $position ] ); + if ( null !== $operator ) { + ++$position; + $right = $this->parse_mysql_show_where_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $right ) { + return null; + } + + return array( + 'type' => 'comparison', + 'operator' => $operator, + 'left' => $left, + 'right' => $right, + ); + } + + $is_not = false; + if ( WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position ]->id ) { + $is_not = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return $is_not ? null : array( + 'type' => 'truthy', + 'expr' => $left, + ); + } + + if ( WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $right = $this->parse_mysql_show_where_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $right ) { + return null; + } + $escape = $this->parse_mysql_show_where_like_escape( $tokens, $position, $end ); + if ( false === $escape ) { + return null; + } + + return array( + 'type' => 'comparison', + 'operator' => $is_not ? 'not_like' : 'like', + 'left' => $left, + 'right' => $right, + 'escape' => $escape, + ); + } + + if ( WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $values = $this->parse_mysql_show_where_value_list( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $values ) { + return null; + } + + return array( + 'type' => 'in', + 'expr' => $left, + 'values' => $values, + 'not' => $is_not, + ); + } + + if ( WP_MySQL_Lexer::BETWEEN_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $lower = $this->parse_mysql_show_where_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( + null === $lower + || ! isset( $tokens[ $position ] ) + || $position >= $end + || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $position ]->id + ) { + return null; + } + + ++$position; + $upper = $this->parse_mysql_show_where_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $upper ) { + return null; + } + + return array( + 'type' => 'between', + 'expr' => $left, + 'lower' => $lower, + 'upper' => $upper, + 'not' => $is_not, + ); + } + + if ( $is_not ) { + return null; + } + + return array( + 'type' => 'truthy', + 'expr' => $left, + ); + } + + /** + * Get a normalized SHOW WHERE comparison operator. + * + * @param WP_MySQL_Token $token Operator token. + * @return string|null Operator, or null when unsupported. + */ + private function get_mysql_show_where_comparison_operator( WP_MySQL_Token $token ): ?string { + switch ( $token->id ) { + case WP_MySQL_Lexer::EQUAL_OPERATOR: + return '='; + case WP_MySQL_Lexer::NOT_EQUAL_OPERATOR: + return '<>'; + case WP_MySQL_Lexer::GREATER_THAN_OPERATOR: + return '>'; + case WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR: + return '>='; + case WP_MySQL_Lexer::LESS_THAN_OPERATOR: + return '<'; + case WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR: + return '<='; + case WP_MySQL_Lexer::NULL_SAFE_EQUAL_OPERATOR: + return '<=>'; + } + + return null; + } + + /** + * Parse an optional SHOW WHERE LIKE ESCAPE clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, advanced on success. + * @param int $end Final token position, exclusive. + * @return string|false|null Escape character, false when invalid, or null for default escaping. + */ + private function parse_mysql_show_where_like_escape( array $tokens, int &$position, int $end ) { + if ( ! isset( $tokens[ $position ] ) || $position >= $end || WP_MySQL_Lexer::ESCAPE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + if ( + ! isset( $tokens[ $position + 1 ] ) + || $position + 1 >= $end + || ! $this->is_mysql_quoted_text_token( $tokens[ $position + 1 ] ) + ) { + return false; + } + + $escape = $tokens[ $position + 1 ]->get_value(); + if ( 1 !== strlen( $escape ) ) { + return false; + } + + $position += 2; + return $escape; + } + + /** + * Parse a scalar value expression for SHOW WHERE predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, advanced on success. + * @param int $end Final token position, exclusive. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array|null Value expression AST, or null when unsupported. + */ + private function parse_mysql_show_where_value_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + $left = $this->parse_mysql_show_where_multiplicative_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $left ) { + return null; + } + + while ( + isset( $tokens[ $position ] ) + && $position < $end + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::PLUS_OPERATOR, WP_MySQL_Lexer::MINUS_OPERATOR ), true ) + ) { + $operator = WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $position ]->id ? '+' : '-'; + ++$position; + $right = $this->parse_mysql_show_where_multiplicative_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( + null === $right + || ! $this->is_mysql_show_where_numeric_value_expression( $left, $numeric_columns ) + || ! $this->is_mysql_show_where_numeric_value_expression( $right, $numeric_columns ) + ) { + return null; + } + + $left = array( + 'type' => 'arithmetic', + 'operator' => $operator, + 'left' => $left, + 'right' => $right, + ); + } + + return $left; + } + + /** + * Parse a multiplicative scalar value expression for SHOW WHERE predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, advanced on success. + * @param int $end Final token position, exclusive. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array|null Value expression AST, or null when unsupported. + */ + private function parse_mysql_show_where_multiplicative_value_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + $left = $this->parse_mysql_show_where_primary_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $left ) { + return null; + } + + while ( + isset( $tokens[ $position ] ) + && $position < $end + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::MULT_OPERATOR, + WP_MySQL_Lexer::DIV_OPERATOR, + WP_MySQL_Lexer::DIV_SYMBOL, + WP_MySQL_Lexer::MOD_OPERATOR, + WP_MySQL_Lexer::MOD_SYMBOL, + ), + true + ) + ) { + $operator = '/'; + if ( WP_MySQL_Lexer::MULT_OPERATOR === $tokens[ $position ]->id ) { + $operator = '*'; + } elseif ( WP_MySQL_Lexer::MOD_OPERATOR === $tokens[ $position ]->id || WP_MySQL_Lexer::MOD_SYMBOL === $tokens[ $position ]->id ) { + $operator = '%'; + } + ++$position; + $right = $this->parse_mysql_show_where_primary_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( + null === $right + || ! $this->is_mysql_show_where_numeric_value_expression( $left, $numeric_columns ) + || ! $this->is_mysql_show_where_numeric_value_expression( $right, $numeric_columns ) + ) { + return null; + } + + $left = array( + 'type' => 'arithmetic', + 'operator' => $operator, + 'left' => $left, + 'right' => $right, + ); + } + + return $left; + } + + /** + * Parse a primary scalar value expression for SHOW WHERE predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, advanced on success. + * @param int $end Final token position, exclusive. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array|null Value expression AST, or null when unsupported. + */ + private function parse_mysql_show_where_primary_value_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return null; + } + + $token = $tokens[ $position ]; + if ( WP_MySQL_Lexer::BINARY_SYMBOL === $token->id ) { + ++$position; + $expression = $this->parse_mysql_show_where_primary_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null === $expression ) { + return null; + } + + return array( + 'type' => 'binary', + 'expr' => $expression, + ); + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token->id ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close ) { + return null; + } + + $inner_position = $position + 1; + $expression = $this->parse_mysql_show_where_value_expression( $tokens, $inner_position, $after_close - 1, $allowed_columns, $numeric_columns ); + if ( null === $expression || $inner_position !== $after_close - 1 ) { + return null; + } + + $position = $after_close; + return $expression; + } + + $function = $this->parse_mysql_show_where_function_value_expression( $tokens, $position, $end, $allowed_columns, $numeric_columns ); + if ( null !== $function ) { + return $function; + } + + $column = $this->get_mysql_show_output_column_name( $token, $allowed_columns ); + if ( null !== $column ) { + ++$position; + return array( + 'type' => 'column', + 'column' => $column, + ); + } + + if ( $this->is_mysql_quoted_text_token( $token ) ) { + ++$position; + return array( + 'type' => 'literal', + 'value' => $token->get_value(), + ); + } + + if ( WP_MySQL_Lexer::NULL_SYMBOL === $token->id ) { + ++$position; + return array( 'type' => 'null' ); + } + + if ( WP_MySQL_Lexer::TRUE_SYMBOL === $token->id || WP_MySQL_Lexer::FALSE_SYMBOL === $token->id ) { + ++$position; + return array( + 'type' => 'number', + 'value' => WP_MySQL_Lexer::TRUE_SYMBOL === $token->id ? '1' : '0', + ); + } + + if ( WP_MySQL_Lexer::MINUS_OPERATOR === $token->id && isset( $tokens[ $position + 1 ] ) && $position + 1 < $end && $this->is_mysql_number_token( $tokens[ $position + 1 ] ) ) { + $value = '-' . $tokens[ $position + 1 ]->get_value(); + $position += 2; + return array( + 'type' => 'number', + 'value' => $value, + ); + } + + if ( $this->is_mysql_number_token( $token ) ) { + ++$position; + return array( + 'type' => 'number', + 'value' => $token->get_value(), + ); + } + + return null; + } + + /** + * Check whether a SHOW WHERE value expression may be used in arithmetic. + * + * @param array $expression Value expression AST. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return bool Whether the expression is numeric. + */ + private function is_mysql_show_where_numeric_value_expression( array $expression, array $numeric_columns ): bool { + switch ( $expression['type'] ?? null ) { + case 'number': + case 'literal': + case 'arithmetic': + case 'column': + case 'null': + return true; + + case 'function': + return in_array( + $expression['function'] ?? null, + array( 'lower', 'upper', 'left', 'right', 'substring', 'length', 'char_length', 'mod' ), + true + ); + + case 'binary': + return isset( $expression['expr'] ) + && is_array( $expression['expr'] ) + && $this->is_mysql_show_where_numeric_value_expression( $expression['expr'], $numeric_columns ); + } + + return false; + } + + /** + * Parse a supported scalar function value for SHOW WHERE predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, advanced on success. + * @param int $end Final token position, exclusive. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array|null Function value AST, or null when unsupported. + */ + private function parse_mysql_show_where_function_value_expression( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + $function_name = $this->get_mysql_show_where_function_name( $tokens[ $position ] ?? null ); + if ( + null === $function_name + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $argument_ranges = 'substring' === $function_name + ? $this->get_mysql_substring_function_arguments( $tokens, $position + 2, $after_close - 1 ) + : $this->split_top_level_mysql_arguments( $tokens, $position + 2, $after_close - 1 ); + if ( null === $argument_ranges ) { + return null; + } + + $arguments = array(); + foreach ( $argument_ranges as $argument_range ) { + $argument_position = $argument_range['start']; + $argument = $this->parse_mysql_show_where_value_expression( $tokens, $argument_position, $argument_range['end'], $allowed_columns, $numeric_columns ); + if ( null === $argument || $argument_position !== $argument_range['end'] ) { + return null; + } + + $arguments[] = $argument; + } + + $argument_count = count( $arguments ); + if ( + ( 'substring' === $function_name && ! in_array( $argument_count, array( 2, 3 ), true ) ) + || ( in_array( $function_name, array( 'left', 'right' ), true ) && 2 !== $argument_count ) + || ( in_array( $function_name, array( 'lower', 'upper', 'length', 'char_length' ), true ) && 1 !== $argument_count ) + || ( + 'mod' === $function_name + && ( + 2 !== $argument_count + || ! $this->is_mysql_show_where_numeric_value_expression( $arguments[0], $numeric_columns ) + || ! $this->is_mysql_show_where_numeric_value_expression( $arguments[1], $numeric_columns ) + ) + ) + ) { + return null; + } + + $position = $after_close; + return array( + 'type' => 'function', + 'function' => $function_name, + 'arguments' => $arguments, + ); + } + + /** + * Get a supported SHOW WHERE scalar function name. + * + * @param WP_MySQL_Token|null $token Function token. + * @return string|null Normalized function name, or null when unsupported. + */ + private function get_mysql_show_where_function_name( ?WP_MySQL_Token $token ): ?string { + if ( null === $token ) { + return null; + } + + $keyword_functions = array( + WP_MySQL_Lexer::LEFT_SYMBOL => 'left', + WP_MySQL_Lexer::MOD_SYMBOL => 'mod', + WP_MySQL_Lexer::RIGHT_SYMBOL => 'right', + WP_MySQL_Lexer::SUBSTR_SYMBOL => 'substring', + WP_MySQL_Lexer::SUBSTRING_SYMBOL => 'substring', + ); + if ( isset( $keyword_functions[ $token->id ] ) ) { + return $keyword_functions[ $token->id ]; + } + + $name = $this->get_mysql_identifier_token_value( $token ); + if ( null === $name ) { + return null; + } + + $name = strtolower( $name ); + if ( in_array( $name, array( 'lcase', 'lower' ), true ) ) { + return 'lower'; + } + + if ( in_array( $name, array( 'ucase', 'upper' ), true ) ) { + return 'upper'; + } + + if ( 'substr' === $name || 'substring' === $name || 'mid' === $name ) { + return 'substring'; + } + + if ( 'length' === $name ) { + return 'length'; + } + + if ( 'char_length' === $name || 'character_length' === $name ) { + return 'char_length'; + } + + if ( 'mod' === $name ) { + return 'mod'; + } + + return in_array( $name, array( 'left', 'right' ), true ) ? $name : null; + } + + /** + * Check whether a token is a supported MySQL numeric literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is numeric. + */ + private function is_mysql_number_token( WP_MySQL_Token $token ): bool { + return $this->is_mysql_unsigned_integer_token( $token ) + || WP_MySQL_Lexer::DECIMAL_NUMBER === $token->id + || WP_MySQL_Lexer::FLOAT_NUMBER === $token->id; + } + + /** + * Parse a non-empty parenthesized value list for SHOW WHERE IN predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, advanced on success. + * @param int $end Final token position, exclusive. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may be used in arithmetic expressions. + * @return array|null Value expressions, or null when unsupported. + */ + private function parse_mysql_show_where_value_list( array $tokens, int &$position, int $end, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close ) { + return null; + } + + $argument_ranges = $this->split_top_level_mysql_arguments( $tokens, $position + 1, $after_close - 1 ); + if ( empty( $argument_ranges ) ) { + return null; + } + + $values = array(); + foreach ( $argument_ranges as $argument_range ) { + $argument_position = $argument_range['start']; + $value = $this->parse_mysql_show_where_value_expression( $tokens, $argument_position, $argument_range['end'], $allowed_columns, $numeric_columns ); + if ( null === $value || $argument_position !== $argument_range['end'] ) { + return null; + } + + $values[] = $value; + } + + $position = $after_close; + return $values; + } + + /** + * Parse a safe simple WHERE filter for dynamic SHOW result sets. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position WHERE token position. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may compare against an unsigned integer literal. + * @return array{column: string, operator: string, value: string}|null Parsed filter, or null when unsupported. + */ + private function get_mysql_show_where_filter( array $tokens, int $position, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[ $position ]->id + || ! $this->is_at_mysql_query_end( $tokens, $position + 4 ) + ) { + return null; + } + + $column = $this->get_mysql_show_output_column_name( $tokens[ $position + 1 ], $allowed_columns ); + if ( null === $column ) { + return null; + } + + $operator_token = $tokens[ $position + 2 ]; + $value_token = $tokens[ $position + 3 ]; + if ( WP_MySQL_Lexer::EQUAL_OPERATOR === $operator_token->id ) { + if ( $this->is_mysql_quoted_text_token( $value_token ) ) { + $value = $value_token->get_value(); + } elseif ( + in_array( $column, $numeric_columns, true ) + && $this->is_mysql_unsigned_integer_token( $value_token ) + ) { + $value = $value_token->get_value(); + } else { + return null; + } + + $operator = '='; + } elseif ( WP_MySQL_Lexer::LIKE_SYMBOL === $operator_token->id && $this->is_mysql_quoted_text_token( $value_token ) ) { + $operator = 'like'; + $value = $value_token->get_value(); + } else { + return null; + } + + return array( + 'column' => $column, + 'operator' => $operator, + 'value' => $value, + ); + } + + /** + * Parse simple AND-combined WHERE filters for static SHOW result sets. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position WHERE token position. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @param string[] $numeric_columns Output columns that may compare against an unsigned integer literal. + * @return array|null Parsed filters, or null when unsupported. + */ + private function get_mysql_show_where_filters( array $tokens, int $position, array $allowed_columns, array $numeric_columns = array() ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return null; + } + + $filters = array(); + $position = $position + 1; + while ( $position < $statement_end ) { + if ( ! isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) ) { + return null; + } + + $column = $this->get_mysql_show_output_column_name( $tokens[ $position ], $allowed_columns ); + if ( null === $column ) { + return null; + } + + $operator_token = $tokens[ $position + 1 ]; + $value_token = $tokens[ $position + 2 ]; + if ( WP_MySQL_Lexer::EQUAL_OPERATOR === $operator_token->id ) { + if ( $this->is_mysql_quoted_text_token( $value_token ) ) { + $value = $value_token->get_value(); + } elseif ( + in_array( $column, $numeric_columns, true ) + && $this->is_mysql_unsigned_integer_token( $value_token ) + ) { + $value = $value_token->get_value(); + } else { + return null; + } + + $operator = '='; + } elseif ( WP_MySQL_Lexer::LIKE_SYMBOL === $operator_token->id && $this->is_mysql_quoted_text_token( $value_token ) ) { + $operator = 'like'; + $value = $value_token->get_value(); + } else { + return null; + } + + $filters[] = array( + 'column' => $column, + 'operator' => $operator, + 'value' => $value, + ); + + $position += 3; + if ( $position === $statement_end ) { + break; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + ++$position; + } + + return empty( $filters ) ? null : $filters; + } + + /** + * Get the MySQL SHOW output column name represented by a token. + * + * @param WP_MySQL_Token $token MySQL token. + * @param array $allowed_columns Allowed output columns keyed by lower-case name. + * @return string|null Output column name, or null when unsupported. + */ + private function get_mysql_show_output_column_name( WP_MySQL_Token $token, array $allowed_columns ): ?string { + $column = $this->get_mysql_identifier_token_value( $token ); + if ( null === $column && $this->is_mysql_show_output_column_keyword_token( $token ) ) { + $column = $token->get_value(); + } + if ( null === $column ) { + return null; + } + + $column_key = strtolower( $column ); + return $allowed_columns[ $column_key ] ?? null; + } + + /** + * Check whether a MySQL keyword token can represent a SHOW output column. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a supported SHOW output column keyword. + */ + private function is_mysql_show_output_column_keyword_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL, + WP_MySQL_Lexer::CHARSET_SYMBOL, + WP_MySQL_Lexer::COLLATION_SYMBOL, + WP_MySQL_Lexer::COLUMN_NAME_SYMBOL, + WP_MySQL_Lexer::COMMENT_SYMBOL, + WP_MySQL_Lexer::CHECKSUM_SYMBOL, + WP_MySQL_Lexer::DATABASE_SYMBOL, + WP_MySQL_Lexer::DEFAULT_SYMBOL, + WP_MySQL_Lexer::ENGINE_SYMBOL, + WP_MySQL_Lexer::HOST_SYMBOL, + WP_MySQL_Lexer::KEY_SYMBOL, + WP_MySQL_Lexer::NAME_SYMBOL, + WP_MySQL_Lexer::NULL_SYMBOL, + WP_MySQL_Lexer::PRIVILEGES_SYMBOL, + WP_MySQL_Lexer::ROWS_SYMBOL, + WP_MySQL_Lexer::STATUS_SYMBOL, + WP_MySQL_Lexer::TABLE_SYMBOL, + WP_MySQL_Lexer::TIME_SYMBOL, + WP_MySQL_Lexer::TYPE_SYMBOL, + WP_MySQL_Lexer::USER_SYMBOL, + WP_MySQL_Lexer::VALUE_SYMBOL, + WP_MySQL_Lexer::VISIBLE_SYMBOL, + ), + true + ); + } + + /** + * Check whether a MySQL token is a quoted text literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is quoted text. + */ + private function is_mysql_quoted_text_token( WP_MySQL_Token $token ): bool { + return WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id + || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id; + } + + /** + * Parse a supported MySQL SHOW COLUMNS/FIELDS statement. + * + * @param string $query MySQL query. + * @return array{schema: string, table: string, full: bool, like: string|null, where: array|null}|null SHOW COLUMNS options, or null when this is not a SHOW COLUMNS/FIELDS statement. + */ + private function get_show_columns_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EXTENDED_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $is_full = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $position ]->id ) { + $is_full = true; + ++$position; + } + + if ( + ! isset( $tokens[ $position ] ) + || ( + WP_MySQL_Lexer::COLUMNS_SYMBOL !== $tokens[ $position ]->id + && WP_MySQL_Lexer::FIELDS_SYMBOL !== $tokens[ $position ]->id + ) + ) { + return null; + } + + ++$position; + if ( + ! isset( $tokens[ $position ] ) + || ( + WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id + && WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $position ]->id + ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + ++$position; + $table_reference = $this->get_show_columns_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $schema_name = $this->get_mysql_read_table_backend_schema( $table_reference['schema'] ); + $table_name = $table_reference['table']; + $position = $table_reference['position']; + + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $position ]->id + ) + ) { + $schema_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $schema_name ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $schema_name = $this->get_mysql_read_table_backend_schema( $schema_name ); + $position += 2; + } + + $like = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position ]->id ) { + if ( + ! isset( $tokens[ $position + 1 ] ) + || ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[ $position + 1 ]->id + ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $like = $tokens[ $position + 1 ]->get_value(); + $position += 2; + } + + $where = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { + if ( null !== $like ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $allowed_columns = array( + 'field' => 'Field', + 'type' => 'Type', + 'null' => 'Null', + 'key' => 'Key', + 'default' => 'Default', + 'extra' => 'Extra', + ); + if ( $is_full ) { + $allowed_columns['collation'] = 'Collation'; + $allowed_columns['privileges'] = 'Privileges'; + $allowed_columns['comment'] = 'Comment'; + } + + $where = $this->get_mysql_show_where_filters( $tokens, $position, $allowed_columns ); + if ( null === $where ) { + $where = $this->get_mysql_show_where_expression_filter( $tokens, $position, $allowed_columns ); + } + if ( null === $where ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $position = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $position ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + return array( + 'schema' => $schema_name, + 'table' => $table_name, + 'full' => $is_full, + 'like' => $like, + 'where' => $where, + ); + } + + /** + * Parse a SHOW COLUMNS table reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Table reference start position. + * @return array{schema: string|null, table: string, position: int}|null Parsed reference, or null when unsupported. + */ + private function get_show_columns_table_reference( array $tokens, int $position ): ?array { + $first_identifier = $this->get_mysql_table_reference_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position ]->id ) { + return array( + 'schema' => null, + 'table' => $first_identifier, + 'position' => $position, + ); + } + + $table_name = $this->get_mysql_table_reference_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $table_name ) { + return null; + } + + return array( + 'schema' => $first_identifier, + 'table' => $table_name, + 'position' => $position + 2, + ); + } + + /** + * Parse a supported MySQL SHOW INDEX/SHOW INDEXES/SHOW KEYS statement. + * + * SHOW EXTENDED INDEX-family statements use the same backing metadata rows; + * hidden index rows are not modeled separately by this compatibility layer. + * + * @param string $query MySQL query. + * @return array{schema: string, table: string, where: array|null}|null SHOW INDEX options, or null when this is not a SHOW INDEX statement. + */ + private function get_show_index_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SHOW_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( WP_MySQL_Lexer::EXTENDED_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( + ! isset( $tokens[ $position ] ) + || ( + WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[ $position ]->id + && WP_MySQL_Lexer::INDEXES_SYMBOL !== $tokens[ $position ]->id + && WP_MySQL_Lexer::KEYS_SYMBOL !== $tokens[ $position ]->id + ) + ) { + return null; + } + + ++$position; + if ( + ! isset( $tokens[ $position ] ) + || ( + WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id + && WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $position ]->id + ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + + ++$position; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $position ]->id + ) + ) { + $schema_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $schema_name ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + + $table_reference['schema'] = $this->get_mysql_read_table_backend_schema( $schema_name ); + $position += 2; + } + + $schema_name = $this->get_mysql_read_table_backend_schema( $table_reference['schema'] ); + $table_name = $table_reference['table']; + $where = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { + $allowed_columns = array( + 'table' => 'Table', + 'non_unique' => 'Non_unique', + 'key_name' => 'Key_name', + 'seq_in_index' => 'Seq_in_index', + 'column_name' => 'Column_name', + 'collation' => 'Collation', + 'cardinality' => 'Cardinality', + 'sub_part' => 'Sub_part', + 'packed' => 'Packed', + 'null' => 'Null', + 'index_type' => 'Index_type', + 'comment' => 'Comment', + 'index_comment' => 'Index_comment', + 'visible' => 'Visible', + 'expression' => 'Expression', + ); + $numeric_columns = array( + 'Non_unique', + 'Seq_in_index', + 'Cardinality', + 'Sub_part', + ); + $where = $this->get_mysql_show_where_filters( + $tokens, + $position, + $allowed_columns, + $numeric_columns + ); + if ( null === $where ) { + $where = $this->get_mysql_show_where_expression_filter( $tokens, $position, $allowed_columns, $numeric_columns ); + } + if ( null === $where ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + + $position = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $position ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + + return array( + 'schema' => $schema_name, + 'table' => $table_name, + 'where' => $where, + ); + } + + /** + * Parse a supported MySQL table administration statement. + * + * @param string $query MySQL query. + * @return array{operation: string, tables: array}|null Administration query, or null when this is not a table administration statement. + */ + private function get_mysql_table_administration_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + switch ( $tokens[0]->id ) { + case WP_MySQL_Lexer::ANALYZE_SYMBOL: + $operation = 'analyze'; + break; + case WP_MySQL_Lexer::CHECK_SYMBOL: + $operation = 'check'; + break; + case WP_MySQL_Lexer::OPTIMIZE_SYMBOL: + $operation = 'optimize'; + break; + case WP_MySQL_Lexer::REPAIR_SYMBOL: + $operation = 'repair'; + break; + default: + return null; + } + + $position = 1; + $this->consume_mysql_table_administration_leading_option( $tokens, $position, $operation ); + + if ( + ! isset( $tokens[ $position ] ) + || ! in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::TABLE_SYMBOL, WP_MySQL_Lexer::TABLES_SYMBOL ), true ) + ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + $tables = array(); + ++$position; + while ( true ) { + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + $tables[] = $table_reference; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + $position = $this->consume_mysql_table_administration_trailing_options( $tokens, $position, $operation ); + if ( null === $position ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + return array( + 'operation' => $operation, + 'tables' => $tables, + ); + } + + /** + * Consume a supported table-administration option before TABLE. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param string $operation Administration operation. + */ + private function consume_mysql_table_administration_leading_option( array $tokens, int &$position, string $operation ): void { + if ( ! in_array( $operation, array( 'analyze', 'optimize', 'repair' ), true ) ) { + return; + } + + if ( + isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::LOCAL_SYMBOL, WP_MySQL_Lexer::NO_WRITE_TO_BINLOG_SYMBOL ), true ) + ) { + ++$position; + } + } + + /** + * Consume supported table-administration options after the table list. + * + * PostgreSQL has no direct equivalent for MySQL's storage-engine maintenance + * modifiers, so they are accepted as compatibility no-ops. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @param string $operation Administration operation. + * @return int|null Position after options, or null when unsupported. + */ + private function consume_mysql_table_administration_trailing_options( array $tokens, int $position, string $operation ): ?int { + if ( 'check' === $operation ) { + while ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::UPGRADE_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + continue; + } + + if ( + isset( $tokens[ $position ] ) + && in_array( + $tokens[ $position ]->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 + ) + ) { + ++$position; + continue; + } + + return null; + } + + return $position; + } + + if ( 'analyze' === $operation ) { + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return $position; + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::HISTOGRAM_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position = $this->consume_mysql_table_administration_histogram_columns( $tokens, $position + 3 ); + if ( null === $position ) { + return null; + } + + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return $position; + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::WITH_SYMBOL === $tokens[ $position ]->id + && $this->is_mysql_unsigned_integer_token( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::BUCKETS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position += 3; + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return $position; + } + } + + return $this->consume_mysql_table_administration_using_data_clause( $tokens, $position ); + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::DROP_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::HISTOGRAM_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $position + 2 ]->id + ) { + return $this->consume_mysql_table_administration_histogram_columns( $tokens, $position + 3 ); + } + + return null; + } + + if ( 'repair' === $operation ) { + while ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + if ( + isset( $tokens[ $position ] ) + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::QUICK_SYMBOL, + WP_MySQL_Lexer::EXTENDED_SYMBOL, + WP_MySQL_Lexer::USE_FRM_SYMBOL, + ), + true + ) + ) { + ++$position; + continue; + } + + return null; + } + + return $position; + } + + return $position; + } + + /** + * Consume an ANALYZE TABLE UPDATE HISTOGRAM USING DATA clause. + * + * PostgreSQL does not consume MySQL histogram JSON, but accepting the clause + * keeps MySQL-compatible maintenance statements as no-ops like SQLite. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @return int|null Position after the clause, or null when unsupported. + */ + private function consume_mysql_table_administration_using_data_clause( array $tokens, int $position ): ?int { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + || WP_MySQL_Lexer::USING_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::DATA_SYMBOL !== $tokens[ $position + 1 ]->id + || ! $this->is_mysql_string_literal_token( $tokens[ $position + 2 ] ) + ) { + return null; + } + + $position += 3; + return $this->is_at_mysql_query_end( $tokens, $position ) ? $position : null; + } + + /** + * Consume a comma-separated ANALYZE TABLE histogram column list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @return int|null Position after the list, or null when unsupported. + */ + private function consume_mysql_table_administration_histogram_columns( array $tokens, int $position ): ?int { + $matched = false; + while ( isset( $tokens[ $position ] ) ) { + $column = $this->get_mysql_identifier_token_value( $tokens[ $position ] ); + if ( null === $column ) { + return null; + } + + $matched = true; + ++$position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + return $matched ? $position : null; + } + + /** + * Parse one table reference from a MySQL table administration statement. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param bool $allow_double_quoted Whether to accept double-quoted text as an identifier. + * @return array{schema: string|null, table: string}|null Parsed table reference, or null when unsupported. + */ + private function get_mysql_table_administration_table_reference( array $tokens, int &$position, bool $allow_double_quoted = false ): ?array { + $first_identifier = $this->get_mysql_table_reference_identifier_token_value( $tokens[ $position ] ?? null, $allow_double_quoted ); + if ( null === $first_identifier ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position ]->id ) { + return array( + 'schema' => null, + 'table' => $first_identifier, + ); + } + + $table_name = $this->get_mysql_table_reference_identifier_token_value( $tokens[ $position + 1 ] ?? null, $allow_double_quoted ); + if ( null === $table_name ) { + return null; + } + + $position += 2; + return array( + 'schema' => $first_identifier, + 'table' => $table_name, + ); + } + + /** + * Get an identifier for a table reference, including supported information_schema relation keywords. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @param bool $allow_double_quoted Whether double-quoted text may identify a table. + * @return string|null Identifier value, or null. + */ + private function get_mysql_table_reference_identifier_token_value( ?WP_MySQL_Token $token, bool $allow_double_quoted = false ): ?string { + $identifier = $this->get_mysql_identifier_token_value( $token, $allow_double_quoted ); + if ( null !== $identifier ) { + return $identifier; + } + + $identifier = $this->get_direct_information_schema_identifier_token_value( $token ); + if ( null === $identifier ) { + return null; + } + + return null === $this->get_direct_information_schema_relation_columns( $identifier ) ? null : $identifier; + } + + /** + * Parse a supported MySQL TRUNCATE TABLE statement. + * + * @param string $query MySQL query. + * @return array{schema: string|null, table: string}|null Truncate query, or null when this is not TRUNCATE. + */ + private function get_mysql_truncate_table_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::TRUNCATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported TRUNCATE TABLE statement.' ); + } + + return $table_reference; + } + + /** + * Parse a supported MySQL LOCK/UNLOCK TABLES statement. + * + * @param string $query MySQL query. + * @return array{operation: string, tables: array}|null Lock query, or null when this is not LOCK/UNLOCK. + */ + private function get_mysql_lock_tables_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::UNLOCK_SYMBOL === $tokens[0]->id ) { + if ( + ! isset( $tokens[1] ) + || ( + WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id + && WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[1]->id + ) + || ! $this->is_at_mysql_query_end( $tokens, 2 ) + ) { + throw new InvalidArgumentException( 'Unsupported UNLOCK TABLES statement.' ); + } + + return array( + 'operation' => 'unlock', + 'tables' => array(), + ); + } + + if ( WP_MySQL_Lexer::LOCK_SYMBOL !== $tokens[0]->id ) { + return null; + } + + if ( + ! isset( $tokens[1] ) + || ( + WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id + && WP_MySQL_Lexer::TABLES_SYMBOL !== $tokens[1]->id + ) + ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + $tables = array(); + $position = 2; + while ( true ) { + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( null === $table_reference || ! isset( $tokens[ $position ] ) ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + if ( ! $this->consume_mysql_lock_tables_alias( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + $mode = $this->consume_mysql_lock_tables_mode( $tokens, $position ); + if ( null === $mode ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + $tables[] = array( + 'schema' => $table_reference['schema'], + 'table' => $table_reference['table'], + 'mode' => $mode, + ); + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + return array( + 'operation' => 'lock', + 'tables' => $tables, + ); + } + + /** + * Consume an optional LOCK TABLES table alias. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return bool Whether no alias was present or a supported alias was consumed. + */ + private function consume_mysql_lock_tables_alias( array $tokens, int &$position ): bool { + $alias_position = $position; + if ( isset( $tokens[ $alias_position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $alias_position ]->id ) { + ++$alias_position; + } + + $alias = $this->get_mysql_identifier_token_value( $tokens[ $alias_position ] ?? null ); + if ( null === $alias ) { + return $alias_position === $position; + } + + $after_alias = $alias_position + 1; + if ( + ! isset( $tokens[ $after_alias ] ) + || ( + WP_MySQL_Lexer::READ_SYMBOL !== $tokens[ $after_alias ]->id + && WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL !== $tokens[ $after_alias ]->id + && WP_MySQL_Lexer::WRITE_SYMBOL !== $tokens[ $after_alias ]->id + ) + ) { + return false; + } + + $position = $after_alias; + return true; + } + + /** + * Consume a supported LOCK TABLES lock type. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return string|null Normalized lock mode, or null when unsupported. + */ + private function consume_mysql_lock_tables_mode( array $tokens, int &$position ): ?string { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::READ_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LOCAL_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + return 'read'; + } + + if ( WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::WRITE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + } elseif ( WP_MySQL_Lexer::WRITE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + return 'write'; + } + + /** + * Execute a supported MySQL LOCK/UNLOCK TABLES statement as a compatibility no-op. + * + * @param array $lock_tables_query Parsed lock query. + * @return int Number of affected rows. + */ + private function execute_mysql_lock_tables_query( array $lock_tables_query ): int { + if ( 'unlock' === $lock_tables_query['operation'] ) { + $this->last_result = 0; + $this->clear_last_column_meta(); + return $this->last_result; + } + + foreach ( $lock_tables_query['tables'] as $table_reference ) { + $requested_schema = $table_reference['schema']; + $table_name = $table_reference['table']; + if ( + ( null === $requested_schema && 0 === strcasecmp( $this->db_name, 'information_schema' ) ) + || ( null !== $requested_schema && 0 === strcasecmp( $requested_schema, 'information_schema' ) ) + ) { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + if ( ! $this->mysql_table_administration_table_exists( $requested_schema, $table_name ) ) { + $table_label = $this->get_mysql_table_administration_result_table_name( $requested_schema, $table_name ); + throw new InvalidArgumentException( sprintf( "Table '%s' doesn't exist", $table_label ) ); + } + } + + $this->last_result = 0; + $this->clear_last_column_meta(); + return $this->last_result; + } + + /** + * Parse safe MySQL FLUSH statements that can be compatibility no-ops. + * + * @param string $query MySQL query. + * @return string|null Flush target, or null when this is not FLUSH. + */ + private function get_mysql_flush_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::FLUSH_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( + isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::LOCAL_SYMBOL, WP_MySQL_Lexer::NO_WRITE_TO_BINLOG_SYMBOL ), true ) + ) { + ++$position; + } + + if ( + isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::TABLE_SYMBOL, WP_MySQL_Lexer::TABLES_SYMBOL ), true ) + && $this->is_at_mysql_query_end( $tokens, $position + 1 ) + ) { + return 'tables'; + } + + if ( + isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::PRIVILEGES_SYMBOL === $tokens[ $position ]->id + && $this->is_at_mysql_query_end( $tokens, $position + 1 ) + ) { + return 'privileges'; + } + + throw new InvalidArgumentException( 'Unsupported FLUSH statement.' ); + } + + /** + * Execute a supported MySQL admin compatibility no-op. + * + * @return int Number of affected rows. + */ + private function execute_mysql_admin_noop_query(): int { + $this->last_result = 0; + $this->clear_last_column_meta(); + return $this->last_result; + } + + /** + * Execute a MySQL table administration statement. + * + * @param array $administration_query Parsed administration query. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed Administration result rows. + */ + private function execute_mysql_table_administration_query( array $administration_query, $fetch_mode, ...$fetch_mode_args ) { + $operation = $administration_query['operation']; + $rows = array(); + + foreach ( $administration_query['tables'] as $table_reference ) { + $requested_schema = $table_reference['schema']; + $table_name = $table_reference['table']; + if ( null !== $requested_schema && 'information_schema' === strtolower( $requested_schema ) ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + $table_label = $this->get_mysql_table_administration_result_table_name( $requested_schema, $table_name ); + if ( $this->mysql_table_administration_table_exists( $requested_schema, $table_name ) ) { + $rows[] = array( + 'Table' => $table_label, + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ); + continue; + } + + $rows[] = array( + 'Table' => $table_label, + 'Op' => $operation, + 'Msg_type' => 'Error', + 'Msg_text' => sprintf( "Table '%s' doesn't exist", $table_name ), + ); + $rows[] = array( + 'Table' => $table_label, + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ); + } + + return $this->set_mysql_static_show_result( + array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Get the MySQL-facing Table column value for an administration result row. + * + * @param string|null $requested_schema Requested schema, or null for the current database. + * @param string $table_name Table name. + * @return string MySQL-facing qualified table name. + */ + private function get_mysql_table_administration_result_table_name( ?string $requested_schema, string $table_name ): string { + $display_schema = null === $requested_schema ? $this->db_name : $requested_schema; + return $display_schema . '.' . $table_name; + } + + /** + * Check whether a table administration target exists. + * + * @param string|null $requested_schema Requested schema, or null for the current database. + * @param string $table_name Table name. + * @return bool Whether the backend table exists. + */ + private function mysql_table_administration_table_exists( ?string $requested_schema, string $table_name ): bool { + $schema_name = $this->get_mysql_table_administration_backend_schema( $requested_schema, $table_name ); + $driver_name = $this->connection->get_driver_name(); + + if ( 'sqlite' === $driver_name ) { + return $this->sqlite_table_administration_table_exists( $schema_name, $table_name ); + } + + $stmt = $this->connection->query( + 'SELECT 1 + FROM pg_catalog.pg_class c + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = c.relnamespace + WHERE n.nspname = ? + AND c.relname = ? + AND c.relkind IN (\'r\', \'p\') + LIMIT 1', + array( $schema_name, $table_name ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Resolve the backend schema for a MySQL table administration target. + * + * @param string|null $requested_schema Requested schema, or null for the current database. + * @param string $table_name Table name. + * @return string Backend schema name. + */ + private function get_mysql_table_administration_backend_schema( ?string $requested_schema, string $table_name ): string { + if ( + null === $requested_schema + || 0 === strcasecmp( $requested_schema, $this->db_name ) + || 0 === strcasecmp( $requested_schema, $this->main_db_name ) + || 0 === strcasecmp( $requested_schema, 'public' ) + ) { + return $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + } + + return $this->resolve_mysql_table_schema_for_introspection( $requested_schema, $table_name ); + } + + /** + * Check whether a SQLite-backed test table administration target exists. + * + * @param string $schema_name Backend schema name. + * @param string $table_name Table name. + * @return bool Whether the table exists. + */ + private function sqlite_table_administration_table_exists( string $schema_name, string $table_name ): bool { + if ( 'temp' === $schema_name ) { + return $this->sqlite_table_administration_table_exists_in_catalog( 'sqlite_temp_master', $table_name ); + } + + if ( 'public' === $schema_name ) { + if ( + $this->sqlite_database_schema_exists( 'public' ) + && $this->sqlite_table_administration_table_exists_in_catalog( + $this->connection->quote_identifier( 'public' ) . '.sqlite_master', + $table_name + ) + ) { + return true; + } + + return $this->sqlite_table_administration_table_exists_in_catalog( 'sqlite_master', $table_name ); + } + + if ( 'main' === $schema_name ) { + return $this->sqlite_table_administration_table_exists_in_catalog( 'sqlite_master', $table_name ); + } + + if ( ! $this->sqlite_database_schema_exists( $schema_name ) ) { + return false; + } + + return $this->sqlite_table_administration_table_exists_in_catalog( + $this->connection->quote_identifier( $schema_name ) . '.sqlite_master', + $table_name + ); + } + + /** + * Check whether a table exists in one SQLite catalog table. + * + * @param string $catalog_sql SQLite catalog table SQL. + * @param string $table_name Table name. + * @return bool Whether the table exists. + */ + private function sqlite_table_administration_table_exists_in_catalog( string $catalog_sql, string $table_name ): bool { + $stmt = $this->connection->query( + sprintf( + 'SELECT name FROM %s WHERE type = \'table\' AND name = ? LIMIT 1', + $catalog_sql + ), + array( $table_name ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Check whether a SQLite attached database schema exists. + * + * @param string $schema_name Schema name. + * @return bool Whether the schema exists. + */ + private function sqlite_database_schema_exists( string $schema_name ): bool { + $stmt = $this->connection->query( 'PRAGMA database_list' ); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $database ) { + if ( isset( $database['name'] ) && $schema_name === (string) $database['name'] ) { + return true; + } + } + + return false; + } + + /** + * Execute a MySQL DESCRIBE/DESC statement through PostgreSQL catalogs. + * + * @param string $schema_name Schema name. + * @param string $table_name Table name. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed DESCRIBE result rows. + */ + private function execute_describe_query( string $schema_name, string $table_name, $fetch_mode, ...$fetch_mode_args ) { + $this->ensure_mysql_schema_metadata_tables(); + + if ( 0 === strcasecmp( $schema_name, 'information_schema' ) ) { + return $this->execute_direct_information_schema_show_columns_query( + $table_name, + false, + null, + null, + $fetch_mode, + ...$fetch_mode_args + ); + } + + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ); + $cache_key = $this->get_mysql_introspection_result_cache_key( + 'describe', + $fetch_mode, + array( $resolved_schema, $table_name, $fetch_mode, $fetch_mode_args ) + ); + if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { + return $this->last_result; + } + + $sql = $this->get_describe_catalog_query(); + $params = array( + $resolved_schema, + $table_name, + ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + $this->last_found_rows = count( $this->last_result ); + + $this->store_mysql_introspection_result_in_cache( $cache_key ); + + return $this->last_result; + } + + /** + * Execute a MySQL SHOW COLUMNS/SHOW FULL COLUMNS statement through PostgreSQL catalogs. + * + * @param string $schema_name Schema name. + * @param string $table_name Table name. + * @param bool $is_full Whether this is SHOW FULL COLUMNS. + * @param string|null $like Optional MySQL LIKE pattern. + * @param array|null $where_filter Optional MySQL WHERE filters. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW COLUMNS result rows. + */ + private function execute_show_columns_query( string $schema_name, string $table_name, bool $is_full, ?string $like, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ) { + $this->ensure_mysql_schema_metadata_tables(); + + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ); + if ( 0 === strcasecmp( $resolved_schema, 'information_schema' ) ) { + return $this->execute_direct_information_schema_show_columns_query( + $table_name, + $is_full, + $like, + $where_filter, + $fetch_mode, + ...$fetch_mode_args + ); + } + + $cache_key = $this->get_mysql_introspection_result_cache_key( + 'show_columns', + $fetch_mode, + array( $resolved_schema, $table_name, $is_full, $like, $where_filter, $fetch_mode, $fetch_mode_args ) + ); + if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { + return $this->last_result; + } + + $sql = $this->get_show_columns_catalog_query( $is_full ); + $params = array( + $resolved_schema, + $table_name, + ); + + if ( null !== $like ) { + $sql .= " AND field_name LIKE ? ESCAPE '\\'"; + $params[] = $like; + } + + $where_expression_filter = $this->is_mysql_show_where_expression_filter( $where_filter ) ? $where_filter : null; + if ( null !== $where_filter && null === $where_expression_filter ) { + foreach ( $where_filter as $filter ) { + $sql .= sprintf( + ' AND %s', + $this->get_mysql_show_where_filter_condition_sql( + $this->get_show_columns_filter_column_expression( $filter['column'] ), + $filter + ) + ); + $params[] = $filter['value']; + } + } + + $sql .= ' +ORDER BY ordinal_position'; + + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + if ( null !== $where_expression_filter ) { + $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + $rows = $this->filter_mysql_static_show_rows( $rows, $where_expression_filter ); + $this->set_mysql_associative_result_rows( $rows, $fetch_mode, ...$fetch_mode_args ); + } else { + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + $this->last_found_rows = count( $this->last_result ); + } + + $this->store_mysql_introspection_result_in_cache( $cache_key ); + + return $this->last_result; + } + + /** + * Execute SHOW COLUMNS for a supported MySQL information_schema relation. + * + * @param string $table_name information_schema relation name. + * @param bool $is_full Whether this is SHOW FULL COLUMNS. + * @param string|null $like Optional MySQL LIKE pattern. + * @param array|null $where_filter Optional MySQL WHERE filters. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW COLUMNS result rows. + */ + private function execute_direct_information_schema_show_columns_query( string $table_name, bool $is_full, ?string $like, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ) { + $columns = $this->get_direct_information_schema_relation_columns( $table_name ); + if ( null === $columns ) { + return $this->set_mysql_static_show_result( + $this->get_show_columns_output_columns( $is_full ), + array(), + $fetch_mode, + ...$fetch_mode_args + ); + } + + $rows = array(); + foreach ( $columns as $column ) { + $row = array( + 'Field' => $column, + 'Type' => $this->get_direct_information_schema_show_column_type( $column ), + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ); + + if ( $is_full ) { + $row = array( + 'Field' => $row['Field'], + 'Type' => $row['Type'], + 'Collation' => null, + 'Null' => $row['Null'], + 'Key' => $row['Key'], + 'Default' => $row['Default'], + 'Extra' => $row['Extra'], + 'Privileges' => 'select', + 'Comment' => '', + ); + } + + $rows[] = $row; + } + + if ( null !== $like ) { + $rows = $this->filter_mysql_static_show_rows( + $rows, + array( + 'type' => 'like', + 'column' => 'Field', + 'pattern' => $like, + ) + ); + } + + if ( null !== $where_filter ) { + $rows = $this->filter_mysql_static_show_rows( + $rows, + $this->is_mysql_show_where_expression_filter( $where_filter ) + ? $where_filter + : array( + 'type' => 'where', + 'conditions' => $where_filter, + ) + ); + } + + return $this->set_mysql_static_show_result( + $this->get_show_columns_output_columns( $is_full ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Get output columns for SHOW COLUMNS or SHOW FULL COLUMNS. + * + * @param bool $is_full Whether this is SHOW FULL COLUMNS. + * @return string[] Output column names. + */ + private function get_show_columns_output_columns( bool $is_full ): array { + if ( $is_full ) { + return array( 'Field', 'Type', 'Collation', 'Null', 'Key', 'Default', 'Extra', 'Privileges', 'Comment' ); + } + + return array( 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ); + } + + /** + * Get a SHOW COLUMNS type for an information_schema output column. + * + * @param string $column Uppercase information_schema column name. + * @return string MySQL type. + */ + private function get_direct_information_schema_show_column_type( string $column ): string { + $type = preg_replace( + '/\s+DEFAULT\s+NULL\z/i', + '', + $this->get_direct_information_schema_create_column_type( $column ) + ); + + return null === $type ? 'varchar(512)' : $type; + } + + /** + * Get the SQL expression that backs a MySQL SHOW COLUMNS output column. + * + * @param string $column MySQL output column name. + * @return string SQL expression. + */ + private function get_show_columns_filter_column_expression( string $column ): string { + switch ( $column ) { + case 'Field': + return 'field_name'; + case 'Type': + return 'column_type'; + case 'Collation': + return 'collation_name'; + case 'Null': + return 'is_nullable'; + case 'Key': + return 'column_key'; + case 'Default': + return 'column_default'; + case 'Extra': + return 'column_extra'; + case 'Privileges': + return "'select,insert,update,references'"; + case 'Comment': + return 'column_comment'; + } + + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + /** + * Get SQL for a parsed simple SHOW WHERE filter. + * + * @param string $expression SQL expression for the SHOW output column. + * @param array $where_filter Parsed SHOW WHERE filter. + * @return string SQL condition. + */ + private function get_mysql_show_where_filter_condition_sql( string $expression, array $where_filter ): string { + if ( 'like' === ( $where_filter['operator'] ?? '=' ) ) { + return sprintf( "%s LIKE ? ESCAPE '\\'", $expression ); + } + + return sprintf( '%s = ?', $expression ); + } + + /** + * Execute a supported MySQL USE statement in session state. + * + * @param string $database_name Requested MySQL-facing database name. + * @return int MySQL-compatible affected row count. + */ + private function execute_mysql_use_statement( string $database_name ): int { + if ( 0 === strcasecmp( $database_name, $this->main_db_name ) ) { + $this->db_name = $this->main_db_name; + $this->last_result = 0; + return $this->last_result; + } + + if ( 0 === strcasecmp( $database_name, 'information_schema' ) ) { + $this->db_name = 'information_schema'; + $this->last_result = 0; + return $this->last_result; + } + + throw new InvalidArgumentException( 'Unsupported USE statement.' ); + } + + /** + * Execute a MySQL SHOW TABLES statement through PostgreSQL catalogs. + * + * @param bool $is_full Whether this is SHOW FULL TABLES. + * @param string $schema_name Backend schema name. + * @param string $database_name MySQL-facing database name. + * @param string|null $like Optional MySQL LIKE pattern. + * @param array|null $where_filter Optional MySQL WHERE filters. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW TABLES result rows. + */ + private function execute_show_tables_query( bool $is_full, string $schema_name, string $database_name, ?string $like, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ) { + if ( 0 === strcasecmp( $database_name, 'information_schema' ) ) { + $columns = array( 'Tables_in_information_schema' ); + if ( $is_full ) { + $columns[] = 'Table_type'; + } + + return $this->set_mysql_static_show_result( $columns, array(), $fetch_mode, ...$fetch_mode_args ); + } + + $table_column = $this->connection->quote_identifier( 'Tables_in_' . $database_name ); + $sql = sprintf( + 'SELECT table_name AS %s%s + FROM information_schema.tables + WHERE table_schema = ? + AND table_type IN (\'BASE TABLE\', \'VIEW\') + AND table_name NOT IN (%s, %s, %s, %s, %s, %s)', + $table_column, + $is_full ? ', CASE WHEN table_type = \'VIEW\' THEN \'VIEW\' ELSE \'BASE TABLE\' END AS "Table_type"' : '', + $this->connection->quote( self::MYSQL_COLUMN_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_INDEX_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_CHECK_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_CHARSET_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_TABLE_METADATA_TABLE ) + ); + $params = array( $schema_name ); + + if ( null !== $like ) { + $sql .= " AND table_name LIKE ? ESCAPE '\\'"; + $params[] = $like; + } + + $where_expression_filter = $this->is_mysql_show_where_expression_filter( $where_filter ) ? $where_filter : null; + if ( null !== $where_filter && null === $where_expression_filter ) { + foreach ( $where_filter as $filter ) { + $sql .= sprintf( + ' AND %s', + $this->get_mysql_show_where_filter_condition_sql( + $this->get_show_tables_filter_column_expression( $filter['column'], $table_column ), + $filter + ) + ); + $params[] = $filter['value']; + } + } + + $sql .= ' +ORDER BY table_name'; + + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + if ( null !== $where_expression_filter ) { + $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + $rows = $this->filter_mysql_static_show_rows( $rows, $where_expression_filter ); + $this->set_mysql_associative_result_rows( $rows, $fetch_mode, ...$fetch_mode_args ); + } else { + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + $this->last_found_rows = count( $this->last_result ); + } + + return $this->last_result; + } + + /** + * Get the SQL expression that backs a MySQL SHOW TABLES output column. + * + * @param string $column MySQL output column name. + * @param string $table_column Quoted Tables_in_* output column. + * @return string SQL expression. + */ + private function get_show_tables_filter_column_expression( string $column, string $table_column ): string { + if ( $table_column === $this->connection->quote_identifier( $column ) ) { + return 'table_name'; + } + + if ( 'Table_type' === $column ) { + return "CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'BASE TABLE' END"; + } + + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + + /** + * Execute a MySQL SHOW TABLE STATUS statement through PostgreSQL catalogs. + * + * @param array $show_table_status_query SHOW TABLE STATUS options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW TABLE STATUS result rows. + */ + private function execute_show_table_status_query( array $show_table_status_query, $fetch_mode, ...$fetch_mode_args ) { + $columns = $this->get_show_table_status_result_columns(); + if ( 0 === strcasecmp( $show_table_status_query['database'], 'information_schema' ) ) { + return $this->set_mysql_static_show_result( $columns, array(), $fetch_mode, ...$fetch_mode_args ); + } + + $rows = array(); + foreach ( $this->get_show_table_status_catalog_rows( $show_table_status_query['schema'] ) as $catalog_row ) { + $table_name = (string) $catalog_row['table_name']; + $identity_column = isset( $catalog_row['identity_column'] ) && null !== $catalog_row['identity_column'] + ? (string) $catalog_row['identity_column'] + : null; + + $rows[] = $this->get_show_table_status_result_row( + $table_name, + null === $identity_column + ? null + : $this->get_show_table_status_auto_increment_value( $table_name, $identity_column, $show_table_status_query['schema'] ), + (string) ( $catalog_row['table_comment'] ?? '' ) + ); + } + + $rows = $this->filter_show_table_status_rows( $rows, $show_table_status_query ); + + return $this->set_mysql_static_show_result( + $columns, + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Get MySQL SHOW TABLE STATUS result columns. + * + * @return string[] Column names. + */ + private function get_show_table_status_result_columns(): array { + return array( + 'Name', + 'Engine', + 'Version', + 'Row_format', + 'Rows', + 'Avg_row_length', + 'Data_length', + 'Max_data_length', + 'Index_length', + 'Data_free', + 'Auto_increment', + 'Create_time', + 'Update_time', + 'Check_time', + 'Collation', + 'Checksum', + 'Create_options', + 'Comment', + ); + } + + /** + * Execute a MySQL SHOW CREATE TABLE statement from stored MySQL schema metadata. + * + * @param array $show_create_table_query SHOW CREATE TABLE options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW CREATE TABLE result rows. + */ + private function execute_show_create_table_query( array $show_create_table_query, $fetch_mode, ...$fetch_mode_args ) { + $this->ensure_mysql_schema_metadata_tables(); + + $table_name = $show_create_table_query['table']; + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( + $show_create_table_query['schema'], + $table_name + ); + + if ( 0 === strcasecmp( $resolved_schema, 'information_schema' ) ) { + $create_statement = $this->get_direct_information_schema_create_table_statement( $table_name ); + if ( null === $create_statement ) { + return $this->set_mysql_static_show_result( + array( 'Table', 'Create Table' ), + array(), + $fetch_mode, + ...$fetch_mode_args + ); + } + + return $this->set_mysql_static_show_result( + array( 'Table', 'Create Table' ), + array( + array( + 'Table' => $table_name, + 'Create Table' => $create_statement, + ), + ), + $fetch_mode, + ...$fetch_mode_args + ); + } + + $cache_key = $this->get_mysql_introspection_result_cache_key( + 'show_create_table', + $fetch_mode, + array( $resolved_schema, $table_name, $fetch_mode, $fetch_mode_args ) + ); + if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { + return $this->last_result; + } + + $columns = $this->get_show_create_table_column_metadata_rows( $resolved_schema, $table_name ); + if ( empty( $columns ) ) { + return $this->set_mysql_static_show_result( + array( 'Table', 'Create Table' ), + array(), + $fetch_mode, + ...$fetch_mode_args + ); + } + + $indexes = $this->get_show_create_table_index_metadata_rows( $resolved_schema, $table_name ); + $foreign_keys = $this->get_show_create_table_foreign_key_metadata_rows( $resolved_schema, $table_name ); + $checks = $this->get_show_create_table_check_constraint_metadata_rows( $resolved_schema, $table_name ); + $table_comment = $this->get_show_create_table_table_comment_metadata( $resolved_schema, $table_name ); + $create_statement = $this->get_mysql_create_table_statement_from_metadata( + $table_name, + $columns, + $indexes, + $foreign_keys, + $checks, + $table_comment, + $this->is_mysql_temporary_schema_name( $resolved_schema ) + ); + $rows = array( + array( + 'Table' => $table_name, + 'Create Table' => $create_statement, + ), + ); + + $result = $this->set_mysql_static_show_result( + array( 'Table', 'Create Table' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + + $this->store_mysql_introspection_result_in_cache( $cache_key ); + + return $result; + } + + /** + * Get column metadata rows for SHOW CREATE TABLE. + * + * @param string $schema_name Backend metadata schema. + * @param string $table_name Table name. + * @return array[] Column metadata rows. + */ + private function get_show_create_table_column_metadata_rows( string $schema_name, string $table_name ): array { + $sql = sprintf( + 'SELECT column_name, ordinal_position, column_type, character_set_name, collation_name, is_nullable, column_default, extra, column_comment + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY ordinal_position', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ); + $params = array( $schema_name, $table_name ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Get index metadata rows for SHOW CREATE TABLE. + * + * @param string $schema_name Backend metadata schema. + * @param string $table_name Table name. + * @return array[] Index metadata rows. + */ + private function get_show_create_table_index_metadata_rows( string $schema_name, string $table_name ): array { + $sql = sprintf( + 'SELECT key_name, index_ordinal, seq_in_index, column_name, non_unique, index_type, "collation" AS "collation", sub_part, index_comment + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY + key_name = \'PRIMARY\' DESC, + non_unique = \'0\' DESC, + index_type = \'SPATIAL\' DESC, + index_type = \'BTREE\' DESC, + index_type = \'FULLTEXT\' DESC, + index_ordinal, + seq_in_index', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ); + $params = array( $schema_name, $table_name ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Get foreign key metadata rows for SHOW CREATE TABLE. + * + * @param string $schema_name Backend metadata schema. + * @param string $table_name Table name. + * @return array[] Foreign key metadata rows. + */ + private function get_show_create_table_foreign_key_metadata_rows( string $schema_name, string $table_name ): array { + $sql = sprintf( + 'SELECT constraint_name, constraint_ordinal, seq_in_index, column_name, referenced_table_schema, referenced_table_name, referenced_column_name, update_rule, delete_rule + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY constraint_name, seq_in_index', + $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ); + $params = array( $schema_name, $table_name ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Get CHECK constraint metadata rows for SHOW CREATE TABLE. + * + * @param string $schema_name Backend metadata schema. + * @param string $table_name Table name. + * @return array[] CHECK constraint metadata rows. + */ + private function get_show_create_table_check_constraint_metadata_rows( string $schema_name, string $table_name ): array { + $sql = sprintf( + 'SELECT constraint_name, constraint_ordinal, check_clause, enforced + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY constraint_ordinal, constraint_name', + $this->connection->quote_identifier( self::MYSQL_CHECK_METADATA_TABLE ) + ); + $params = array( $schema_name, $table_name ); + $stmt = $this->connection->query( $sql, $params ); + + $metadata_rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + if ( ! empty( $metadata_rows ) ) { + return $metadata_rows; + } + + $catalog_sql = 'SELECT + tc.constraint_name, + cc.check_clause, + \'YES\' AS enforced + FROM information_schema.table_constraints tc + INNER JOIN information_schema.check_constraints cc + ON cc.constraint_schema = tc.constraint_schema + AND cc.constraint_name = tc.constraint_name + WHERE tc.table_schema = ? + AND tc.table_name = ? + AND tc.constraint_type = ? + ORDER BY tc.constraint_name'; + $catalog_params = array( $schema_name, $table_name, 'CHECK' ); + + try { + $stmt = $this->connection->query( $catalog_sql, $catalog_params ); + } catch ( PDOException $e ) { + return array(); + } + + $this->last_postgresql_queries[] = array( + 'sql' => $catalog_sql, + 'params' => $catalog_params, + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Get table comment metadata for SHOW CREATE TABLE. + * + * @param string $schema_name Backend metadata schema. + * @param string $table_name Table name. + * @return string Table comment. + */ + private function get_show_create_table_table_comment_metadata( string $schema_name, string $table_name ): string { + $sql = sprintf( + 'SELECT table_comment + FROM %s + WHERE table_schema = ? AND table_name = ? + LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_TABLE_METADATA_TABLE ) + ); + $params = array( $schema_name, $table_name ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + $comment = $stmt->fetchColumn(); + return false === $comment ? '' : (string) $comment; + } + + /** + * Build a MySQL CREATE TABLE statement from stored MySQL metadata rows. + * + * @param string $table_name Table name. + * @param array[] $columns Column metadata rows. + * @param array[] $indexes Index metadata rows. + * @param array[] $foreign_keys Foreign key metadata rows. + * @param array[] $checks CHECK constraint metadata rows. + * @param string $table_comment Table comment. + * @return string MySQL-compatible CREATE TABLE statement. + */ + private function get_mysql_create_table_statement_from_metadata( string $table_name, array $columns, array $indexes, array $foreign_keys, array $checks, string $table_comment = '', bool $temporary = false ): string { + $definitions = array(); + foreach ( $columns as $column ) { + $definitions[] = $this->get_mysql_create_table_column_definition_from_metadata( $column ); + } + + foreach ( $this->group_show_create_table_index_metadata_rows( $indexes ) as $index ) { + $definitions[] = $this->get_mysql_create_table_index_definition_from_metadata( $index ); + } + + foreach ( $this->group_show_create_table_foreign_key_metadata_rows( $foreign_keys ) as $foreign_key ) { + $definitions[] = $this->get_mysql_create_table_foreign_key_definition_from_metadata( $foreign_key ); + } + + foreach ( $checks as $check ) { + $definitions[] = $this->get_mysql_create_table_check_constraint_definition_from_metadata( $check ); + } + + $collation = $this->get_mysql_create_table_collation_from_metadata( $columns ); + $charset = $this->get_mysql_charset_from_collation( $collation ); + + $sql = sprintf( + "CREATE %sTABLE %s (\n%s\n) ENGINE=InnoDB DEFAULT CHARSET=%s COLLATE=%s", + $temporary ? 'TEMPORARY ' : '', + $this->quote_mysql_identifier( $table_name ), + implode( ",\n", $definitions ), + $charset, + $collation + ); + + if ( '' !== $table_comment ) { + $sql .= ' COMMENT=' . $this->quote_mysql_utf8_string_literal( $table_comment ); + } + + return $sql; + } + + /** + * Check whether a backend schema name represents the active temporary table namespace. + * + * @param string $schema_name Backend schema name. + * @return bool Whether the schema is temporary. + */ + private function is_mysql_temporary_schema_name( string $schema_name ): bool { + return 0 === strcasecmp( $schema_name, 'temp' ) + || 0 === strcasecmp( $schema_name, 'pg_temp' ) + || 1 === preg_match( '/^pg_temp_[0-9]+$/i', $schema_name ); + } + + /** + * Build one MySQL CHECK constraint definition from stored metadata. + * + * @param array $check CHECK constraint metadata row. + * @return string CHECK constraint definition SQL. + */ + private function get_mysql_create_table_check_constraint_definition_from_metadata( array $check ): string { + $sql = sprintf( + ' CONSTRAINT %s CHECK (%s)', + $this->quote_mysql_identifier( (string) $check['constraint_name'] ), + (string) $check['check_clause'] + ); + + if ( 'NO' === strtoupper( (string) ( $check['enforced'] ?? 'YES' ) ) ) { + $sql .= ' /*!80016 NOT ENFORCED */'; + } + + return $sql; + } + + /** + * Build one MySQL column definition from stored metadata. + * + * @param array $column Column metadata row. + * @return string Column definition SQL. + */ + private function get_mysql_create_table_column_definition_from_metadata( array $column ): string { + $extra = (string) ( $column['extra'] ?? '' ); + $sql = sprintf( + ' %s %s', + $this->quote_mysql_identifier( (string) $column['column_name'] ), + (string) $column['column_type'] + ); + + if ( 'NO' === strtoupper( (string) $column['is_nullable'] ) ) { + $sql .= ' NOT NULL'; + } + + if ( false !== stripos( $extra, 'auto_increment' ) ) { + $sql .= ' AUTO_INCREMENT'; + } + + if ( null !== $column['column_default'] ) { + $default = (string) $column['column_default']; + if ( $this->is_mysql_current_timestamp_default_metadata( $default ) ) { + $sql .= ' DEFAULT ' . $default; + } elseif ( $this->mysql_column_extra_has_default_generated( $extra ) ) { + $sql .= ' DEFAULT (' . $default . ')'; + } else { + $sql .= ' DEFAULT ' . $this->quote_mysql_utf8_string_literal( $default ); + } + } elseif ( 'NO' !== strtoupper( (string) $column['is_nullable'] ) ) { + $sql .= ' DEFAULT NULL'; + } + + if ( $this->mysql_column_extra_has_on_update_current_timestamp( $extra ) ) { + $sql .= ' ON UPDATE CURRENT_TIMESTAMP'; + } + + if ( '' !== (string) ( $column['column_comment'] ?? '' ) ) { + $sql .= ' COMMENT ' . $this->quote_mysql_utf8_string_literal( (string) $column['column_comment'] ); + } + + return $sql; + } + + /** + * Check whether stored default metadata is a MySQL current timestamp expression. + * + * @param string $default_value Default metadata. + * @return bool Whether the default should render unquoted. + */ + private function is_mysql_current_timestamp_default_metadata( string $default_value ): bool { + return 1 === preg_match( '/^current_timestamp(?:\((?:[0-6])?\))?$/i', $default_value ); + } + + /** + * Group stored index metadata rows by index name. + * + * @param array[] $indexes Index metadata rows. + * @return array[] Grouped index metadata rows. + */ + private function group_show_create_table_index_metadata_rows( array $indexes ): array { + $grouped = array(); + foreach ( $indexes as $index ) { + $key_name = (string) $index['key_name']; + if ( ! isset( $grouped[ $key_name ] ) ) { + $grouped[ $key_name ] = array(); + } + + $grouped[ $key_name ][] = $index; + } + + return array_values( $grouped ); + } + + /** + * Build one MySQL key definition from grouped stored metadata. + * + * @param array[] $index Grouped index metadata rows. + * @return string Key definition SQL. + */ + private function get_mysql_create_table_index_definition_from_metadata( array $index ): string { + $first = $index[0]; + if ( 'PRIMARY' === strtoupper( (string) $first['key_name'] ) ) { + $sql = sprintf( + ' PRIMARY KEY (%s)', + implode( ', ', $this->get_mysql_create_table_index_column_definitions( $index ) ) + ); + } else { + $sql = sprintf( + ' %s%sKEY %s (%s)', + '0' === (string) $first['non_unique'] ? 'UNIQUE ' : '', + 'BTREE' !== strtoupper( (string) $first['index_type'] ) ? strtoupper( (string) $first['index_type'] ) . ' ' : '', + $this->quote_mysql_identifier( (string) $first['key_name'] ), + implode( ', ', $this->get_mysql_create_table_index_column_definitions( $index ) ) + ); + } + + if ( '' !== (string) ( $first['index_comment'] ?? '' ) ) { + $sql .= ' COMMENT ' . $this->quote_mysql_utf8_string_literal( (string) $first['index_comment'] ); + } + + return $sql; + } + + /** + * Build quoted MySQL key part definitions from grouped index metadata rows. + * + * @param array[] $index Grouped index metadata rows. + * @return string[] Key part definitions. + */ + private function get_mysql_create_table_index_column_definitions( array $index ): array { + $columns = array(); + foreach ( $index as $column ) { + $definition = $this->quote_mysql_identifier( (string) $column['column_name'] ); + if ( null !== $column['sub_part'] ) { + $definition .= sprintf( '(%d)', (int) $column['sub_part'] ); + } + if ( 'D' === strtoupper( (string) ( $column['collation'] ?? '' ) ) ) { + $definition .= ' DESC'; + } + + $columns[] = $definition; + } + + return $columns; + } + + /** + * Group stored foreign key metadata rows by constraint name. + * + * @param array[] $foreign_keys Foreign key metadata rows. + * @return array[] Grouped foreign key metadata rows. + */ + private function group_show_create_table_foreign_key_metadata_rows( array $foreign_keys ): array { + $grouped = array(); + foreach ( $foreign_keys as $foreign_key ) { + $constraint_name = (string) $foreign_key['constraint_name']; + if ( ! isset( $grouped[ $constraint_name ] ) ) { + $grouped[ $constraint_name ] = array(); + } + + $grouped[ $constraint_name ][] = $foreign_key; + } + + return array_values( $grouped ); + } + + /** + * Build one MySQL foreign key definition from grouped stored metadata. + * + * @param array[] $foreign_key Grouped foreign key metadata rows. + * @return string Foreign key definition SQL. + */ + private function get_mysql_create_table_foreign_key_definition_from_metadata( array $foreign_key ): string { + $first = $foreign_key[0]; + $columns = array(); + $referenced_columns = array(); + + foreach ( $foreign_key as $column ) { + $columns[] = $this->quote_mysql_identifier( (string) $column['column_name'] ); + $referenced_columns[] = $this->quote_mysql_identifier( (string) $column['referenced_column_name'] ); + } + + $sql = sprintf( + ' CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)', + $this->quote_mysql_identifier( (string) $first['constraint_name'] ), + implode( ', ', $columns ), + $this->quote_mysql_identifier( (string) $first['referenced_table_name'] ), + implode( ', ', $referenced_columns ) + ); + + if ( 'NO ACTION' !== strtoupper( (string) $first['delete_rule'] ) ) { + $sql .= ' ON DELETE ' . strtoupper( (string) $first['delete_rule'] ); + } + + if ( 'NO ACTION' !== strtoupper( (string) $first['update_rule'] ) ) { + $sql .= ' ON UPDATE ' . strtoupper( (string) $first['update_rule'] ); + } + + return $sql; + } + + /** + * Get a table collation for SHOW CREATE TABLE from column metadata. + * + * @param array[] $columns Column metadata rows. + * @return string MySQL collation. + */ + private function get_mysql_create_table_collation_from_metadata( array $columns ): string { + foreach ( $columns as $column ) { + if ( ! empty( $column['collation_name'] ) ) { + return (string) $column['collation_name']; + } + } + + return $this->collation; + } + + /** + * Get a MySQL charset name from a collation. + * + * @param string $collation MySQL collation. + * @return string MySQL charset. + */ + private function get_mysql_charset_from_collation( string $collation ): string { + $underscore_position = strpos( $collation, '_' ); + if ( false === $underscore_position ) { + return $collation; + } + + return substr( $collation, 0, $underscore_position ); + } + + /** + * Quote an identifier for use in a MySQL query. + * + * @param string $identifier Unquoted identifier value. + * @return string Quoted identifier. + */ + private function quote_mysql_identifier( string $identifier ): string { + return '`' . str_replace( '`', '``', $identifier ) . '`'; + } + + /** + * Quote a MySQL UTF-8 string literal for SHOW CREATE TABLE output. + * + * @param string $literal Literal value. + * @return string Quoted 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 ) . "'"; + } + + /** + * Get base table rows used by SHOW TABLE STATUS. + * + * @param string $schema_name Backend schema name. + * @return array[] Catalog rows. + */ + private function get_show_table_status_catalog_rows( string $schema_name ): array { + $this->ensure_mysql_schema_metadata_tables(); + + $sql = sprintf( + 'SELECT + t.table_name, + COALESCE(tm.table_comment, \'\') AS table_comment, + ( + SELECT c.column_name + FROM information_schema.columns c + WHERE c.table_schema = t.table_schema + AND c.table_name = t.table_name + AND ( + c.is_identity = \'YES\' + OR LOWER(COALESCE(c.column_default, \'\')) LIKE \'nextval(%%\' + ) + ORDER BY c.ordinal_position + LIMIT 1 + ) AS identity_column + FROM information_schema.tables t + LEFT JOIN %s tm + ON tm.table_schema = t.table_schema + AND tm.table_name = t.table_name + WHERE t.table_schema = ? + AND t.table_type = ? + AND t.table_name NOT IN (?, ?, ?, ?, ?, ?) + ORDER BY t.table_name', + $this->connection->quote_identifier( self::MYSQL_TABLE_METADATA_TABLE ) + ); + $params = array( + $schema_name, + 'BASE TABLE', + self::MYSQL_COLUMN_METADATA_TABLE, + self::MYSQL_INDEX_METADATA_TABLE, + self::MYSQL_FOREIGN_KEY_METADATA_TABLE, + self::MYSQL_CHECK_METADATA_TABLE, + self::MYSQL_CHARSET_METADATA_TABLE, + self::MYSQL_TABLE_METADATA_TABLE, + ); + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Build a MySQL-shaped SHOW TABLE STATUS row. + * + * @param string $table_name Table name. + * @param string|null $auto_increment Next auto-increment value, or null. + * @param string $comment Table comment. + * @return array MySQL-shaped row. + */ + private function get_show_table_status_result_row( string $table_name, ?string $auto_increment, string $comment = '' ): array { + return array( + 'Name' => $table_name, + 'Engine' => 'InnoDB', + 'Version' => '10', + 'Row_format' => 'Dynamic', + 'Rows' => '0', + 'Avg_row_length' => '0', + 'Data_length' => '0', + 'Max_data_length' => '0', + 'Index_length' => '0', + 'Data_free' => '0', + 'Auto_increment' => $auto_increment, + 'Create_time' => gmdate( 'Y-m-d H:i:s' ), + 'Update_time' => null, + 'Check_time' => null, + 'Collation' => $this->collation, + 'Checksum' => null, + 'Create_options' => '', + 'Comment' => $comment, + ); + } + + /** + * Get the next MySQL-compatible AUTO_INCREMENT value for a table. + * + * @param string $table_name Table name. + * @param string $identity_column Identity column name. + * @param string $table_schema Backend schema name. + * @return string|null Next AUTO_INCREMENT value, or null when unavailable. + */ + private function get_show_table_status_auto_increment_value( string $table_name, string $identity_column, string $table_schema = 'public' ): ?string { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + if ( 'pgsql' === $driver_name ) { + return $this->get_postgresql_show_table_status_auto_increment_value( $table_schema, $table_name, $identity_column ); + } + + if ( 'sqlite' === $driver_name ) { + return $this->get_sqlite_show_table_status_auto_increment_value( $table_name ); + } + + return null; + } + + /** + * Get the next AUTO_INCREMENT value from PostgreSQL identity sequence state. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param string $identity_column Identity column name. + * @return string|null Next AUTO_INCREMENT value, or null when unavailable. + */ + private function get_postgresql_show_table_status_auto_increment_value( string $table_schema, string $table_name, string $identity_column ): ?string { + $sequence_sql = 'SELECT + seq_ns.nspname AS sequence_schema, + seq.relname AS sequence_name + FROM ( + SELECT pg_catalog.pg_get_serial_sequence(format(\'%I.%I\', ?, ?), ?)::regclass AS sequence_oid + ) identity_sequence + LEFT JOIN pg_catalog.pg_class seq + ON seq.oid = identity_sequence.sequence_oid + LEFT JOIN pg_catalog.pg_namespace seq_ns + ON seq_ns.oid = seq.relnamespace'; + $stmt = $this->connection->query( + $sequence_sql, + array( $table_schema, $table_name, $identity_column ) + ); + $this->last_postgresql_queries[] = array( + 'sql' => $sequence_sql, + 'params' => array( $table_schema, $table_name, $identity_column ), + ); + + $sequence = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( + false === $sequence + || empty( $sequence['sequence_schema'] ) + || empty( $sequence['sequence_name'] ) + ) { + return '1'; + } + + $sequence_identifier = $this->get_postgresql_qualified_identifier( + (string) $sequence['sequence_schema'], + (string) $sequence['sequence_name'] + ); + $table_identifier = $this->get_postgresql_qualified_identifier( $table_schema, $table_name ); + $sql = sprintf( + 'WITH sequence_state AS ( + SELECT last_value, is_called FROM %1$s + ), + table_state AS ( + SELECT MAX(%2$s) AS max_identity_value FROM %3$s + ) + SELECT GREATEST( + CASE WHEN sequence_state.is_called THEN sequence_state.last_value + 1 ELSE sequence_state.last_value END, + COALESCE(table_state.max_identity_value + 1, 1) + ) AS auto_increment + FROM sequence_state, table_state', + $sequence_identifier, + $this->connection->quote_identifier( $identity_column ), + $table_identifier + ); + $stmt = $this->connection->query( $sql ); + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => array(), + ); + + $value = $stmt->fetchColumn(); + return false === $value ? '1' : (string) $value; + } + + /** + * Get the next AUTO_INCREMENT value from SQLite sequence state in tests. + * + * @param string $table_name Table name. + * @return string Next AUTO_INCREMENT value. + */ + private function get_sqlite_show_table_status_auto_increment_value( string $table_name ): string { + $sequence_table_sql = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence' LIMIT 1"; + $has_sequence_table = $this->connection->query( $sequence_table_sql )->fetchColumn(); + $this->last_postgresql_queries[] = array( + 'sql' => $sequence_table_sql, + 'params' => array(), + ); + + if ( false === $has_sequence_table ) { + return '1'; + } + + $stmt = $this->connection->query( + 'SELECT seq + 1 FROM sqlite_sequence WHERE name = ?', + array( $table_name ) + ); + $this->last_postgresql_queries[] = array( + 'sql' => 'SELECT seq + 1 FROM sqlite_sequence WHERE name = ?', + 'params' => array( $table_name ), + ); + + $value = $stmt->fetchColumn(); + return false === $value ? '1' : (string) $value; + } + + /** + * Filter SHOW TABLE STATUS rows with a parsed filter. + * + * @param array[] $rows SHOW TABLE STATUS rows. + * @param array $show_table_status_query Parsed SHOW TABLE STATUS options. + * @return array[] Filtered rows. + */ + private function filter_show_table_status_rows( array $rows, array $show_table_status_query ): array { + if ( 'all' === $show_table_status_query['filter_type'] ) { + return $rows; + } + + if ( 'where' === $show_table_status_query['filter_type'] ) { + return $this->filter_mysql_static_show_rows( + $rows, + array( + 'type' => 'where', + 'conditions' => $show_table_status_query['conditions'] ?? array(), + ) + ); + } + + if ( 'where_expression' === $show_table_status_query['filter_type'] ) { + return $this->filter_mysql_static_show_rows( + $rows, + array( + 'type' => 'where_expression', + 'predicate' => $show_table_status_query['predicate'] ?? null, + ) + ); + } + + return array_values( + array_filter( + $rows, + function ( array $row ) use ( $show_table_status_query ): bool { + if ( 'like' === $show_table_status_query['filter_type'] ) { + $column = $show_table_status_query['filter_column'] ?? 'Name'; + return null !== $show_table_status_query['filter_pattern'] + && array_key_exists( $column, $row ) + && null !== $row[ $column ] + && $this->matches_mysql_like_pattern( (string) $row[ $column ], $show_table_status_query['filter_pattern'] ); + } + + if ( 'exact' === $show_table_status_query['filter_type'] ) { + $column = $show_table_status_query['filter_column']; + $pattern = $show_table_status_query['filter_pattern']; + return null !== $column + && null !== $pattern + && array_key_exists( $column, $row ) + && null !== $row[ $column ] + && 0 === strcasecmp( (string) $row[ $column ], $pattern ); + } + + if ( 'auto_increment_gt' === $show_table_status_query['filter_type'] ) { + return null !== $row['Auto_increment'] + && null !== $show_table_status_query['filter_threshold'] + && $this->is_unsigned_integer_string_greater_than( + (string) $row['Auto_increment'], + $show_table_status_query['filter_threshold'] + ); + } + + return 'auto_increment_is_null' === $show_table_status_query['filter_type'] + && null === $row['Auto_increment']; + } + ) + ); + } + + /** + * Compare two unsigned integer strings without losing precision. + * + * @param string $left Left integer. + * @param string $right Right integer. + * @return bool Whether left is greater than right. + */ + private function is_unsigned_integer_string_greater_than( string $left, string $right ): bool { + $left = ltrim( $left, '0' ); + $right = ltrim( $right, '0' ); + $left = '' === $left ? '0' : $left; + $right = '' === $right ? '0' : $right; + + if ( strlen( $left ) !== strlen( $right ) ) { + return strlen( $left ) > strlen( $right ); + } + + return strcmp( $left, $right ) > 0; + } + + /** + * Execute a simple MySQL variable SELECT query from emulated variable state. + * + * @param array $mysql_variable_select_query Parsed variable SELECT query. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed Variable SELECT result rows. + */ + private function execute_mysql_variable_select_query( array $mysql_variable_select_query, $fetch_mode, ...$fetch_mode_args ) { + return $this->set_mysql_static_show_result( + $mysql_variable_select_query['columns'], + array( $mysql_variable_select_query['row'] ), + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Execute a MySQL SHOW VARIABLES statement from emulated session state. + * + * @param array $show_variables_query SHOW VARIABLES options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW VARIABLES result rows. + */ + private function execute_show_variables_query( array $show_variables_query, $fetch_mode, ...$fetch_mode_args ) { + $variables = 'global' === ( $show_variables_query['scope'] ?? 'session' ) + ? $this->get_mysql_global_variables() + : $this->get_mysql_session_variables(); + $rows = array(); + + foreach ( $variables as $variable_name => $value ) { + $rows[] = array( + 'Variable_name' => $variable_name, + 'Value' => $value, + ); + } + + $rows = $this->filter_mysql_static_show_rows( $rows, $show_variables_query ); + + $this->last_found_rows = 0; + $this->last_column_meta = array( + array( + 'name' => 'Variable_name', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Variable_name', + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 64, + 'precision' => 0, + 'native_type' => 'string', + ), + array( + 'name' => 'Value', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Value', + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 1024, + 'precision' => 0, + 'native_type' => 'string', + ), + ); + + if ( PDO::FETCH_ASSOC === $fetch_mode ) { + $this->last_result = $rows; + return $this->last_result; + } + + if ( PDO::FETCH_NUM === $fetch_mode ) { + $this->last_result = array_map( 'array_values', $rows ); + return $this->last_result; + } + + $this->last_result = array_map( + static function ( array $row ) { + return (object) $row; + }, + $rows + ); + + return $this->last_result; + } + + /** + * Get static MySQL-compatible character set metadata rows. + * + * @return array[] Rows keyed by information_schema.CHARACTER_SETS columns. + */ + private function get_mysql_static_character_set_rows(): array { + return array( + array( + 'CHARACTER_SET_NAME' => 'binary', + 'DEFAULT_COLLATE_NAME' => 'binary', + 'DESCRIPTION' => 'Binary pseudo charset', + 'MAXLEN' => '1', + ), + array( + 'CHARACTER_SET_NAME' => 'utf8', + 'DEFAULT_COLLATE_NAME' => 'utf8_general_ci', + 'DESCRIPTION' => 'UTF-8 Unicode', + 'MAXLEN' => '3', + ), + array( + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'DEFAULT_COLLATE_NAME' => 'utf8mb4_0900_ai_ci', + 'DESCRIPTION' => 'UTF-8 Unicode', + 'MAXLEN' => '4', + ), + ); + } + + /** + * Get static MySQL-compatible collation metadata rows. + * + * @return array[] Rows keyed by information_schema.COLLATIONS columns. + */ + private function get_mysql_static_collation_rows(): array { + return array( + array( + 'COLLATION_NAME' => 'binary', + 'CHARACTER_SET_NAME' => 'binary', + 'ID' => '63', + 'IS_DEFAULT' => 'Yes', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'NO PAD', + ), + array( + 'COLLATION_NAME' => 'utf8_bin', + 'CHARACTER_SET_NAME' => 'utf8', + 'ID' => '83', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + array( + 'COLLATION_NAME' => 'utf8_general_ci', + 'CHARACTER_SET_NAME' => 'utf8', + 'ID' => '33', + 'IS_DEFAULT' => 'Yes', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + array( + 'COLLATION_NAME' => 'utf8_unicode_ci', + 'CHARACTER_SET_NAME' => 'utf8', + 'ID' => '192', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '8', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + array( + 'COLLATION_NAME' => 'utf8mb4_bin', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'ID' => '46', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + array( + 'COLLATION_NAME' => 'utf8mb4_unicode_ci', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'ID' => '224', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '8', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + array( + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'ID' => '255', + 'IS_DEFAULT' => 'Yes', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '0', + 'PAD_ATTRIBUTE' => 'NO PAD', + ), + ); + } + + /** + * Get static MySQL-compatible SHOW COLLATION rows. + * + * @return array[] SHOW COLLATION rows. + */ + private function get_mysql_static_show_collation_rows(): array { + $rows = array(); + foreach ( $this->get_mysql_static_collation_rows() as $row ) { + $rows[] = array( + 'Collation' => $row['COLLATION_NAME'], + 'Charset' => $row['CHARACTER_SET_NAME'], + 'Id' => $row['ID'], + 'Default' => $row['IS_DEFAULT'], + 'Compiled' => $row['IS_COMPILED'], + 'Sortlen' => $row['SORTLEN'], + 'Pad_attribute' => $row['PAD_ATTRIBUTE'], + ); + } + return $rows; + } + + /** + * Get static MySQL-compatible SHOW CHARACTER SET rows. + * + * @return array[] SHOW CHARACTER SET rows. + */ + private function get_mysql_static_show_character_set_rows(): array { + $rows = array(); + foreach ( $this->get_mysql_static_character_set_rows() as $row ) { + $rows[] = array( + 'Charset' => $row['CHARACTER_SET_NAME'], + 'Description' => $row['DESCRIPTION'], + 'Default collation' => $row['DEFAULT_COLLATE_NAME'], + 'Maxlen' => $row['MAXLEN'], + ); + } + return $rows; + } + + /** + * Get static MySQL-compatible SHOW ENGINES rows. + * + * @return array[] SHOW ENGINES rows. + */ + private function get_mysql_static_show_engine_rows(): array { + return array( + array( + 'Engine' => 'InnoDB', + 'Support' => 'DEFAULT', + 'Comment' => 'Supports transactions, row-level locking, and foreign keys', + 'Transactions' => 'YES', + 'XA' => 'YES', + 'Savepoints' => 'YES', + ), + array( + 'Engine' => 'MEMORY', + 'Support' => 'YES', + 'Comment' => 'Hash based, stored in memory, useful for temporary tables', + 'Transactions' => 'NO', + 'XA' => 'NO', + 'Savepoints' => 'NO', + ), + array( + 'Engine' => 'MyISAM', + 'Support' => 'YES', + 'Comment' => 'MyISAM storage engine', + 'Transactions' => 'NO', + 'XA' => 'NO', + 'Savepoints' => 'NO', + ), + ); + } + + /** + * Execute a MySQL SHOW CHARACTER SET statement from static MySQL-compatible metadata. + * + * @param array $show_character_set_query SHOW CHARACTER SET options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW CHARACTER SET result rows. + */ + private function execute_show_character_set_query( array $show_character_set_query, $fetch_mode, ...$fetch_mode_args ) { + $rows = $this->filter_mysql_static_show_rows( + $this->get_mysql_static_show_character_set_rows(), + $show_character_set_query + ); + + return $this->set_mysql_static_show_result( + array( 'Charset', 'Description', 'Default collation', 'Maxlen' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Execute a MySQL SHOW COLLATION statement from static MySQL-compatible metadata. + * + * @param array $show_collation_query SHOW COLLATION options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW COLLATION result rows. + */ + private function execute_show_collation_query( array $show_collation_query, $fetch_mode, ...$fetch_mode_args ) { + $rows = $this->filter_mysql_static_show_rows( + $this->get_mysql_static_show_collation_rows(), + $show_collation_query + ); + + return $this->set_mysql_static_show_result( + array( 'Collation', 'Charset', 'Id', 'Default', 'Compiled', 'Sortlen', 'Pad_attribute' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Execute a MySQL SHOW DATABASES/SHOW SCHEMAS statement from emulated database metadata. + * + * @param array $show_databases_query SHOW DATABASES options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW DATABASES result rows. + */ + private function execute_show_databases_query( array $show_databases_query, $fetch_mode, ...$fetch_mode_args ) { + $rows = $this->filter_mysql_static_show_rows( + array( + array( 'Database' => 'information_schema' ), + array( 'Database' => $this->main_db_name ), + ), + $show_databases_query + ); + + return $this->set_mysql_static_show_result( + array( 'Database' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Execute a MySQL SHOW CREATE DATABASE/SCHEMA statement from emulated metadata. + * + * @param array $show_create_database_query SHOW CREATE DATABASE options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW CREATE DATABASE result rows. + */ + private function execute_show_create_database_query( array $show_create_database_query, $fetch_mode, ...$fetch_mode_args ) { + $database = (string) $show_create_database_query['database']; + $if_not_exists = ! empty( $show_create_database_query['if_not_exists'] ); + if ( 0 !== strcasecmp( $database, $this->main_db_name ) && 0 !== strcasecmp( $database, 'information_schema' ) ) { + $rows = array(); + } else { + $rows = array( + array( + 'Database' => $database, + 'Create Database' => sprintf( + 'CREATE DATABASE %s%s DEFAULT CHARACTER SET %s COLLATE %s', + $if_not_exists ? 'IF NOT EXISTS ' : '', + $this->quote_mysql_identifier( $database ), + $this->charset, + $this->collation + ), + ), + ); + } + + return $this->set_mysql_static_show_result( + array( 'Database', 'Create Database' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Execute a MySQL SHOW ENGINES statement from static MySQL-compatible metadata. + * + * @param array $show_engines_query SHOW ENGINES options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW ENGINES result rows. + */ + private function execute_show_engines_query( array $show_engines_query, $fetch_mode, ...$fetch_mode_args ) { + $rows = $this->filter_mysql_static_show_rows( + $this->get_mysql_static_show_engine_rows(), + $show_engines_query + ); + + return $this->set_mysql_static_show_result( + array( 'Engine', 'Support', 'Comment', 'Transactions', 'XA', 'Savepoints' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Execute a MySQL SHOW PLUGINS statement from static MySQL-compatible metadata. + * + * The PostgreSQL adapter does not load MySQL plugins. Return the compatible + * empty result shape, matching the empty information_schema.plugins shim. + * + * @param array $show_plugins_query SHOW PLUGINS options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW PLUGINS result rows. + */ + private function execute_show_plugins_query( array $show_plugins_query, $fetch_mode, ...$fetch_mode_args ) { + $rows = $this->filter_mysql_static_show_rows( array(), $show_plugins_query ); + + $this->last_found_rows = 0; + return $this->set_mysql_static_show_result( + array( 'Name', 'Status', 'Type', 'Library', 'License' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Execute a MySQL SHOW GRANTS statement from static MySQL-compatible metadata. + * + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW GRANTS result rows. + */ + private function execute_show_grants_query( $fetch_mode, ...$fetch_mode_args ) { + $this->last_found_rows = 1; + + $result = $this->set_mysql_static_show_result( + array( self::MYSQL_SHOW_GRANTS_COLUMN ), + array( + array( + self::MYSQL_SHOW_GRANTS_COLUMN => self::MYSQL_SHOW_GRANTS_VALUE, + ), + ), + $fetch_mode, + ...$fetch_mode_args + ); + $this->last_column_meta[0]['len'] = 4096; + + return $result; + } + + /** + * Execute a MySQL SHOW STATUS statement from bounded static status rows. + * + * @param array $show_status_query SHOW STATUS options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW STATUS result rows. + */ + private function execute_show_status_query( array $show_status_query, $fetch_mode, ...$fetch_mode_args ) { + $rows = array(); + foreach ( $this->get_mysql_status_variables() as $variable_name => $value ) { + $rows[] = array( + 'Variable_name' => $variable_name, + 'Value' => $value, + ); + } + + $rows = $this->filter_mysql_static_show_rows( $rows, $show_status_query ); + + $this->last_found_rows = count( $rows ); + return $this->set_mysql_static_show_result( + array( 'Variable_name', 'Value' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Execute a MySQL SHOW WARNINGS/ERRORS statement. + * + * PostgreSQL errors are not accumulated in a MySQL diagnostics area. Returning + * an empty diagnostics set keeps admin/WP-CLI callers from issuing unsupported + * backend SHOW statements while preserving the MySQL result shape. + * + * @param array $show_diagnostics_query SHOW WARNINGS/ERRORS options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW diagnostics result rows. + */ + private function execute_show_diagnostics_query( array $show_diagnostics_query, $fetch_mode, ...$fetch_mode_args ) { + if ( 'count' === $show_diagnostics_query['type'] ) { + $this->last_found_rows = 1; + return $this->set_mysql_static_show_result( + array( $show_diagnostics_query['count_column'] ), + array( + array( + $show_diagnostics_query['count_column'] => '0', + ), + ), + $fetch_mode, + ...$fetch_mode_args + ); + } + + $this->last_found_rows = 0; + return $this->set_mysql_static_show_result( + array( 'Level', 'Code', 'Message' ), + array(), + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Execute a MySQL SHOW PROCESSLIST statement from the current emulated session. + * + * @param array $show_processlist_query SHOW PROCESSLIST options. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW PROCESSLIST result rows. + */ + private function execute_show_processlist_query( array $show_processlist_query, $fetch_mode, ...$fetch_mode_args ) { + $info = (string) $this->last_mysql_query; + if ( ! $show_processlist_query['full'] && strlen( $info ) > 100 ) { + $info = substr( $info, 0, 100 ); + } + + $rows = array( + array( + 'Id' => '1', + 'User' => 'root', + 'Host' => 'localhost', + 'db' => $this->db_name, + 'Command' => 'Query', + 'Time' => '0', + 'State' => '', + 'Info' => $info, + ), + ); + + if ( isset( $show_processlist_query['where_filter'] ) && is_array( $show_processlist_query['where_filter'] ) ) { + $rows = $this->filter_mysql_static_show_rows( $rows, $show_processlist_query['where_filter'] ); + } + + if ( isset( $show_processlist_query['limit'] ) && is_array( $show_processlist_query['limit'] ) ) { + $rows = array_slice( + $rows, + $show_processlist_query['limit']['offset'], + $show_processlist_query['limit']['count'] + ); + } + + return $this->set_mysql_static_show_result( + array( 'Id', 'User', 'Host', 'db', 'Command', 'Time', 'State', 'Info' ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * Filter static SHOW rows with a parsed MySQL LIKE or WHERE filter. + * + * @param array[] $rows Rows keyed by output column names. + * @param array $show_filter Parsed SHOW filter. + * @return array[] Filtered rows. + */ + private function filter_mysql_static_show_rows( array $rows, array $show_filter ): array { + if ( 'all' === $show_filter['type'] ) { + return $rows; + } + + if ( 'where' === $show_filter['type'] ) { + $conditions = $show_filter['conditions'] ?? array(); + return array_values( + array_filter( + $rows, + function ( array $row ) use ( $conditions ): bool { + foreach ( $conditions as $condition ) { + $column = $condition['column'] ?? null; + $value = $condition['value'] ?? null; + if ( null === $column || null === $value || ! array_key_exists( $column, $row ) ) { + return false; + } + + if ( 'like' === ( $condition['operator'] ?? null ) ) { + if ( ! $this->matches_mysql_like_pattern( (string) $row[ $column ], (string) $value ) ) { + return false; + } + continue; + } + + if ( 0 !== strcasecmp( (string) $row[ $column ], (string) $value ) ) { + return false; + } + } + + return true; + } + ) + ); + } + + if ( 'where_expression' === $show_filter['type'] ) { + $predicate = $show_filter['predicate'] ?? null; + if ( ! is_array( $predicate ) ) { + return array(); + } + + return array_values( + array_filter( + $rows, + function ( array $row ) use ( $predicate ): bool { + return $this->evaluate_mysql_show_where_predicate( $predicate, $row ); + } + ) + ); + } + + $column = $show_filter['column']; + $pattern = $show_filter['pattern']; + + return array_values( + array_filter( + $rows, + function ( array $row ) use ( $show_filter, $column, $pattern ): bool { + if ( null === $column || null === $pattern || ! array_key_exists( $column, $row ) ) { + return false; + } + + if ( 'like' === $show_filter['type'] ) { + return $this->matches_mysql_like_pattern( (string) $row[ $column ], $pattern ); + } + + return 0 === strcasecmp( (string) $row[ $column ], $pattern ); + } + ) + ); + } + + /** + * Evaluate a SHOW WHERE predicate against one materialized SHOW row. + * + * @param array $predicate Predicate AST. + * @param array $row SHOW output row keyed by column names. + * @return bool Whether the row matches. + */ + private function evaluate_mysql_show_where_predicate( array $predicate, array $row ): bool { + switch ( $predicate['type'] ?? null ) { + case 'and': + return $this->evaluate_mysql_show_where_predicate( $predicate['left'], $row ) + && $this->evaluate_mysql_show_where_predicate( $predicate['right'], $row ); + + case 'or': + return $this->evaluate_mysql_show_where_predicate( $predicate['left'], $row ) + || $this->evaluate_mysql_show_where_predicate( $predicate['right'], $row ); + + case 'not': + return ! $this->evaluate_mysql_show_where_predicate( $predicate['expr'], $row ); + + case 'truthy': + return $this->is_mysql_show_where_truthy( + $this->evaluate_mysql_show_where_value( $predicate['expr'], $row ) + ); + + case 'is_null': + $is_null = null === $this->evaluate_mysql_show_where_value( $predicate['expr'], $row ); + return ! empty( $predicate['not'] ) ? ! $is_null : $is_null; + + case 'comparison': + $operator = $predicate['operator'] ?? null; + $left = $this->evaluate_mysql_show_where_value( $predicate['left'], $row ); + $right = $this->evaluate_mysql_show_where_value( $predicate['right'], $row ); + return $this->evaluate_mysql_show_where_comparison( + $left, + $operator, + $right, + $this->mysql_show_where_value_expression_has_binary_modifier( $predicate['left'] ) + || $this->mysql_show_where_value_expression_has_binary_modifier( $predicate['right'] ), + $predicate['escape'] ?? null + ); + + case 'in': + $left = $this->evaluate_mysql_show_where_value( $predicate['expr'], $row ); + if ( null === $left ) { + return false; + } + + $left_is_binary = $this->mysql_show_where_value_expression_has_binary_modifier( $predicate['expr'] ); + $has_null = false; + foreach ( $predicate['values'] ?? array() as $value_expression ) { + $value = $this->evaluate_mysql_show_where_value( $value_expression, $row ); + if ( null === $value ) { + $has_null = true; + continue; + } + + if ( + $this->evaluate_mysql_show_where_comparison( + $left, + '=', + $value, + $left_is_binary || $this->mysql_show_where_value_expression_has_binary_modifier( $value_expression ) + ) + ) { + return empty( $predicate['not'] ); + } + } + + return ! empty( $predicate['not'] ) && ! $has_null; + + case 'between': + $value = $this->evaluate_mysql_show_where_value( $predicate['expr'], $row ); + $lower = $this->evaluate_mysql_show_where_value( $predicate['lower'], $row ); + $upper = $this->evaluate_mysql_show_where_value( $predicate['upper'], $row ); + if ( null === $value || null === $lower || null === $upper ) { + return false; + } + + $value_is_binary = $this->mysql_show_where_value_expression_has_binary_modifier( $predicate['expr'] ); + $matches = $this->compare_mysql_show_where_values( + $value, + $lower, + $value_is_binary || $this->mysql_show_where_value_expression_has_binary_modifier( $predicate['lower'] ) + ) >= 0 + && $this->compare_mysql_show_where_values( + $value, + $upper, + $value_is_binary || $this->mysql_show_where_value_expression_has_binary_modifier( $predicate['upper'] ) + ) <= 0; + return ! empty( $predicate['not'] ) ? ! $matches : $matches; + } + + return false; + } + + /** + * Evaluate one SHOW WHERE scalar value expression. + * + * @param array $expression Value expression AST. + * @param array $row SHOW output row keyed by column names. + * @return scalar|null Evaluated value. + */ + private function evaluate_mysql_show_where_value( array $expression, array $row ) { + switch ( $expression['type'] ?? null ) { + case 'column': + $column = $expression['column'] ?? null; + return is_string( $column ) && array_key_exists( $column, $row ) ? $row[ $column ] : null; + + case 'literal': + case 'number': + return $expression['value'] ?? null; + + case 'null': + return null; + + case 'function': + return $this->evaluate_mysql_show_where_function_value( $expression, $row ); + + case 'binary': + return isset( $expression['expr'] ) && is_array( $expression['expr'] ) + ? $this->evaluate_mysql_show_where_value( $expression['expr'], $row ) + : null; + + case 'arithmetic': + $left = $this->evaluate_mysql_show_where_value( $expression['left'], $row ); + $right = $this->evaluate_mysql_show_where_value( $expression['right'], $row ); + return $this->evaluate_mysql_show_where_arithmetic_value( + $left, + $expression['operator'] ?? null, + $right + ); + } + + return null; + } + + /** + * Evaluate one SHOW WHERE arithmetic expression. + * + * @param scalar|null $left Left value. + * @param string|null $operator Arithmetic operator. + * @param scalar|null $right Right value. + * @return float|null Evaluated numeric value, or null when not numeric. + */ + private function evaluate_mysql_show_where_arithmetic_value( $left, ?string $operator, $right ): ?float { + $left_number = $this->coerce_mysql_show_where_numeric_value( $left ); + $right_number = $this->coerce_mysql_show_where_numeric_value( $right ); + if ( null === $left_number || null === $right_number ) { + return null; + } + + if ( '+' === $operator ) { + return $left_number + $right_number; + } + + if ( '-' === $operator ) { + return $left_number - $right_number; + } + + if ( '*' === $operator ) { + return $left_number * $right_number; + } + + if ( '/' === $operator ) { + return 0.0 === $right_number ? null : $left_number / $right_number; + } + + if ( '%' === $operator ) { + return 0.0 === $right_number ? null : fmod( $left_number, $right_number ); + } + + return null; + } + + /** + * Coerce one SHOW WHERE value to a numeric operand. + * + * @param scalar|null $value Value to coerce. + * @return float|null Numeric value, or null when not numeric. + */ + private function coerce_mysql_show_where_numeric_value( $value ): ?float { + if ( null === $value ) { + return null; + } + + if ( is_bool( $value ) ) { + return $value ? 1.0 : 0.0; + } + + if ( is_int( $value ) || is_float( $value ) ) { + return (float) $value; + } + + if ( is_string( $value ) ) { + $value = ltrim( $value ); + if ( preg_match( '/\A[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?)|(?:\.[0-9]+))(?:[eE][+-]?[0-9]+)?/', $value, $matches ) ) { + return (float) $matches[0]; + } + + return 0.0; + } + + return null; + } + + /** + * Evaluate one supported SHOW WHERE scalar function. + * + * @param array $expression Function value expression AST. + * @param array $row SHOW output row keyed by column names. + * @return scalar|null Evaluated value. + */ + private function evaluate_mysql_show_where_function_value( array $expression, array $row ) { + $function = $expression['function'] ?? null; + $arguments = array(); + foreach ( $expression['arguments'] ?? array() as $argument ) { + $arguments[] = $this->evaluate_mysql_show_where_value( $argument, $row ); + } + + if ( in_array( null, $arguments, true ) ) { + return null; + } + + switch ( $function ) { + case 'lower': + return strtolower( (string) $arguments[0] ); + + case 'upper': + return strtoupper( (string) $arguments[0] ); + + case 'left': + $length = (int) $arguments[1]; + return $length <= 0 ? '' : substr( (string) $arguments[0], 0, $length ); + + case 'right': + $length = (int) $arguments[1]; + return $length <= 0 ? '' : substr( (string) $arguments[0], -$length ); + + case 'substring': + $value = (string) $arguments[0]; + $start = (int) $arguments[1]; + $length = $arguments[2] ?? null; + if ( 0 === $start ) { + return ''; + } + + $offset = $start > 0 ? $start - 1 : strlen( $value ) + $start; + if ( null === $length ) { + return substr( $value, $offset ); + } + + $length = (int) $length; + return $length <= 0 ? '' : substr( $value, $offset, $length ); + + case 'length': + case 'char_length': + return strlen( (string) $arguments[0] ); + + case 'mod': + return $this->evaluate_mysql_show_where_arithmetic_value( $arguments[0], '%', $arguments[1] ); + } + + return null; + } + + /** + * Evaluate a SHOW WHERE comparison. + * + * @param scalar|null $left Left value. + * @param string|null $operator Comparison operator. + * @param scalar|null $right Right value. + * @return bool Whether the comparison matches. + */ + private function evaluate_mysql_show_where_comparison( $left, ?string $operator, $right, bool $binary = false, ?string $escape = null ): bool { + if ( '<=>' === $operator ) { + if ( null === $left || null === $right ) { + return null === $left && null === $right; + } + + return 0 === $this->compare_mysql_show_where_values( $left, $right, $binary ); + } + + if ( null === $left || null === $right ) { + return false; + } + + if ( 'like' === $operator || 'not_like' === $operator ) { + $matches = $this->matches_mysql_like_pattern( (string) $left, (string) $right, $escape, $binary ); + return 'not_like' === $operator ? ! $matches : $matches; + } + + $comparison = $this->compare_mysql_show_where_values( $left, $right, $binary ); + switch ( $operator ) { + case '=': + return 0 === $comparison; + case '<>': + return 0 !== $comparison; + case '>': + return $comparison > 0; + case '>=': + return $comparison >= 0; + case '<': + return $comparison < 0; + case '<=': + return $comparison <= 0; + } + + return false; + } + + /** + * Check whether a parsed SHOW WHERE value expression has a BINARY modifier. + * + * @param array $expression Value expression AST. + * @return bool Whether the expression should use binary string comparison semantics. + */ + private function mysql_show_where_value_expression_has_binary_modifier( array $expression ): bool { + if ( 'binary' === ( $expression['type'] ?? null ) ) { + return true; + } + + if ( 'arithmetic' === ( $expression['type'] ?? null ) ) { + return ( + isset( $expression['left'] ) + && is_array( $expression['left'] ) + && $this->mysql_show_where_value_expression_has_binary_modifier( $expression['left'] ) + ) || ( + isset( $expression['right'] ) + && is_array( $expression['right'] ) + && $this->mysql_show_where_value_expression_has_binary_modifier( $expression['right'] ) + ); + } + + return false; + } + + /** + * Evaluate one scalar SHOW WHERE value using MySQL boolean coercion. + * + * @param scalar|null $value Value to coerce. + * @return bool Whether the value is true in a WHERE predicate. + */ + private function is_mysql_show_where_truthy( $value ): bool { + if ( null === $value ) { + return false; + } + + if ( is_bool( $value ) ) { + return $value; + } + + if ( is_int( $value ) || is_float( $value ) || is_string( $value ) ) { + return 0.0 !== (float) $value; + } + + return false; + } + + /** + * Compare two non-null SHOW WHERE scalar values using MySQL-ish coercion. + * + * @param scalar $left Left value. + * @param scalar $right Right value. + * @return int Negative, zero, or positive comparison result. + */ + private function compare_mysql_show_where_values( $left, $right, bool $binary = false ): int { + if ( ! $binary && is_numeric( $left ) && is_numeric( $right ) ) { + $left_number = (float) $left; + $right_number = (float) $right; + if ( $left_number === $right_number ) { + return 0; + } + + return $left_number < $right_number ? -1 : 1; + } + + return $binary ? strcmp( (string) $left, (string) $right ) : strcasecmp( (string) $left, (string) $right ); + } + + /** + * Store static SHOW result rows using common MySQL-shaped metadata. + * + * @param string[] $columns Result column names. + * @param array[] $rows Rows keyed by column names. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed Result rows formatted for the requested fetch mode. + */ + private function set_mysql_static_show_result( array $columns, array $rows, $fetch_mode, ...$fetch_mode_args ) { + $this->last_found_rows = count( $rows ); + $this->last_column_meta = array(); + foreach ( $columns as $column ) { + $this->last_column_meta[] = array( + 'name' => $column, + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => $column, + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 1024, + 'precision' => 0, + 'native_type' => 'string', + ); + } + $this->last_column_count = count( $this->last_column_meta ); + + if ( PDO::FETCH_ASSOC === $fetch_mode ) { + $this->last_result = $rows; + return $this->last_result; + } + + if ( PDO::FETCH_NUM === $fetch_mode ) { + $this->last_result = array_map( 'array_values', $rows ); + return $this->last_result; + } + + $this->last_result = array_map( + static function ( array $row ) { + return (object) $row; + }, + $rows + ); + + return $this->last_result; + } + + /** + * Store already-fetched associative SHOW rows using the requested fetch mode. + * + * @param array[] $rows Rows keyed by result column names. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed Result rows formatted for the requested fetch mode. + */ + private function set_mysql_associative_result_rows( array $rows, $fetch_mode, ...$fetch_mode_args ) { + $this->last_found_rows = count( $rows ); + + if ( PDO::FETCH_ASSOC === $fetch_mode ) { + $this->last_result = $rows; + return $this->last_result; + } + + if ( PDO::FETCH_NUM === $fetch_mode ) { + $this->last_result = array_map( 'array_values', $rows ); + return $this->last_result; + } + + $this->last_result = array_map( + static function ( array $row ) { + return (object) $row; + }, + $rows + ); + + return $this->last_result; + } + + /** + * Match a string against a MySQL LIKE pattern. + * + * @param string $value Value to check. + * @param string $pattern MySQL LIKE pattern. + * @param string|null $escape Escape character, or null for the default backslash escape. + * @param bool $case_sensitive Whether matching should be case-sensitive. + * @return bool Whether the pattern matches. + */ + private function matches_mysql_like_pattern( string $value, string $pattern, ?string $escape = null, bool $case_sensitive = false ): bool { + $regex = '/^'; + $length = strlen( $pattern ); + $escape_char = null === $escape ? '\\' : $escape; + + for ( $i = 0; $i < $length; $i++ ) { + $char = $pattern[ $i ]; + if ( '' !== $escape_char && $escape_char === $char && $i + 1 < $length ) { + ++$i; + $regex .= preg_quote( $pattern[ $i ], '/' ); + continue; + } + + if ( '%' === $char ) { + $regex .= '.*'; + continue; + } + + if ( '_' === $char ) { + $regex .= '.'; + continue; + } + + $regex .= preg_quote( $char, '/' ); + } + + $regex .= $case_sensitive ? '$/' : '$/i'; + return 1 === preg_match( $regex, $value ); + } + + /** + * Get MySQL-compatible session variables exposed by SHOW VARIABLES. + * + * @return array Session variables keyed by lowercase name. + */ + private function get_mysql_session_variables(): array { + return array_replace( + $this->get_default_mysql_system_variable_values(), + $this->get_read_only_mysql_system_variable_values(), + array( + 'character_set_client' => $this->charset, + 'character_set_connection' => $this->charset, + 'character_set_results' => $this->charset, + 'character_set_database' => $this->charset, + 'character_set_server' => $this->charset, + 'collation_connection' => $this->collation, + 'collation_database' => $this->collation, + 'collation_server' => $this->collation, + 'sql_mode' => $this->get_sql_mode(), + ), + $this->mysql_session_variable_values + ); + } + + /** + * Get MySQL-compatible global variables exposed by SHOW GLOBAL VARIABLES. + * + * @return array Global variables keyed by lowercase name. + */ + private function get_mysql_global_variables(): array { + return array_replace( + $this->get_default_mysql_system_variable_values(), + $this->get_read_only_mysql_system_variable_values(), + array( + 'character_set_client' => self::DEFAULT_MYSQL_CHARSET, + 'character_set_connection' => self::DEFAULT_MYSQL_CHARSET, + 'character_set_results' => self::DEFAULT_MYSQL_CHARSET, + 'character_set_database' => self::DEFAULT_MYSQL_CHARSET, + 'character_set_server' => self::DEFAULT_MYSQL_CHARSET, + 'collation_connection' => self::DEFAULT_MYSQL_COLLATION, + 'collation_database' => self::DEFAULT_MYSQL_COLLATION, + 'collation_server' => self::DEFAULT_MYSQL_COLLATION, + 'sql_mode' => implode( ',', self::DEFAULT_MYSQL_SQL_MODES ), + ), + $this->mysql_global_variable_values + ); + } + + /** + * Get bounded MySQL-compatible status variables. + * + * These rows are intentionally conservative. They cover common admin and + * WP-CLI probes without pretending to expose live server counters. + * + * @return array Status variables keyed by MySQL display name. + */ + private function get_mysql_status_variables(): array { + return array( + 'Aborted_clients' => '0', + 'Aborted_connects' => '0', + 'Bytes_received' => '0', + 'Bytes_sent' => '0', + 'Connections' => '1', + 'Created_tmp_disk_tables' => '0', + 'Created_tmp_files' => '0', + 'Created_tmp_tables' => '0', + 'Handler_read_first' => '0', + 'Handler_read_key' => '0', + 'Handler_read_next' => '0', + 'Handler_read_prev' => '0', + 'Handler_read_rnd' => '0', + 'Handler_read_rnd_next' => '0', + 'Handler_write' => '0', + 'Open_tables' => '0', + 'Opened_tables' => '0', + 'Questions' => '0', + 'Slow_queries' => '0', + 'Threads_cached' => '0', + 'Threads_connected' => '1', + 'Threads_created' => '1', + 'Threads_running' => '1', + 'Uptime' => '0', + ); + } + + /** + * Synchronize SET NAMES/CHARSET state with individual session variables. + */ + private function sync_mysql_charset_session_variables(): void { + foreach ( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ) as $variable + ) { + $this->mysql_session_variable_values[ $variable ] = $this->charset; + } + + foreach ( + array( + 'collation_connection', + 'collation_database', + 'collation_server', + ) as $variable + ) { + $this->mysql_session_variable_values[ $variable ] = $this->collation; + } + } + + /** + * Set an emulated MySQL session variable. + * + * @param string $name Lowercase variable name. + * @param string $value Variable value. + */ + private function set_mysql_session_variable_value( string $name, string $value ): void { + if ( 'sql_mode' === $name ) { + $this->set_sql_mode( $value ); + return; + } + + $this->mysql_session_variable_values[ $name ] = $value; + } + + /** + * Set an emulated MySQL global variable. + * + * @param string $name Lowercase variable name. + * @param string $value Variable value. + */ + private function set_mysql_global_variable_value( string $name, string $value ): void { + if ( 'sql_mode' === $name ) { + $value = implode( ',', $this->normalize_mysql_sql_modes( $value ) ); + } + + $this->mysql_global_variable_values[ $name ] = $value; + } + + /** + * Get an emulated MySQL system variable value. + * + * @param string $name Variable name. + * @param string|null $scope Optional variable scope. + * @return string|null Variable value, or null when unsupported. + */ + private function get_mysql_system_variable_value( string $name, ?string $scope = null ): ?string { + $name = strtolower( $name ); + $variables = 'global' === $scope ? $this->get_mysql_global_variables() : $this->get_mysql_session_variables(); + if ( array_key_exists( $name, $variables ) ) { + return $variables[ $name ]; + } + + if ( 'sql_mode' === $name ) { + return $this->get_sql_mode(); + } + + $read_only_variables = $this->get_read_only_mysql_system_variable_values(); + if ( array_key_exists( $name, $read_only_variables ) ) { + return $read_only_variables[ $name ]; + } + + $defaults = $this->get_default_mysql_system_variable_values(); + return array_key_exists( $name, $defaults ) ? $defaults[ $name ] : null; + } + + /** + * Get a stored MySQL user variable value. + * + * @param string $name Normalized user variable name. + * @return string|null User variable value, or null when unset. + */ + private function get_mysql_user_variable_value( string $name ): ?string { + return array_key_exists( $name, $this->mysql_user_variables ) ? $this->mysql_user_variables[ $name ] : null; + } + + /** + * Translate a MySQL variable reference to a PostgreSQL literal expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Variable token position. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when not a variable. + */ + private function translate_mysql_variable_reference_to_postgresql( array $tokens, int $position ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + return array( + 'sql' => $this->get_mysql_variable_literal_sql( + $this->get_mysql_user_variable_value( + $this->normalize_mysql_user_variable_name( $tokens[ $position ]->get_value() ) + ) + ), + 'token_id' => $tokens[ $position ]->id, + 'position' => $position, + ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $reference_position = $position; + $display = null; + $scope = null; + $name = $this->parse_mysql_system_variable_reference( $tokens, $reference_position, $display, $scope ); + if ( null === $name ) { + throw new InvalidArgumentException( 'Unsupported MySQL system variable.' ); + } + + $value = $this->get_mysql_system_variable_value( $name, $scope ); + if ( null === $value ) { + throw new InvalidArgumentException( 'Unsupported MySQL system variable.' ); + } + + return array( + 'sql' => $this->get_mysql_variable_literal_sql( $value ), + 'token_id' => $tokens[ $position ]->id, + 'position' => $reference_position - 1, + ); + } + + /** + * Render a MySQL variable value as a PostgreSQL literal. + * + * @param string|null $value Variable value. + * @return string PostgreSQL literal SQL. + */ + private function get_mysql_variable_literal_sql( ?string $value ): string { + return null === $value ? 'NULL' : $this->connection->quote( $value ); + } + + /** + * Parse a MySQL @@system_variable reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param string|null $display Optional display name, populated when requested. + * @param string|null $scope Optional variable scope, populated when requested. + * @return string|null Lowercase system variable name, or null when unsupported. + */ + private function parse_mysql_system_variable_reference( array $tokens, int &$position, ?string &$display = null, ?string &$scope = null ): ?string { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $display_parts = array( '@@' ); + $scope = null; + ++$position; + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::GLOBAL_SYMBOL, + WP_MySQL_Lexer::LOCAL_SYMBOL, + WP_MySQL_Lexer::SESSION_SYMBOL, + ), + true + ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $scope = strtolower( $tokens[ $position ]->get_value() ); + $display_parts[] = $tokens[ $position ]->get_value(); + $display_parts[] = '.'; + $position += 2; + } + + if ( ! isset( $tokens[ $position ] ) || ! $this->is_mysql_system_variable_name_token( $tokens[ $position ] ) ) { + return null; + } + + $display_parts[] = $tokens[ $position ]->get_value(); + $name = strtolower( $tokens[ $position++ ]->get_value() ); + $display = implode( '', $display_parts ); + return $name; + } + + /** + * Check whether a token can be a system variable name. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token can name a supported variable. + */ + private function is_mysql_system_variable_name_token( WP_MySQL_Token $token ): bool { + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL, + WP_MySQL_Lexer::AT_SIGN_SYMBOL, + WP_MySQL_Lexer::AT_TEXT_SUFFIX, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::EOF, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + ), + true + ) + ) { + return false; + } + + return '' !== $token->get_value(); + } + + /** + * Normalize a MySQL user variable name for storage. + * + * @param string $name User variable token value. + * @return string Normalized user variable name. + */ + private function normalize_mysql_user_variable_name( string $name ): string { + return strtolower( ltrim( $name, '@' ) ); + } + + /** + * Normalize a SET value for a supported system variable. + * + * @param string $name Lowercase variable name. + * @param string $value Raw assignment value. + * @param string|null $scope Optional SET scope. + * @return string|null Normalized value, or null when unsupported. + */ + private function normalize_mysql_system_variable_assignment_value( string $name, string $value, ?string $scope = null ): ?string { + $normalized_value = strtolower( trim( $value, "'\"` \t\n\r\0\x0B" ) ); + if ( 'group_concat_max_len' === $name && 'global' === $scope ) { + return null; + } + + if ( 'default' === $normalized_value ) { + if ( 'sql_mode' === $name ) { + return 'DEFAULT'; + } + + if ( $this->is_mysql_charset_session_variable( $name ) ) { + return self::DEFAULT_MYSQL_CHARSET; + } + + if ( $this->is_mysql_collation_session_variable( $name ) ) { + return self::DEFAULT_MYSQL_COLLATION; + } + + $defaults = $this->get_default_mysql_system_variable_values(); + return array_key_exists( $name, $defaults ) ? $defaults[ $name ] : null; + } + + if ( 'group_concat_max_len' === $name ) { + return preg_match( '/\A[0-9]+\z/', $normalized_value ) ? $normalized_value : null; + } + + if ( $this->is_mysql_boolean_system_variable( $name ) ) { + return $this->normalize_mysql_boolean_system_variable_value( $value ); + } + + if ( $this->is_mysql_charset_session_variable( $name ) || $this->is_mysql_collation_session_variable( $name ) ) { + return strtolower( trim( $value, "'\"` \t\n\r\0\x0B" ) ); + } + + return $value; + } + + /** + * Normalize a MySQL boolean system variable value. + * + * @param string $value Raw assignment value. + * @return string|null Normalized 1/0 value, or null when unsupported. + */ + private function normalize_mysql_boolean_system_variable_value( string $value ): ?string { + $value = strtolower( trim( $value, "'\"` \t\n\r\0\x0B" ) ); + if ( in_array( $value, array( '1', 'on', 'true' ), true ) ) { + return '1'; + } + + if ( in_array( $value, array( '0', 'off', 'false' ), true ) ) { + return '0'; + } + + return null; + } + + /** + * Check whether a MySQL system variable is supported by the emulation layer. + * + * @param string $name Lowercase variable name. + * @return bool Whether the variable is supported. + */ + private function is_supported_mysql_system_variable( string $name ): bool { + $name = strtolower( $name ); + if ( + $this->is_mysql_charset_session_variable( $name ) + || $this->is_mysql_collation_session_variable( $name ) + || 'sql_mode' === $name + ) { + return true; + } + + $defaults = $this->get_default_mysql_system_variable_values(); + return array_key_exists( $name, $defaults ); + } + + /** + * Check whether a variable stores a charset name. + * + * @param string $name Lowercase variable name. + * @return bool Whether this is a charset variable. + */ + private function is_mysql_charset_session_variable( string $name ): bool { + return in_array( + $name, + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + true + ); + } + + /** + * Check whether a variable stores a collation name. + * + * @param string $name Lowercase variable name. + * @return bool Whether this is a collation variable. + */ + private function is_mysql_collation_session_variable( string $name ): bool { + return in_array( + $name, + array( + 'collation_connection', + 'collation_database', + 'collation_server', + ), + true + ); + } + + /** + * Check whether a variable accepts MySQL boolean values. + * + * @param string $name Lowercase variable name. + * @return bool Whether this is a boolean variable. + */ + private function is_mysql_boolean_system_variable( string $name ): bool { + return in_array( + $name, + array( + 'autocommit', + 'big_tables', + 'end_markers_in_json', + 'explicit_defaults_for_timestamp', + 'foreign_key_checks', + 'keep_files_on_create', + 'log_bin_trust_function_creators', + 'old_alter_table', + 'print_identified_with_as_hex', + 'pseudo_replica_mode', + 'pseudo_slave_mode', + '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_log_bin', + 'sql_notes', + 'sql_quote_show_create', + 'sql_safe_updates', + 'sql_warnings', + 'transaction_read_only', + 'tx_read_only', + 'unique_checks', + ), + true + ); + } + + /** + * Get defaults for supported MySQL system variables. + * + * @return array Default values keyed by lowercase name. + */ + private function get_default_mysql_system_variable_values(): array { + return array( + 'autocommit' => '1', + 'big_tables' => '0', + 'default_collation_for_utf8mb4' => 'utf8mb4_0900_ai_ci', + 'default_storage_engine' => 'InnoDB', + 'end_markers_in_json' => '0', + 'explicit_defaults_for_timestamp' => '1', + 'foreign_key_checks' => '1', + 'group_concat_max_len' => '1024', + 'innodb_lock_wait_timeout' => '50', + 'interactive_timeout' => '28800', + 'keep_files_on_create' => '0', + 'lock_wait_timeout' => '31536000', + 'log_bin_trust_function_creators' => '0', + 'max_allowed_packet' => '67108864', + 'net_read_timeout' => '30', + 'net_write_timeout' => '60', + 'old_alter_table' => '0', + 'print_identified_with_as_hex' => '0', + 'pseudo_replica_mode' => '0', + 'pseudo_slave_mode' => '0', + 'require_row_format' => '0', + 'resultset_metadata' => 'FULL', + 'select_into_disk_sync' => '0', + 'session_track_gtids' => 'OFF', + 'session_track_schema' => '1', + 'session_track_state_change' => '0', + 'session_track_transaction_info' => 'OFF', + 'show_create_table_skip_secondary_engine' => '0', + 'show_create_table_verbosity' => '0', + 'sql_auto_is_null' => '0', + 'sql_big_selects' => '1', + 'sql_buffer_result' => '0', + 'sql_log_bin' => '1', + 'sql_notes' => '1', + 'sql_quote_show_create' => '1', + 'sql_safe_updates' => '0', + 'sql_warnings' => '0', + 'storage_engine' => 'InnoDB', + 'time_zone' => 'SYSTEM', + 'transaction_isolation' => 'REPEATABLE-READ', + 'transaction_read_only' => '0', + 'tx_isolation' => 'REPEATABLE-READ', + 'tx_read_only' => '0', + 'unique_checks' => '1', + 'use_secondary_engine' => 'ON', + 'wait_timeout' => '28800', + ); + } + + /** + * Get read-only MySQL system variable values. + * + * @return array Read-only values keyed by lowercase name. + */ + private function get_read_only_mysql_system_variable_values(): array { + return array( + 'gtid_purged' => '', + 'hostname' => 'localhost', + 'large_files_support' => 'ON', + 'log_bin' => '0', + 'lower_case_table_names' => '0', + 'port' => '3306', + 'protocol_version' => '10', + 'server_id' => '0', + 'socket' => '', + 'version' => $this->get_mysql_version_string(), + 'version_comment' => 'MySQL Community Server - GPL', + ); + } + + /** + * Get the emulated MySQL server version string. + * + * @return string MySQL-compatible version string. + */ + private function get_mysql_version_string(): string { + $version = (string) $this->mysql_version; + return sprintf( + '%d.%d.%d', + $version[0], + substr( $version, 1, 2 ), + substr( $version, 3, 2 ) + ); + } + + /** + * Normalize a MySQL charset name. + * + * @param string $charset Charset name. + * @return string Normalized charset. + */ + private function normalize_mysql_charset_name( string $charset ): string { + $charset = strtolower( trim( $charset, "'\"` \t\n\r\0\x0B" ) ); + return 'utf8mb3' === $charset ? 'utf8' : $charset; + } + + /** + * Normalize a MySQL collation name. + * + * @param string $collation Collation name. + * @return string Normalized collation. + */ + private function normalize_mysql_collation_name( string $collation ): string { + $collation = strtolower( trim( $collation, "'\"` \t\n\r\0\x0B" ) ); + if ( 0 === strpos( $collation, 'utf8mb3_' ) ) { + return 'utf8_' . substr( $collation, strlen( 'utf8mb3_' ) ); + } + + return $collation; + } + + /** + * Get the default MySQL collation for a charset. + * + * @param string $charset Charset name. + * @return string Collation name. + */ + private function get_default_mysql_collation_for_charset( string $charset ): string { + $charset = $this->normalize_mysql_charset_name( $charset ); + $collations = array( + 'ascii' => 'ascii_general_ci', + 'big5' => 'big5_chinese_ci', + 'binary' => 'binary', + 'cp1251' => 'cp1251_general_ci', + 'hebrew' => 'hebrew_general_ci', + 'koi8r' => 'koi8r_general_ci', + 'latin1' => 'latin1_swedish_ci', + 'tis620' => 'tis620_thai_ci', + 'ujis' => 'ujis_japanese_ci', + 'utf8' => 'utf8_general_ci', + 'utf8mb4' => 'utf8mb4_unicode_ci', + ); + + return $collations[ $charset ] ?? $charset . '_general_ci'; + } + + /** + * Execute a MySQL SHOW INDEX/SHOW INDEXES/SHOW KEYS statement through PostgreSQL catalogs. + * + * @param string $table_name Table name. + * @param array|null $where_filter Optional MySQL WHERE filters. + * @param int $fetch_mode PDO fetch mode. + * @param array ...$fetch_mode_args Additional fetch mode arguments. + * @return mixed SHOW INDEX result rows. + */ + private function execute_show_index_query( string $schema_name, string $table_name, ?array $where_filter, $fetch_mode, ...$fetch_mode_args ) { + $this->ensure_mysql_schema_metadata_tables(); + + $resolved_schema = $this->resolve_mysql_table_schema_for_introspection( $schema_name, $table_name ); + if ( 0 === strcasecmp( $resolved_schema, 'information_schema' ) ) { + return $this->set_mysql_static_show_result( + $this->get_show_index_output_columns(), + array(), + $fetch_mode, + ...$fetch_mode_args + ); + } + + $cache_key = $this->get_mysql_introspection_result_cache_key( + 'show_index', + $fetch_mode, + array( $resolved_schema, $table_name, $where_filter, $fetch_mode, $fetch_mode_args ) + ); + if ( $this->load_mysql_introspection_result_from_cache( $cache_key ) ) { + return $this->last_result; + } + + $sql = $this->mysql_index_metadata_has_rows( $resolved_schema, $table_name ) + ? $this->get_show_index_metadata_query() + : $this->get_show_index_catalog_query(); + $params = array( + $resolved_schema, + $table_name, + ); + + $where_expression_filter = $this->is_mysql_show_where_expression_filter( $where_filter ) ? $where_filter : null; + if ( null !== $where_filter && null === $where_expression_filter ) { + $where_conditions = array(); + foreach ( $where_filter as $filter ) { + $where_conditions[] = $this->get_mysql_show_where_filter_condition_sql( + $this->connection->quote_identifier( $filter['column'] ), + $filter + ); + $params[] = $filter['value']; + } + + $sql .= ' + WHERE ' . implode( ' AND ', $where_conditions ); + } + + $sql .= ' +ORDER BY + "Key_name" = \'PRIMARY\' DESC, + "Non_unique" = \'0\' DESC, + "Index_type" = \'SPATIAL\' DESC, + "Index_type" = \'BTREE\' DESC, + "Index_type" = \'FULLTEXT\' DESC, + postgresql_index_oid, + CAST("Seq_in_index" AS integer)'; + + $stmt = $this->connection->query( $sql, $params ); + + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + $this->last_column_meta = $this->normalize_column_meta( $stmt ); + if ( null !== $where_expression_filter ) { + $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + $rows = $this->filter_mysql_static_show_rows( $rows, $where_expression_filter ); + $this->set_mysql_associative_result_rows( $rows, $fetch_mode, ...$fetch_mode_args ); + } else { + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + } + + $this->store_mysql_introspection_result_in_cache( $cache_key ); + + return $this->last_result; + } + + /** + * Get output columns for SHOW INDEX-family statements. + * + * @return string[] Output column names. + */ + private function get_show_index_output_columns(): array { + return array( + 'Table', + 'Non_unique', + 'Key_name', + 'Seq_in_index', + 'Column_name', + 'Collation', + 'Cardinality', + 'Sub_part', + 'Packed', + 'Null', + 'Index_type', + 'Comment', + 'Index_comment', + 'Visible', + 'Expression', + ); + } + + /** + * Load a cached MySQL introspection result into the current query state. + * + * @param string|null $cache_key Cache key, or null when this query shape is not cacheable. + * @return bool Whether a cached result was loaded. + */ + private function load_mysql_introspection_result_from_cache( ?string $cache_key ): bool { + if ( null === $cache_key ) { + return false; + } + + if ( ! array_key_exists( $cache_key, $this->mysql_introspection_result_cache ) ) { + return false; + } + + $cached = $this->mysql_introspection_result_cache[ $cache_key ]; + if ( + ! $this->try_copy_mysql_introspection_cache_value( $cached['column_meta'], $column_meta ) + || ! $this->try_copy_mysql_introspection_cache_value( $cached['result'], $result ) + ) { + unset( $this->mysql_introspection_result_cache[ $cache_key ] ); + return false; + } + + $this->last_column_meta = $column_meta; + $this->last_result = $result; + $this->last_found_rows = count( $result ); + + return true; + } + + /** + * Store the current MySQL introspection result in the request-local cache. + * + * @param string|null $cache_key Cache key, or null when this query shape is not cacheable. + */ + private function store_mysql_introspection_result_in_cache( ?string $cache_key ): void { + if ( null === $cache_key ) { + return; + } + + if ( + ! $this->try_copy_mysql_introspection_cache_value( $this->last_column_meta, $column_meta ) + || ! $this->try_copy_mysql_introspection_cache_value( $this->last_result, $result ) + ) { + return; + } + + $this->mysql_introspection_result_cache[ $cache_key ] = array( + 'column_meta' => $column_meta, + 'result' => $result, + ); + } + + /** + * Get a cache key for a MySQL introspection query shape. + * + * @param string $query_type Query type. + * @param mixed $fetch_mode PDO fetch mode. + * @param array $parts Query shape parts. + * @return string|null Cache key, or null when the query shape is not cacheable. + */ + private function get_mysql_introspection_result_cache_key( string $query_type, $fetch_mode, array $parts ): ?string { + if ( PDO::FETCH_FUNC === ( (int) $fetch_mode & self::PDO_FETCH_STYLE_MASK ) ) { + return null; + } + + if ( ! $this->is_mysql_introspection_cache_key_value_safe( $parts ) ) { + return null; + } + + return $query_type . "\0" . serialize( $parts ); + } + + /** + * Check whether a value can safely participate in an introspection cache key. + * + * @param mixed $value Value to inspect. + * @param int $depth Recursion depth guard. + * @return bool Whether the value can be safely serialized into a cache key. + */ + private function is_mysql_introspection_cache_key_value_safe( $value, int $depth = 0 ): bool { + if ( 20 < $depth ) { + return false; + } + + if ( null === $value || is_scalar( $value ) ) { + return true; + } + + if ( ! is_array( $value ) ) { + return false; + } + + foreach ( $value as $key => $item ) { + if ( ! is_int( $key ) && ! is_string( $key ) ) { + return false; + } + + if ( ! $this->is_mysql_introspection_cache_key_value_safe( $item, $depth + 1 ) ) { + return false; + } + } + + return true; + } + + /** + * Copy cached introspection data before exposing it to callers. + * + * @param mixed $value Cached value. + * @param mixed $copy Copied value. + * @param int $depth Recursion depth guard. + * @return bool Whether the value could be copied safely. + */ + private function try_copy_mysql_introspection_cache_value( $value, &$copy, int $depth = 0 ): bool { + if ( 20 < $depth ) { + return false; + } + + if ( is_array( $value ) ) { + $copy = array(); + foreach ( $value as $key => $item ) { + if ( ! $this->try_copy_mysql_introspection_cache_value( $item, $item_copy, $depth + 1 ) ) { + return false; + } + $copy[ $key ] = $item_copy; + } + return true; + } + + if ( is_object( $value ) ) { + if ( 'stdClass' !== get_class( $value ) ) { + return false; + } + + $copy = clone $value; + return true; + } + + if ( is_resource( $value ) ) { + return false; + } + + $copy = $value; + return true; + } + + /** + * Resolve the backend schema for an unqualified MySQL table introspection query. + * + * @param string $schema_name Requested schema name. + * @param string $table_name Requested table name. + * @return string Backend schema name. + */ + private function resolve_mysql_table_schema_for_introspection( string $schema_name, string $table_name ): string { + if ( 'public' !== $schema_name ) { + return $schema_name; + } + + $cache_key = $this->get_mysql_metadata_cache_key( $schema_name, $table_name ); + if ( isset( $this->mysql_table_schema_introspection_cache[ $cache_key ] ) ) { + return $this->mysql_table_schema_introspection_cache[ $cache_key ]; + } + + $temporary_schema = $this->get_active_temporary_table_schema( $table_name ); + $resolved_schema = null === $temporary_schema ? $schema_name : $temporary_schema; + + $this->mysql_table_schema_introspection_cache[ $cache_key ] = $resolved_schema; + return $resolved_schema; + } + + /** + * Get the active temporary schema for a table name. + * + * @param string $table_name Table name. + * @return string|null Temporary schema name, or null when no active temporary table exists. + */ + private function get_active_temporary_table_schema( string $table_name ): ?string { + $driver_name = $this->connection->get_driver_name(); + $pdo_driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + + if ( 'sqlite' === $driver_name ) { + return $this->get_active_sqlite_temporary_table_schema( $table_name ); + } + + try { + $stmt = $this->connection->query( + 'SELECT n.nspname + FROM pg_catalog.pg_class c + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = c.relnamespace + WHERE n.oid = pg_my_temp_schema() + AND lower(c.relname) = lower(?) + AND c.relkind IN (\'r\', \'p\') + LIMIT 1', + array( $table_name ) + ); + } catch ( PDOException $e ) { + if ( 'sqlite' !== $pdo_driver_name ) { + throw $e; + } + + return $this->get_active_sqlite_temporary_table_schema( $table_name ); + } + + $schema_name = $stmt->fetchColumn(); + return false === $schema_name ? null : (string) $schema_name; + } + + /** + * Get the active SQLite temporary schema for a table name. + * + * @param string $table_name Table name. + * @return string|null Temporary schema name, or null when no active temporary table exists. + */ + private function get_active_sqlite_temporary_table_schema( string $table_name ): ?string { + $stmt = $this->connection->query( + "SELECT name FROM sqlite_temp_master WHERE type = 'table' AND LOWER(name) = LOWER(?) LIMIT 1", + array( $table_name ) + ); + + return false === $stmt->fetchColumn() ? null : 'temp'; + } + + /** + * Get the PostgreSQL catalog query backing MySQL DESCRIBE/DESC. + * + * @return string SQL query. + */ + private function get_describe_catalog_query(): string { + $column_metadata_table = $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ); + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + + return sprintf( + 'WITH requested_table AS ( + SELECT ? AS table_schema, ? AS table_name +), +catalog_columns AS ( + SELECT + c.column_name AS field_name, + COALESCE( + cm.column_type, + CASE + WHEN c.data_type = \'character varying\' THEN + \'varchar\' || CASE + WHEN c.character_maximum_length IS NULL THEN \'\' + ELSE \'(\' || CAST(c.character_maximum_length AS text) || \')\' + END + WHEN c.data_type = \'character\' THEN + \'char\' || CASE + WHEN c.character_maximum_length IS NULL THEN \'\' + ELSE \'(\' || CAST(c.character_maximum_length AS text) || \')\' + END + WHEN c.data_type = \'integer\' THEN \'int\' + WHEN c.data_type = \'timestamp without time zone\' THEN \'datetime\' + ELSE c.data_type + END + ) AS column_type, + COALESCE(cm.is_nullable, c.is_nullable) AS is_nullable, + CASE + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + AND UPPER(im.key_name) = \'PRIMARY\' + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + AND im.non_unique = \'0\' + ) THEN \'UNI\' + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + ) THEN \'MUL\' + WHEN EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + INNER JOIN information_schema.key_column_usage kcu + ON kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND tc.constraint_type = \'PRIMARY KEY\' + AND kcu.column_name = c.column_name + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + INNER JOIN information_schema.key_column_usage kcu + ON kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND tc.constraint_type = \'UNIQUE\' + AND kcu.column_name = c.column_name + ) THEN \'UNI\' + ELSE \'\' + END AS column_key, + CASE + WHEN cm.column_name IS NOT NULL THEN cm.column_default + ELSE c.column_default + END AS column_default, + COALESCE( + cm.extra, + CASE + WHEN c.is_identity = \'YES\' THEN \'auto_increment\' + WHEN c.column_default LIKE \'nextval(%%\' THEN \'auto_increment\' + ELSE \'\' + END + ) AS column_extra, + c.ordinal_position + FROM requested_table rt + INNER JOIN information_schema.columns c + ON c.table_schema = rt.table_schema + AND c.table_name = rt.table_name + LEFT JOIN %1$s cm + ON cm.table_schema = c.table_schema + AND cm.table_name = c.table_name + AND cm.column_name = c.column_name +), +metadata_columns AS ( + SELECT + cm.column_name AS field_name, + cm.column_type, + cm.is_nullable, + CASE + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + AND UPPER(im.key_name) = \'PRIMARY\' + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + AND im.non_unique = \'0\' + ) THEN \'UNI\' + WHEN EXISTS ( + SELECT 1 + FROM %2$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + ) THEN \'MUL\' + ELSE \'\' + END AS column_key, + cm.column_default, + cm.extra AS column_extra, + cm.ordinal_position + FROM requested_table rt + INNER JOIN %1$s cm + ON cm.table_schema = rt.table_schema + AND cm.table_name = rt.table_name + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.columns c + WHERE c.table_schema = cm.table_schema + AND c.table_name = cm.table_name + AND c.column_name = cm.column_name + ) +), +describe_rows AS ( + SELECT * FROM catalog_columns + UNION ALL + SELECT * FROM metadata_columns +) +SELECT + field_name AS "Field", + column_type AS "Type", + is_nullable AS "Null", + column_key AS "Key", + column_default AS "Default", + column_extra AS "Extra" +FROM describe_rows +ORDER BY ordinal_position', + $column_metadata_table, + $index_metadata_table + ); + } + + /** + * Get the PostgreSQL catalog query backing MySQL SHOW COLUMNS/FULL COLUMNS. + * + * @param bool $is_full Whether the query should emit SHOW FULL COLUMNS fields. + * @return string SQL query. + */ + private function get_show_columns_catalog_query( bool $is_full ): string { + $column_metadata_table = $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ); + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + + $type_expression = 'CASE + WHEN c.data_type = \'character varying\' THEN + \'varchar\' || CASE + WHEN c.character_maximum_length IS NULL THEN \'\' + ELSE \'(\' || CAST(c.character_maximum_length AS text) || \')\' + END + WHEN c.data_type = \'character\' THEN + \'char\' || CASE + WHEN c.character_maximum_length IS NULL THEN \'\' + ELSE \'(\' || CAST(c.character_maximum_length AS text) || \')\' + END + WHEN c.data_type = \'integer\' THEN \'int\' + WHEN c.data_type = \'timestamp without time zone\' THEN \'datetime\' + ELSE c.data_type + END'; + + $catalog_collation_expression = 'CASE + WHEN cm.column_type IS NOT NULL THEN + CASE + WHEN LOWER(cm.column_type) LIKE \'char%\' + OR LOWER(cm.column_type) LIKE \'varchar%\' + OR LOWER(cm.column_type) LIKE \'%text%\' THEN COALESCE(cm.collation_name, c.collation_name, \'utf8mb4_unicode_ci\') + ELSE NULL + END + WHEN c.data_type IN (\'character varying\', \'character\', \'text\') THEN COALESCE(c.collation_name, \'utf8mb4_unicode_ci\') + ELSE NULL + END'; + + $metadata_collation_expression = 'CASE + WHEN LOWER(cm.column_type) LIKE \'char%\' + OR LOWER(cm.column_type) LIKE \'varchar%\' + OR LOWER(cm.column_type) LIKE \'%text%\' THEN COALESCE(cm.collation_name, \'utf8mb4_unicode_ci\') + ELSE NULL + END'; + + $catalog_key_expression = sprintf( + 'CASE + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + AND UPPER(im.key_name) = \'PRIMARY\' + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + AND im.non_unique = \'0\' + ) THEN \'UNI\' + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = c.table_schema + AND im.table_name = c.table_name + AND im.column_name = c.column_name + ) THEN \'MUL\' + WHEN EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + INNER JOIN information_schema.key_column_usage kcu + ON kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND tc.constraint_type = \'PRIMARY KEY\' + AND kcu.column_name = c.column_name + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + INNER JOIN information_schema.key_column_usage kcu + ON kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND tc.constraint_type = \'UNIQUE\' + AND kcu.column_name = c.column_name + ) THEN \'UNI\' + ELSE \'\' + END', + $index_metadata_table + ); + + $metadata_key_expression = sprintf( + 'CASE + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + AND UPPER(im.key_name) = \'PRIMARY\' + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + AND im.non_unique = \'0\' + ) THEN \'UNI\' + WHEN EXISTS ( + SELECT 1 + FROM %1$s im + WHERE im.table_schema = cm.table_schema + AND im.table_name = cm.table_name + AND im.column_name = cm.column_name + ) THEN \'MUL\' + ELSE \'\' + END', + $index_metadata_table + ); + + $catalog_extra_expression = 'CASE + WHEN c.is_identity = \'YES\' THEN \'auto_increment\' + WHEN c.column_default LIKE \'nextval(%\' THEN \'auto_increment\' + ELSE \'\' + END'; + + if ( $is_full ) { + $fields = 'field_name AS "Field", + column_type AS "Type", + collation_name AS "Collation", + is_nullable AS "Null", + column_key AS "Key", + column_default AS "Default", + column_extra AS "Extra", + \'select,insert,update,references\' AS "Privileges", + column_comment AS "Comment"'; + } else { + $fields = 'field_name AS "Field", + column_type AS "Type", + is_nullable AS "Null", + column_key AS "Key", + column_default AS "Default", + column_extra AS "Extra"'; + } + + return sprintf( + 'WITH requested_table AS ( + SELECT ? AS table_schema, ? AS table_name +), +catalog_columns AS ( + SELECT + c.column_name AS field_name, + COALESCE(cm.column_type, %3$s) AS column_type, + %4$s AS collation_name, + COALESCE(cm.is_nullable, c.is_nullable) AS is_nullable, + %5$s AS column_key, + CASE + WHEN cm.column_name IS NOT NULL THEN cm.column_default + ELSE c.column_default + END AS column_default, + COALESCE(cm.extra, %7$s) AS column_extra, + COALESCE(cm.column_comment, \'\') AS column_comment, + c.ordinal_position + FROM requested_table rt + INNER JOIN information_schema.columns c + ON c.table_schema = rt.table_schema + AND c.table_name = rt.table_name + LEFT JOIN %1$s cm + ON cm.table_schema = c.table_schema + AND cm.table_name = c.table_name + AND cm.column_name = c.column_name +), +metadata_columns AS ( + SELECT + cm.column_name AS field_name, + cm.column_type, + %8$s AS collation_name, + cm.is_nullable, + %6$s AS column_key, + cm.column_default, + cm.extra AS column_extra, + cm.column_comment, + cm.ordinal_position + FROM requested_table rt + INNER JOIN %1$s cm + ON cm.table_schema = rt.table_schema + AND cm.table_name = rt.table_name + WHERE NOT EXISTS ( + SELECT 1 + FROM information_schema.columns c + WHERE c.table_schema = cm.table_schema + AND c.table_name = cm.table_name + AND c.column_name = cm.column_name + ) +), +show_columns_rows AS ( + SELECT * FROM catalog_columns + UNION ALL + SELECT * FROM metadata_columns +) +SELECT + %9$s +FROM show_columns_rows +WHERE 1 = 1', + $column_metadata_table, + $index_metadata_table, + $type_expression, + $catalog_collation_expression, + $catalog_key_expression, + $metadata_key_expression, + $catalog_extra_expression, + $metadata_collation_expression, + $fields + ); + } + + /** + * Get the PostgreSQL catalog query backing MySQL SHOW INDEX/SHOW INDEXES/SHOW KEYS. + * + * @return string SQL query. + */ + private function get_show_index_catalog_query(): string { + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + + return sprintf( + 'WITH requested_table AS ( + SELECT ? AS table_schema, ? AS table_name +), +metadata_exists AS ( + SELECT EXISTS ( + SELECT 1 + FROM %1$s im + INNER JOIN requested_table rt + ON rt.table_schema = im.table_schema + AND rt.table_name = im.table_name + ) AS has_metadata +), +metadata_index_rows AS ( + SELECT + im.table_name AS "Table", + im.non_unique AS "Non_unique", + im.key_name AS "Key_name", + CAST(im.seq_in_index AS text) AS "Seq_in_index", + im.column_name AS "Column_name", + CASE WHEN im.index_type = \'FULLTEXT\' THEN NULL ELSE COALESCE(im."collation", \'A\') END AS "Collation", + \'0\' AS "Cardinality", + im.sub_part AS "Sub_part", + NULL AS "Packed", + im.nullable AS "Null", + im.index_type AS "Index_type", + \'\' AS "Comment", + im.index_comment AS "Index_comment", + \'YES\' AS "Visible", + NULL AS "Expression", + im.index_ordinal AS postgresql_index_oid + FROM %1$s im + INNER JOIN requested_table rt + ON rt.table_schema = im.table_schema + AND rt.table_name = im.table_name +), +index_columns AS ( + SELECT + t.relname AS table_name, + CAST(idx.oid AS bigint) AS postgresql_index_oid, + idx.relname AS postgresql_index_name, + i.indisunique, + i.indisprimary, + am.amname AS access_method, + k.ordinality AS seq_in_index, + k.attnum, + a.attname AS column_name, + a.attnotnull, + CASE + WHEN 0 = k.attnum THEN pg_catalog.pg_get_indexdef(i.indexrelid, CAST(k.ordinality AS integer), true) + ELSE NULL + END AS expression + FROM pg_catalog.pg_class t + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = t.relnamespace + INNER JOIN pg_catalog.pg_index i + ON i.indrelid = t.oid + INNER JOIN pg_catalog.pg_class idx + ON idx.oid = i.indexrelid + INNER JOIN pg_catalog.pg_am am + ON am.oid = idx.relam + CROSS JOIN LATERAL pg_catalog.unnest(i.indkey) WITH ORDINALITY AS k(attnum, ordinality) + LEFT JOIN pg_catalog.pg_attribute a + ON a.attrelid = t.oid + AND a.attnum = k.attnum + INNER JOIN requested_table rt + ON rt.table_schema = n.nspname + AND rt.table_name = t.relname + WHERE k.ordinality <= i.indnkeyatts + AND i.indisvalid + AND i.indislive +), +catalog_index_rows AS ( + SELECT + table_name AS "Table", + CASE WHEN indisunique THEN \'0\' ELSE \'1\' END AS "Non_unique", + CASE + WHEN indisprimary THEN \'PRIMARY\' + WHEN postgresql_index_name LIKE table_name || \'__%%\' THEN SUBSTRING(postgresql_index_name FROM CHAR_LENGTH(table_name || \'__\') + 1) + ELSE postgresql_index_name + END AS "Key_name", + CAST(seq_in_index AS text) AS "Seq_in_index", + column_name AS "Column_name", + \'A\' AS "Collation", + \'0\' AS "Cardinality", + NULL AS "Sub_part", + NULL AS "Packed", + CASE + WHEN 0 = attnum OR attnotnull THEN \'\' + ELSE \'YES\' + END AS "Null", + UPPER(access_method) AS "Index_type", + \'\' AS "Comment", + \'\' AS "Index_comment", + \'YES\' AS "Visible", + expression AS "Expression", + postgresql_index_oid + FROM index_columns + WHERE NOT (SELECT has_metadata FROM metadata_exists) +), +show_index_rows AS ( + SELECT * FROM metadata_index_rows + UNION ALL + SELECT * FROM catalog_index_rows + ) + SELECT + "Table", + "Non_unique", + "Key_name", + "Seq_in_index", + "Column_name", + "Collation", + "Cardinality", + "Sub_part", + "Packed", + "Null", + "Index_type", + "Comment", + "Index_comment", + "Visible", + "Expression" + FROM show_index_rows', + $index_metadata_table + ); + } + + /** + * Get the metadata-only query backing MySQL SHOW INDEX/SHOW INDEXES/SHOW KEYS. + * + * @return string SQL query. + */ + private function get_show_index_metadata_query(): string { + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + + return sprintf( + 'SELECT + "Table", + "Non_unique", + "Key_name", + "Seq_in_index", + "Column_name", + "Collation", + "Cardinality", + "Sub_part", + "Packed", + "Null", + "Index_type", + "Comment", + "Index_comment", + "Visible", + "Expression" +FROM ( + SELECT + im.table_name AS "Table", + im.non_unique AS "Non_unique", + im.key_name AS "Key_name", + CAST(im.seq_in_index AS text) AS "Seq_in_index", + im.column_name AS "Column_name", + CASE WHEN im.index_type = \'FULLTEXT\' THEN NULL ELSE COALESCE(im."collation", \'A\') END AS "Collation", + \'0\' AS "Cardinality", + im.sub_part AS "Sub_part", + NULL AS "Packed", + im.nullable AS "Null", + im.index_type AS "Index_type", + \'\' AS "Comment", + im.index_comment AS "Index_comment", + \'YES\' AS "Visible", + NULL AS "Expression", + im.index_ordinal AS postgresql_index_oid + FROM %s im + WHERE im.table_schema = ? + AND im.table_name = ? +) AS show_index_rows', + $index_metadata_table + ); + } + + /** + * Get results of the last query. + * + * @return mixed + */ + public function get_query_results() { + return $this->last_result; + } + + /** + * Get return value of the last query() function call. + * + * @return mixed + */ + public function get_last_return_value() { + return $this->last_result; + } + + /** + * Get the number of columns returned by the last query. + * + * @return int + */ + public function get_last_column_count(): int { + if ( null !== $this->last_column_meta_statement ) { + return $this->last_column_count; + } + + return count( $this->last_column_meta ); + } + + /** + * Get column metadata for results of the last query. + * + * @return array + */ + public function get_last_column_meta(): array { + $this->materialize_last_column_meta(); + return $this->last_column_meta; + } + + /** + * Begin a transaction. + */ + public function beginTransaction(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + $this->connection->get_pdo()->beginTransaction(); + $this->connection->reset_statement_savepoint_state(); + } + + /** + * A temporary alias for back compatibility. + * + * @see self::beginTransaction() + */ + public function begin_transaction(): void { + $this->beginTransaction(); + } + + /** + * Commit the current transaction. + */ + public function commit(): void { + $this->connection->get_pdo()->commit(); + $this->connection->reset_statement_savepoint_state(); + } + + /** + * Rollback the current transaction. + */ + public function rollback(): void { + $this->connection->get_pdo()->rollBack(); + $this->connection->reset_statement_savepoint_state(); + } + + /** + * Reset per-query state. + */ + private function reset_query_state(): void { + $this->last_result = null; + $this->last_column_meta = array(); + $this->last_column_count = 0; + $this->last_column_meta_statement = null; + $this->last_column_meta_excluded_names = array(); + $this->last_mysql_query = null; + $this->last_postgresql_queries = array(); + + $this->mysql_last_insert_id_assignment_value = null; + $this->mysql_last_insert_id_assignment_translation_enabled = false; + } + + /** + * Clear column metadata for a non-result statement. + */ + private function clear_last_column_meta(): void { + $this->last_column_meta = array(); + $this->last_column_count = 0; + $this->last_column_meta_statement = null; + $this->last_column_meta_excluded_names = array(); + } + + /** + * Store a statement for lazy column metadata normalization. + * + * @param PDOStatement $stmt Statement with result columns. + * @param int $column_count Number of result columns. + */ + private function set_lazy_last_column_meta( PDOStatement $stmt, int $column_count ): void { + $this->last_column_meta = array(); + $this->last_column_count = $column_count; + $this->last_column_meta_statement = $stmt; + $this->last_column_meta_excluded_names = array(); + } + + /** + * Normalize deferred column metadata when a caller actually needs it. + */ + private function materialize_last_column_meta(): void { + if ( null === $this->last_column_meta_statement ) { + return; + } + + $this->last_column_meta = $this->normalize_column_meta( + $this->last_column_meta_statement, + $this->last_column_meta_excluded_names + ); + $this->last_column_count = count( $this->last_column_meta ); + $this->last_column_meta_statement = null; + $this->last_column_meta_excluded_names = array(); + } + + /** + * Translate the WordPress options cleanup DELETE ... REGEXP query. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_wordpress_options_regexp_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3], $tokens[4], $tokens[5], $tokens[6] ) + || WP_MySQL_Lexer::DELETE_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[1]->id + || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[3]->id + || WP_MySQL_Lexer::REGEXP_SYMBOL !== $tokens[5]->id + ) { + return null; + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[2] ); + $column = $this->get_mysql_identifier_token_value( $tokens[4] ); + if ( null === $table_name || null === $column || ! $this->is_wordpress_options_table_name( $table_name ) ) { + return null; + } + + if ( + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT !== $tokens[6]->id + && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT !== $tokens[6]->id + ) { + return null; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, 7 ) ) { + return null; + } + + return sprintf( + 'DELETE FROM %s WHERE %s ~* %s', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $column ), + $this->connection->quote( $tokens[6]->get_value() ) + ); + } + + /** + * Translate WordPress expired transient cleanup DELETE statements. + * + * Core emits a MySQL multi-table DELETE that removes both transient values + * and their timeout rows. PostgreSQL does not support that DELETE syntax, so + * this rewrites only the exact WordPress options-table shape to a CTE-backed + * single-table DELETE. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_wordpress_expired_transients_delete_query( string $query ): ?string { + $pattern = '/^\s*DELETE\s+a\s*,\s*b\s+FROM\s+([A-Za-z0-9_]+)\s+a\s*,\s*\1\s+b\s+WHERE\s+a\.option_name\s+LIKE\s+([\'"])([^\'"]+)\\2\s+AND\s+a\.option_name\s+NOT\s+LIKE\s+([\'"])([^\'"]+)\\4\s+AND\s+b\.option_name\s*=\s*CONCAT\s*\(\s*([\'"])([^\'"]+)\\6\s*,\s*SUBSTRING\s*\(\s*a\.option_name\s*,\s*([0-9]+)\s*\)\s*\)\s+AND\s+b\.option_value\s*<\s*([0-9]+)\s*;?\s*$/is'; + if ( ! preg_match( $pattern, $query, $matches ) ) { + return null; + } + + $table_name = $matches[1]; + $value_like = $matches[3]; + $timeout_like = $matches[5]; + $timeout_prefix = $matches[7]; + $substring_from = (int) $matches[8]; + $expires_before = $matches[9]; + + if ( + ! $this->is_wordpress_options_table_name( $table_name ) + || ! in_array( $timeout_prefix, array( '_transient_timeout_', '_site_transient_timeout_' ), true ) + || ( '_transient_timeout_' === $timeout_prefix && 12 !== $substring_from ) + || ( '_site_transient_timeout_' === $timeout_prefix && 17 !== $substring_from ) + ) { + return null; + } + + return sprintf( + 'WITH expired_transients AS ( + SELECT a.option_name AS value_name, b.option_name AS timeout_name + FROM %1$s a + INNER JOIN %1$s b + ON b.option_name = %2$s || SUBSTR(a.option_name, %3$d) + WHERE a.option_name LIKE %4$s ESCAPE %5$s + AND a.option_name NOT LIKE %6$s ESCAPE %5$s + AND CAST(b.option_value AS BIGINT) < %7$s +) +DELETE FROM %1$s +WHERE option_name IN ( + SELECT value_name FROM expired_transients + UNION + SELECT timeout_name FROM expired_transients +)', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote( $timeout_prefix ), + $substring_from, + $this->connection->quote( $value_like ), + $this->connection->quote( '\\' ), + $this->connection->quote( $timeout_like ), + $expires_before + ); + } + + /** + * Translate MySQL single-target orphan cleanup DELETE statements. + * + * WooCommerce emits DELETE alias FROM target alias LEFT JOIN related alias + * ... WHERE related.id IS NULL to purge orphaned metadata. PostgreSQL does + * not support MySQL's DELETE target list, and rewriting the LEFT JOIN to a + * PostgreSQL USING join would change the anti-join semantics. Keep this path + * constrained to the exact single LEFT JOIN null-rejection shape. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_mysql_left_join_orphan_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3], $tokens[4], $tokens[5], $tokens[6], $tokens[7], $tokens[8], $tokens[9] ) + || WP_MySQL_Lexer::DELETE_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[2]->id + || WP_MySQL_Lexer::LEFT_SYMBOL !== $tokens[5]->id + || WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[6]->id + || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[9]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 10 ); + if ( null === $statement_end ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, 10, $statement_end ); + if ( null === $where_position || 10 >= $where_position || $where_position + 1 >= $statement_end ) { + return null; + } + + $delete_alias = $this->get_mysql_identifier_token_value( $tokens[1] ); + $target_table = $this->get_mysql_identifier_token_value( $tokens[3] ); + $target_alias = $this->get_mysql_identifier_token_value( $tokens[4] ); + $joined_table = $this->get_mysql_identifier_token_value( $tokens[7] ); + $joined_alias = $this->get_mysql_identifier_token_value( $tokens[8] ); + if ( + null === $delete_alias + || null === $target_table + || null === $target_alias + || null === $joined_table + || null === $joined_alias + || strtolower( $delete_alias ) !== strtolower( $target_alias ) + ) { + return null; + } + + if ( ! $this->is_mysql_null_rejected_join_alias_predicate( $tokens, $where_position + 1, $statement_end, $joined_alias ) ) { + return null; + } + + return sprintf( + 'DELETE FROM %s AS %s WHERE NOT EXISTS (SELECT 1 FROM %s AS %s WHERE %s)', + $this->connection->quote_identifier( $target_table ), + $this->translate_mysql_identifier_value_to_postgresql( $target_alias ), + $this->connection->quote_identifier( $joined_table ), + $this->translate_mysql_identifier_value_to_postgresql( $joined_alias ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, 10, $where_position ) + ); + } + + /** + * Translate supported MySQL target-list DELETE statements. + * + * PostgreSQL cannot delete from multiple target tables directly. First + * materialize each target row ctid from the MySQL table-reference list, then + * delete each target through writable CTEs and return the summed affected row + * count. ORDER BY clauses are translated in the materialized source so + * unsupported expressions fail closed before execution. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_mysql_multi_target_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::DELETE_SYMBOL !== $tokens[0]->id + ) { + return null; + } + + $position = 1; + $this->consume_mysql_delete_modifiers( $tokens, $position ); + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::FROM_SYMBOL === $tokens[ $position ]->id ) { + $using_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::USING_SYMBOL, $position + 1, $statement_end ); + if ( null === $using_position || $position + 1 >= $using_position || $using_position + 1 >= $statement_end ) { + return null; + } + + $target_aliases = $this->parse_mysql_delete_target_aliases( $tokens, $position + 1, $using_position ); + $table_references_start = $using_position + 1; + } else { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $position, $statement_end ); + if ( null === $from_position || $position >= $from_position || $from_position + 1 >= $statement_end ) { + return null; + } + + $target_aliases = $this->parse_mysql_delete_target_aliases( $tokens, $position, $from_position ); + $table_references_start = $from_position + 1; + } + + if ( null === $target_aliases ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $table_references_start, $statement_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $table_references_start, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $table_references_start, $statement_end ); + $order_end = $limit_position ?? $statement_end; + if ( + ( null !== $order_position && null !== $where_position && $order_position < $where_position ) + || ( null !== $limit_position && null !== $where_position && $limit_position < $where_position ) + || ( null !== $limit_position && null !== $order_position && $limit_position < $order_position ) + || ( null !== $order_position && ! $this->is_nonempty_mysql_order_by_clause( $tokens, $order_position, $order_end ) ) + ) { + return null; + } + + $from_end = $where_position ?? $order_position ?? $limit_position ?? $statement_end; + if ( $table_references_start >= $from_end ) { + return null; + } + + $information_schema_source_translation = null; + if ( + 0 === strcasecmp( $this->db_name, 'information_schema' ) + || $this->direct_information_schema_source_range_references_information_schema( $tokens, $table_references_start, $from_end ) + ) { + $information_schema_source_translation = $this->get_direct_information_schema_dml_source_translation( + $query, + $tokens, + $table_references_start, + $from_end + ); + if ( null === $information_schema_source_translation ) { + return null; + } + $scope = $information_schema_source_translation['scope']; + $source_sql = $information_schema_source_translation['sql']; + } else { + $scope = $this->get_mysql_select_scope( $tokens, $table_references_start, $from_end ); + if ( null === $scope || ! empty( $scope['unknown'] ) ) { + return null; + } + $source_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $table_references_start, $from_end ); + } + + $target_tables = array(); + $target_groups = array(); + foreach ( $target_aliases as $target_alias ) { + $target_key = strtolower( $target_alias ); + if ( ! isset( $scope['aliases'][ $target_key ] ) ) { + return null; + } + + $target_table = $scope['aliases'][ $target_key ]; + $this->get_mysql_writable_table_backend_schema( + array( + 'schema' => $target_table['schema'], + 'table' => $target_table['table'], + ), + 'DELETE' + ); + $target_physical_name = strtolower( $target_table['schema'] . '.' . $target_table['table'] ); + if ( ! isset( $target_groups[ $target_physical_name ] ) ) { + $target_groups[ $target_physical_name ] = array( + 'alias' => $target_alias, + 'table' => $target_table['table'], + 'ctid_aliases' => array(), + ); + } + + $target_tables[] = array( + 'alias' => $target_alias, + 'table' => $target_table['table'], + 'physical_name' => $target_physical_name, + ); + } + + $where_sql = ''; + if ( null !== $where_position ) { + $where_end = $order_position ?? $limit_position ?? $statement_end; + if ( $where_position + 1 >= $where_end ) { + return null; + } + + if ( null !== $information_schema_source_translation ) { + $where = $this->translate_direct_information_schema_dml_predicate_to_postgresql( + $query, + $tokens, + $where_position + 1, + $where_end, + $information_schema_source_translation['context'] + ); + if ( null === $where ) { + return null; + } + $where_sql = ' WHERE ' . $where; + } else { + if ( ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $where_end ) ) { + return null; + } + + $where = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + $where_sql = ' WHERE ' . $where['sql']; + } + } + + $order_sql = ''; + if ( null !== $order_position ) { + $order_sql = $this->translate_mysql_joined_dml_order_by_clause_to_postgresql( $tokens, $order_position, $order_end, $scope ); + if ( null === $order_sql ) { + return null; + } + } + + $limit_sql = ''; + if ( null !== $limit_position ) { + $limit_sql = $this->translate_simple_dml_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end, true ); + if ( null === $limit_sql ) { + return null; + } + } + + $select_columns = array(); + foreach ( $target_tables as $index => $target_table ) { + $ctid_alias = 'mysql_delete_target_' . $index . '_ctid'; + $target_alias_sql = $this->connection->quote_identifier( $target_table['alias'] ); + + $select_columns[] = sprintf( + '%s.ctid AS %s', + $target_alias_sql, + $this->connection->quote_identifier( $ctid_alias ) + ); + $target_groups[ $target_table['physical_name'] ]['ctid_aliases'][] = $ctid_alias; + } + + $delete_ctes = array(); + $count_parts = array(); + foreach ( array_values( $target_groups ) as $index => $target_group ) { + $delete_cte_name = 'mysql_delete_target_' . $index; + $target_alias_sql = $this->connection->quote_identifier( $target_group['alias'] ); + if ( 1 === count( $target_group['ctid_aliases'] ) ) { + $ctid_predicate = sprintf( + '%s.ctid = mysql_delete_rows.%s', + $target_alias_sql, + $this->connection->quote_identifier( $target_group['ctid_aliases'][0] ) + ); + } else { + $ctid_selects = array(); + foreach ( $target_group['ctid_aliases'] as $ctid_alias ) { + $ctid_selects[] = sprintf( + 'SELECT %s FROM mysql_delete_rows', + $this->connection->quote_identifier( $ctid_alias ) + ); + } + $ctid_predicate = sprintf( + '%s.ctid IN (%s)', + $target_alias_sql, + implode( ' UNION ', $ctid_selects ) + ); + } + + $delete_ctes[] = sprintf( + '%s AS (DELETE FROM %s AS %s USING mysql_delete_rows WHERE %s RETURNING 1)', + $delete_cte_name, + $this->connection->quote_identifier( $target_group['table'] ), + $target_alias_sql, + $ctid_predicate + ); + $count_parts[] = sprintf( '(SELECT COUNT(*) FROM %s)', $delete_cte_name ); + } + + return sprintf( + 'WITH mysql_delete_rows AS MATERIALIZED (SELECT %s FROM %s%s%s%s), %s SELECT %s AS affected_rows', + implode( ', ', $select_columns ), + $source_sql, + $where_sql, + $order_sql, + $limit_sql, + implode( ', ', $delete_ctes ), + implode( ' + ', $count_parts ) + ); + } + + /** + * Translate a DML source range that reads information_schema relations. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $source_start First source token. + * @param int $source_end Final source token, exclusive. + * @return array{scope: array, sql: string, context: array}|null Source translation, or null. + */ + private function get_direct_information_schema_dml_source_translation( string $query, array $tokens, int $source_start, int $source_end ): ?array { + $parsed_sources = $this->parse_direct_information_schema_select_sources( $query, $tokens, $source_start, $source_end ); + if ( null === $parsed_sources ) { + return null; + } + + $context = array( + 'sources' => $parsed_sources['sources'], + 'join_predicate_ranges' => $parsed_sources['join_predicate_ranges'], + 'join_predicate_replacements' => $parsed_sources['join_predicate_replacements'], + 'using_columns' => $parsed_sources['using_columns'], + ); + + $scope = array( + 'tables' => array(), + 'aliases' => array(), + 'unknown' => false, + ); + foreach ( $context['sources'] as $source ) { + if ( ! isset( $source['table'] ) ) { + continue; + } + + $table = array( + 'schema' => $this->resolve_mysql_table_schema_for_introspection( 'public', $source['table'] ), + 'table' => $source['table'], + ); + $alias = strtolower( $source['alias'] ); + if ( isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $scope['tables'][] = $table; + $scope['aliases'][ $alias ] = $table; + } + + if ( empty( $scope['tables'] ) ) { + return null; + } + + $replacements = $this->get_direct_information_schema_dml_source_replacements( $tokens, $context ); + if ( null === $replacements ) { + return null; + } + + return array( + 'scope' => $scope, + 'sql' => $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, $source_start, $source_end, $replacements ), + 'context' => $context, + ); + } + + /** + * Get source and JOIN-predicate replacements for an information_schema DML source. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $context Direct information_schema context. + * @return array[]|null Replacement ranges, or null when unsupported. + */ + private function get_direct_information_schema_dml_source_replacements( array $tokens, array $context ): ?array { + $replacements = array(); + foreach ( $context['join_predicate_replacements'] as $replacement ) { + $replacements[] = $replacement; + } + + foreach ( $context['join_predicate_ranges'] as $range ) { + $current_database_function_replacements = $this->get_direct_information_schema_current_database_function_replacements( + $tokens, + $range['start'], + $range['end'] + ); + if ( null === $current_database_function_replacements ) { + return null; + } + + $column_replacements = $this->get_direct_information_schema_column_replacements( + $tokens, + $range['start'], + $range['end'], + $context, + $current_database_function_replacements + ); + if ( null === $column_replacements ) { + return null; + } + + foreach ( array_merge( $current_database_function_replacements, $column_replacements ) as $replacement ) { + $replacements[] = $replacement; + } + } + + $source_replacements = $this->get_direct_information_schema_source_replacements( $context ); + if ( null === $source_replacements ) { + return null; + } + + foreach ( $source_replacements as $replacement ) { + $replacements[] = $replacement; + } + + usort( + $replacements, + static function ( array $left, array $right ): int { + return $left['start'] <=> $right['start']; + } + ); + + return $replacements; + } + + /** + * Translate a DML predicate that may reference information_schema sources. + * + * @param string|null $query Original MySQL query, or null when nested SELECTs are unsupported. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token. + * @param int $end Final predicate token, exclusive. + * @param array $context Direct information_schema context. + * @return string|null PostgreSQL predicate SQL, or null when unsupported. + */ + private function translate_direct_information_schema_dml_predicate_to_postgresql( ?string $query, array $tokens, int $start, int $end, array $context ): ?string { + $nested_select_replacements = array(); + if ( $this->contains_mysql_token( $tokens, $start, $end, array( WP_MySQL_Lexer::SELECT_SYMBOL ) ) ) { + if ( + null === $query + || $this->contains_mysql_token( $tokens, $start, $end, array( WP_MySQL_Lexer::UNION_SYMBOL ) ) + ) { + return null; + } + + $nested_select_replacements = $this->get_direct_information_schema_nested_select_replacements( + $query, + $tokens, + array( + array( + 'start' => $start, + 'end' => $end, + ), + ) + ); + if ( + null === $nested_select_replacements + || ! $this->direct_information_schema_nested_selects_are_covered( $tokens, $start, $end, $nested_select_replacements ) + ) { + return null; + } + } + + $current_database_function_replacements = $this->get_direct_information_schema_current_database_function_replacements( $tokens, $start, $end, $nested_select_replacements ); + if ( null === $current_database_function_replacements ) { + return null; + } + + $column_replacements = $this->get_direct_information_schema_column_replacements( + $tokens, + $start, + $end, + $context, + array_merge( $nested_select_replacements, $current_database_function_replacements ) + ); + if ( null === $column_replacements ) { + return null; + } + + $replacements = array_merge( $nested_select_replacements, $current_database_function_replacements, $column_replacements ); + usort( + $replacements, + static function ( array $left, array $right ): int { + return $left['start'] <=> $right['start']; + } + ); + + return $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, $start, $end, $replacements ); + } + + /** + * Parse a MySQL DELETE target alias list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First target token. + * @param int $end Final target token, exclusive. + * @return string[]|null Target aliases, or null when unsupported. + */ + private function parse_mysql_delete_target_aliases( array $tokens, int $start, int $end ): ?array { + $aliases = array(); + $position = $start; + + while ( $position < $end ) { + $alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $alias ) { + return null; + } + ++$position; + + if ( + $position + 1 < $end + && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position ]->id ?? null ) + && WP_MySQL_Lexer::MULT_OPERATOR === ( $tokens[ $position + 1 ]->id ?? null ) + ) { + $position += 2; + } + + $alias_key = strtolower( $alias ); + if ( isset( $aliases[ $alias_key ] ) ) { + return null; + } + $aliases[ $alias_key ] = $alias; + + if ( $position === $end ) { + break; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return null; + } + ++$position; + } + + return empty( $aliases ) ? null : array_values( $aliases ); + } + + /** + * Translate MySQL single-target joined DELETE statements. + * + * bbPress emits DELETE alias FROM target AS alias LEFT JOIN ... WHERE ... + * repair queries. PostgreSQL has no MySQL-style DELETE target list, and a + * direct DELETE USING rewrite would collapse LEFT JOIN semantics. Select the + * target physical rows through an equivalent joined subquery instead. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_mysql_single_target_join_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2] ) + || WP_MySQL_Lexer::DELETE_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[2]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 3 ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, 3, $statement_end ); + if ( null === $where_position || 3 >= $where_position || $where_position + 1 >= $statement_end ) { + return null; + } + + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 3, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 3, $statement_end ); + if ( + ( null !== $order_position && $order_position < $where_position ) + || ( null !== $limit_position && $limit_position < $where_position ) + || ( null !== $limit_position && null !== $order_position && $limit_position < $order_position ) + ) { + return null; + } + + $delete_alias = $this->get_mysql_identifier_token_value( $tokens[1] ); + $target_ref = $this->parse_mysql_table_reference( $tokens, 3, $where_position ); + if ( null === $delete_alias || null === $target_ref ) { + return null; + } + $this->get_mysql_writable_table_backend_schema( + array( + 'schema' => $target_ref['schema'], + 'table' => $target_ref['table'], + ), + 'DELETE' + ); + + $target_alias = null === $target_ref['alias'] ? $target_ref['table'] : $target_ref['alias']; + if ( + strtolower( $delete_alias ) !== strtolower( $target_alias ) + && strtolower( $delete_alias ) !== strtolower( $target_ref['table'] ) + ) { + return null; + } + + if ( null === $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::JOIN_SYMBOL, $target_ref['position'], $where_position ) ) { + return null; + } + + $information_schema_source_translation = null; + if ( + 0 === strcasecmp( $this->db_name, 'information_schema' ) + || $this->direct_information_schema_source_range_references_information_schema( $tokens, 3, $where_position ) + ) { + $information_schema_source_translation = $this->get_direct_information_schema_dml_source_translation( + $query, + $tokens, + 3, + $where_position + ); + if ( null === $information_schema_source_translation ) { + return null; + } + $scope = $information_schema_source_translation['scope']; + $source_sql = $information_schema_source_translation['sql']; + } else { + $scope = $this->get_mysql_select_scope( $tokens, 3, $where_position ); + if ( null === $scope || ! empty( $scope['unknown'] ) ) { + return null; + } + $source_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 3, $where_position ); + } + + $where_end = $order_position ?? $limit_position ?? $statement_end; + if ( $where_position + 1 >= $where_end ) { + return null; + } + + if ( null !== $information_schema_source_translation ) { + $where_sql = $this->translate_direct_information_schema_dml_predicate_to_postgresql( + $query, + $tokens, + $where_position + 1, + $where_end, + $information_schema_source_translation['context'] + ); + if ( null === $where_sql ) { + return null; + } + } else { + $where = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + $where_sql = $where['sql']; + } + + $order_sql = ''; + if ( null !== $order_position ) { + $order_end = $limit_position ?? $statement_end; + $order_sql = $this->translate_mysql_joined_dml_order_by_clause_to_postgresql( + $tokens, + $order_position, + $order_end, + $scope + ); + if ( null === $order_sql ) { + return null; + } + } + + $limit_sql = ''; + if ( null !== $limit_position ) { + $limit_sql = $this->translate_simple_dml_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end, true ); + if ( null === $limit_sql ) { + return null; + } + } + + $target_alias_sql = $this->connection->quote_identifier( $target_alias ); + + return sprintf( + 'DELETE FROM %s AS %s WHERE %s.ctid IN (SELECT %s.ctid FROM %s WHERE %s%s%s)', + $this->connection->quote_identifier( $target_ref['table'] ), + $target_alias_sql, + $target_alias_sql, + $target_alias_sql, + $source_sql, + $where_sql, + $order_sql, + $limit_sql + ); + } + + /** + * Check whether a WHERE clause is the null-rejected side of a LEFT JOIN. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First WHERE predicate token. + * @param int $end Final WHERE predicate token, exclusive. + * @param string $alias Joined table alias. + * @return bool Whether the predicate matches ". IS NULL". + */ + private function is_mysql_null_rejected_join_alias_predicate( array $tokens, int $start, int $end, string $alias ): bool { + if ( + $start + 5 !== $end + || WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + || WP_MySQL_Lexer::IS_SYMBOL !== ( $tokens[ $start + 3 ]->id ?? null ) + || WP_MySQL_Lexer::NULL_SYMBOL !== ( $tokens[ $start + 4 ]->id ?? null ) + ) { + return false; + } + + $predicate_alias = $this->get_mysql_identifier_token_value( $tokens[ $start ] ?? null ); + $column = $this->get_mysql_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + return null !== $predicate_alias + && null !== $column + && strtolower( $predicate_alias ) === strtolower( $alias ); + } + + /** + * Translate simple single-table MySQL DELETE statements to PostgreSQL. + * + * WordPress option deletes emit a single target table and a plain WHERE + * clause. Some plugins also use MySQL's single-table alias and ORDER BY + * forms. Ordered deletes are rewritten through PostgreSQL ctid subqueries so + * ORDER BY expressions are translated and unsupported expressions fail closed. + * Multi-table DELETE variants fall through unchanged so unsupported SQL still + * fails visibly in the backend. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_simple_mysql_delete_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0] ) + || WP_MySQL_Lexer::DELETE_SYMBOL !== $tokens[0]->id + ) { + return null; + } + + $position = 1; + $this->consume_mysql_delete_modifiers( $tokens, $position ); + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $table_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $statement_end ); + if ( null === $table_reference ) { + return null; + } + + $table_name = $table_reference['table']; + $alias = $table_reference['alias']; + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $position, $statement_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $position, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $position, $statement_end ); + + if ( + ( null !== $where_position && $where_position !== $position ) + || ( null !== $order_position && null !== $where_position && $order_position < $where_position ) + || ( null !== $limit_position && null !== $where_position && $limit_position < $where_position ) + || ( null !== $limit_position && null !== $order_position && $limit_position < $order_position ) + || ( null === $where_position && null === $order_position && null !== $limit_position && $limit_position !== $position ) + ) { + return null; + } + + $unsupported_tokens = array( + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::REGEXP_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::USING_SYMBOL, + ); + $unsupported_token_scan_end = $order_position ?? $limit_position ?? $statement_end; + if ( $this->contains_top_level_mysql_token( $tokens, $position, $unsupported_token_scan_end, $unsupported_tokens ) ) { + return null; + } + + $where_end = $order_position ?? $limit_position ?? $statement_end; + $where_sql = null; + $scope = $this->get_mysql_single_table_scope( $table_name, $alias ); + if ( null !== $where_position ) { + $where_replacements = $this->get_simple_mysql_dml_predicate_nested_select_replacements( + $query, + $tokens, + $where_position + 1, + $where_end + ); + if ( + $where_position + 1 >= $where_end + || null === $where_replacements + || ! $this->is_supported_simple_mysql_expression_fragment_with_replacements( $tokens, $where_position + 1, $where_end, $where_replacements ) + ) { + return null; + } + + $where = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope, + $where_replacements + ); + $where_sql = $where['sql']; + } + + $order_sql = ''; + if ( null !== $order_position ) { + $order_end = $limit_position ?? $statement_end; + $order_sql = $this->translate_mysql_joined_dml_order_by_clause_to_postgresql( + $tokens, + $order_position, + $order_end, + $scope + ); + if ( null === $order_sql ) { + return null; + } + } + + $limit_sql = ''; + if ( null !== $limit_position ) { + $limit_sql = $this->translate_simple_dml_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end, true ); + if ( null === $limit_sql ) { + return null; + } + } + + $table_sql = $this->get_postgresql_dml_table_reference_sql( $table_name, $alias ); + if ( '' !== $order_sql || '' !== $limit_sql ) { + $subquery_where_sql = null === $where_sql ? '' : ' WHERE ' . $where_sql; + return sprintf( + 'DELETE FROM %s WHERE %s IN (SELECT %s FROM %s%s%s%s)', + $table_sql, + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $table_sql, + $subquery_where_sql, + $order_sql, + $limit_sql + ); + } + + $sql = 'DELETE FROM ' . $table_sql; + if ( null !== $where_sql ) { + $sql .= ' WHERE ' . $where_sql; + } + + return $sql; + } + + /** + * Check whether a top-level DELETE statement reached the unsupported fallback. + * + * @param string $query MySQL query. + * @return bool Whether this is an unsupported DELETE statement. + */ + private function is_unsupported_mysql_delete_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + return isset( $tokens[0] ) && WP_MySQL_Lexer::DELETE_SYMBOL === $tokens[0]->id; + } + + /** + * Translate supported INSERT ... ON DUPLICATE KEY UPDATE queries. + * + * WordPress emits MySQL upserts for a small set of VALUES inserts. Keep this + * path structured and metadata-backed: explicit column-list VALUES inserts, + * VALUES inserts whose missing column list can be inferred from table + * metadata, simple INSERT ... SET assignments, and conservative INSERT ... + * SELECT forms are supported. The conflict target must resolve to a known + * primary/unique key. + * + * @param string $query MySQL query. + * @return array|null PostgreSQL query data, or null when the query is unsupported. + */ + private function translate_mysql_on_duplicate_key_update_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $position = 1; + $this->consume_mysql_insert_priority_modifier( $tokens, $position ); + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $table_reference_start = $position; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + $table_reference_end = $position; + + $column_metadata = null; + $infer_columns_from_metadata = false; + $insert_select_column_list = false; + $value_rows = null; + $value_range_rows = array(); + $probe_safe_rows = array(); + $upsert_source_aliases = array(); + $on_duplicate = $this->find_on_duplicate_key_update_clause( $tokens, $position ); + if ( null === $on_duplicate ) { + return null; + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $columns = array(); + $infer_columns_from_metadata = true; + $insert_select_column_list = true; + } elseif ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position ]->id ) { + $columns = array(); + $infer_columns_from_metadata = true; + $insert_select_column_list = true; + } elseif ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + } elseif ( $this->is_mysql_values_row_list_keyword_token( $tokens[ $position ] ?? null ) ) { + $columns = array(); + $infer_columns_from_metadata = true; + } elseif ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $set_end = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $position, $on_duplicate ) ?? $on_duplicate; + $set_assignments = $this->parse_simple_mysql_insert_set_assignments( $table_name, $tokens, $position, $set_end ); + if ( null === $set_assignments ) { + return null; + } + + $columns = $set_assignments['columns']; + $value_rows = array( $set_assignments['values'] ); + $value_range_rows = array( $set_assignments['ranges'] ); + $probe_safe_rows = array( $set_assignments['probe_safe_values'] ); + $position = $set_end; + if ( $position < $on_duplicate ) { + $upsert_source_aliases = $this->parse_mysql_upsert_values_alias_clause( $tokens, $position, $on_duplicate, $columns ); + if ( null === $upsert_source_aliases ) { + return null; + } + } + } else { + return null; + } + + if ( $infer_columns_from_metadata ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + $columns = $this->get_mysql_dml_column_names_from_metadata( $column_metadata ); + if ( null === $columns ) { + return null; + } + } + + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + ) + ) { + return $this->translate_mysql_insert_select_on_duplicate_key_update_query( + $table_name, + $columns, + $tokens, + $position, + $on_duplicate, + $statement_end, + $table_reference_start, + $table_reference_end, + $insert_select_column_list + ); + } + + if ( null === $value_rows ) { + if ( ! $this->is_mysql_values_row_list_keyword_token( $tokens[ $position ] ?? null ) ) { + return null; + } + + ++$position; + $value_rows = $this->parse_mysql_values_rows( $tokens, $position, $on_duplicate, count( $columns ), $probe_safe_rows, $value_range_rows ); + if ( null === $value_rows ) { + return null; + } + + $upsert_source_aliases = $this->parse_mysql_upsert_values_alias_clause( $tokens, $position, $on_duplicate, $columns ); + if ( null === $upsert_source_aliases ) { + return null; + } + } + + if ( null === $column_metadata ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + } + foreach ( $value_rows as $row_index => &$values ) { + $value_ranges = $value_range_rows[ $row_index ] ?? array(); + $this->validate_strict_mysql_dml_values_for_columns( + $columns, + $value_ranges, + $tokens, + $column_metadata + ); + $this->normalize_mysql_auto_increment_zero_values_for_columns( + $columns, + $values, + $value_ranges, + $tokens, + $column_metadata + ); + $this->normalize_strict_mysql_dml_values_for_columns( + $columns, + $values, + $value_ranges, + $tokens, + $column_metadata + ); + $this->normalize_non_strict_mysql_dml_values_for_columns( + $columns, + $values, + $value_ranges, + $tokens, + $column_metadata + ); + } + unset( $values ); + + $table_column_lookup = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $conflict_target = $this->get_mysql_upsert_conflict_target( + $table_name, + $columns, + $value_rows, + $probe_safe_rows + ); + $uses_omitted_conflict_target = false; + if ( + null === $conflict_target + && 1 === count( $value_rows ) + && null === $this->get_mysql_auto_increment_column_from_metadata( $table_column_lookup ) + ) { + $conflict_target = $this->get_mysql_upsert_omitted_column_conflict_target( $table_name, $columns ); + $uses_omitted_conflict_target = null !== $conflict_target; + } + $conflict_columns = null === $conflict_target ? array() : $conflict_target['columns']; + $conflict_indexes = null === $conflict_target ? null : $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ); + if ( null !== $conflict_target && null === $conflict_indexes ) { + if ( ! $uses_omitted_conflict_target ) { + return null; + } + } + + $column_lookup = array(); + foreach ( $columns as $column ) { + $column_lookup[ strtolower( $column ) ] = $column; + } + + $position = $on_duplicate + 4; + + $assignment_effects = array(); + $assignments = $this->parse_upsert_update_assignments( $table_name, $tokens, $position, $statement_end, $column_lookup, $table_column_lookup, $upsert_source_aliases, $assignment_effects ); + if ( null === $assignments || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + if ( null === $conflict_target ) { + if ( count( $value_rows ) < 2 ) { + return null; + } + + if ( isset( $assignment_effects['last_insert_id_column'] ) ) { + return null; + } + + $per_row_upsert = $this->get_mysql_per_row_upsert_statements_for_ambiguous_targets( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $assignments, + $assignment_effects['assigned_columns'] ?? array() + ); + if ( null === $per_row_upsert ) { + return null; + } + + return array( + 'action' => 'upsert', + 'statements' => $per_row_upsert['statements'], + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $per_row_upsert['inserted_value_rows'][0] ?? array(), + 'value_rows' => $per_row_upsert['inserted_value_rows'], + 'insert_id_value_rows' => $value_rows, + 'conflict_columns' => $per_row_upsert['conflict_columns'], + 'conflict_index_groups' => $per_row_upsert['conflict_index_groups'], + 'inserted_new_row' => count( $per_row_upsert['inserted_value_rows'] ) > 0, + ); + } + + if ( $uses_omitted_conflict_target ) { + $inserted_value_rows = $value_rows; + } else { + $inserted_value_rows = $this->get_mysql_upsert_inserted_value_rows( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $conflict_target['parts'] + ); + if ( null === $inserted_value_rows ) { + return null; + } + } + + $last_insert_id_on_duplicate_key_update = null; + if ( isset( $assignment_effects['last_insert_id_column'] ) ) { + $last_insert_id_row = $this->get_mysql_upsert_last_insert_id_row_for_value_rows( + $table_name, + (string) $assignment_effects['last_insert_id_column'], + $value_rows, + $probe_safe_rows, + $conflict_indexes, + count( $inserted_value_rows ) > 0 + ); + if ( null === $last_insert_id_row ) { + return null; + } + if ( $last_insert_id_row['found'] ) { + $last_insert_id_on_duplicate_key_update = $last_insert_id_row['value']; + } + } + + $sql_value_rows = array(); + foreach ( $value_rows as $values ) { + $sql_value_rows[] = '(' . implode( ', ', $values ) . ')'; + } + + $column_sql = implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ); + $conflict_sql = sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + implode( ', ', $conflict_target['sql'] ), + implode( ', ', $assignments ) + ); + $upsert_query = array( + 'action' => 'upsert', + 'sql' => sprintf( + 'INSERT INTO %s (%s) %s %s', + $this->connection->quote_identifier( $table_name ), + $column_sql, + 'VALUES ' . implode( ', ', $sql_value_rows ), + $conflict_sql + ), + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $inserted_value_rows[0] ?? array(), + 'value_rows' => $inserted_value_rows, + 'insert_id_value_rows' => $value_rows, + 'conflict_columns' => $conflict_columns, + 'conflict_indexes' => $conflict_indexes, + 'inserted_new_row' => count( $inserted_value_rows ) > 0, + ); + if ( null !== $last_insert_id_on_duplicate_key_update ) { + $upsert_query['last_insert_id_on_duplicate_key_update'] = $last_insert_id_on_duplicate_key_update; + } + + if ( null !== $conflict_indexes && $this->has_duplicate_mysql_upsert_conflict_value_rows( $value_rows, $probe_safe_rows, $conflict_indexes ) ) { + $statements = array(); + foreach ( $value_rows as $values ) { + $statements[] = sprintf( + 'INSERT INTO %s (%s) VALUES (%s) %s', + $this->connection->quote_identifier( $table_name ), + $column_sql, + implode( ', ', $values ), + $conflict_sql + ); + } + $upsert_query['statements'] = $statements; + } + + return $upsert_query; + } + + /** + * Check whether a query is an unsupported INSERT ... ON DUPLICATE KEY UPDATE statement. + * + * @param string $query MySQL query. + * @return bool Whether the query contains an unsupported upsert clause. + */ + private function is_unsupported_mysql_on_duplicate_key_update_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + return isset( $tokens[0] ) + && WP_MySQL_Lexer::INSERT_SYMBOL === $tokens[0]->id + && null !== $this->find_on_duplicate_key_update_clause( $tokens, 1 ); + } + + /** + * Translate conservative INSERT ... SELECT ... ON DUPLICATE KEY UPDATE queries. + * + * Single literal-row SELECTs can use the direct PostgreSQL ON CONFLICT shape. + * Real SELECT sources are materialized once so duplicate incoming conflict + * keys can replay one row at a time with MySQL's sequential upsert semantics. + * AUTO_INCREMENT insert ID tracking uses literal rows when possible and + * materialized source rows when runtime ordering is required. + * + * @param string $table_name Target table name. + * @param string[] $columns Insert target columns. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position at SELECT or parenthesized SELECT. + * @param int $on_duplicate ON DUPLICATE KEY UPDATE token position. + * @param int $statement_end Final statement token position, exclusive. + * @param int $table_reference_start First target table-reference token. + * @param int $table_reference_end Final target table-reference token, exclusive. + * @param bool $insert_column_list Whether to inject an inferred column list. + * @return array|null PostgreSQL query data, or null when unsupported. + */ + private function translate_mysql_insert_select_on_duplicate_key_update_query( + string $table_name, + array $columns, + array $tokens, + int $position, + int $on_duplicate, + int $statement_end, + int $table_reference_start, + int $table_reference_end, + bool $insert_column_list = false + ): ?array { + $select_start = $position; + $select_end = $on_duplicate; + $table_reference_sql = $this->get_mysql_main_database_table_reference_sql( + $tokens, + $table_reference_start, + $table_reference_end + ); + if ( $insert_column_list ) { + $table_reference_sql .= ' (' . implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ) . ')'; + } + $outer_replacements = array( + array( + 'start' => 0, + 'end' => $table_reference_end, + 'sql' => 'INSERT INTO ' . $table_reference_sql, + ), + ); + $closing_replacement = array(); + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $on_duplicate ); + if ( + null === $after_close + || $after_close !== $on_duplicate + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $select_start = $position + 1; + $select_end = $on_duplicate - 1; + $outer_replacements[] = array( + 'start' => $position, + 'end' => $position + 1, + 'sql' => '', + ); + $closing_replacement[] = array( + 'start' => $on_duplicate - 1, + 'end' => $on_duplicate, + 'sql' => '', + ); + } + + if ( ! isset( $tokens[ $select_start ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $select_start ]->id ) { + return null; + } + + $table_column_lookup = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $table_column_lookup ); + $literal_value_row = null; + $explicit_identity_columns = array(); + $ambiguous_conflict_candidates = array(); + + $conflict_target = $this->get_mysql_upsert_conflict_target( $table_name, $columns ); + if ( null === $conflict_target ) { + $literal_value_row = $this->get_mysql_insert_select_upsert_literal_value_row( + $table_name, + $columns, + $tokens, + $select_start, + $select_end + ); + if ( null !== $literal_value_row ) { + $conflict_target = $this->get_mysql_upsert_conflict_target( + $table_name, + $columns, + array( $literal_value_row['values'] ), + array( $literal_value_row['probe_safe_values'] ) + ); + } + if ( null === $conflict_target ) { + if ( null !== $literal_value_row ) { + return null; + } + + $ambiguous_conflict_candidates = $this->get_mysql_upsert_conflict_target_candidates( $table_name, $columns ); + if ( count( $ambiguous_conflict_candidates ) < 2 ) { + return null; + } + } + } + $conflict_columns = null === $conflict_target + ? $this->get_mysql_upsert_conflict_candidate_columns( $ambiguous_conflict_candidates ) + : $conflict_target['columns']; + + $column_lookup = array(); + foreach ( $columns as $column ) { + $column_lookup[ strtolower( $column ) ] = $column; + } + + $assignment_position = $on_duplicate + 4; + $assignment_effects = array(); + $assignments = $this->parse_upsert_update_assignments( + $table_name, + $tokens, + $assignment_position, + $statement_end, + $column_lookup, + $table_column_lookup, + array(), + $assignment_effects + ); + if ( null === $assignments || ! $this->is_at_mysql_query_end( $tokens, $assignment_position ) ) { + return null; + } + + if ( null === $conflict_target ) { + if ( isset( $assignment_effects['last_insert_id_column'] ) ) { + return null; + } + + if ( + null !== $auto_increment_column + && ! $this->can_mysql_insert_select_upsert_skip_auto_increment_literal_probe( + $auto_increment_column, + $columns, + $conflict_columns + ) + ) { + if ( ! $this->mysql_dml_column_list_contains_column( $columns, $auto_increment_column ) ) { + return null; + } + + $explicit_identity_columns[ strtolower( $auto_increment_column ) ] = true; + } + + $replacements = $this->get_mysql_insert_select_projection_replacements( + $table_name, + $columns, + $tokens, + $select_start, + $select_end + ); + if ( null === $replacements ) { + return null; + } + $replacements = array_merge( $outer_replacements, $replacements, $closing_replacement ); + + $materialized_flow = $this->get_mysql_insert_select_upsert_materialized_flow_for_ambiguous_targets( + $table_name, + $columns, + $tokens, + $select_start, + $select_end, + $ambiguous_conflict_candidates, + $assignments, + $assignment_effects['assigned_columns'] ?? array() + ); + if ( null === $materialized_flow ) { + return null; + } + + return array_merge( + array( + 'action' => 'upsert', + 'sql' => $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 0, + $on_duplicate, + $replacements + ), + 'table_name' => $table_name, + 'columns' => $columns, + 'conflict_columns' => $conflict_columns, + 'inserted_new_row' => true, + 'value_rows' => null, + 'insert_id_value_rows' => null, + 'insert_id_unknown' => ! empty( $explicit_identity_columns ), + 'explicit_identity_columns' => $explicit_identity_columns, + ), + $materialized_flow + ); + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ?? array() ); + if ( null === $conflict_indexes ) { + return null; + } + + $last_insert_id_on_duplicate_key_update = null; + $last_insert_id_column_on_duplicate_key_update = null; + if ( isset( $assignment_effects['last_insert_id_column'] ) ) { + if ( null === $literal_value_row ) { + $literal_value_row = $this->get_mysql_insert_select_upsert_literal_value_row( + $table_name, + $columns, + $tokens, + $select_start, + $select_end + ); + } + if ( null === $literal_value_row ) { + $last_insert_id_column_on_duplicate_key_update = (string) $assignment_effects['last_insert_id_column']; + } else { + $last_insert_id_row = $this->get_mysql_upsert_conflicting_row_column_value( + $table_name, + (string) $assignment_effects['last_insert_id_column'], + $literal_value_row['values'], + $literal_value_row['probe_safe_values'], + $conflict_indexes + ); + if ( null === $last_insert_id_row ) { + return null; + } + if ( $last_insert_id_row['found'] ) { + $last_insert_id_on_duplicate_key_update = $last_insert_id_row['value']; + } + } + } + $conflict_sql = sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + implode( ', ', $conflict_target['sql'] ), + implode( ', ', $assignments ) + ); + + $inserted_value_rows = null; + $insert_id_value_rows = null; + if ( null !== $auto_increment_column ) { + if ( null === $literal_value_row ) { + $literal_value_row = $this->get_mysql_insert_select_upsert_literal_value_row( + $table_name, + $columns, + $tokens, + $select_start, + $select_end + ); + } + if ( null === $literal_value_row ) { + if ( + ! $this->can_mysql_insert_select_upsert_skip_auto_increment_literal_probe( + $auto_increment_column, + $columns, + $conflict_columns + ) + ) { + if ( ! $this->mysql_dml_column_list_contains_column( $columns, $auto_increment_column ) ) { + return null; + } + + $explicit_identity_columns[ strtolower( $auto_increment_column ) ] = true; + } + } else { + $insert_id_value_rows = array( $literal_value_row['insert_id_values'] ); + $inserted_value_rows = $this->get_mysql_upsert_inserted_value_rows( + $table_name, + $columns, + $insert_id_value_rows, + array( $literal_value_row['probe_safe_values'] ), + $conflict_target['parts'] + ); + if ( null === $inserted_value_rows ) { + return null; + } + } + } + + $replacements = $this->get_mysql_insert_select_projection_replacements( + $table_name, + $columns, + $tokens, + $select_start, + $select_end + ); + if ( null === $replacements ) { + return null; + } + $replacements = array_merge( $outer_replacements, $replacements, $closing_replacement ); + + $upsert_query = array( + 'action' => 'upsert', + 'sql' => sprintf( + '%s %s', + $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 0, + $on_duplicate, + $replacements + ), + $conflict_sql + ), + 'table_name' => $table_name, + 'columns' => $columns, + 'conflict_columns' => $conflict_columns, + 'conflict_indexes' => $conflict_indexes, + 'inserted_new_row' => null === $inserted_value_rows ? true : count( $inserted_value_rows ) > 0, + 'value_rows' => $inserted_value_rows, + 'insert_id_value_rows' => $insert_id_value_rows, + 'insert_id_unknown' => ! empty( $explicit_identity_columns ), + 'explicit_identity_columns' => $explicit_identity_columns, + ); + if ( null !== $last_insert_id_on_duplicate_key_update ) { + $upsert_query['last_insert_id_on_duplicate_key_update'] = $last_insert_id_on_duplicate_key_update; + } + if ( null !== $last_insert_id_column_on_duplicate_key_update ) { + $upsert_query['last_insert_id_column_on_duplicate_key_update'] = $last_insert_id_column_on_duplicate_key_update; + } + + $literal_select_row = $literal_value_row; + if ( null === $literal_select_row ) { + $literal_select_row = $this->get_mysql_insert_select_upsert_literal_value_row( + $table_name, + $columns, + $tokens, + $select_start, + $select_end + ); + } + if ( null === $literal_select_row ) { + $materialized_flow = $this->get_mysql_insert_select_upsert_materialized_flow( + $table_name, + $columns, + $tokens, + $select_start, + $select_end, + $conflict_indexes, + $conflict_target['parts'], + $conflict_sql + ); + if ( null === $materialized_flow ) { + return null; + } + + $upsert_query = array_merge( $upsert_query, $materialized_flow ); + } + + return $upsert_query; + } + + /** + * Get a materialized execution flow for real SELECT-sourced upserts. + * + * @param string $table_name Target table name. + * @param string[] $columns Insert target columns. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $select_start SELECT token position. + * @param int $select_end Final SELECT token position, exclusive. + * @param array[] $conflict_indexes Conflict target column/index tuples. + * @param array[] $conflict_parts Conflict target key parts. + * @param string $conflict_sql PostgreSQL ON CONFLICT clause. + * @return array|null Materialized flow metadata, or null when unsupported. + */ + private function get_mysql_insert_select_upsert_materialized_flow( string $table_name, array $columns, array $tokens, int $select_start, int $select_end, array $conflict_indexes, array $conflict_parts, string $conflict_sql ): ?array { + $select_sql = $this->get_mysql_replace_select_source_sql( + $table_name, + $columns, + array(), + $tokens, + $select_start, + $select_end + ); + if ( null === $select_sql ) { + return null; + } + + $temp_table_hash = substr( md5( 'upsert-select' . "\0" . $table_name . "\0" . $select_start . "\0" . $select_end . "\0" . implode( "\0", $columns ) ), 0, 12 ); + $temp_table_name = '__wp_pg_upsert_select_' . $temp_table_hash; + $ordinal_table_name = '__wp_pg_upsert_select_ord_' . $temp_table_hash; + $quoted_temp_table = $this->connection->quote_identifier( $temp_table_name ); + $quoted_ordinal_table = $this->connection->quote_identifier( $ordinal_table_name ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_upsert_rows' ); + $quoted_target_table = $this->connection->quote_identifier( $table_name ); + + $duplicate_conflict_rows_sql = $this->get_mysql_replace_select_duplicate_conflict_rows_sql( + $quoted_temp_table, + $rows_alias, + array( $conflict_indexes ) + ); + if ( null === $duplicate_conflict_rows_sql ) { + return null; + } + + $insert_projection_sql = array(); + foreach ( $columns as $column ) { + $insert_projection_sql[] = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( $column ) + ); + } + + $drop_sql = sprintf( 'DROP TABLE IF EXISTS %s', $quoted_temp_table ); + $insert_sql = sprintf( + 'INSERT INTO %s (%s) SELECT %s FROM %s AS %s WHERE 1 = 1 %s', + $quoted_target_table, + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + implode( ', ', $insert_projection_sql ), + $quoted_temp_table, + $rows_alias, + $conflict_sql + ); + + return array( + 'upsert_select_materialized' => true, + 'materialize_statements' => array( + $drop_sql, + sprintf( 'CREATE TEMPORARY TABLE %s AS %s', $quoted_temp_table, $select_sql ), + ), + 'mutation_statements' => array( + $insert_sql, + ), + 'cleanup_statements' => array( + $drop_sql, + ), + 'duplicate_conflict_rows_sql' => $duplicate_conflict_rows_sql, + 'source_table_sql' => $quoted_temp_table, + 'ordinal_source_table_sql' => $quoted_ordinal_table, + 'conflict_sql' => $conflict_sql, + 'conflict_parts' => $conflict_parts, + ); + } + + /** + * Get a materialized execution flow for real SELECT-sourced upserts with multiple arbiters. + * + * @param string $table_name Table name. + * @param string[] $columns Insert target columns. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $select_start SELECT token position. + * @param int $select_end Final SELECT token position, exclusive. + * @param array[] $candidates Metadata-backed unique-key candidates. + * @param string[] $assignments PostgreSQL UPDATE assignments. + * @param string[] $assigned_columns Assignment target columns keyed by lowercase name. + * @return array|null Materialized flow metadata, or null when unsupported. + */ + private function get_mysql_insert_select_upsert_materialized_flow_for_ambiguous_targets( string $table_name, array $columns, array $tokens, int $select_start, int $select_end, array $candidates, array $assignments, array $assigned_columns ): ?array { + if ( count( $candidates ) < 2 ) { + return null; + } + + $conflict_targets = array(); + $conflict_index_groups = array(); + foreach ( $candidates as $candidate ) { + foreach ( $candidate['parts'] as $part ) { + if ( isset( $assigned_columns[ strtolower( (string) ( $part['column'] ?? '' ) ) ] ) ) { + return null; + } + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $candidate['parts'] ); + if ( null === $conflict_indexes ) { + return null; + } + + $conflict_targets[] = $this->get_mysql_upsert_conflict_target_from_candidate( $candidate ); + $conflict_index_groups[] = $conflict_indexes; + } + + $select_sql = $this->get_mysql_replace_select_source_sql( + $table_name, + $columns, + array(), + $tokens, + $select_start, + $select_end + ); + if ( null === $select_sql ) { + return null; + } + + $temp_table_hash = substr( md5( 'upsert-select-ambiguous' . "\0" . $table_name . "\0" . $select_start . "\0" . $select_end . "\0" . implode( "\0", $columns ) ), 0, 12 ); + $temp_table_name = '__wp_pg_upsert_select_' . $temp_table_hash; + $ordinal_table_name = '__wp_pg_upsert_select_ord_' . $temp_table_hash; + $quoted_temp_table = $this->connection->quote_identifier( $temp_table_name ); + $quoted_ordinal_table = $this->connection->quote_identifier( $ordinal_table_name ); + $drop_sql = sprintf( 'DROP TABLE IF EXISTS %s', $quoted_temp_table ); + + return array( + 'upsert_select_materialized' => true, + 'upsert_select_ambiguous_conflict_targets' => true, + 'materialize_statements' => array( + $drop_sql, + sprintf( 'CREATE TEMPORARY TABLE %s AS %s', $quoted_temp_table, $select_sql ), + ), + 'mutation_statements' => array(), + 'cleanup_statements' => array( + $drop_sql, + ), + 'source_table_sql' => $quoted_temp_table, + 'ordinal_source_table_sql' => $quoted_ordinal_table, + 'conflict_targets' => $conflict_targets, + 'conflict_index_groups' => $conflict_index_groups, + 'assignments' => $assignments, + ); + } + + /** + * Check whether a DML column list contains a column name. + * + * @param string[] $columns DML column names. + * @param string $column_name Column name to find. + * @return bool Whether the column list contains the column. + */ + private function mysql_dml_column_list_contains_column( array $columns, string $column_name ): bool { + foreach ( $columns as $column ) { + if ( 0 === strcasecmp( (string) $column, $column_name ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a non-literal INSERT ... SELECT upsert may skip AUTO_INCREMENT probing. + * + * @param string $auto_increment_column AUTO_INCREMENT column name. + * @param string[] $columns Insert target columns. + * @param string[] $conflict_columns Resolved conflict target columns. + * @return bool Whether the generated column is outside the insert and conflict targets. + */ + private function can_mysql_insert_select_upsert_skip_auto_increment_literal_probe( string $auto_increment_column, array $columns, array $conflict_columns ): bool { + $auto_increment_key = strtolower( $auto_increment_column ); + foreach ( $columns as $column ) { + if ( strtolower( (string) $column ) === $auto_increment_key ) { + return false; + } + } + + foreach ( $conflict_columns as $column ) { + if ( strtolower( (string) $column ) === $auto_increment_key ) { + return false; + } + } + + return true; + } + + /** + * Get a bounded literal row from a supported SELECT-sourced upsert. + * + * Conflict and identity decisions only consume probe-safe columns. Constant + * expressions in other projections may be retained as translated SQL and + * flagged as unsafe for probing. + * + * @param string $table_name Target table name. + * @param string[] $columns Target column names. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $select_start SELECT token position. + * @param int $select_end Final SELECT token position, exclusive. + * @return array{values: string[], insert_id_values: string[], probe_safe_values: bool[]}|null Literal row data, or null when unsupported. + */ + private function get_mysql_insert_select_upsert_literal_value_row( string $table_name, array $columns, array $tokens, int $select_start, int $select_end ): ?array { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $select_start + 1, $select_end ); + if ( null !== $from_position ) { + if ( + $from_position + 2 !== $select_end + || WP_MySQL_Lexer::DUAL_SYMBOL !== ( $tokens[ $from_position + 1 ]->id ?? null ) + ) { + return null; + } + } + + $projection_end = $from_position ?? $select_end; + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $select_start + 1, $projection_end ); + if ( null === $projection_ranges || count( $projection_ranges ) !== count( $columns ) ) { + return null; + } + + $target_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $values = array(); + $insert_id_values = array(); + $probe_safe_values = array(); + foreach ( $projection_ranges as $index => $range ) { + $probe_safe = $this->is_supported_mysql_upsert_conflict_probe_token_sequence( $tokens, $range['start'], $range['end'] ); + $constant_expression = false; + $constant_integer_expression = null; + if ( ! $probe_safe ) { + $constant_expression = $this->is_supported_mysql_upsert_literal_select_expression( $tokens, $range['start'], $range['end'] ); + if ( ! $constant_expression ) { + return null; + } + + $constant_integer_expression = $this->get_mysql_constant_integer_expression_value( $tokens, $range['start'], $range['end'] ); + $probe_safe = true; + } + + $column_key = strtolower( (string) $columns[ $index ] ); + $projection_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $range['start'], $range['end'] ); + $insert_id_sql = $projection_sql; + $column_metadata = $target_metadata[ $column_key ] ?? null; + if ( null !== $column_metadata ) { + $coerced_sql = $this->get_mysql_insert_select_projection_sql_for_target_column( + $table_name, + $column_metadata, + $tokens, + $range['start'], + $range['end'], + $projection_sql, + null + ); + if ( null !== $coerced_sql ) { + $projection_sql = $coerced_sql; + } + + if ( + $this->is_mysql_auto_increment_column_metadata( $column_metadata ) + ) { + if ( $this->is_mysql_generated_auto_increment_value_sql( $projection_sql ) ) { + $insert_id_sql = $projection_sql; + } else { + if ( null === $constant_integer_expression && $constant_expression ) { + return null; + } + + if ( null !== $constant_integer_expression ) { + $insert_id_sql = $constant_integer_expression; + } + } + } + } + + $values[] = $projection_sql; + $insert_id_values[] = $insert_id_sql; + $probe_safe_values[] = $probe_safe; + } + + return array( + 'values' => $values, + 'insert_id_values' => $insert_id_values, + 'probe_safe_values' => $probe_safe_values, + ); + } + + /** + * Parse a bounded sequence of one or more MySQL VALUES rows. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $end Final token position, exclusive. + * @param int $expected_count Expected number of row values. + * @param array $probe_safe_rows Updated with conflict-probe safety flags. + * @param array $value_range_rows Updated with original token ranges for each value. + * @return array[]|null Translated PostgreSQL VALUES rows, or null when unsupported. + */ + private function parse_mysql_values_rows( array $tokens, int &$position, int $end, int $expected_count, array &$probe_safe_rows, array &$value_range_rows ): ?array { + $rows = array(); + $probe_safe_rows = array(); + $value_range_rows = array(); + + while ( $position < $end ) { + $probe_safe_values = array(); + $parsed_values = $this->parse_mysql_value_list_with_probe_safety( $tokens, $position, $probe_safe_values ); + if ( null === $parsed_values || count( $parsed_values['values'] ) !== $expected_count ) { + return null; + } + + $rows[] = $parsed_values['values']; + $probe_safe_rows[] = $probe_safe_values; + $value_range_rows[] = $parsed_values['ranges']; + + if ( + $position === $end + || WP_MySQL_Lexer::AS_SYMBOL === ( $tokens[ $position ]->id ?? null ) + ) { + return $rows; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + } + + return count( $rows ) > 0 ? $rows : null; + } + + /** + * Parse an optional MySQL VALUES-row alias for ON DUPLICATE KEY UPDATE. + * + * MySQL 8 accepts "VALUES (...) AS row_alias" and optional column aliases. + * The alias is only meaningful inside the update expressions, where it maps + * back to the inserted row represented by PostgreSQL's excluded pseudo-table. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $end ON DUPLICATE token position. + * @param string[] $columns Insert target columns in value order. + * @return array{row: string, qualified: array, unqualified: array}|array{}|null Alias map, empty when no alias is present, or null when malformed. + */ + private function parse_mysql_upsert_values_alias_clause( array $tokens, int &$position, int $end, array $columns ): ?array { + if ( $position === $end ) { + return array(); + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AS_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $row_alias = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $row_alias ) { + return null; + } + + $position += 2; + + $qualified = array(); + foreach ( $columns as $column ) { + $qualified[ strtolower( $column ) ] = $column; + } + + $unqualified = array(); + if ( $position < $end && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $column_aliases = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $column_aliases || count( $column_aliases ) !== count( $columns ) ) { + return null; + } + + $seen_aliases = array(); + foreach ( $column_aliases as $index => $column_alias ) { + $alias_key = strtolower( $column_alias ); + if ( isset( $seen_aliases[ $alias_key ] ) ) { + return null; + } + + $seen_aliases[ $alias_key ] = true; + $qualified[ $alias_key ] = $columns[ $index ]; + $unqualified[ $alias_key ] = $columns[ $index ]; + } + } + + if ( $position !== $end ) { + return null; + } + + return array( + 'row' => strtolower( $row_alias ), + 'qualified' => $qualified, + 'unqualified' => $unqualified, + ); + } + + /** + * Parse a parenthesized single-row MySQL VALUES list with probe safety. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param bool[] $probe_safety Updated with per-value conflict-probe safety. + * @return array{values: string[], ranges: array[]}|null Translated SQL values and token ranges, or null when unsupported. + */ + private function parse_mysql_value_list_with_probe_safety( array $tokens, int &$position, array &$probe_safety ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $values = array(); + $ranges = array(); + $probe_safety = array(); + $value_start = $position; + $depth = 0; + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + ++$position; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + if ( 0 === $depth ) { + if ( $value_start === $position ) { + return null; + } + + $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $position ); + $ranges[] = array( + 'start' => $value_start, + 'end' => $position, + ); + $probe_safety[] = $this->is_supported_mysql_upsert_conflict_probe_token_sequence( $tokens, $value_start, $position ); + ++$position; + return array( + 'values' => $values, + 'ranges' => $ranges, + ); + } + + --$depth; + ++$position; + continue; + } + + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + if ( $value_start === $position ) { + return null; + } + + $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $position ); + $ranges[] = array( + 'start' => $value_start, + 'end' => $position, + ); + $probe_safety[] = $this->is_supported_mysql_upsert_conflict_probe_token_sequence( $tokens, $value_start, $position ); + $value_start = $position + 1; + } + + ++$position; + } + + return null; + } + + /** + * Check whether a VALUES item is safe for a conflict preflight probe. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position, inclusive. + * @param int $end Final value token position, exclusive. + * @return bool Whether the value is a deterministic literal. + */ + private function is_supported_mysql_upsert_conflict_probe_token_sequence( array $tokens, int $start, int $end ): bool { + if ( $start + 1 !== $end || ! isset( $tokens[ $start ] ) ) { + return false; + } + + return in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::BIN_NUMBER, + WP_MySQL_Lexer::DECIMAL_NUMBER, + WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, + WP_MySQL_Lexer::FALSE_SYMBOL, + WP_MySQL_Lexer::FLOAT_NUMBER, + WP_MySQL_Lexer::HEX_NUMBER, + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::NULL_SYMBOL, + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + WP_MySQL_Lexer::TRUE_SYMBOL, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ); + } + + /** + * Check whether a SELECT-sourced upsert projection is a bounded constant expression. + * + * These expressions may be translated into the final INSERT ... SELECT, but + * they are not safe for conflict preflight probes. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position, inclusive. + * @param int $end Final value token position, exclusive. + * @return bool Whether the expression is a supported constant expression. + */ + private function is_supported_mysql_upsert_literal_select_expression( array $tokens, int $start, int $end ): bool { + if ( $start >= $end ) { + return false; + } + + for ( $position = $start; $position < $end; $position++ ) { + if ( + null !== $this->get_mysql_identifier_token_value( $tokens[ $position ] ) + || in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::COMMA_SYMBOL, WP_MySQL_Lexer::DOT_SYMBOL ), true ) + ) { + return false; + } + + if ( ! $this->is_supported_simple_mysql_expression_token( $tokens[ $position ] ) ) { + return false; + } + } + + return true; + } + + /** + * Resolve the PostgreSQL upsert conflict target from MySQL index metadata. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @param array[] $value_rows Optional translated VALUES rows for ambiguous target probing. + * @param array[] $probe_safe_rows Optional per-value conflict-probe safety flags. + * @return array{columns: string[], parts: array, sql: string[]}|null Conflict target, or null when unsupported. + */ + private function get_mysql_upsert_conflict_target( + string $table_name, + array $columns, + ?array $value_rows = null, + ?array $probe_safe_rows = null + ): ?array { + $insert_columns = array(); + foreach ( $columns as $column ) { + $insert_column = strtolower( $column ); + + $insert_columns[] = $insert_column; + } + sort( $insert_columns, SORT_STRING ); + + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ) . "\0" . serialize( $insert_columns ); + if ( array_key_exists( $cache_key, $this->mysql_upsert_conflict_target_cache ) ) { + $cached = $this->mysql_upsert_conflict_target_cache[ $cache_key ]; + return null === $cached ? null : $cached; + } + + $candidates = $this->get_mysql_upsert_conflict_target_candidates( $table_name, $columns ); + + if ( 1 !== count( $candidates ) ) { + if ( null !== $value_rows && null !== $probe_safe_rows && count( $candidates ) > 1 ) { + return $this->get_mysql_upsert_conflict_target_for_value_rows( + $table_name, + $columns, + $candidates, + $value_rows, + $probe_safe_rows + ); + } + + return null; + } + + $conflict_target = $this->get_mysql_upsert_conflict_target_from_candidate( $candidates[0] ); + + $this->mysql_upsert_conflict_target_cache[ $cache_key ] = $conflict_target; + return $conflict_target; + } + + /** + * Resolve a single unique-key target whose columns are not all in the INSERT list. + * + * PostgreSQL can still arbitrate these upserts because omitted columns use + * their table defaults in the excluded row. Keep this to one target so we do + * not guess between multiple MySQL duplicate-key candidates. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @return array{columns: string[], parts: array, sql: string[]}|null Conflict target, or null when unsupported. + */ + private function get_mysql_upsert_omitted_column_conflict_target( string $table_name, array $columns ): ?array { + $insert_column_lookup = array(); + foreach ( $columns as $column ) { + $insert_column_lookup[ strtolower( (string) $column ) ] = true; + } + + $omitted_candidates = array(); + foreach ( $this->get_mysql_upsert_conflict_target_candidates( $table_name, $columns, true ) as $candidate ) { + foreach ( $candidate['columns'] as $column ) { + if ( ! isset( $insert_column_lookup[ strtolower( (string) $column ) ] ) ) { + $omitted_candidates[] = $candidate; + continue 2; + } + } + } + + if ( 1 !== count( $omitted_candidates ) ) { + return null; + } + + return $this->get_mysql_upsert_conflict_target_from_candidate( $omitted_candidates[0] ); + } + + /** + * Get metadata-backed unique-key candidates usable as PostgreSQL upsert arbiters. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @param bool $allow_omitted_columns Whether unique keys may include columns omitted from the INSERT list. + * @return array}> Conflict candidates. + */ + private function get_mysql_upsert_conflict_target_candidates( string $table_name, array $columns, bool $allow_omitted_columns = false ): array { + $insert_column_lookup = array(); + foreach ( $columns as $column ) { + $insert_column_lookup[ strtolower( (string) $column ) ] = true; + } + + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $stmt = $this->connection->query( + sprintf( + 'SELECT key_name, column_name, index_type, sub_part + FROM %s + WHERE table_schema = ? AND table_name = ? AND non_unique = \'0\' + ORDER BY + CASE WHEN UPPER(key_name) = \'PRIMARY\' THEN 0 ELSE 1 END, + index_ordinal, + seq_in_index', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + $indexes = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $key_name = (string) ( $row['key_name'] ?? '' ); + if ( '' === $key_name ) { + continue; + } + + if ( ! isset( $indexes[ $key_name ] ) ) { + $indexes[ $key_name ] = array( + 'columns' => array(), + 'index_type' => strtoupper( (string) ( $row['index_type'] ?? 'BTREE' ) ), + 'parts' => array(), + ); + } + + $column_name = (string) ( $row['column_name'] ?? '' ); + if ( '' === $column_name ) { + continue; + } + + $indexes[ $key_name ]['columns'][] = $column_name; + $indexes[ $key_name ]['parts'][] = array( + 'column' => $column_name, + 'sub_part' => null !== ( $row['sub_part'] ?? null ) && '' !== (string) $row['sub_part'] ? (string) $row['sub_part'] : null, + ); + } + + $candidates = array(); + foreach ( $indexes as $index ) { + if ( empty( $index['columns'] ) ) { + continue; + } + + if ( in_array( $index['index_type'], array( 'FULLTEXT', 'SPATIAL' ), true ) ) { + continue; + } + + foreach ( $index['columns'] as $column ) { + if ( ! $allow_omitted_columns && ! isset( $insert_column_lookup[ strtolower( $column ) ] ) ) { + continue 2; + } + } + + $candidates[] = array( + 'columns' => $index['columns'], + 'parts' => $index['parts'], + ); + } + + return $candidates; + } + + /** + * Get the unique set of columns used by a list of upsert conflict candidates. + * + * @param array[] $candidates Metadata-backed unique-key candidates. + * @return string[] Candidate column names. + */ + private function get_mysql_upsert_conflict_candidate_columns( array $candidates ): array { + $columns = array(); + foreach ( $candidates as $candidate ) { + foreach ( $candidate['columns'] ?? array() as $column ) { + $column_key = strtolower( (string) $column ); + if ( isset( $columns[ $column_key ] ) ) { + continue; + } + + $columns[ $column_key ] = (string) $column; + } + } + + return array_values( $columns ); + } + + /** + * Resolve an ambiguous upsert conflict target from deterministic VALUES rows. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @param array[] $candidates Candidate unique-key targets. + * @param array[] $value_rows Translated PostgreSQL VALUES rows. + * @param array[] $probe_safe_rows Per-value conflict-probe safety flags. + * @return array{columns: string[], parts: array, sql: string[]}|null Conflict target, or null when unsupported. + */ + private function get_mysql_upsert_conflict_target_for_value_rows( + string $table_name, + array $columns, + array $candidates, + array $value_rows, + array $probe_safe_rows + ): ?array { + $conflicting_candidates = array(); + + foreach ( $candidates as $candidate ) { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $candidate['parts'] ); + if ( null === $conflict_indexes ) { + return null; + } + + $candidate_conflicts = false; + foreach ( $value_rows as $row_index => $values ) { + $probe_safety = $probe_safe_rows[ $row_index ] ?? array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( ! isset( $probe_safety[ $conflict_index['index'] ] ) || ! $probe_safety[ $conflict_index['index'] ] ) { + return null; + } + } + + $conflict_exists = $this->mysql_upsert_conflict_exists( $table_name, $values, $conflict_indexes ); + if ( null === $conflict_exists ) { + return null; + } + + if ( $conflict_exists ) { + $candidate_conflicts = true; + } + } + + if ( $candidate_conflicts ) { + $conflicting_candidates[] = $candidate; + } + } + + if ( 1 === count( $conflicting_candidates ) ) { + return $this->get_mysql_upsert_conflict_target_from_candidate( $conflicting_candidates[0] ); + } + + if ( 0 === count( $conflicting_candidates ) ) { + $conflict_index_groups = array(); + foreach ( $candidates as $candidate ) { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $candidate['parts'] ); + if ( null === $conflict_indexes ) { + return null; + } + $conflict_index_groups[] = $conflict_indexes; + } + if ( $this->has_duplicate_mysql_replace_conflict_value_rows_in_groups( $value_rows, $probe_safe_rows, $conflict_index_groups ) ) { + return null; + } + + return $this->get_mysql_upsert_conflict_target_from_candidate( $candidates[0] ); + } + + return null; + } + + /** + * Build per-row upsert statements when one PostgreSQL arbiter cannot model MySQL. + * + * MySQL checks every unique key for each VALUES row. PostgreSQL requires one + * ON CONFLICT target, so mixed deterministic batches are replayed row by row + * only when each row has zero or one provable conflict target. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @param array[] $value_rows Translated PostgreSQL VALUES rows. + * @param array[] $probe_safe_rows Per-value conflict-probe safety flags. + * @param string[] $assignments PostgreSQL UPDATE assignments. + * @param string[] $assigned_columns Assignment target columns keyed by lowercase name. + * @return array{statements: string[], inserted_value_rows: array[], conflict_columns: string[], conflict_index_groups: array[]}|null Per-row flow, or null when unsupported. + */ + private function get_mysql_per_row_upsert_statements_for_ambiguous_targets( string $table_name, array $columns, array $value_rows, array $probe_safe_rows, array $assignments, array $assigned_columns ): ?array { + $candidates = $this->get_mysql_upsert_conflict_target_candidates( $table_name, $columns ); + if ( count( $candidates ) < 2 ) { + return null; + } + + $conflict_index_groups = array(); + foreach ( $candidates as $candidate ) { + foreach ( $candidate['parts'] as $part ) { + if ( isset( $assigned_columns[ strtolower( (string) ( $part['column'] ?? '' ) ) ] ) ) { + return null; + } + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $candidate['parts'] ); + if ( null === $conflict_indexes ) { + return null; + } + + $conflict_index_groups[] = $conflict_indexes; + } + + $column_sql = implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ); + $inserted_value_rows = array(); + $statements = array(); + $seen_inserted_values = array(); + $used_columns = array(); + + foreach ( $value_rows as $row_index => $values ) { + $probe_safety = $probe_safe_rows[ $row_index ] ?? array(); + $matching_indexes = array(); + + foreach ( $conflict_index_groups as $candidate_index => $conflict_indexes ) { + foreach ( $conflict_indexes as $conflict_index ) { + if ( ! isset( $probe_safety[ $conflict_index['index'] ] ) || ! $probe_safety[ $conflict_index['index'] ] ) { + return null; + } + } + + $conflict_exists = $this->mysql_upsert_conflict_exists( $table_name, $values, $conflict_indexes ); + if ( null === $conflict_exists ) { + return null; + } + + $seen_key = $this->get_mysql_replace_conflict_seen_key_for_row( $values, $conflict_indexes ); + $seen_conflict = null !== $seen_key && isset( $seen_inserted_values[ $candidate_index ][ $seen_key ] ); + if ( $conflict_exists || $seen_conflict ) { + $matching_indexes[] = $candidate_index; + } + } + + if ( count( $matching_indexes ) > 1 ) { + return null; + } + + $target_index = 1 === count( $matching_indexes ) ? $matching_indexes[0] : 0; + $conflict_target = $this->get_mysql_upsert_conflict_target_from_candidate( $candidates[ $target_index ] ); + foreach ( $conflict_target['columns'] as $column ) { + $used_columns[ strtolower( $column ) ] = $column; + } + + $statements[] = sprintf( + 'INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s', + $this->connection->quote_identifier( $table_name ), + $column_sql, + implode( ', ', $values ), + implode( ', ', $conflict_target['sql'] ), + implode( ', ', $assignments ) + ); + + if ( 0 === count( $matching_indexes ) ) { + $inserted_value_rows[] = $values; + foreach ( $conflict_index_groups as $candidate_index => $conflict_indexes ) { + $seen_key = $this->get_mysql_replace_conflict_seen_key_for_row( $values, $conflict_indexes ); + if ( null !== $seen_key ) { + $seen_inserted_values[ $candidate_index ][ $seen_key ] = true; + } + } + } + } + + return array( + 'statements' => $statements, + 'inserted_value_rows' => $inserted_value_rows, + 'conflict_columns' => array_values( $used_columns ), + 'conflict_index_groups' => $conflict_index_groups, + ); + } + + /** + * Build a normalized conflict target from a unique-key candidate. + * + * @param array{columns: string[], parts: array} $candidate Unique-key candidate. + * @return array{columns: string[], parts: array, sql: string[]} Conflict target. + */ + private function get_mysql_upsert_conflict_target_from_candidate( array $candidate ): array { + $conflict_target = array( + 'columns' => array_values( $candidate['columns'] ), + 'parts' => array_values( $candidate['parts'] ), + 'sql' => array(), + ); + foreach ( $conflict_target['parts'] as $part ) { + $conflict_target['sql'][] = $this->get_mysql_index_key_part_sql( $part['column'], $part['sub_part'] ); + } + + return $conflict_target; + } + + /** + * Get the VALUES rows that will insert rather than update on conflict. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @param array[] $value_rows Translated PostgreSQL VALUES rows. + * @param array[] $probe_safe_rows Per-value conflict-probe safety flags. + * @param array[] $conflict_parts Conflict target key parts. + * @return array[]|null Inserted VALUES rows, or null when unsupported. + */ + private function get_mysql_upsert_inserted_value_rows( string $table_name, array $columns, array $value_rows, array $probe_safe_rows, array $conflict_parts ): ?array { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_parts ); + if ( null === $conflict_indexes ) { + return null; + } + + $inserted_rows = array(); + foreach ( $value_rows as $row_index => $values ) { + $probe_safety = $probe_safe_rows[ $row_index ] ?? array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( ! isset( $probe_safety[ $conflict_index['index'] ] ) || ! $probe_safety[ $conflict_index['index'] ] ) { + return null; + } + } + + $conflict_exists = $this->mysql_upsert_conflict_exists( $table_name, $values, $conflict_indexes ); + if ( null === $conflict_exists ) { + return null; + } + + if ( $conflict_exists ) { + continue; + } + + $inserted_rows[] = $values; + } + + return $inserted_rows; + } + + /** + * Resolve conflict key parts to value indexes in an INSERT column list. + * + * @param string[] $columns Inserted column names. + * @param array[] $conflict_parts Conflict target key parts. + * @return array|null Conflict indexes, or null when unsupported. + */ + private function get_mysql_upsert_conflict_indexes( array $columns, array $conflict_parts ): ?array { + $column_indexes = array(); + foreach ( $columns as $index => $column ) { + $column_indexes[ strtolower( $column ) ] = $index; + } + + $conflict_indexes = array(); + foreach ( $conflict_parts as $part ) { + $column = (string) ( $part['column'] ?? '' ); + $column_key = strtolower( $column ); + if ( ! isset( $column_indexes[ $column_key ] ) ) { + return null; + } + + $conflict_indexes[] = array( + 'column' => $column, + 'index' => $column_indexes[ $column_key ], + 'sub_part' => $part['sub_part'] ?? null, + ); + } + + return $conflict_indexes; + } + + /** + * Check whether a VALUES row conflicts with the selected upsert target. + * + * @param string $table_name Table name. + * @param array $values Translated PostgreSQL VALUES row. + * @param array $conflict_indexes Conflict target column/index tuples. + * @return bool|null Whether the row currently conflicts, or null when unsupported. + */ + private function mysql_upsert_conflict_exists( string $table_name, array $values, array $conflict_indexes ): ?bool { + $where = array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( ! array_key_exists( $conflict_index['index'], $values ) ) { + return null; + } + + $value = (string) $values[ $conflict_index['index'] ]; + if ( $this->is_mysql_generated_auto_increment_value_sql( $value ) ) { + return false; + } + + if ( null !== ( $conflict_index['sub_part'] ?? null ) && '' !== (string) $conflict_index['sub_part'] ) { + $where[] = sprintf( + '%s = SUBSTR(CAST(%s AS text), 1, %d)', + $this->get_mysql_index_key_part_sql( (string) $conflict_index['column'], $conflict_index['sub_part'] ), + $value, + (int) $conflict_index['sub_part'] + ); + } else { + $where[] = sprintf( + '%s = %s', + $this->connection->quote_identifier( (string) $conflict_index['column'] ), + $value + ); + } + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s WHERE %s LIMIT 1', + $this->connection->quote_identifier( $table_name ), + implode( ' AND ', $where ) + ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Resolve LAST_INSERT_ID(column) for deterministic VALUES-sourced upserts. + * + * @param string $table_name Table name. + * @param string $column_name Target column to read. + * @param array[] $value_rows Translated PostgreSQL VALUES rows. + * @param array[] $probe_safe_rows Per-value conflict-probe safety flags. + * @param array $conflict_indexes Conflict target column/index tuples. + * @param bool $has_inserted_rows Whether the batch contains rows that do not currently conflict. + * @return array{found: bool, value: mixed}|null Conflict row value, or null when unsupported. + */ + private function get_mysql_upsert_last_insert_id_row_for_value_rows( string $table_name, string $column_name, array $value_rows, array $probe_safe_rows, array $conflict_indexes, bool $has_inserted_rows ): ?array { + $found = false; + $value = null; + foreach ( $value_rows as $row_index => $values ) { + $row = $this->get_mysql_upsert_conflicting_row_column_value( + $table_name, + $column_name, + $values, + $probe_safe_rows[ $row_index ] ?? array(), + $conflict_indexes + ); + if ( null === $row ) { + return null; + } + + if ( ! $row['found'] ) { + continue; + } + + $found = true; + $value = $row['value']; + } + + if ( $found && $has_inserted_rows ) { + return null; + } + + return array( + 'found' => $found, + 'value' => $value, + ); + } + + /** + * Fetch a conflicting target-row column for a deterministic upsert row. + * + * @param string $table_name Table name. + * @param string $column_name Target column to read. + * @param array $values Translated PostgreSQL VALUES row. + * @param array $probe_safety Per-value conflict-probe safety flags. + * @param array $conflict_indexes Conflict target column/index tuples. + * @return array{found: bool, value: mixed}|null Conflict row value, or null when unsupported. + */ + private function get_mysql_upsert_conflicting_row_column_value( string $table_name, string $column_name, array $values, array $probe_safety, array $conflict_indexes ): ?array { + $where = array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( + ! array_key_exists( $conflict_index['index'], $values ) + || empty( $probe_safety[ $conflict_index['index'] ] ) + ) { + return null; + } + + $value = (string) $values[ $conflict_index['index'] ]; + if ( $this->is_mysql_generated_auto_increment_value_sql( $value ) ) { + return array( + 'found' => false, + 'value' => null, + ); + } + + if ( null !== ( $conflict_index['sub_part'] ?? null ) && '' !== (string) $conflict_index['sub_part'] ) { + $where[] = sprintf( + '%s = SUBSTR(CAST(%s AS text), 1, %d)', + $this->get_mysql_index_key_part_sql( (string) $conflict_index['column'], $conflict_index['sub_part'] ), + $value, + (int) $conflict_index['sub_part'] + ); + } else { + $where[] = sprintf( + '%s = %s', + $this->connection->quote_identifier( (string) $conflict_index['column'] ), + $value + ); + } + } + + if ( empty( $where ) ) { + return null; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT %s FROM %s WHERE %s LIMIT 1', + $this->connection->quote_identifier( $column_name ), + $this->connection->quote_identifier( $table_name ), + implode( ' AND ', $where ) + ) + ); + $value = $stmt->fetchColumn(); + + return array( + 'found' => false !== $value, + 'value' => false === $value ? null : $value, + ); + } + + /** + * Check whether an upsert batch contains duplicate deterministic conflict rows. + * + * PostgreSQL cannot update the same row twice in one INSERT ... ON CONFLICT + * statement. MySQL applies VALUES rows sequentially, so duplicate conflict + * keys need one PostgreSQL statement per input row. + * + * @param array[] $value_rows Translated VALUES rows. + * @param array[] $probe_safe_rows Per-value probe safety. + * @param array $conflict_indexes Conflict target column/index tuples. + * @return bool Whether PostgreSQL needs per-row statements. + */ + private function has_duplicate_mysql_upsert_conflict_value_rows( array $value_rows, array $probe_safe_rows, array $conflict_indexes ): bool { + return $this->has_duplicate_mysql_replace_conflict_value_rows( $value_rows, $probe_safe_rows, $conflict_indexes ); + } + + /** + * Translate simple MySQL REPLACE statements to PostgreSQL. + * + * WordPress' wpdb::replace() emits VALUES rows with an explicit column list. + * Columnless VALUES rows are supported when stored MySQL metadata can infer + * the target columns. LOW_PRIORITY and DELAYED are accepted as compatibility + * no-ops. For rows with a known WordPress unique key, use PostgreSQL's ON + * CONFLICT update path and synthesize MySQL's affected-row count in query(). + * Without a known conflict column, fall back to a plain INSERT so PostgreSQL + * still reports normal constraint and length errors. + * + * @param string $query MySQL query. + * @return array|null PostgreSQL query data, or null when the query is unsupported. + */ + private function translate_simple_mysql_replace_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::REPLACE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + $this->consume_mysql_replace_priority_modifier( $tokens, $position ); + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $table_reference_start = $position; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + $table_reference_end = $position; + + $column_metadata = null; + $insert_column_list = false; + $value_rows = null; + $value_range_rows = array(); + $probe_safe_rows = array(); + $statement_end = null; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + $columns = $this->get_mysql_dml_column_names_from_metadata( $column_metadata ); + $insert_column_list = true; + if ( null === $columns ) { + return null; + } + } elseif ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + } elseif ( $this->is_mysql_values_row_list_keyword_token( $tokens[ $position ] ?? null ) ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + $columns = $this->get_mysql_dml_column_names_from_metadata( $column_metadata ); + if ( null === $columns ) { + return null; + } + } elseif ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position ]->id ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + $columns = $this->get_mysql_dml_column_names_from_metadata( $column_metadata ); + $insert_column_list = true; + if ( null === $columns ) { + return null; + } + } elseif ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position ]->id ) { + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return null; + } + + ++$position; + $set_assignments = $this->parse_simple_mysql_insert_set_assignments( $table_name, $tokens, $position, $statement_end ); + if ( null === $set_assignments ) { + return null; + } + + $columns = $set_assignments['columns']; + $value_rows = array( $set_assignments['values'] ); + $value_range_rows = array( $set_assignments['ranges'] ); + $probe_safe_rows = array( $set_assignments['probe_safe_values'] ); + $position = $statement_end; + } else { + return null; + } + + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + ) + ) { + return $this->translate_simple_mysql_replace_select_query( + $query, + $table_name, + $columns, + $tokens, + $position, + $table_reference_start, + $table_reference_end, + $insert_column_list + ); + } + + if ( null === $value_rows ) { + if ( ! $this->is_mysql_values_row_list_keyword_token( $tokens[ $position ] ?? null ) ) { + return null; + } + + ++$position; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return null; + } + + $value_rows = $this->parse_mysql_values_rows( $tokens, $position, $statement_end, count( $columns ), $probe_safe_rows, $value_range_rows ); + if ( null === $value_rows || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + } + + if ( null === $column_metadata ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + } + foreach ( $value_rows as $row_index => &$values ) { + $value_ranges = $value_range_rows[ $row_index ] ?? array(); + $this->validate_strict_mysql_dml_values_for_columns( + $columns, + $value_ranges, + $tokens, + $column_metadata + ); + $this->normalize_mysql_auto_increment_zero_values_for_columns( + $columns, + $values, + $value_ranges, + $tokens, + $column_metadata + ); + $this->normalize_strict_mysql_dml_values_for_columns( + $columns, + $values, + $value_ranges, + $tokens, + $column_metadata + ); + $this->normalize_non_strict_mysql_dml_values_for_columns( + $columns, + $values, + $value_ranges, + $tokens, + $column_metadata + ); + } + unset( $values ); + $this->append_non_strict_dml_defaults_for_omitted_value_rows( $table_name, $columns, $value_rows, $column_metadata ); + + $value_sql_rows = array(); + foreach ( $value_rows as $values ) { + $value_sql_rows[] = '(' . implode( ', ', $values ) . ')'; + } + + $sql = sprintf( + 'INSERT INTO %s (%s) VALUES %s', + $this->connection->quote_identifier( $table_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + implode( ', ', $value_sql_rows ) + ); + + $conflict_target = $this->get_mysql_replace_conflict_target( + $table_name, + $columns, + $value_rows, + $probe_safe_rows + ); + $delete_conflict_index_groups = $this->get_mysql_replace_delete_conflict_index_groups( + $table_name, + $columns, + $value_rows, + $probe_safe_rows + ); + $all_delete_conflict_index_groups = $this->get_mysql_replace_select_delete_conflict_index_groups( $table_name, $columns ); + if ( + null !== $conflict_target + && count( $all_delete_conflict_index_groups ) > count( $delete_conflict_index_groups ) + ) { + $materialized_values_flow = $this->get_mysql_replace_values_delete_then_insert_flow( + $table_name, + $columns, + $value_rows, + $conflict_target, + $all_delete_conflict_index_groups + ); + if ( null !== $materialized_values_flow ) { + return array_merge( + array( + 'action' => 'replace', + 'table_name' => $table_name, + 'columns' => $columns, + 'value_rows' => $value_rows, + 'conflict_column' => $conflict_target['columns'][0] ?? null, + 'conflict_target' => $conflict_target, + 'conflict_probe_safe_rows' => $probe_safe_rows, + 'inserted_new_row' => true, + ), + $materialized_values_flow + ); + } + } + if ( null === $conflict_target ) { + if ( ! empty( $delete_conflict_index_groups ) ) { + $delete_insert_statements = $this->get_mysql_replace_delete_then_insert_statements( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $delete_conflict_index_groups, + $this->has_duplicate_mysql_replace_conflict_value_rows_in_groups( $value_rows, $probe_safe_rows, $delete_conflict_index_groups ) + ); + if ( null !== $delete_insert_statements ) { + return array( + 'action' => 'replace', + 'sql' => $sql, + 'statements' => $delete_insert_statements, + 'table_name' => $table_name, + 'columns' => $columns, + 'value_rows' => $value_rows, + 'conflict_column' => null, + 'conflict_probe_safe_rows' => $probe_safe_rows, + 'delete_then_insert' => true, + 'inserted_new_row' => true, + ); + } + } + + return array( + 'action' => 'replace', + 'sql' => $sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'value_rows' => $value_rows, + 'conflict_column' => null, + 'conflict_value' => null, + 'inserted_new_row' => true, + ); + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ); + if ( null === $conflict_indexes ) { + return null; + } + if ( empty( $delete_conflict_index_groups ) ) { + $delete_conflict_index_groups = array( $conflict_indexes ); + } + + $assignments = array(); + foreach ( $columns as $column ) { + $assignments[] = sprintf( + '%s = excluded.%s', + $this->connection->quote_identifier( $column ), + $this->connection->quote_identifier( $column ) + ); + } + + $conflict_sql = sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + implode( ', ', $conflict_target['sql'] ), + implode( ', ', $assignments ) + ); + $conflict_column = $conflict_target['columns'][0] ?? null; + $replace_query = array( + 'action' => 'replace', + 'sql' => $sql . ' ' . $conflict_sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'value_rows' => $value_rows, + 'conflict_column' => $conflict_column, + 'conflict_target' => $conflict_target, + 'conflict_indexes' => $conflict_indexes, + 'conflict_probe_safe_rows' => $probe_safe_rows, + 'inserted_new_row' => true, + ); + + $has_duplicate_conflict_rows = $this->has_duplicate_mysql_replace_conflict_value_rows_in_groups( $value_rows, $probe_safe_rows, $delete_conflict_index_groups ); + $delete_insert_statements = $this->get_mysql_replace_delete_then_insert_statements( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $delete_conflict_index_groups, + $has_duplicate_conflict_rows + ); + if ( null !== $delete_insert_statements ) { + $replace_query['statements'] = $delete_insert_statements; + $replace_query['delete_then_insert'] = true; + $replace_query['inserted_new_row'] = true; + } elseif ( $has_duplicate_conflict_rows ) { + $statements = array(); + foreach ( $value_rows as $values ) { + $statements[] = sprintf( + 'INSERT INTO %s (%s) VALUES (%s) %s', + $this->connection->quote_identifier( $table_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + implode( ', ', $values ), + $conflict_sql + ); + } + $replace_query['statements'] = $statements; + } + + return $replace_query; + } + + /** + * Build delete-then-insert statements for deterministic REPLACE rows. + * + * MySQL and SQLite REPLACE delete matching rows before inserting the new + * row, which makes DELETE triggers, cascades, and omitted-column defaults + * observable. Limit this emulation to conflict keys whose incoming values + * are safe to evaluate in both the DELETE predicate and INSERT row. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @param array[] $value_rows Translated VALUES rows. + * @param array[] $probe_safe_rows Per-value conflict-probe safety flags. + * @param array[] $conflict_index_groups Conflict target column/index tuple groups. + * @param bool $sequential_statements Whether every row must run as its own DELETE/INSERT pair. + * @return string[]|null PostgreSQL statements, or null when delete-then-insert is unsafe. + */ + private function get_mysql_replace_delete_then_insert_statements( string $table_name, array $columns, array $value_rows, array $probe_safe_rows, array $conflict_index_groups, bool $sequential_statements ): ?array { + $quoted_table = $this->connection->quote_identifier( $table_name ); + $column_sql = implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ); + + $row_predicates = array(); + foreach ( $value_rows as $row_index => $values ) { + $predicate = $this->get_mysql_replace_delete_predicate_for_row( + $values, + $probe_safe_rows[ $row_index ] ?? array(), + $conflict_index_groups + ); + if ( false === $predicate ) { + return null; + } + + $row_predicates[ $row_index ] = $predicate; + } + + $statements = array(); + if ( $sequential_statements ) { + foreach ( $value_rows as $row_index => $values ) { + if ( null !== $row_predicates[ $row_index ] ) { + $statements[] = sprintf( + 'DELETE FROM %s WHERE %s', + $quoted_table, + $row_predicates[ $row_index ] + ); + } + + $statements[] = sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $quoted_table, + $column_sql, + implode( ', ', $values ) + ); + } + + return $statements; + } + + $delete_predicates = array_values( + array_filter( + $row_predicates, + static function ( $predicate ): bool { + return null !== $predicate; + } + ) + ); + if ( ! empty( $delete_predicates ) ) { + $statements[] = sprintf( + 'DELETE FROM %s WHERE %s', + $quoted_table, + implode( + ' OR ', + array_map( + static function ( string $predicate ): string { + return '(' . $predicate . ')'; + }, + $delete_predicates + ) + ) + ); + } + + $sql_value_rows = array(); + foreach ( $value_rows as $values ) { + $sql_value_rows[] = '(' . implode( ', ', $values ) . ')'; + } + + $statements[] = sprintf( + 'INSERT INTO %s (%s) VALUES %s', + $quoted_table, + $column_sql, + implode( ', ', $sql_value_rows ) + ); + + return $statements; + } + + /** + * Build a DELETE predicate for one deterministic REPLACE row. + * + * @param array $values Translated VALUES row. + * @param array $probe_safety Per-value conflict-probe safety flags. + * @param array[] $conflict_index_groups Conflict target column/index tuple groups. + * @return string|false|null Predicate SQL, false when unsafe, or null when the row cannot conflict. + */ + private function get_mysql_replace_delete_predicate_for_row( array $values, array $probe_safety, array $conflict_index_groups ) { + $predicates = array(); + foreach ( $conflict_index_groups as $conflict_indexes ) { + $predicate = $this->get_mysql_replace_delete_predicate_for_row_conflict_indexes( + $values, + $probe_safety, + $conflict_indexes + ); + if ( false === $predicate ) { + return false; + } + + if ( null !== $predicate ) { + $predicates[] = $predicate; + } + } + + if ( empty( $predicates ) ) { + return null; + } + + if ( 1 === count( $predicates ) ) { + return $predicates[0]; + } + + return implode( + ' OR ', + array_map( + static function ( string $predicate ): string { + return '(' . $predicate . ')'; + }, + $predicates + ) + ); + } + + /** + * Build a DELETE predicate for one deterministic REPLACE row and one conflict key. + * + * @param array $values Translated VALUES row. + * @param array $probe_safety Per-value conflict-probe safety flags. + * @param array $conflict_indexes Conflict target column/index tuples. + * @return string|false|null Predicate SQL, false when unsafe, or null when the row cannot conflict. + */ + private function get_mysql_replace_delete_predicate_for_row_conflict_indexes( array $values, array $probe_safety, array $conflict_indexes ) { + $where = array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( + ! array_key_exists( $conflict_index['index'], $values ) + || ! isset( $probe_safety[ $conflict_index['index'] ] ) + || ! $probe_safety[ $conflict_index['index'] ] + ) { + return false; + } + + $value = trim( (string) $values[ $conflict_index['index'] ] ); + if ( + '' === $value + || 'NULL' === strtoupper( $value ) + || $this->is_mysql_generated_auto_increment_value_sql( $value ) + ) { + return null; + } + + if ( null !== ( $conflict_index['sub_part'] ?? null ) && '' !== (string) $conflict_index['sub_part'] ) { + $where[] = sprintf( + '%s = SUBSTR(CAST(%s AS text), 1, %d)', + $this->get_mysql_index_key_part_sql( (string) $conflict_index['column'], $conflict_index['sub_part'] ), + $value, + (int) $conflict_index['sub_part'] + ); + } else { + $where[] = sprintf( + '%s = %s', + $this->connection->quote_identifier( (string) $conflict_index['column'] ), + $value + ); + } + } + + return empty( $where ) ? null : implode( ' AND ', $where ); + } + + /** + * Get metadata-backed unique-key groups usable for deterministic REPLACE deletes. + * + * @param string $table_name Table name. + * @param array $columns Inserted column names. + * @param array[] $value_rows Translated VALUES rows. + * @param array[] $probe_safe_rows Per-value conflict-probe safety flags. + * @return array[] Conflict index groups. + */ + private function get_mysql_replace_delete_conflict_index_groups( string $table_name, array $columns, array $value_rows, array $probe_safe_rows ): array { + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $stmt = $this->connection->query( + sprintf( + 'SELECT key_name, column_name, index_type, sub_part + FROM %s + WHERE table_schema = ? AND table_name = ? AND non_unique = \'0\' + ORDER BY + CASE WHEN UPPER(key_name) = \'PRIMARY\' THEN 0 ELSE 1 END, + index_ordinal, + seq_in_index', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + $indexes = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $key_name = (string) ( $row['key_name'] ?? '' ); + if ( '' === $key_name ) { + continue; + } + + if ( ! isset( $indexes[ $key_name ] ) ) { + $indexes[ $key_name ] = array( + 'index_type' => strtoupper( (string) ( $row['index_type'] ?? 'BTREE' ) ), + 'parts' => array(), + ); + } + + $column_name = (string) ( $row['column_name'] ?? '' ); + if ( '' === $column_name ) { + continue; + } + + $indexes[ $key_name ]['parts'][] = array( + 'column' => $column_name, + 'sub_part' => null !== ( $row['sub_part'] ?? null ) && '' !== (string) $row['sub_part'] ? (string) $row['sub_part'] : null, + ); + } + + $conflict_index_groups = array(); + foreach ( $indexes as $index ) { + if ( empty( $index['parts'] ) || in_array( $index['index_type'], array( 'FULLTEXT', 'SPATIAL' ), true ) ) { + continue; + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $index['parts'] ); + if ( null === $conflict_indexes ) { + continue; + } + + if ( ! $this->mysql_replace_conflict_indexes_are_probe_safe_for_rows( $conflict_indexes, $value_rows, $probe_safe_rows ) ) { + continue; + } + + $conflict_index_groups[] = $conflict_indexes; + } + + return $conflict_index_groups; + } + + /** + * Check whether every incoming row can safely probe one conflict key. + * + * @param array $conflict_indexes Conflict target column/index tuples. + * @param array[] $value_rows Translated VALUES rows. + * @param array[] $probe_safe_rows Per-value conflict-probe safety flags. + * @return bool Whether the conflict key is safe for all rows. + */ + private function mysql_replace_conflict_indexes_are_probe_safe_for_rows( array $conflict_indexes, array $value_rows, array $probe_safe_rows ): bool { + foreach ( $value_rows as $row_index => $values ) { + $probe_safety = $probe_safe_rows[ $row_index ] ?? array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( + ! array_key_exists( $conflict_index['index'], $values ) + || ! isset( $probe_safety[ $conflict_index['index'] ] ) + || ! $probe_safety[ $conflict_index['index'] ] + ) { + return false; + } + } + } + + return true; + } + + /** + * Translate explicit-column MySQL REPLACE ... SELECT statements. + * + * @param string $query MySQL query. + * @param string $table_name Target table name. + * @param string[] $columns Target column names. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position at SELECT or parenthesized SELECT. + * @param int $table_reference_start First target table-reference token. + * @param int $table_reference_end Final target table-reference token, exclusive. + * @param bool $insert_column_list Whether to inject an inferred column list. + * @return array|null PostgreSQL query data, or null when unsupported. + */ + private function translate_simple_mysql_replace_select_query( + string $query, + string $table_name, + array $columns, + array $tokens, + int $position, + int $table_reference_start, + int $table_reference_end, + bool $insert_column_list = false + ): ?array { + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $select_columns = $columns; + $default_columns = $this->get_non_strict_dml_defaults_for_omitted_columns( $table_name, $columns ); + foreach ( $default_columns as $default_column ) { + $columns[] = $default_column['column']; + } + + $table_reference_sql = $this->get_mysql_main_database_table_reference_sql( + $tokens, + $table_reference_start, + $table_reference_end + ); + if ( $insert_column_list || ! empty( $default_columns ) ) { + $table_reference_sql .= ' (' . implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ) . ')'; + } + $select_start = $position; + $select_end = $statement_end; + $outer_replacements = array( + array( + 'start' => 0, + 'end' => ! empty( $default_columns ) ? $position : $table_reference_end, + 'sql' => 'INSERT INTO ' . $table_reference_sql, + ), + ); + $closing_replacement = array(); + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( + null === $after_close + || $after_close !== $statement_end + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $select_start = $position + 1; + $select_end = $statement_end - 1; + $outer_replacements[] = array( + 'start' => $position, + 'end' => $position + 1, + 'sql' => '', + ); + $closing_replacement[] = array( + 'start' => $statement_end - 1, + 'end' => $statement_end, + 'sql' => '', + ); + } + + if ( ! isset( $tokens[ $select_start ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $select_start ]->id ) { + return null; + } + + $select_replacements = $this->get_mysql_insert_select_projection_replacements( + $table_name, + $select_columns, + $tokens, + $select_start, + $select_end + ); + if ( null === $select_replacements ) { + return null; + } + + $direct_information_schema_select_sql = $this->get_insert_select_direct_information_schema_select_sql( + $query, + $tokens, + $select_start, + $select_end + ); + if ( null !== $direct_information_schema_select_sql ) { + if ( $this->mysql_replacements_overlap_range( $select_replacements, $select_start, $select_end ) ) { + return null; + } + + $select_replacements[] = array( + 'start' => $select_start, + 'end' => $select_end, + 'sql' => $direct_information_schema_select_sql, + ); + } elseif ( $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, $select_start, $select_end ) ) { + return null; + } + + if ( ! empty( $default_columns ) ) { + usort( + $select_replacements, + static function ( array $left, array $right ): int { + return $left['start'] <=> $right['start']; + } + ); + + $select_sql = $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $select_start, + $select_end, + $select_replacements + ); + $select_replacements = array( + array( + 'start' => $select_start, + 'end' => $select_end, + 'sql' => $this->append_mysql_select_default_projection_sql( + $select_sql, + $default_columns, + '__wp_pg_replace_source' + ), + ), + ); + } + + $replacements = array_merge( $outer_replacements, $select_replacements, $closing_replacement ); + usort( + $replacements, + static function ( array $left, array $right ): int { + return $left['start'] <=> $right['start']; + } + ); + + $sql = $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 0, + $statement_end, + $replacements + ); + + $replace_select_value_rows = null; + $replace_select_probe_safe_rows = null; + $replace_select_literal_row = $this->get_mysql_insert_select_upsert_literal_value_row( + $table_name, + $select_columns, + $tokens, + $select_start, + $select_end + ); + if ( null !== $replace_select_literal_row && empty( $default_columns ) ) { + $replace_select_value_rows = array( $replace_select_literal_row['values'] ); + $replace_select_probe_safe_rows = array( $replace_select_literal_row['probe_safe_values'] ); + } + + $conflict_target = $this->get_mysql_replace_conflict_target( + $table_name, + $columns, + $replace_select_value_rows, + $replace_select_probe_safe_rows + ); + $conflict_column = null; + $affected_rows_count_sql = null; + if ( null !== $conflict_target ) { + $conflict_column = $conflict_target['columns'][0] ?? null; + $replace_select_flow = $this->get_mysql_replace_select_delete_then_insert_flow( + $table_name, + $columns, + $select_columns, + $default_columns, + $conflict_target, + $tokens, + $select_start, + $select_end + ); + if ( null === $replace_select_flow ) { + return null; + } + + $sql = $replace_select_flow['sql']; + $affected_rows_count_sql = $replace_select_flow['replace_select_affected_rows_sql']; + } + + $replace_query = array( + 'action' => 'replace', + 'sql' => $sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'conflict_column' => $conflict_column, + 'conflict_target' => $conflict_target, + 'conflict_value' => null, + 'replace_select_affected_rows_sql' => $affected_rows_count_sql, + 'inserted_new_row' => true, + ); + + if ( null !== $conflict_target ) { + $replace_query = array_merge( $replace_query, $replace_select_flow ); + } + + return $replace_query; + } + + /** + * Build a materialized delete-then-insert flow for REPLACE ... VALUES. + * + * @param string $table_name Target table name. + * @param string[] $columns Target column names. + * @param array[] $value_rows Translated VALUES rows. + * @param array $conflict_target Conflict target. + * @param array[] $conflict_index_groups Conflict target column/index tuple groups. + * @return array|null Materialized flow metadata, or null when unsupported. + */ + private function get_mysql_replace_values_delete_then_insert_flow( string $table_name, array $columns, array $value_rows, array $conflict_target, array $conflict_index_groups ): ?array { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ?? array() ); + if ( null === $conflict_indexes || empty( $conflict_index_groups ) || empty( $value_rows ) ) { + return null; + } + + $select_rows = array(); + foreach ( $value_rows as $row_index => $values ) { + if ( count( $values ) !== count( $columns ) ) { + return null; + } + + $projections = array(); + foreach ( $values as $column_index => $value_sql ) { + $projection = (string) $value_sql; + if ( 0 === $row_index ) { + $projection .= ' AS ' . $this->connection->quote_identifier( $columns[ $column_index ] ); + } + $projections[] = $projection; + } + + $select_rows[] = 'SELECT ' . implode( ', ', $projections ); + } + + $select_sql = implode( ' UNION ALL ', $select_rows ); + $temp_table_hash = substr( md5( $table_name . "\0" . implode( "\0", $columns ) . "\0" . implode( "\0", array_map( 'implode', $value_rows ) ) ), 0, 12 ); + $temp_table_name = '__wp_pg_replace_values_' . $temp_table_hash; + $ordinal_table_name = '__wp_pg_replace_values_ord_' . $temp_table_hash; + $quoted_temp_table = $this->connection->quote_identifier( $temp_table_name ); + $quoted_ordinal_table = $this->connection->quote_identifier( $ordinal_table_name ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_replace_rows' ); + $target_alias = $this->connection->quote_identifier( '__wp_pg_replace_target' ); + $quoted_target_table = $this->connection->quote_identifier( $table_name ); + $delete_predicate_sql = $this->get_mysql_replace_select_delete_predicate_sql( + $target_alias, + $rows_alias, + $conflict_index_groups + ); + if ( null === $delete_predicate_sql ) { + return null; + } + + $insert_projection_sql = array(); + foreach ( $columns as $column ) { + $insert_projection_sql[] = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( $column ) + ); + } + + $affected_rows_count_sql = $this->get_mysql_replace_select_affected_rows_count_sql( + $table_name, + $columns, + $columns, + array(), + $conflict_target, + array(), + 0, + 0, + $quoted_temp_table, + $conflict_index_groups + ); + if ( null === $affected_rows_count_sql ) { + return null; + } + + $duplicate_conflict_rows_sql = $this->get_mysql_replace_select_duplicate_conflict_rows_sql( + $quoted_temp_table, + $rows_alias, + $conflict_index_groups + ); + if ( null === $duplicate_conflict_rows_sql ) { + return null; + } + + $delete_sql = sprintf( + 'DELETE FROM %s AS %s WHERE EXISTS (SELECT 1 FROM %s AS %s WHERE %s)', + $quoted_target_table, + $target_alias, + $quoted_temp_table, + $rows_alias, + $delete_predicate_sql + ); + $insert_sql = sprintf( + 'INSERT INTO %s (%s) SELECT %s FROM %s AS %s', + $quoted_target_table, + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + implode( ', ', $insert_projection_sql ), + $quoted_temp_table, + $rows_alias + ); + $drop_sql = sprintf( 'DROP TABLE IF EXISTS %s', $quoted_temp_table ); + + return array( + 'sql' => $insert_sql, + 'statements' => array( + $drop_sql, + sprintf( 'CREATE TEMPORARY TABLE %s AS %s', $quoted_temp_table, $select_sql ), + $delete_sql, + $insert_sql, + $drop_sql, + ), + 'materialize_statements' => array( + $drop_sql, + sprintf( 'CREATE TEMPORARY TABLE %s AS %s', $quoted_temp_table, $select_sql ), + ), + 'mutation_statements' => array( + $delete_sql, + $insert_sql, + ), + 'cleanup_statements' => array( + $drop_sql, + ), + 'replace_select_materialized' => true, + 'replace_select_affected_rows_sql' => $affected_rows_count_sql, + 'duplicate_conflict_rows_sql' => $duplicate_conflict_rows_sql, + 'source_table_sql' => $quoted_temp_table, + 'ordinal_source_table_sql' => $quoted_ordinal_table, + 'conflict_indexes' => $conflict_indexes, + 'conflict_index_groups' => $conflict_index_groups, + ); + } + + /** + * Build a materialized delete-then-insert flow for REPLACE ... SELECT. + * + * @param string $table_name Target table name. + * @param string[] $columns Target column names. + * @param string[] $select_columns Original SELECT target column names. + * @param array[] $default_columns Metadata-derived default projections. + * @param array $conflict_target Conflict target. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $select_start SELECT token position. + * @param int $select_end Final SELECT token position, exclusive. + * @return array|null Materialized flow metadata, or null when unsupported. + */ + private function get_mysql_replace_select_delete_then_insert_flow( string $table_name, array $columns, array $select_columns, array $default_columns, array $conflict_target, array $tokens, int $select_start, int $select_end ): ?array { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ?? array() ); + if ( null === $conflict_indexes ) { + return null; + } + $conflict_index_groups = $this->get_mysql_replace_select_delete_conflict_index_groups( $table_name, $columns ); + if ( empty( $conflict_index_groups ) ) { + $conflict_index_groups = array( $conflict_indexes ); + } + + $select_sql = $this->get_mysql_replace_select_source_sql( + $table_name, + $select_columns, + $default_columns, + $tokens, + $select_start, + $select_end + ); + if ( null === $select_sql ) { + return null; + } + + $temp_table_hash = substr( md5( $table_name . "\0" . $select_start . "\0" . $select_end . "\0" . implode( "\0", $columns ) ), 0, 12 ); + $temp_table_name = '__wp_pg_replace_select_' . $temp_table_hash; + $ordinal_table_name = '__wp_pg_replace_select_ord_' . $temp_table_hash; + $quoted_temp_table = $this->connection->quote_identifier( $temp_table_name ); + $quoted_ordinal_table = $this->connection->quote_identifier( $ordinal_table_name ); + $rows_alias = $this->connection->quote_identifier( '__wp_pg_replace_rows' ); + $target_alias = $this->connection->quote_identifier( '__wp_pg_replace_target' ); + $quoted_target_table = $this->connection->quote_identifier( $table_name ); + $delete_predicate_sql = $this->get_mysql_replace_select_delete_predicate_sql( + $target_alias, + $rows_alias, + $conflict_index_groups + ); + if ( null === $delete_predicate_sql ) { + return null; + } + + $insert_projection_sql = array(); + foreach ( $columns as $column ) { + $insert_projection_sql[] = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( $column ) + ); + } + + $affected_rows_count_sql = $this->get_mysql_replace_select_affected_rows_count_sql( + $table_name, + $columns, + $select_columns, + $default_columns, + $conflict_target, + $tokens, + $select_start, + $select_end, + $quoted_temp_table, + $conflict_index_groups + ); + if ( null === $affected_rows_count_sql ) { + return null; + } + + $duplicate_conflict_rows_sql = $this->get_mysql_replace_select_duplicate_conflict_rows_sql( + $quoted_temp_table, + $rows_alias, + $conflict_index_groups + ); + if ( null === $duplicate_conflict_rows_sql ) { + return null; + } + + $delete_sql = sprintf( + 'DELETE FROM %s AS %s WHERE EXISTS (SELECT 1 FROM %s AS %s WHERE %s)', + $quoted_target_table, + $target_alias, + $quoted_temp_table, + $rows_alias, + $delete_predicate_sql + ); + $insert_sql = sprintf( + 'INSERT INTO %s (%s) SELECT %s FROM %s AS %s', + $quoted_target_table, + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + implode( ', ', $insert_projection_sql ), + $quoted_temp_table, + $rows_alias + ); + $drop_sql = sprintf( 'DROP TABLE IF EXISTS %s', $quoted_temp_table ); + + return array( + 'sql' => $insert_sql, + 'statements' => array( + $drop_sql, + sprintf( 'CREATE TEMPORARY TABLE %s AS %s', $quoted_temp_table, $select_sql ), + $delete_sql, + $insert_sql, + $drop_sql, + ), + 'materialize_statements' => array( + $drop_sql, + sprintf( 'CREATE TEMPORARY TABLE %s AS %s', $quoted_temp_table, $select_sql ), + ), + 'mutation_statements' => array( + $delete_sql, + $insert_sql, + ), + 'cleanup_statements' => array( + $drop_sql, + ), + 'replace_select_materialized' => true, + 'replace_select_affected_rows_sql' => $affected_rows_count_sql, + 'duplicate_conflict_rows_sql' => $duplicate_conflict_rows_sql, + 'source_table_sql' => $quoted_temp_table, + 'ordinal_source_table_sql' => $quoted_ordinal_table, + 'conflict_indexes' => $conflict_indexes, + 'conflict_index_groups' => $conflict_index_groups, + ); + } + + /** + * Get metadata-backed unique-key groups usable for materialized REPLACE ... SELECT deletes. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @return array[] Conflict index groups. + */ + private function get_mysql_replace_select_delete_conflict_index_groups( string $table_name, array $columns ): array { + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $stmt = $this->connection->query( + sprintf( + 'SELECT key_name, column_name, index_type, sub_part + FROM %s + WHERE table_schema = ? AND table_name = ? AND non_unique = \'0\' + ORDER BY + CASE WHEN UPPER(key_name) = \'PRIMARY\' THEN 0 ELSE 1 END, + index_ordinal, + seq_in_index', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + $indexes = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $key_name = (string) ( $row['key_name'] ?? '' ); + if ( '' === $key_name ) { + continue; + } + + if ( ! isset( $indexes[ $key_name ] ) ) { + $indexes[ $key_name ] = array( + 'index_type' => strtoupper( (string) ( $row['index_type'] ?? 'BTREE' ) ), + 'parts' => array(), + ); + } + + $column_name = (string) ( $row['column_name'] ?? '' ); + if ( '' === $column_name ) { + continue; + } + + $indexes[ $key_name ]['parts'][] = array( + 'column' => $column_name, + 'sub_part' => null !== ( $row['sub_part'] ?? null ) && '' !== (string) $row['sub_part'] ? (string) $row['sub_part'] : null, + ); + } + + $conflict_index_groups = array(); + foreach ( $indexes as $index ) { + if ( empty( $index['parts'] ) || in_array( $index['index_type'], array( 'FULLTEXT', 'SPATIAL' ), true ) ) { + continue; + } + + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $index['parts'] ); + if ( null !== $conflict_indexes ) { + $conflict_index_groups[] = $conflict_indexes; + } + } + + return $conflict_index_groups; + } + + /** + * Get a projected source SELECT for REPLACE ... SELECT materialization. + * + * @param string $table_name Target table name. + * @param string[] $select_columns Original SELECT target column names. + * @param array[] $default_columns Metadata-derived default projections. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $select_start SELECT token position. + * @param int $select_end Final SELECT token position, exclusive. + * @return string|null PostgreSQL SELECT SQL, or null when unsupported. + */ + private function get_mysql_replace_select_source_sql( string $table_name, array $select_columns, array $default_columns, array $tokens, int $select_start, int $select_end ): ?string { + if ( $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, $select_start, $select_end ) ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $select_start + 1, $select_end ); + $projection_end = $from_position ?? $select_end; + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $select_start + 1, $projection_end ); + if ( null === $projection_ranges || count( $projection_ranges ) !== count( $select_columns ) ) { + return null; + } + + $target_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $scope = null; + if ( null !== $from_position ) { + $first_clause_position = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + $from_position + 1, + $select_end + ) ?? $select_end; + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $first_clause_position ); + } + + $alias_replacements = array(); + foreach ( $projection_ranges as $index => $range ) { + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( $tokens, $range['start'], $range['end'] ); + if ( null === $expression_bounds ) { + return null; + } + + $expression_start = $expression_bounds['start']; + $expression_end = $expression_bounds['end']; + $projection_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $expression_start, $expression_end ); + $column_metadata = $target_metadata[ strtolower( $select_columns[ $index ] ) ] ?? null; + if ( null !== $column_metadata ) { + $coerced_sql = $this->get_mysql_insert_select_projection_sql_for_target_column( + $table_name, + $column_metadata, + $tokens, + $expression_start, + $expression_end, + $projection_sql, + $scope + ); + if ( null !== $coerced_sql ) { + $projection_sql = $coerced_sql; + } + } + + $alias_replacements[] = array( + 'start' => $range['start'], + 'end' => $range['end'], + 'sql' => $projection_sql . ' AS ' . $this->connection->quote_identifier( $select_columns[ $index ] ), + ); + } + + $select_sql = $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $select_start, + $select_end, + $alias_replacements + ); + if ( ! empty( $default_columns ) ) { + $select_sql = $this->append_mysql_select_default_projection_sql( + $select_sql, + $default_columns, + '__wp_pg_replace_rows_source' + ); + } + + return $select_sql; + } + + /** + * Get a preflight affected-row count query for REPLACE ... SELECT. + * + * @param string $table_name Target table name. + * @param string[] $columns Target column names. + * @param string[] $select_columns Original SELECT target column names. + * @param array[] $default_columns Metadata-derived default projections. + * @param array $conflict_target Conflict target. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $select_start SELECT token position. + * @param int $select_end Final SELECT token position, exclusive. + * @param string|null $source_table_sql Optional materialized source table SQL. + * @param array[]|null $conflict_index_groups Optional conflict index groups. + * @return string|null PostgreSQL count SQL, or null when unsupported. + */ + private function get_mysql_replace_select_affected_rows_count_sql( string $table_name, array $columns, array $select_columns, array $default_columns, array $conflict_target, array $tokens, int $select_start, int $select_end, ?string $source_table_sql = null, ?array $conflict_index_groups = null ): ?string { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ?? array() ); + if ( null === $conflict_indexes ) { + return null; + } + if ( null === $conflict_index_groups ) { + $conflict_index_groups = array( $conflict_indexes ); + } + + $select_sql = null; + if ( null === $source_table_sql ) { + $select_sql = $this->get_mysql_replace_select_source_sql( + $table_name, + $select_columns, + $default_columns, + $tokens, + $select_start, + $select_end + ); + if ( null === $select_sql ) { + return null; + } + } + + $rows_alias = $this->connection->quote_identifier( '__wp_pg_replace_rows' ); + if ( null !== $source_table_sql ) { + $target_alias = $this->connection->quote_identifier( '__wp_pg_replace_target' ); + $delete_predicate_sql = $this->get_mysql_replace_select_delete_predicate_sql( + $target_alias, + $rows_alias, + $conflict_index_groups + ); + if ( null === $delete_predicate_sql ) { + return null; + } + + return sprintf( + 'SELECT ((SELECT COUNT(*) FROM %1$s) + (SELECT COUNT(*) FROM %2$s AS %3$s WHERE EXISTS (SELECT 1 FROM %1$s AS %4$s WHERE %5$s))) AS affected_rows, (SELECT COUNT(*) FROM %1$s) AS inserted_rows', + $source_table_sql, + $this->connection->quote_identifier( $table_name ), + $target_alias, + $rows_alias, + $delete_predicate_sql + ); + } + + $conflict_exists = $this->get_mysql_replace_select_conflict_exists_sql( + $table_name, + $rows_alias, + $conflict_index_groups + ); + if ( null === $conflict_exists ) { + return null; + } + + return sprintf( + 'SELECT COALESCE(SUM(CASE WHEN %1$s THEN 2 ELSE 1 END), 0) AS affected_rows, COALESCE(SUM(CASE WHEN %1$s THEN 0 ELSE 1 END), 0) AS inserted_rows FROM (%2$s) AS %3$s', + $conflict_exists, + null === $source_table_sql ? $select_sql : 'SELECT * FROM ' . $source_table_sql, + $rows_alias + ); + } + + /** + * Append constant default projections to a translated SELECT. + * + * @param string $select_sql Translated SELECT SQL. + * @param array[] $default_columns Default column descriptors. + * @param string $source_alias Unquoted derived-table alias. + * @return string SELECT SQL with appended default projections. + */ + private function append_mysql_select_default_projection_sql( string $select_sql, array $default_columns, string $source_alias ): string { + $quoted_source_alias = $this->connection->quote_identifier( $source_alias ); + $projection_sql = array( + $quoted_source_alias . '.*', + ); + + foreach ( $default_columns as $default_column ) { + $projection_sql[] = sprintf( + '%s AS %s', + $default_column['sql'], + $this->connection->quote_identifier( $default_column['column'] ) + ); + } + + return sprintf( + 'SELECT %s FROM (%s) AS %s WHERE 1 = 1', + implode( ', ', $projection_sql ), + $select_sql, + $quoted_source_alias + ); + } + + /** + * Get a REPLACE ... SELECT preflight conflict predicate. + * + * @param string $table_name Target table name. + * @param string $rows_alias Quoted derived-table alias for incoming rows. + * @param array $conflict_index_groups Conflict target column/index tuple groups. + * @return string|null EXISTS predicate SQL, or null when unsupported. + */ + private function get_mysql_replace_select_conflict_exists_sql( string $table_name, string $rows_alias, array $conflict_index_groups ): ?string { + $group_predicates = array(); + foreach ( $conflict_index_groups as $conflict_indexes ) { + $where = array(); + foreach ( $conflict_indexes as $conflict_index ) { + $column = (string) ( $conflict_index['column'] ?? '' ); + if ( '' === $column ) { + return null; + } + + $incoming_value = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( $column ) + ); + if ( null !== ( $conflict_index['sub_part'] ?? null ) && '' !== (string) $conflict_index['sub_part'] ) { + $incoming_value = sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $incoming_value, + (int) $conflict_index['sub_part'] + ); + } + + $where[] = sprintf( + '%s = %s', + $this->get_mysql_index_key_part_sql( $column, $conflict_index['sub_part'] ?? null ), + $incoming_value + ); + } + + if ( empty( $where ) ) { + return null; + } + + $group_predicates[] = '(' . implode( ' AND ', $where ) . ')'; + } + + if ( empty( $group_predicates ) ) { + return null; + } + + return sprintf( + 'EXISTS (SELECT 1 FROM %s WHERE %s)', + $this->connection->quote_identifier( $table_name ), + implode( ' OR ', $group_predicates ) + ); + } + + /** + * Get a materialized REPLACE ... SELECT target/source conflict predicate. + * + * @param string $target_alias Quoted target table alias. + * @param string $rows_alias Quoted materialized rows alias. + * @param array $conflict_index_groups Conflict target column/index tuple groups. + * @return string|null Predicate SQL, or null when unsupported. + */ + private function get_mysql_replace_select_delete_predicate_sql( string $target_alias, string $rows_alias, array $conflict_index_groups ): ?string { + $group_predicates = array(); + foreach ( $conflict_index_groups as $conflict_indexes ) { + $where = array(); + foreach ( $conflict_indexes as $conflict_index ) { + $column = (string) ( $conflict_index['column'] ?? '' ); + if ( '' === $column ) { + return null; + } + + $target_value = sprintf( + '%s.%s', + $target_alias, + $this->connection->quote_identifier( $column ) + ); + $incoming_value = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( $column ) + ); + if ( null !== ( $conflict_index['sub_part'] ?? null ) && '' !== (string) $conflict_index['sub_part'] ) { + $target_value = sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $target_value, + (int) $conflict_index['sub_part'] + ); + $incoming_value = sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $incoming_value, + (int) $conflict_index['sub_part'] + ); + } + + $where[] = sprintf( + '%s = %s', + $target_value, + $incoming_value + ); + } + + if ( empty( $where ) ) { + return null; + } + + $group_predicates[] = '(' . implode( ' AND ', $where ) . ')'; + } + + return empty( $group_predicates ) ? null : implode( ' OR ', $group_predicates ); + } + + /** + * Get a duplicate incoming conflict-key probe for materialized REPLACE ... SELECT. + * + * @param string $source_table_sql Quoted materialized source table SQL. + * @param string $rows_alias Quoted materialized rows alias. + * @param array $conflict_index_groups Conflict target column/index tuple groups. + * @return string|null Probe SQL, or null when unsupported. + */ + private function get_mysql_replace_select_duplicate_conflict_rows_sql( string $source_table_sql, string $rows_alias, array $conflict_index_groups ): ?string { + $probes = array(); + foreach ( $conflict_index_groups as $conflict_indexes ) { + $key_sql = array(); + $not_null_sql = array(); + foreach ( $conflict_indexes as $conflict_index ) { + $column = (string) ( $conflict_index['column'] ?? '' ); + if ( '' === $column ) { + return null; + } + + $incoming_column = sprintf( + '%s.%s', + $rows_alias, + $this->connection->quote_identifier( $column ) + ); + $not_null_sql[] = $incoming_column . ' IS NOT NULL'; + if ( null !== ( $conflict_index['sub_part'] ?? null ) && '' !== (string) $conflict_index['sub_part'] ) { + $key_sql[] = sprintf( + 'SUBSTR(CAST(%s AS text), 1, %d)', + $incoming_column, + (int) $conflict_index['sub_part'] + ); + } else { + $key_sql[] = $incoming_column; + } + } + + if ( empty( $key_sql ) ) { + return null; + } + + $probes[] = sprintf( + 'SELECT 1 FROM %s AS %s WHERE %s GROUP BY %s HAVING COUNT(*) > 1', + $source_table_sql, + $rows_alias, + implode( ' AND ', $not_null_sql ), + implode( ', ', $key_sql ) + ); + } + + return empty( $probes ) ? null : implode( ' UNION ALL ', $probes ) . ' LIMIT 1'; + } + + /** + * Get the MySQL-compatible affected-row count for a translated REPLACE query. + * + * @param array $replace_query Translated REPLACE query data. + * @return int|null MySQL-compatible row count, or null when the backend row count should be used. + */ + private function get_mysql_replace_return_value( array &$replace_query ): ?int { + if ( + ! isset( $replace_query['table_name'], $replace_query['conflict_column'] ) + || null === $replace_query['conflict_column'] + ) { + return null; + } + + if ( ! empty( $replace_query['delete_then_insert'] ) ) { + return null; + } + + if ( isset( $replace_query['replace_select_affected_rows_sql'] ) && is_string( $replace_query['replace_select_affected_rows_sql'] ) ) { + $stmt = $this->connection->query( $replace_query['replace_select_affected_rows_sql'] ); + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + if ( ! is_array( $row ) || ! isset( $row['affected_rows'] ) ) { + return null; + } + + $replace_query['inserted_new_row'] = isset( $row['inserted_rows'] ) && (int) $row['inserted_rows'] > 0; + return (int) $row['affected_rows']; + } + + if ( + ! isset( $replace_query['value_rows'], $replace_query['conflict_indexes'] ) + || ! is_array( $replace_query['value_rows'] ) + || ! is_array( $replace_query['conflict_indexes'] ) + ) { + return null; + } + + $probe_safe_rows = isset( $replace_query['conflict_probe_safe_rows'] ) && is_array( $replace_query['conflict_probe_safe_rows'] ) + ? $replace_query['conflict_probe_safe_rows'] + : array(); + + $return_value = 0; + $inserted_new_row = false; + $seen_values = array(); + foreach ( $replace_query['value_rows'] as $row_index => $values ) { + if ( ! is_array( $values ) ) { + return null; + } + + $probe_safety = $probe_safe_rows[ $row_index ] ?? array(); + foreach ( $replace_query['conflict_indexes'] as $conflict_index ) { + if ( ! isset( $probe_safety[ $conflict_index['index'] ] ) || ! $probe_safety[ $conflict_index['index'] ] ) { + return null; + } + } + + $seen_key = $this->get_mysql_replace_conflict_seen_key_for_row( $values, $replace_query['conflict_indexes'] ); + $replace_conflict_exists = null !== $seen_key && isset( $seen_values[ $seen_key ] ); + if ( ! $replace_conflict_exists ) { + $replace_conflict_exists = $this->mysql_upsert_conflict_exists( + (string) $replace_query['table_name'], + $values, + $replace_query['conflict_indexes'] + ); + if ( null === $replace_conflict_exists ) { + return null; + } + } + + $return_value += $replace_conflict_exists ? 2 : 1; + if ( ! $replace_conflict_exists ) { + $inserted_new_row = true; + } + if ( null !== $seen_key ) { + $seen_values[ $seen_key ] = true; + } + } + + $replace_query['inserted_new_row'] = ! empty( $replace_query['delete_then_insert'] ) ? true : $inserted_new_row; + return $return_value; + } + + /** + * Resolve the conflict target for a REPLACE statement. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @param array[]|null $value_rows Optional translated VALUES rows. + * @param array[]|null $probe_safe_rows Optional per-value conflict-probe safety flags. + * @return array{columns: string[], parts: array, sql: string[]}|null Conflict target. + */ + private function get_mysql_replace_conflict_target( string $table_name, array $columns, ?array $value_rows = null, ?array $probe_safe_rows = null ): ?array { + $metadata_target = $this->get_mysql_upsert_conflict_target( $table_name, $columns, $value_rows, $probe_safe_rows ); + $heuristic_target = $this->get_simple_replace_conflict_target( $table_name, $columns ); + if ( null === $heuristic_target ) { + return $metadata_target; + } + + if ( null === $metadata_target ) { + return $heuristic_target; + } + + if ( + $this->is_mysql_replace_conflict_target_backed_by_unique_metadata( $table_name, $heuristic_target ) + && + null !== $value_rows + && null !== $probe_safe_rows + && ! $this->mysql_replace_conflict_target_has_existing_conflict( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $metadata_target + ) + ) { + return $heuristic_target; + } + + return $metadata_target; + } + + /** + * Check whether a REPLACE conflict target corresponds to MySQL unique metadata. + * + * @param string $table_name Table name. + * @param array $conflict_target Conflict target. + * @return bool Whether the target has a matching unique metadata entry. + */ + private function is_mysql_replace_conflict_target_backed_by_unique_metadata( string $table_name, array $conflict_target ): bool { + $target_parts = $conflict_target['parts'] ?? array(); + if ( empty( $target_parts ) ) { + return false; + } + + $this->ensure_mysql_schema_metadata_tables(); + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $stmt = $this->connection->query( + sprintf( + 'SELECT key_name, column_name, index_type, sub_part + FROM %s + WHERE table_schema = ? AND table_name = ? AND non_unique = \'0\' + ORDER BY + key_name, + index_ordinal, + seq_in_index', + $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + $indexes = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $key_name = (string) ( $row['key_name'] ?? '' ); + if ( '' === $key_name ) { + continue; + } + + if ( ! isset( $indexes[ $key_name ] ) ) { + $indexes[ $key_name ] = array( + 'index_type' => strtoupper( (string) ( $row['index_type'] ?? 'BTREE' ) ), + 'parts' => array(), + ); + } + + $indexes[ $key_name ]['parts'][] = array( + 'column' => (string) ( $row['column_name'] ?? '' ), + 'sub_part' => null !== ( $row['sub_part'] ?? null ) && '' !== (string) $row['sub_part'] ? (string) $row['sub_part'] : null, + ); + } + + foreach ( $indexes as $index ) { + if ( in_array( $index['index_type'], array( 'FULLTEXT', 'SPATIAL' ), true ) ) { + continue; + } + + if ( count( $index['parts'] ) !== count( $target_parts ) ) { + continue; + } + + foreach ( $target_parts as $part_index => $part ) { + $index_part = $index['parts'][ $part_index ]; + if ( + 0 !== strcasecmp( (string) ( $index_part['column'] ?? '' ), (string) ( $part['column'] ?? '' ) ) + || (string) ( $index_part['sub_part'] ?? '' ) !== (string) ( $part['sub_part'] ?? '' ) + ) { + continue 2; + } + } + + return true; + } + + return false; + } + + /** + * Build a one-column fallback REPLACE conflict target. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @return array{columns: string[], parts: array, sql: string[]}|null Conflict target. + */ + private function get_simple_replace_conflict_target( string $table_name, array $columns ): ?array { + $conflict_column = $this->get_simple_replace_conflict_column( $table_name, $columns ); + if ( null === $conflict_column ) { + return null; + } + + return array( + 'columns' => array( $conflict_column ), + 'parts' => array( + array( + 'column' => $conflict_column, + 'sub_part' => null, + ), + ), + 'sql' => array( $this->connection->quote_identifier( $conflict_column ) ), + ); + } + + /** + * Check whether a resolved metadata conflict target currently matches any incoming row. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @param array[] $value_rows Translated VALUES rows. + * @param array[] $probe_safe_rows Per-value conflict-probe safety flags. + * @param array $conflict_target Conflict target. + * @return bool Whether an existing row matches the target. + */ + private function mysql_replace_conflict_target_has_existing_conflict( string $table_name, array $columns, array $value_rows, array $probe_safe_rows, array $conflict_target ): bool { + $conflict_indexes = $this->get_mysql_upsert_conflict_indexes( $columns, $conflict_target['parts'] ?? array() ); + if ( null === $conflict_indexes ) { + return true; + } + + foreach ( $value_rows as $row_index => $values ) { + $probe_safety = $probe_safe_rows[ $row_index ] ?? array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( ! isset( $probe_safety[ $conflict_index['index'] ] ) || ! $probe_safety[ $conflict_index['index'] ] ) { + return true; + } + } + + $conflict_exists = $this->mysql_upsert_conflict_exists( $table_name, $values, $conflict_indexes ); + if ( null === $conflict_exists || $conflict_exists ) { + return true; + } + } + + return false; + } + + /** + * Check whether a REPLACE batch contains duplicate deterministic conflict rows. + * + * @param array[] $value_rows Translated VALUES rows. + * @param array[] $probe_safe_rows Per-value probe safety. + * @param array $conflict_indexes Conflict target column/index tuples. + * @return bool Whether PostgreSQL needs per-row statements. + */ + private function has_duplicate_mysql_replace_conflict_value_rows( array $value_rows, array $probe_safe_rows, array $conflict_indexes ): bool { + $seen_values = array(); + foreach ( $value_rows as $row_index => $values ) { + $probe_safety = $probe_safe_rows[ $row_index ] ?? array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( ! isset( $probe_safety[ $conflict_index['index'] ] ) || ! $probe_safety[ $conflict_index['index'] ] ) { + return false; + } + } + + $seen_key = $this->get_mysql_replace_conflict_seen_key_for_row( $values, $conflict_indexes ); + if ( null === $seen_key ) { + continue; + } + + if ( isset( $seen_values[ $seen_key ] ) ) { + return true; + } + + $seen_values[ $seen_key ] = true; + } + + return false; + } + + /** + * Check whether a REPLACE batch duplicates any deterministic conflict key. + * + * @param array[] $value_rows Translated VALUES rows. + * @param array[] $probe_safe_rows Per-value probe safety. + * @param array[] $conflict_index_groups Conflict target column/index tuple groups. + * @return bool Whether PostgreSQL needs per-row statements. + */ + private function has_duplicate_mysql_replace_conflict_value_rows_in_groups( array $value_rows, array $probe_safe_rows, array $conflict_index_groups ): bool { + foreach ( $conflict_index_groups as $conflict_indexes ) { + if ( $this->has_duplicate_mysql_replace_conflict_value_rows( $value_rows, $probe_safe_rows, $conflict_indexes ) ) { + return true; + } + } + + return false; + } + + /** + * Normalize deterministic REPLACE conflict SQL values for in-statement tracking. + * + * @param array $values Translated VALUES row. + * @param array $conflict_indexes Conflict target column/index tuples. + * @return string|null Stable lookup key, or null when the row should not conflict. + */ + private function get_mysql_replace_conflict_seen_key_for_row( array $values, array $conflict_indexes ): ?string { + $parts = array(); + foreach ( $conflict_indexes as $conflict_index ) { + if ( ! array_key_exists( $conflict_index['index'], $values ) ) { + return null; + } + + $value = trim( (string) $values[ $conflict_index['index'] ] ); + if ( + '' === $value + || 'NULL' === strtoupper( $value ) + || $this->is_mysql_generated_auto_increment_value_sql( $value ) + ) { + return null; + } + + if ( null !== ( $conflict_index['sub_part'] ?? null ) && '' !== (string) $conflict_index['sub_part'] ) { + $value = $this->get_mysql_replace_conflict_prefix_seen_value( $value, (int) $conflict_index['sub_part'] ); + } + + $parts[] = $value; + } + + return implode( "\0", $parts ); + } + + /** + * Apply a prefix key length to a deterministic SQL literal when possible. + * + * @param string $value_sql SQL value. + * @param int $length Prefix character length. + * @return string Prefix lookup value. + */ + private function get_mysql_replace_conflict_prefix_seen_value( string $value_sql, int $length ): string { + if ( $length <= 0 || strlen( $value_sql ) < 2 || "'" !== $value_sql[0] || "'" !== $value_sql[ strlen( $value_sql ) - 1 ] ) { + return $value_sql; + } + + $value = str_replace( "''", "'", substr( $value_sql, 1, -1 ) ); + $count = preg_match_all( '/./us', $value, $matches ); + if ( false !== $count ) { + $value = implode( '', array_slice( $matches[0], 0, $length ) ); + } else { + $value = substr( $value, 0, $length ); + } + + return "'" . str_replace( "'", "''", $value ) . "'"; + } + + /** + * Choose the conflict column for a WordPress REPLACE statement. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @return string|null Conflict column name, or null when unknown. + */ + private function get_simple_replace_conflict_column( string $table_name, array $columns ): ?string { + $column_lookup = array(); + foreach ( $columns as $column ) { + $column_lookup[ strtolower( $column ) ] = $column; + } + + if ( $this->is_wordpress_options_table_name( $table_name ) && isset( $column_lookup['option_name'] ) ) { + return $column_lookup['option_name']; + } + + if ( $this->is_mysql_wordpress_table_name( $table_name, 'wc_customer_lookup' ) && isset( $column_lookup['customer_id'] ) ) { + return $column_lookup['customer_id']; + } + + if ( $this->is_mysql_wordpress_table_name( $table_name, 'wc_product_meta_lookup' ) && isset( $column_lookup['product_id'] ) ) { + return $column_lookup['product_id']; + } + + foreach ( + array( + 'id', + 'comment_id', + 'link_id', + 'option_id', + 'meta_id', + 'umeta_id', + 'term_id', + 'term_taxonomy_id', + ) as $candidate + ) { + if ( isset( $column_lookup[ $candidate ] ) ) { + return $column_lookup[ $candidate ]; + } + } + + return null; + } + + /** + * Translate simple MySQL INSERT statements to PostgreSQL. + * + * WordPress CRUD helpers emit a narrow INSERT INTO table (columns) VALUES + * (...) shape. INSERT IGNORE uses PostgreSQL's conflict no-op syntax for + * the same VALUES shape. Simple single-row INSERT ... SET assignments are + * normalized into that same PostgreSQL INSERT form. MySQL priority + * modifiers are accepted as compatibility no-ops. Other trailing clauses + * fall through unchanged. Columnless VALUES rows are supported when stored + * MySQL metadata can infer target columns. + * + * @param string $query MySQL query. + * @return array|null PostgreSQL query data, or null when the query is unsupported. + */ + private function translate_simple_mysql_insert_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + $ignore = false; + $this->consume_mysql_insert_priority_modifier( $tokens, $position ); + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[ $position ]->id ) { + $ignore = true; + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return null; + } + + $column_metadata = null; + $value_rows = null; + $value_range_rows = array(); + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $set_assignments = $this->parse_simple_mysql_insert_set_assignments( $table_name, $tokens, $position, $statement_end ); + if ( null === $set_assignments ) { + return null; + } + + $columns = $set_assignments['columns']; + $value_rows = array( $set_assignments['values'] ); + $value_range_rows = array( $set_assignments['ranges'] ); + } elseif ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + } elseif ( $this->is_mysql_values_row_list_keyword_token( $tokens[ $position ] ?? null ) ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + $columns = $this->get_mysql_dml_column_names_from_metadata( $column_metadata ); + if ( null === $columns ) { + return null; + } + } else { + return null; + } + + if ( null === $value_rows ) { + if ( ! $this->is_mysql_values_row_list_keyword_token( $tokens[ $position ] ?? null ) ) { + return null; + } + + ++$position; + $probe_safe_rows = array(); + $value_range_rows = array(); + $value_rows = $this->parse_mysql_values_rows( $tokens, $position, $statement_end, count( $columns ), $probe_safe_rows, $value_range_rows ); + if ( null === $value_rows || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + } + + if ( null === $column_metadata ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + } + foreach ( $value_rows as $row_index => &$values ) { + $value_ranges = $value_range_rows[ $row_index ] ?? array(); + $this->validate_strict_mysql_dml_values_for_columns( + $columns, + $value_ranges, + $tokens, + $column_metadata + ); + $this->normalize_mysql_auto_increment_zero_values_for_columns( + $columns, + $values, + $value_ranges, + $tokens, + $column_metadata + ); + $this->normalize_strict_mysql_dml_values_for_columns( + $columns, + $values, + $value_ranges, + $tokens, + $column_metadata + ); + $this->normalize_non_strict_mysql_dml_values_for_columns( + $columns, + $values, + $value_ranges, + $tokens, + $column_metadata + ); + } + unset( $values ); + $this->append_non_strict_dml_defaults_for_omitted_value_rows( $table_name, $columns, $value_rows, $column_metadata ); + + $sql_value_rows = array(); + foreach ( $value_rows as $values ) { + $sql_value_rows[] = '(' . implode( ', ', $values ) . ')'; + } + + $sql = sprintf( + 'INSERT INTO %s (%s) VALUES %s', + $this->connection->quote_identifier( $table_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + implode( ', ', $sql_value_rows ) + ); + + return array( + 'action' => 'insert', + 'sql' => $ignore ? $sql . ' ON CONFLICT DO NOTHING' : $sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $value_rows[0] ?? array(), + 'value_rows' => $value_rows, + 'ignore' => $ignore, + 'inserted_new_row' => true, + ); + } + + /** + * Check whether a query is a MySQL REPLACE statement. + * + * @param string $query MySQL query. + * @return bool Whether the query starts with REPLACE. + */ + private function is_mysql_replace_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + return isset( $tokens[0] ) && WP_MySQL_Lexer::REPLACE_SYMBOL === $tokens[0]->id; + } + + /** + * Check whether a token is a MySQL VALUES/VALUE row-list keyword. + * + * MySQL accepts singular VALUE as a synonym for VALUES before row lists. Keep + * this separate from VALUES(column) upsert-expression handling. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return bool Whether the token starts a VALUES row list. + */ + private function is_mysql_values_row_list_keyword_token( ?WP_MySQL_Token $token ): bool { + return null !== $token + && in_array( + $token->id, + array( + WP_MySQL_Lexer::VALUES_SYMBOL, + WP_MySQL_Lexer::VALUE_SYMBOL, + ), + true + ); + } + + /** + * Check whether an unsupported INSERT ... SET query was not translated. + * + * @param string $query MySQL query. + * @return bool Whether the query is an INSERT ... SET shape. + */ + private function is_unsupported_mysql_insert_set_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return false; + } + + return null !== $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::SET_SYMBOL, + 1, + $statement_end + ); + } + + /** + * Check whether an INSERT statement contains unsupported clauses. + * + * @param string $query MySQL query. + * @return bool Whether this INSERT should fail before backend execution. + */ + private function is_unsupported_mysql_insert_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return false; + } + + return $this->contains_top_level_mysql_token( + $tokens, + 1, + $statement_end, + array( + WP_MySQL_Lexer::PARTITION_SYMBOL, + WP_MySQL_Lexer::RETURNING_SYMBOL, + ) + ); + } + + /** + * Parse a simple single-row MySQL INSERT ... SET assignment list. + * + * @param string $table_name Target table name. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First assignment token position. + * @param int $end Final assignment token position, exclusive. + * @return array{columns: string[], values: string[], ranges: array[], probe_safe_values: bool[]}|null Insert columns and values, or null when unsupported. + */ + private function parse_simple_mysql_insert_set_assignments( string $table_name, array $tokens, int $start, int $end ): ?array { + $on_duplicate = $this->find_on_duplicate_key_update_clause( $tokens, $start ); + if ( $start >= $end || ( null !== $on_duplicate && $on_duplicate < $end ) ) { + return null; + } + + $assignments = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $assignments || empty( $assignments ) ) { + return null; + } + + $columns = array(); + $values = array(); + $ranges = array(); + $probe_safe = array(); + $column_lookup = array(); + foreach ( $assignments as $assignment ) { + $target = $this->parse_simple_mysql_insert_set_assignment_target( + $table_name, + $tokens, + $assignment['start'], + $assignment['end'] + ); + if ( null === $target ) { + return null; + } + + $column = $target['column']; + $value_start = $target['value_start']; + + $column_key = strtolower( $column ); + if ( isset( $column_lookup[ $column_key ] ) ) { + return null; + } + $column_lookup[ $column_key ] = true; + + $columns[] = $column; + $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $assignment['end'] ); + $ranges[] = array( + 'start' => $value_start, + 'end' => $assignment['end'], + ); + $probe_safe[] = $this->is_supported_mysql_upsert_conflict_probe_token_sequence( $tokens, $value_start, $assignment['end'] ); + } + + return array( + 'columns' => $columns, + 'values' => $values, + 'ranges' => $ranges, + 'probe_safe_values' => $probe_safe, + ); + } + + /** + * Parse the target side of a simple INSERT/REPLACE ... SET assignment. + * + * @param string $table_name Target table name. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start Assignment start token position. + * @param int $end Assignment end token position, exclusive. + * @return array{column: string, value_start: int}|null Parsed assignment target, or null when unsupported. + */ + private function parse_simple_mysql_insert_set_assignment_target( string $table_name, array $tokens, int $start, int $end ): ?array { + $first = $this->get_mysql_insert_set_identifier_token_value( $tokens[ $start ] ?? null ); + if ( null === $first ) { + return null; + } + + $position = $start + 1; + if ( WP_MySQL_Lexer::EQUAL_OPERATOR === ( $tokens[ $position ]->id ?? null ) ) { + return $position + 1 < $end + ? array( + 'column' => $first, + 'value_start' => $position + 1, + ) + : null; + } + + if ( WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return null; + } + + $second = $this->get_mysql_insert_set_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $second ) { + return null; + } + $position += 2; + + if ( WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $third = $this->get_mysql_insert_set_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( + null === $third + || 0 !== strcasecmp( $first, $this->main_db_name ) + || 0 !== strcasecmp( $second, $table_name ) + ) { + return null; + } + + $column = $third; + $position += 2; + } else { + if ( ! $this->is_mysql_dml_table_qualifier( $first, $table_name, null ) ) { + return null; + } + + $column = $second; + } + + if ( + WP_MySQL_Lexer::EQUAL_OPERATOR !== ( $tokens[ $position ]->id ?? null ) + || $position + 1 >= $end + ) { + return null; + } + + return array( + 'column' => $column, + 'value_start' => $position + 1, + ); + } + + /** + * Get an identifier token value for INSERT/REPLACE ... SET assignment targets. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Identifier value, or null when unsupported. + */ + private function get_mysql_insert_set_identifier_token_value( ?WP_MySQL_Token $token ): ?string { + $identifier = $this->get_mysql_dml_identifier_token_value( $token ); + if ( null !== $identifier ) { + return $identifier; + } + + return null !== $token && WP_MySQL_Lexer::NAME_SYMBOL === $token->id + ? $token->get_value() + : null; + } + + /** + * Translate simple MySQL INSERT ... SELECT statements to PostgreSQL. + * + * Action Scheduler uses INSERT ... SELECT FROM DUAL and then reads + * insert_id. The generic compatibility rewrite can produce executable SQL, + * but it does not mark the statement as insert-like. Keep this parser narrow: + * explicit table, optional explicit or metadata-inferred column list, then + * a SELECT body. + * + * @param string $query MySQL query. + * @return array|null PostgreSQL query data, or null when unsupported. + */ + private function translate_simple_mysql_insert_select_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::INSERT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + $ignore = false; + $this->consume_mysql_insert_priority_modifier( $tokens, $position ); + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[ $position ]->id ) { + $ignore = true; + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $table_reference_start = $position; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + $table_reference_end = $position; + $table_reference_sql = $this->get_mysql_main_database_table_reference_sql( + $tokens, + $table_reference_start, + $table_reference_end + ); + + $insert_column_list = false; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + $columns = $this->get_mysql_dml_column_names_from_metadata( $column_metadata ); + $insert_column_list = true; + if ( null === $columns ) { + return null; + } + } elseif ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position ]->id ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + $columns = $this->get_mysql_dml_column_names_from_metadata( $column_metadata ); + $insert_column_list = true; + if ( null === $columns ) { + return null; + } + } else { + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + } + if ( $insert_column_list ) { + $table_reference_sql .= ' (' . implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ) . ')'; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $select_start = $position; + $select_end = $statement_end; + $outer_replacements = array( + array( + 'start' => 0, + 'end' => $table_reference_end, + 'sql' => 'INSERT INTO ' . $table_reference_sql, + ), + ); + $closing_replacement = array(); + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( + null === $after_close + || $after_close !== $statement_end + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $select_start = $position + 1; + $select_end = $statement_end - 1; + $outer_replacements[] = array( + 'start' => $position, + 'end' => $position + 1, + 'sql' => '', + ); + $closing_replacement[] = array( + 'start' => $statement_end - 1, + 'end' => $statement_end, + 'sql' => '', + ); + } + + if ( ! isset( $tokens[ $select_start ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $select_start ]->id ) { + return null; + } + + $replacements = $this->get_mysql_insert_select_projection_replacements( + $table_name, + $columns, + $tokens, + $select_start, + $select_end + ); + if ( null === $replacements ) { + return null; + } + $replacements = array_merge( $outer_replacements, $replacements, $closing_replacement ); + + $direct_information_schema_select_sql = $this->get_insert_select_direct_information_schema_select_sql( + $query, + $tokens, + $select_start, + $select_end + ); + if ( null !== $direct_information_schema_select_sql ) { + if ( $this->mysql_replacements_overlap_range( $replacements, $select_start, $select_end ) ) { + return null; + } + + $replacements[] = array( + 'start' => $select_start, + 'end' => $select_end, + 'sql' => $direct_information_schema_select_sql, + ); + } elseif ( $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, $select_start, $select_end ) ) { + return null; + } + + usort( + $replacements, + static function ( array $left, array $right ): int { + return $left['start'] <=> $right['start']; + } + ); + + $sql = $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 0, + $statement_end, + $replacements + ); + if ( $ignore ) { + $sql .= ' ON CONFLICT DO NOTHING'; + } + + $table_column_lookup = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $table_column_lookup ); + $literal_value_row = null; + $insert_id_value_rows = null; + $value_rows = null; + $explicit_identity_columns = array(); + if ( + null !== $auto_increment_column + && $this->mysql_dml_column_list_contains_column( $columns, $auto_increment_column ) + ) { + $literal_value_row = $this->get_mysql_insert_select_upsert_literal_value_row( + $table_name, + $columns, + $tokens, + $select_start, + $select_end + ); + if ( null === $literal_value_row ) { + $explicit_identity_columns[ strtolower( $auto_increment_column ) ] = true; + } else { + $value_rows = array( $literal_value_row['values'] ); + $insert_id_value_rows = array( $literal_value_row['insert_id_values'] ); + } + } + + return array( + 'action' => 'insert', + 'sql' => $sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'ignore' => $ignore, + 'inserted_new_row' => true, + 'value_rows' => $value_rows, + 'insert_id_value_rows' => $insert_id_value_rows, + 'insert_id_unknown' => ! empty( $explicit_identity_columns ), + 'explicit_identity_columns' => $explicit_identity_columns, + ); + } + + /** + * Get translated SELECT SQL for INSERT ... SELECT sourcing information_schema. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $select_start SELECT token position. + * @param int $select_end Final SELECT token position, exclusive. + * @return string|null PostgreSQL SELECT SQL, or null when not a supported information_schema SELECT. + */ + private function get_insert_select_direct_information_schema_select_sql( string $query, array $tokens, int $select_start, int $select_end ): ?string { + $select_query = $this->get_mysql_token_range_sql( $query, $tokens, $select_start, $select_end ); + if ( null === $select_query ) { + return null; + } + + return $this->translate_direct_information_schema_select_query( $select_query ); + } + + /** + * Check whether a SELECT range must use the information_schema rewrite path. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $select_start SELECT token position. + * @param int $select_end Final SELECT token position, exclusive. + * @return bool Whether falling through to the backend would be unsafe. + */ + private function mysql_select_range_requires_direct_information_schema_rewrite( array $tokens, int $select_start, int $select_end ): bool { + if ( $this->select_references_direct_information_schema_relation( $tokens, $select_start + 1, $select_end ) ) { + return true; + } + + return 0 === strcasecmp( $this->db_name, 'information_schema' ) + && $this->mysql_select_range_has_non_dual_table_reference( $tokens, $select_start, $select_end ); + } + + /** + * Check whether a SELECT range reads a real table source, ignoring exact DUAL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $select_start SELECT token position. + * @param int $select_end Final SELECT token position, exclusive. + * @return bool Whether the range contains a non-DUAL FROM reference. + */ + private function mysql_select_range_has_non_dual_table_reference( array $tokens, int $select_start, int $select_end ): bool { + for ( $position = $select_start + 1; $position < $select_end; $position++ ) { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + $dual_translation = $this->translate_mysql_dual_table_reference_to_postgresql( $tokens, $position, $select_end ); + if ( null !== $dual_translation ) { + $position = $dual_translation['position']; + continue; + } + + return true; + } + + return false; + } + + /** + * Check whether replacement ranges overlap a token range. + * + * @param array[] $replacements Replacement ranges. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether any replacement overlaps the range. + */ + private function mysql_replacements_overlap_range( array $replacements, int $start, int $end ): bool { + foreach ( $replacements as $replacement ) { + if ( max( $start, $replacement['start'] ) < min( $end, $replacement['end'] ) ) { + return true; + } + } + + return false; + } + + /** + * Get projection replacements for INSERT ... SELECT target compatibility. + * + * @param string $table_name Target table name. + * @param string[] $columns Target column names. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $select_start SELECT token position. + * @param int $select_end Final SELECT token position, exclusive. + * @return array[]|null Replacement ranges, or null when unsupported. + */ + private function get_mysql_insert_select_projection_replacements( string $table_name, array $columns, array $tokens, int $select_start, int $select_end ): ?array { + $select_clause_tokens = array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ); + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $select_start + 1, $select_end ); + $projection_end = $from_position; + if ( null === $projection_end ) { + $projection_end = $this->find_first_top_level_mysql_token( + $tokens, + $select_clause_tokens, + $select_start + 1, + $select_end + ) ?? $select_end; + } + + if ( $select_start + 1 >= $projection_end ) { + return array(); + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $select_start + 1, $projection_end ); + if ( null === $projection_ranges || count( $projection_ranges ) !== count( $columns ) ) { + return null; + } + + $target_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + if ( empty( $target_metadata ) ) { + return array(); + } + + $scope = null; + $group_items = null; + if ( null !== $from_position ) { + $first_clause_position = $this->find_first_top_level_mysql_token( + $tokens, + $select_clause_tokens, + $from_position + 1, + $select_end + ) ?? $select_end; + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $first_clause_position ); + + $group_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, $from_position + 1, $select_end ); + if ( + null !== $group_position + && isset( $tokens[ $group_position + 1 ] ) + && WP_MySQL_Lexer::BY_SYMBOL === $tokens[ $group_position + 1 ]->id + ) { + $group_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ), + $group_position + 2, + $select_end + ) ?? $select_end; + $group_items = $this->split_top_level_mysql_arguments( $tokens, $group_position + 2, $group_end ); + if ( null === $group_items ) { + return null; + } + } + } + + $replacements = array(); + foreach ( $projection_ranges as $index => $range ) { + $column_key = strtolower( $columns[ $index ] ); + $column_metadata = $target_metadata[ $column_key ] ?? null; + if ( null === $column_metadata ) { + continue; + } + + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( $tokens, $range['start'], $range['end'] ); + if ( null === $expression_bounds ) { + return null; + } + + $expression_start = $expression_bounds['start']; + $expression_end = $expression_bounds['end']; + $projection_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $expression_start, $expression_end ); + $changed = false; + if ( + null !== $group_items + && ! $this->is_mysql_insert_select_grouped_projection_expression( $tokens, $expression_start, $expression_end, $group_items ) + ) { + $projection_sql = sprintf( 'MIN(%s)', $projection_sql ); + $changed = true; + } + + $coerced_sql = $this->get_mysql_insert_select_projection_sql_for_target_column( + $table_name, + $column_metadata, + $tokens, + $expression_start, + $expression_end, + $projection_sql, + $scope + ); + if ( null !== $coerced_sql ) { + $projection_sql = $coerced_sql; + $changed = true; + } + + if ( ! $changed ) { + continue; + } + + $replacements[] = array( + 'start' => $range['start'], + 'end' => $range['end'], + 'sql' => $projection_sql, + ); + } + + return $replacements; + } + + /** + * Check whether an INSERT ... SELECT projection is already grouped or aggregate-safe. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token. + * @param int $end Final projection token, exclusive. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether the projection can be selected without an aggregate wrapper. + */ + private function is_mysql_insert_select_grouped_projection_expression( array $tokens, int $start, int $end, array $group_items ): bool { + if ( $this->is_mysql_constant_projection_expression( $tokens, $start, $end ) || $this->contains_mysql_aggregate_call( $tokens, $start, $end ) ) { + return true; + } + + foreach ( $group_items as $group_item ) { + if ( $this->are_mysql_token_ranges_equivalent( $tokens, $start, $end, $group_item['start'], $group_item['end'] ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a projection expression is a simple constant. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token. + * @param int $end Final projection token, exclusive. + * @return bool Whether the expression is constant. + */ + private function is_mysql_constant_projection_expression( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && ( + $this->is_mysql_string_literal_token( $tokens[ $start ] ) + || $this->is_mysql_numeric_literal_token( $tokens[ $start ] ) + || WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $start ]->id + ); + } + + /** + * Coerce an INSERT ... SELECT projection to the target column type when needed. + * + * @param string $table_name Target table name. + * @param array $column_metadata Target column metadata. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token. + * @param int $end Final projection token, exclusive. + * @param string $projection_sql Already translated projection SQL. + * @param array|null $scope Source SELECT table scope. + * @return string|null Coerced projection SQL, or null when generic SQL is sufficient. + */ + private function get_mysql_insert_select_projection_sql_for_target_column( string $table_name, array $column_metadata, array $tokens, int $start, int $end, string $projection_sql, ?array $scope ): ?string { + $this->validate_strict_mysql_dml_value_for_column( $column_metadata, $tokens, $start, $end ); + + if ( + ! $this->is_mysql_sql_mode_active( 'NO_AUTO_VALUE_ON_ZERO' ) + && $this->is_mysql_auto_increment_column_metadata( $column_metadata ) + && $this->is_mysql_zero_literal_range( $tokens, $start, $end ) + ) { + return $this->get_mysql_insert_select_auto_increment_generated_value_sql( $table_name, $column_metadata ); + } + + $value_sql = $this->get_strict_mysql_dml_value_sql_for_column( $column_metadata, $tokens, $start, $end ); + if ( null !== $value_sql ) { + return $value_sql; + } + + $value_sql = $this->get_non_strict_mysql_dml_value_sql_for_column( $column_metadata, $tokens, $start, $end ); + if ( null !== $value_sql ) { + return $value_sql; + } + + $value_sql = $this->get_strict_mysql_dml_temporal_expression_sql_for_column( $column_metadata, $tokens, $start, $end, $projection_sql ); + if ( null !== $value_sql ) { + return $value_sql; + } + + $target_type = (string) ( $column_metadata['column_type'] ?? '' ); + if ( $this->is_mysql_integer_family_column_type( $target_type ) ) { + if ( null !== $scope ) { + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( null !== $reference && $reference['end'] === $end && $this->is_mysql_integer_column_reference( $reference, $scope ) ) { + return null; + } + } + + if ( $this->is_mysql_integer_numeric_literal_range( $tokens, $start, $end ) ) { + return null; + } + + return $this->get_postgresql_mysql_integer_cast_sql( $projection_sql ); + } + + if ( ! $this->is_mysql_text_family_column_type( $target_type ) ) { + return null; + } + + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + return null; + } + + if ( null !== $scope ) { + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( null !== $reference && $reference['end'] === $end && $this->is_mysql_text_family_column_reference( $reference, $scope ) ) { + return null; + } + } + + return sprintf( 'CAST(%s AS text)', $projection_sql ); + } + + /** + * Store a MySQL-compatible insert ID after a successful insert-like query. + * + * PostgreSQL PDO exposes the sequence value, which can be stale when the + * caller explicitly supplies an AUTO_INCREMENT value. MySQL reports that + * explicit value through mysqli_insert_id(), and WordPress relies on it. + * + * @param array $dml_query Translated DML query metadata. + * @param int $affected_rows Backend affected row count. + */ + private function set_last_insert_id_after_dml_success( array $dml_query, int $affected_rows ): void { + if ( $affected_rows <= 0 ) { + $this->last_insert_id = 0; + return; + } + + $inserted_new_row = ! isset( $dml_query['inserted_new_row'] ) || $dml_query['inserted_new_row']; + + if ( + ! isset( $dml_query['table_name'], $dml_query['columns'] ) + || ! is_array( $dml_query['columns'] ) + ) { + $this->last_insert_id = $inserted_new_row ? $this->get_connection_last_insert_id() : 0; + return; + } + + $metadata_lookup = $this->get_mysql_dml_column_metadata_lookup( (string) $dml_query['table_name'] ); + if ( empty( $metadata_lookup ) ) { + $this->last_insert_id = $inserted_new_row ? $this->get_connection_last_insert_id() : 0; + return; + } + + $auto_increment_column = $this->get_mysql_auto_increment_column_from_metadata( $metadata_lookup ); + if ( null === $auto_increment_column ) { + $this->last_insert_id = 0; + return; + } + + if ( ! empty( $dml_query['insert_id_unknown'] ) ) { + $this->last_insert_id = 0; + return; + } + + if ( array_key_exists( 'last_insert_id_on_duplicate_key_update', $dml_query ) ) { + $last_insert_id = $dml_query['last_insert_id_on_duplicate_key_update']; + $this->last_insert_id = is_numeric( $last_insert_id ) ? (int) $last_insert_id : $last_insert_id; + return; + } + + $explicit_insert_id = $this->get_explicit_mysql_auto_increment_insert_id( + $auto_increment_column, + $dml_query['columns'], + $this->get_dml_insert_id_value_rows( $dml_query ) + ); + if ( null !== $explicit_insert_id ) { + $this->last_insert_id = $explicit_insert_id; + return; + } + + if ( ! $inserted_new_row ) { + $this->last_insert_id = 0; + return; + } + + $this->last_insert_id = $this->get_connection_last_insert_id(); + } + + /** + * Read the backend connection's last insert ID. + * + * @return int|string Last insert ID, or 0 when unavailable. + */ + private function get_connection_last_insert_id() { + try { + $insert_id = $this->connection->get_last_insert_id(); + } catch ( Throwable $e ) { + return 0; + } + + return is_numeric( $insert_id ) ? (int) $insert_id : $insert_id; + } + + /** + * Get the MySQL AUTO_INCREMENT column from DML metadata. + * + * @param array $metadata_lookup Column metadata lookup. + * @return string|null AUTO_INCREMENT column name, or null when absent. + */ + private function get_mysql_auto_increment_column_from_metadata( array $metadata_lookup ): ?string { + foreach ( $metadata_lookup as $column_metadata ) { + if ( ! $this->is_mysql_auto_increment_column_metadata( $column_metadata ) ) { + continue; + } + + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( '' !== $column_name ) { + return $column_name; + } + } + + return null; + } + + /** + * Get DML value rows from translated insert metadata. + * + * @param array $dml_query Translated DML query metadata. + * @return array[] DML value rows. + */ + private function get_dml_insert_value_rows( array $dml_query ): array { + if ( isset( $dml_query['value_rows'] ) && is_array( $dml_query['value_rows'] ) ) { + return $dml_query['value_rows']; + } + + if ( isset( $dml_query['values'] ) && is_array( $dml_query['values'] ) ) { + return array( $dml_query['values'] ); + } + + return array(); + } + + /** + * Get DML value rows used for MySQL insert ID detection. + * + * @param array $dml_query Translated DML query metadata. + * @return array[] DML value rows. + */ + private function get_dml_insert_id_value_rows( array $dml_query ): array { + if ( isset( $dml_query['insert_id_value_rows'] ) && is_array( $dml_query['insert_id_value_rows'] ) ) { + return $dml_query['insert_id_value_rows']; + } + + return $this->get_dml_insert_value_rows( $dml_query ); + } + + /** + * Get an explicitly supplied AUTO_INCREMENT insert ID from DML values. + * + * @param string $auto_increment_column AUTO_INCREMENT column name. + * @param string[] $columns DML column names. + * @param array[] $value_rows DML value rows. + * @return int|string|null Explicit insert ID, or null when not supplied. + */ + private function get_explicit_mysql_auto_increment_insert_id( string $auto_increment_column, array $columns, array $value_rows ) { + $auto_increment_index = null; + foreach ( $columns as $index => $column ) { + if ( strtolower( (string) $column ) === strtolower( $auto_increment_column ) ) { + $auto_increment_index = $index; + break; + } + } + + if ( null === $auto_increment_index ) { + return null; + } + + foreach ( $value_rows as $values ) { + if ( ! is_array( $values ) || ! isset( $values[ $auto_increment_index ] ) ) { + continue; + } + + $insert_id = $this->get_mysql_insert_id_from_value_sql( (string) $values[ $auto_increment_index ] ); + if ( null !== $insert_id ) { + return $insert_id; + } + } + + return null; + } + + /** + * Parse a simple integer SQL value as a MySQL insert ID. + * + * @param string $value_sql Translated SQL value. + * @return int|string|null Insert ID, or null for DEFAULT/NULL/unsupported values. + */ + private function get_mysql_insert_id_from_value_sql( string $value_sql ) { + $value_sql = trim( $value_sql ); + if ( '' === $value_sql || in_array( strtoupper( $value_sql ), array( 'DEFAULT', 'NULL' ), true ) ) { + return null; + } + + if ( + strlen( $value_sql ) >= 2 + && ( + ( "'" === $value_sql[0] && "'" === $value_sql[ strlen( $value_sql ) - 1 ] ) + || ( '"' === $value_sql[0] && '"' === $value_sql[ strlen( $value_sql ) - 1 ] ) + ) + ) { + $value_sql = substr( $value_sql, 1, -1 ); + } + + if ( isset( $value_sql[0] ) && '+' === $value_sql[0] ) { + $value_sql = substr( $value_sql, 1 ); + } + + if ( '' === $value_sql || ! ctype_digit( $value_sql ) ) { + return null; + } + + $value_sql = ltrim( $value_sql, '0' ); + if ( '' === $value_sql ) { + $value_sql = '0'; + } + + return is_numeric( $value_sql ) ? (int) $value_sql : $value_sql; + } + + /** + * Evaluate a bounded MySQL integer constant expression. + * + * This is only used for MySQL insert-id bookkeeping after the expression has + * already passed the no-identifiers upsert literal-expression guard. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position, inclusive. + * @param int $end Final expression token position, exclusive. + * @return string|null Non-negative integer string, or null when unsupported. + */ + private function get_mysql_constant_integer_expression_value( array $tokens, int $start, int $end ): ?string { + $position = $start; + $value = $this->parse_mysql_constant_integer_expression( $tokens, $position, $end ); + if ( null === $value || $position !== $end || $value < 0 ) { + return null; + } + + return (string) $value; + } + + /** + * Parse a constant integer expression with + and - operators. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $end Final expression token position, exclusive. + * @return int|null Parsed integer, or null when unsupported. + */ + private function parse_mysql_constant_integer_expression( array $tokens, int &$position, int $end ): ?int { + $value = $this->parse_mysql_constant_integer_term( $tokens, $position, $end ); + if ( null === $value ) { + return null; + } + + while ( $position < $end && isset( $tokens[ $position ] ) ) { + $operator = $tokens[ $position ]->id; + if ( ! in_array( $operator, array( WP_MySQL_Lexer::PLUS_OPERATOR, WP_MySQL_Lexer::MINUS_OPERATOR ), true ) ) { + break; + } + + ++$position; + $right = $this->parse_mysql_constant_integer_term( $tokens, $position, $end ); + if ( null === $right ) { + return null; + } + + $value = WP_MySQL_Lexer::PLUS_OPERATOR === $operator ? $value + $right : $value - $right; + if ( ! is_int( $value ) ) { + return null; + } + } + + return $value; + } + + /** + * Parse a constant integer term with multiplication. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $end Final expression token position, exclusive. + * @return int|null Parsed integer, or null when unsupported. + */ + private function parse_mysql_constant_integer_term( array $tokens, int &$position, int $end ): ?int { + $value = $this->parse_mysql_constant_integer_factor( $tokens, $position, $end ); + if ( null === $value ) { + return null; + } + + while ( + $position < $end + && isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[ $position ]->id + ) { + ++$position; + $right = $this->parse_mysql_constant_integer_factor( $tokens, $position, $end ); + if ( null === $right ) { + return null; + } + + $value *= $right; + if ( ! is_int( $value ) ) { + return null; + } + } + + return $value; + } + + /** + * Parse a constant integer factor. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $end Final expression token position, exclusive. + * @return int|null Parsed integer, or null when unsupported. + */ + private function parse_mysql_constant_integer_factor( array $tokens, int &$position, int $end ): ?int { + if ( $position >= $end || ! isset( $tokens[ $position ] ) ) { + return null; + } + + $sign = 1; + while ( + $position < $end + && isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::PLUS_OPERATOR, WP_MySQL_Lexer::MINUS_OPERATOR ), true ) + ) { + if ( WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[ $position ]->id ) { + $sign *= -1; + } + ++$position; + } + + if ( $position >= $end || ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $value = $this->parse_mysql_constant_integer_expression( $tokens, $position, $end ); + if ( + null === $value + || $position >= $end + || ! isset( $tokens[ $position ] ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position ]->id + ) { + return null; + } + ++$position; + return $sign * $value; + } + + if ( + ! in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ) + ) { + return null; + } + + $value = ltrim( $tokens[ $position ]->get_bytes(), '+' ); + ++$position; + if ( '' === $value || ! ctype_digit( $value ) ) { + return null; + } + + $value = ltrim( $value, '0' ); + if ( '' === $value ) { + return 0; + } + + $max = (string) PHP_INT_MAX; + if ( strlen( $value ) > strlen( $max ) || ( strlen( $value ) === strlen( $max ) && strcmp( $value, $max ) > 0 ) ) { + return null; + } + + return $sign * (int) $value; + } + + /** + * Repair PostgreSQL identity sequences for successful explicit identity writes. + * + * @param array $dml_query Translated DML query metadata. + * @param int $affected_rows Backend affected row count. + */ + private function repair_dml_identity_sequences_after_success( array $dml_query, int $affected_rows ): void { + if ( $affected_rows <= 0 ) { + return; + } + + if ( isset( $dml_query['inserted_new_row'] ) && ! $dml_query['inserted_new_row'] ) { + return; + } + + if ( + ! isset( $dml_query['table_name'], $dml_query['columns'] ) + || ! is_array( $dml_query['columns'] ) + ) { + return; + } + + $explicit_identity_columns = array(); + if ( + isset( $dml_query['explicit_identity_columns'] ) + && is_array( $dml_query['explicit_identity_columns'] ) + ) { + foreach ( $dml_query['explicit_identity_columns'] as $column => $explicit ) { + if ( $explicit ) { + $explicit_identity_columns[ strtolower( (string) $column ) ] = true; + } + } + } + + if ( isset( $dml_query['value_rows'] ) && is_array( $dml_query['value_rows'] ) ) { + $explicit_identity_columns = $this->get_explicit_dml_identity_column_lookup_from_rows( + $dml_query['columns'], + $dml_query['value_rows'] + ) + $explicit_identity_columns; + } elseif ( isset( $dml_query['values'] ) && is_array( $dml_query['values'] ) ) { + $explicit_identity_columns = $this->get_explicit_dml_identity_column_lookup( + $dml_query['columns'], + $dml_query['values'] + ) + $explicit_identity_columns; + } elseif ( empty( $explicit_identity_columns ) ) { + return; + } + + if ( empty( $explicit_identity_columns ) || ! $this->is_postgresql_catalog_available_for_dml_identity_repair() ) { + return; + } + + $table_name = (string) $dml_query['table_name']; + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $metadata = $this->get_dml_identity_column_metadata( $table_schema, $table_name ); + + foreach ( $metadata as $column_metadata ) { + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( ! isset( $explicit_identity_columns[ strtolower( $column_name ) ] ) ) { + continue; + } + + if ( ! $this->is_existing_dbdelta_column_identity( $column_metadata ) ) { + continue; + } + + $sequence_schema = (string) ( $column_metadata['sequence_schema'] ?? '' ); + $sequence_name = (string) ( $column_metadata['sequence_name'] ?? '' ); + if ( '' === $sequence_schema || '' === $sequence_name ) { + continue; + } + + $this->repair_postgresql_identity_sequence( + $table_schema, + $table_name, + $column_name, + $sequence_schema, + $sequence_name + ); + } + } + + /** + * Get explicitly supplied non-default DML identity columns. + * + * @param string[] $columns DML column names. + * @param string[] $values Translated DML value expressions. + * @return array Lowercase column lookup. + */ + private function get_explicit_dml_identity_column_lookup( array $columns, array $values ): array { + $explicit_columns = array(); + + foreach ( $columns as $index => $column ) { + if ( ! isset( $values[ $index ] ) || ! $this->is_explicit_dml_identity_value( (string) $values[ $index ] ) ) { + continue; + } + + $explicit_columns[ strtolower( (string) $column ) ] = true; + } + + return $explicit_columns; + } + + /** + * Get explicitly supplied non-default DML identity columns from VALUES rows. + * + * @param string[] $columns DML column names. + * @param array[] $value_rows Translated DML value rows. + * @return array Lowercase column lookup. + */ + private function get_explicit_dml_identity_column_lookup_from_rows( array $columns, array $value_rows ): array { + $explicit_columns = array(); + + foreach ( $value_rows as $values ) { + if ( ! is_array( $values ) ) { + continue; + } + + foreach ( $this->get_explicit_dml_identity_column_lookup( $columns, $values ) as $column => $explicit ) { + $explicit_columns[ $column ] = $explicit; + } + } + + return $explicit_columns; + } + + /** + * Check whether a DML value is an explicit identity value. + * + * DEFAULT and NULL do not represent caller-supplied auto_increment values. + * + * @param string $value_sql Translated value SQL. + * @return bool Whether the value is explicit. + */ + private function is_explicit_dml_identity_value( string $value_sql ): bool { + $value_sql = trim( $value_sql ); + if ( '' === $value_sql ) { + return false; + } + + return ! in_array( strtoupper( $value_sql ), array( 'DEFAULT', 'NULL' ), true ); + } + + /** + * Get PostgreSQL/MySQL metadata for DML identity repair. + * + * @param string $table_schema Backend table schema. + * @param string $table_name Table name. + * @return array[] Column metadata rows. + */ + private function get_dml_identity_column_metadata( string $table_schema, string $table_name ): array { + $this->ensure_mysql_schema_metadata_tables(); + + $cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + if ( array_key_exists( $cache_key, $this->mysql_dml_identity_column_metadata_cache ) ) { + return $this->mysql_dml_identity_column_metadata_cache[ $cache_key ]; + } + + try { + $stmt = $this->connection->query( + sprintf( + 'SELECT + c.column_name, + c.data_type, + c.is_identity, + c.column_default, + cm.column_type AS mysql_column_type, + cm.extra AS mysql_extra, + seq_ns.nspname AS sequence_schema, + seq.relname AS sequence_name + FROM information_schema.columns c + LEFT JOIN %s cm + ON cm.table_schema = c.table_schema + AND cm.table_name = c.table_name + AND cm.column_name = c.column_name + LEFT JOIN LATERAL ( + SELECT pg_catalog.pg_get_serial_sequence(format(\'%%I.%%I\', c.table_schema, c.table_name), c.column_name)::regclass AS sequence_oid + ) identity_sequence ON TRUE + LEFT JOIN pg_catalog.pg_class seq + ON seq.oid = identity_sequence.sequence_oid + LEFT JOIN pg_catalog.pg_namespace seq_ns + ON seq_ns.oid = seq.relnamespace + WHERE c.table_schema = ? + AND c.table_name = ? + ORDER BY c.ordinal_position', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + $this->mysql_dml_identity_column_metadata_cache[ $cache_key ] = $stmt->fetchAll( PDO::FETCH_ASSOC ); + } catch ( PDOException $e ) { + if ( 'pgsql' === (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ) ) { + throw $e; + } + + $this->mysql_dml_identity_column_metadata_cache[ $cache_key ] = array(); + } + + return $this->mysql_dml_identity_column_metadata_cache[ $cache_key ]; + } + + /** + * Check whether PostgreSQL catalog metadata is available for identity repair. + * + * @return bool Whether catalog-backed identity repair can run. + */ + private function is_postgresql_catalog_available_for_dml_identity_repair(): bool { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + if ( 'pgsql' === $driver_name ) { + return true; + } + + if ( 'sqlite' === $driver_name ) { + return $this->sqlite_information_schema_columns_table_exists(); + } + + return false; + } + + /** + * Check whether the SQLite test shim has an information_schema.columns fixture. + * + * @return bool Whether the fixture table exists. + */ + private function sqlite_information_schema_columns_table_exists(): bool { + $stmt = $this->connection->query( 'PRAGMA database_list' ); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $database ) { + if ( isset( $database['name'] ) && 'information_schema' === $database['name'] ) { + $tables = $this->connection->query( + "SELECT 1 FROM information_schema.sqlite_master WHERE type = 'table' AND name = 'columns' LIMIT 1" + ); + return false !== $tables->fetchColumn(); + } + } + + return false; + } + + /** + * Monotonically synchronize a PostgreSQL identity sequence with its table. + * + * @param string $table_schema Backend table schema. + * @param string $table_name Table name. + * @param string $column_name Identity column name. + * @param string $sequence_schema Sequence schema. + * @param string $sequence_name Sequence name. + */ + private function repair_postgresql_identity_sequence( + string $table_schema, + string $table_name, + string $column_name, + string $sequence_schema, + string $sequence_name + ): void { + $sequence_query = $this->get_postgresql_identity_sequence_repair_query( + $table_schema, + $table_name, + $column_name, + $sequence_schema, + $sequence_name + ); + + $this->connection->query( $sequence_query['sql'], $sequence_query['params'] ); + $this->last_postgresql_queries[] = $sequence_query; + } + + /** + * Get PostgreSQL sequence adjustment statements for ALTER TABLE AUTO_INCREMENT. + * + * @param string $table_name Target table name. + * @param string $auto_increment_column AUTO_INCREMENT column name. + * @param int $minimum_sequence_value Minimum last sequence value. + * @return string[] PostgreSQL statements. + */ + private function get_postgresql_auto_increment_alter_statements( string $table_name, string $auto_increment_column, int $minimum_sequence_value ): array { + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $metadata = $this->get_dml_identity_column_metadata( $table_schema, $table_name ); + + foreach ( $metadata as $column_metadata ) { + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( 0 !== strcasecmp( $column_name, $auto_increment_column ) ) { + continue; + } + + if ( ! $this->is_existing_dbdelta_column_identity( $column_metadata ) ) { + continue; + } + + $sequence_schema = (string) ( $column_metadata['sequence_schema'] ?? '' ); + $sequence_name = (string) ( $column_metadata['sequence_name'] ?? '' ); + if ( '' === $sequence_schema || '' === $sequence_name ) { + continue; + } + + $sequence_query = $this->get_postgresql_identity_sequence_repair_query( + $table_schema, + $table_name, + $column_name, + $sequence_schema, + $sequence_name, + $minimum_sequence_value + ); + + return array( + str_replace( + 'CAST(? AS regclass)', + 'CAST(' . $this->connection->quote( $sequence_query['params'][0] ) . ' AS regclass)', + $sequence_query['sql'] + ), + ); + } + + return array(); + } + + /** + * Build a SQLite sequence adjustment statement for ALTER TABLE AUTO_INCREMENT tests. + * + * @param string $table_name Target table name. + * @param string $auto_increment_column AUTO_INCREMENT column name. + * @param int $minimum_sequence_value Minimum last sequence value. + * @return string[] SQLite statements. + */ + private function get_sqlite_auto_increment_alter_statements( string $table_schema, string $table_name, string $auto_increment_column, int $minimum_sequence_value ): array { + $is_temporary = $this->is_mysql_temporary_schema_name( $table_schema ); + $table_identifier = $is_temporary + ? 'temp.' . $this->connection->quote_identifier( $table_name ) + : $this->connection->quote_identifier( $table_name ); + $sequence_table = $is_temporary ? 'temp.sqlite_sequence' : 'sqlite_sequence'; + + $sequence_value_sql = sprintf( + 'MAX(%d, COALESCE((SELECT MAX(%s) FROM %s), 0))', + $minimum_sequence_value, + $this->connection->quote_identifier( $auto_increment_column ), + $table_identifier + ); + + return array( + sprintf( + 'DELETE FROM %s WHERE name = %s', + $sequence_table, + $this->connection->quote( $table_name ) + ), + sprintf( + 'INSERT INTO %s (name, seq) VALUES (%s, (SELECT %s))', + $sequence_table, + $this->connection->quote( $table_name ), + $sequence_value_sql + ), + ); + } + + /** + * Build a guarded PostgreSQL identity sequence repair query. + * + * @param string $table_schema Backend table schema. + * @param string $table_name Table name. + * @param string $column_name Identity column name. + * @param string $sequence_schema Sequence schema. + * @param string $sequence_name Sequence name. + * @param int|null $minimum_sequence_value Optional minimum last sequence value. + * @return array{sql: string, params: array} Query data. + */ + private function get_postgresql_identity_sequence_repair_query( + string $table_schema, + string $table_name, + string $column_name, + string $sequence_schema, + string $sequence_name, + ?int $minimum_sequence_value = null + ): array { + $sequence_identifier = $this->get_postgresql_qualified_identifier( $sequence_schema, $sequence_name ); + $table_value_sql = sprintf( + 'SELECT MAX(%s) AS max_identity_value FROM %s', + $this->connection->quote_identifier( $column_name ), + $this->get_postgresql_qualified_identifier( $table_schema, $table_name ) + ); + $positive_guard = ''; + if ( null !== $minimum_sequence_value ) { + $table_value_sql = sprintf( + 'SELECT GREATEST(COALESCE(MAX(%s), 0), %d) AS max_identity_value FROM %s', + $this->connection->quote_identifier( $column_name ), + max( 0, $minimum_sequence_value ), + $this->get_postgresql_qualified_identifier( $table_schema, $table_name ) + ); + $positive_guard = ' + AND table_state.max_identity_value > 0'; + } + + return array( + 'sql' => sprintf( + 'WITH sequence_state AS ( + SELECT last_value, is_called FROM %1$s + ), + table_state AS ( + %2$s + ) + SELECT pg_catalog.setval(CAST(? AS regclass), table_state.max_identity_value, true) + FROM sequence_state, table_state + WHERE table_state.max_identity_value IS NOT NULL%3$s + AND ( + table_state.max_identity_value > sequence_state.last_value + OR (table_state.max_identity_value = sequence_state.last_value AND NOT sequence_state.is_called) + )', + $sequence_identifier, + $table_value_sql, + $positive_guard + ), + 'params' => array( $sequence_identifier ), + ); + } + + /** + * Quote a schema-qualified PostgreSQL identifier. + * + * @param string $schema_name Schema name. + * @param string $object_name Object name. + * @return string Quoted schema-qualified identifier. + */ + private function get_postgresql_qualified_identifier( string $schema_name, string $object_name ): string { + return $this->connection->quote_identifier( $schema_name ) . '.' . $this->connection->quote_identifier( $object_name ); + } + + /** + * Translate simple single-table MySQL UPDATE statements to PostgreSQL. + * + * WordPress CRUD updates emit a narrow MySQL shape with one table, + * backticked identifiers, and plain SET/WHERE clauses. Some plugins use + * single-table aliases and ORDER BY forms. Ordered updates are rewritten + * through PostgreSQL ctid subqueries so ORDER BY expressions are translated + * and unsupported expressions fail closed. Inner joined UPDATE syntax is + * rewritten separately to PostgreSQL UPDATE ... FROM. + * + * @param string $query MySQL query. + * @param array $cte_names Known CTE names keyed lowercase. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_simple_mysql_update_query( string $query, array $cte_names = array() ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::UPDATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $joined_update = $this->translate_mysql_inner_join_update_query( $query, $tokens, $statement_end ); + if ( null !== $joined_update ) { + return $joined_update; + } + + $joined_update = $this->translate_mysql_outer_join_update_query( $tokens, $statement_end ); + if ( null !== $joined_update ) { + return $joined_update; + } + + $position = 1; + $this->consume_mysql_update_modifiers( $tokens, $position, $statement_end ); + + $table_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $statement_end ); + if ( null === $table_reference ) { + return null; + } + + $table_name = $table_reference['table']; + $alias = $table_reference['alias']; + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $position, $statement_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $position, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $position, $statement_end ); + + if ( + ( null !== $order_position && null !== $where_position && $order_position < $where_position ) + || ( null !== $limit_position && null !== $where_position && $limit_position < $where_position ) + || ( null !== $limit_position && null !== $order_position && $limit_position < $order_position ) + ) { + return null; + } + + $set_end_candidates = array_filter( + array( $where_position, $order_position, $limit_position ), + 'is_int' + ); + $set_end = empty( $set_end_candidates ) ? $statement_end : min( $set_end_candidates ); + if ( ! $this->is_supported_simple_update_set_clause( $table_name, $alias, $tokens, $position, $set_end ) ) { + return null; + } + + $unsupported_tokens = array( + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ); + if ( $this->contains_top_level_mysql_token( $tokens, $position, $statement_end, $unsupported_tokens ) ) { + return null; + } + + $scope = $this->get_mysql_single_table_scope( $table_name, $alias ); + $update_set_clause = $this->translate_simple_mysql_update_set_clause( $table_name, $alias, $tokens, $position, $set_end ); + if ( null === $update_set_clause ) { + return null; + } + + $table_sql = $this->get_postgresql_dml_table_reference_sql( $table_name, $alias ); + $sql = sprintf( + 'UPDATE %s SET %s', + $table_sql, + $update_set_clause['set_sql'] + ); + + $where_end = $order_position ?? $limit_position ?? $statement_end; + $where_sql = null; + if ( null !== $where_position ) { + $where_replacements = $this->get_simple_mysql_dml_predicate_nested_select_replacements( + $query, + $tokens, + $where_position + 1, + $where_end, + $cte_names + ); + if ( + $where_position + 1 >= $where_end + || null === $where_replacements + || ! $this->is_supported_simple_mysql_expression_fragment_with_replacements( $tokens, $where_position + 1, $where_end, $where_replacements ) + ) { + return null; + } + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope, + $where_replacements + ); + $where_sql = $where_sql['sql']; + } + + $order_sql = ''; + if ( null !== $order_position ) { + $order_end = $limit_position ?? $statement_end; + $order_sql = $this->translate_mysql_joined_dml_order_by_clause_to_postgresql( + $tokens, + $order_position, + $order_end, + $scope + ); + if ( null === $order_sql ) { + return null; + } + } + + $limit_sql = ''; + if ( null !== $limit_position ) { + $limit_sql = $this->translate_simple_dml_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end, true ); + if ( null === $limit_sql ) { + return null; + } + } + + $predicates = array(); + if ( '' !== $order_sql || '' !== $limit_sql ) { + $subquery_where_sql = null === $where_sql ? '' : ' WHERE ' . $where_sql; + $predicates[] = sprintf( + '%s IN (SELECT %s FROM %s%s%s%s)', + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $table_sql, + $subquery_where_sql, + $order_sql, + $limit_sql + ); + } elseif ( null !== $where_sql ) { + $predicates[] = $where_sql; + } + $predicates[] = $update_set_clause['changed_predicate_sql']; + + if ( count( $predicates ) > 1 ) { + $sql .= ' WHERE (' . implode( ') AND (', $predicates ) . ')'; + } else { + $sql .= ' WHERE ' . $predicates[0]; + } + + return $sql; + } + + /** + * Translate MySQL WITH ... UPDATE statements to PostgreSQL. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_mysql_cte_prefixed_update_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + $cte = $this->get_mysql_cte_prefixed_update_data( $query, $tokens ); + if ( null === $cte ) { + return null; + } + + $update_sql = $this->get_mysql_token_range_sql( $query, $tokens, $cte['update_position'], $cte['statement_end'] ); + if ( null === $update_sql ) { + return null; + } + + $translated_update = $this->translate_simple_mysql_update_query( $update_sql, $cte['names'] ); + if ( null === $translated_update ) { + return null; + } + + return $cte['sql'] . ' ' . $translated_update; + } + + /** + * Parse the leading CTE list for a WITH ... UPDATE statement. + * + * @param string $query MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return array{sql: string, names: array, update_position: int, statement_end: int}|null CTE data, or null when unsupported. + */ + private function get_mysql_cte_prefixed_update_data( string $query, array $tokens ): ?array { + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::WITH_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::RECURSIVE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $cte_names = array(); + while ( $position < $statement_end ) { + $cte_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $cte_name ) { + return null; + } + $cte_names[ strtolower( $cte_name ) ] = true; + ++$position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_column_list = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $after_column_list ) { + return null; + } + $position = $after_column_list; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AS_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + ++$position; + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id + || ! in_array( $tokens[ $position + 1 ]->id, array( WP_MySQL_Lexer::SELECT_SYMBOL, WP_MySQL_Lexer::WITH_SYMBOL ), true ) + ) { + return null; + } + + $after_cte_body = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $after_cte_body ) { + return null; + } + $position = $after_cte_body; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $position ]->id ) { + $cte_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $position ); + if ( null === $this->get_mysql_token_range_sql( $query, $tokens, 0, $position ) ) { + return null; + } + + return array( + 'sql' => $cte_sql, + 'names' => $cte_names, + 'update_position' => $position, + 'statement_end' => $statement_end, + ); + } + + return null; + } + + return null; + } + + /** + * Check whether a top-level UPDATE statement reached the unsupported fallback. + * + * @param string $query MySQL query. + * @return bool Whether this is an unsupported UPDATE statement. + */ + private function is_unsupported_mysql_update_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + return isset( $tokens[0] ) && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[0]->id; + } + + /** + * Check whether a WITH ... UPDATE statement reached the unsupported fallback. + * + * @param string $query MySQL query. + * @return bool Whether this is an unsupported CTE-prefixed UPDATE statement. + */ + private function is_unsupported_mysql_cte_prefixed_update_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::WITH_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + return null !== $statement_end + && null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::UPDATE_SYMBOL, 1, $statement_end ); + } + + /** + * Check whether a MySQL UPDATE statement includes the IGNORE modifier. + * + * @param string $query MySQL query. + * @return bool Whether the query is UPDATE IGNORE. + */ + private function is_mysql_update_ignore_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::UPDATE_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return false; + } + + return $this->mysql_update_modifiers_include_ignore( $tokens, 1, $statement_end ); + } + + /** + * Check whether an UPDATE IGNORE exception should be skipped. + * + * @param PDOException $exception Query exception. + * @return bool Whether the exception is a data-integrity constraint failure. + */ + private function is_mysql_update_ignore_constraint_exception( PDOException $exception ): bool { + $sqlstate = (string) $exception->getCode(); + if ( 0 === strpos( $sqlstate, '23' ) ) { + return true; + } + + $message = strtolower( $exception->getMessage() ); + foreach ( array( 'constraint failed', 'unique constraint', 'not null constraint', 'check constraint', 'foreign key constraint', 'duplicate key value' ) as $needle ) { + if ( false !== strpos( $message, $needle ) ) { + return true; + } + } + + return false; + } + + /** + * Translate supported MySQL outer-joined UPDATE statements. + * + * PostgreSQL UPDATE ... FROM does not preserve unmatched outer-join rows. + * Select the target row ctid and computed assignment values through the + * original joined table reference, then update by ctid from that derived + * row set. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $statement_end Final statement token, exclusive. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_mysql_outer_join_update_query( array $tokens, int $statement_end ): ?string { + $set_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::SET_SYMBOL, 1, $statement_end ); + if ( null === $set_position ) { + return null; + } + + $has_left_join = $this->contains_top_level_mysql_token( $tokens, 1, $set_position, array( WP_MySQL_Lexer::LEFT_SYMBOL ) ); + $has_right_join = $this->contains_top_level_mysql_token( $tokens, 1, $set_position, array( WP_MySQL_Lexer::RIGHT_SYMBOL ) ); + if ( + $has_left_join === $has_right_join + || $this->contains_top_level_mysql_token( + $tokens, + 1, + $set_position, + array( + WP_MySQL_Lexer::NATURAL_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::USING_SYMBOL, + ) + ) + ) { + return null; + } + + $position = 1; + $this->consume_mysql_update_modifiers( $tokens, $position, $set_position ); + + $target_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $set_position ); + if ( + null === $target_reference + || $position >= $set_position + ) { + return null; + } + + $table_name = $target_reference['table']; + $alias = $target_reference['alias']; + $target_reference_alias = null === $alias ? $table_name : $alias; + + $scope = $this->get_mysql_select_scope( $tokens, 1, $set_position ); + if ( null === $scope || ! empty( $scope['unknown'] ) ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $set_position + 1, $statement_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $set_position + 1, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $set_position + 1, $statement_end ); + $order_end = $limit_position ?? $statement_end; + if ( + ( null !== $order_position && null !== $where_position && $order_position < $where_position ) + || ( null !== $limit_position && null !== $where_position && $limit_position < $where_position ) + || ( null !== $limit_position && null !== $order_position && $limit_position < $order_position ) + || ( null !== $order_position && ! $this->is_nonempty_mysql_order_by_clause( $tokens, $order_position, $order_end ) ) + ) { + return null; + } + + $set_end = $where_position ?? $order_position ?? $limit_position ?? $statement_end; + if ( $set_position + 1 >= $set_end ) { + return null; + } + + $update_set_clause = $this->translate_mysql_joined_update_set_clause_for_derived_source( + $table_name, + $target_reference_alias, + $tokens, + $set_position + 1, + $set_end, + $scope + ); + if ( null === $update_set_clause ) { + return null; + } + + $where_sql = ''; + if ( null !== $where_position ) { + $where_end = $order_position ?? $limit_position ?? $statement_end; + if ( + $where_position + 1 >= $where_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $where_end ) + ) { + return null; + } + + $where = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + $where_sql = ' WHERE ' . $where['sql']; + } + + $order_sql = ''; + if ( null !== $order_position ) { + $order_sql = $this->translate_mysql_joined_dml_order_by_clause_to_postgresql( $tokens, $order_position, $order_end, $scope ); + if ( null === $order_sql ) { + return null; + } + } + + $limit_sql = ''; + if ( null !== $limit_position ) { + $limit_sql = $this->translate_simple_dml_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end, true ); + if ( null === $limit_sql ) { + return null; + } + } + + $source_alias = 'mysql_update_values'; + $source_alias_sql = $this->connection->quote_identifier( $source_alias ); + $target_ctid_alias = 'mysql_update_target_ctid'; + $target_alias_sql = $this->connection->quote_identifier( $target_reference_alias ); + $select_values = array_merge( + array( + sprintf( + '%s.ctid AS %s', + $target_alias_sql, + $this->connection->quote_identifier( $target_ctid_alias ) + ), + ), + $update_set_clause['select_sql'] + ); + $source_sql = sprintf( + '(SELECT %s FROM %s%s%s%s) AS %s', + implode( ', ', $select_values ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, 1, $set_position ), + $where_sql, + $order_sql, + $limit_sql, + $source_alias_sql + ); + + return sprintf( + 'UPDATE %s SET %s FROM %s WHERE (%s = %s.%s) AND (%s)', + $this->get_postgresql_dml_table_reference_sql( $table_name, $alias ), + $update_set_clause['set_sql'], + $source_sql, + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $source_alias_sql, + $this->connection->quote_identifier( $target_ctid_alias ), + $update_set_clause['changed_predicate_sql'] + ); + } + + /** + * Translate a joined UPDATE SET clause for a derived source rewrite. + * + * @param string $table_name Table name. + * @param string|null $alias Target alias. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First SET-clause token position. + * @param int $end Final SET-clause token position, exclusive. + * @param array $scope Statement table scope. + * @param array|null $information_schema_context Optional direct information_schema context. + * @return array{set_sql: string, select_sql: string[], changed_predicate_sql: string}|null PostgreSQL SET data, or null when unsupported. + */ + private function translate_mysql_joined_update_set_clause_for_derived_source( string $table_name, ?string $alias, array $tokens, int $start, int $end, array $scope, ?array $information_schema_context = null ): ?array { + $column_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $assignments = array(); + $select_expressions = array(); + $changed_predicates = array(); + $source_alias_sql = $this->connection->quote_identifier( 'mysql_update_values' ); + $value_index = 0; + + for ( $position = $start; $position < $end; ) { + $target = $this->parse_simple_mysql_update_assignment_target( $table_name, $alias, $tokens, $position, $end ); + if ( null === $target ) { + return null; + } + + $target_column = $target['column']; + if ( ! isset( $tokens[ $target['end'] ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $target['end'] ]->id ) { + return null; + } + + $value_start = $target['end'] + 1; + $assignment_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $value_start, + $end + ) ?? $end; + + if ( $value_start >= $assignment_end ) { + return null; + } + + $target_column_key = strtolower( $target_column ); + $target_metadata = $column_metadata[ $target_column_key ] ?? null; + $coerced_default_sql = null; + + if ( null !== $target_metadata ) { + $this->validate_strict_mysql_dml_value_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + + if ( + null !== $target_metadata + && ! $this->is_mysql_strict_sql_mode_active() + && $this->is_mysql_null_token_sequence( $tokens, $value_start, $assignment_end ) + ) { + $coerced_default_sql = $this->get_non_strict_dml_default_sql_for_column( $target_metadata ); + } + + $value_sql = $coerced_default_sql; + if ( null === $value_sql && null !== $target_metadata ) { + $value_sql = $this->get_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + if ( null === $value_sql && null !== $target_metadata ) { + $value_sql = $this->get_non_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + if ( null === $value_sql && null !== $information_schema_context ) { + $value_sql = $this->translate_direct_information_schema_dml_predicate_to_postgresql( + null, + $tokens, + $value_start, + $assignment_end, + $information_schema_context + ); + if ( null === $value_sql ) { + return null; + } + } + if ( null === $value_sql ) { + $expression_sql = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $value_start, + $assignment_end, + $scope + ); + $value_sql = $expression_sql['sql']; + if ( + $expression_sql['changed'] + && null !== $target_metadata + && $this->is_mysql_text_family_column_type( (string) ( $target_metadata['column_type'] ?? '' ) ) + ) { + $value_sql = sprintf( 'CAST(%s AS text)', $value_sql ); + } + } + if ( null !== $target_metadata ) { + $guarded_value_sql = $this->get_strict_mysql_dml_temporal_expression_sql_for_column( + $target_metadata, + $tokens, + $value_start, + $assignment_end, + $value_sql + ); + if ( null !== $guarded_value_sql ) { + $value_sql = $guarded_value_sql; + } + } + + $value_alias = 'mysql_update_value_' . $value_index; + $value_alias_sql = $this->connection->quote_identifier( $value_alias ); + + $select_expressions[] = sprintf( '%s AS %s', $value_sql, $value_alias_sql ); + $assignments[] = sprintf( + '%s = %s.%s', + $this->connection->quote_identifier( $target_column ), + $source_alias_sql, + $value_alias_sql + ); + $changed_predicates[] = sprintf( + '%s IS DISTINCT FROM (%s.%s)', + $this->get_postgresql_dml_column_reference_sql( $target_column, $alias ), + $source_alias_sql, + $value_alias_sql + ); + + ++$value_index; + $position = $assignment_end; + if ( $position === $end ) { + break; + } + + ++$position; + } + + if ( 0 === count( $assignments ) ) { + return null; + } + + return array( + 'set_sql' => implode( ', ', $assignments ), + 'select_sql' => $select_expressions, + 'changed_predicate_sql' => implode( ' OR ', $changed_predicates ), + ); + } + + /** + * Translate supported MySQL joined and multi-source UPDATE statements. + * + * PostgreSQL UPDATE ... FROM can represent MySQL single-target UPDATE + * statements whose extra table references only qualify the target rows. + * Assignments to any non-target table and NATURAL/RIGHT joins remain + * unsupported. Bounded or ordered single-target joined updates select target + * ctids through a derived source before updating so MySQL ORDER BY clauses + * are translated and invalid expressions fail closed. + * + * @param string $query MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $statement_end Final statement token, exclusive. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_mysql_inner_join_update_query( string $query, array $tokens, int $statement_end ): ?string { + $set_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::SET_SYMBOL, 1, $statement_end ); + if ( null === $set_position ) { + return null; + } + + $position = 1; + $this->consume_mysql_update_modifiers( $tokens, $position, $set_position ); + + $source_start = $position; + $first_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $set_position ); + if ( + null === $first_reference + || $position >= $set_position + ) { + return null; + } + + if ( + 0 === strcasecmp( $this->db_name, 'information_schema' ) + || $this->direct_information_schema_source_range_references_information_schema( $tokens, $source_start, $set_position ) + ) { + return $this->translate_mysql_information_schema_join_update_query( + $query, + $tokens, + $statement_end, + $source_start, + $set_position, + $first_reference + ); + } + + $first_table = $first_reference['table']; + $first_alias = $first_reference['alias']; + $first_reference_alias = null === $first_alias ? $first_table : $first_alias; + $scope = $this->get_mysql_single_table_scope( $first_table, $first_alias ); + $from_parts = array(); + $join_predicates = array(); + $current_join_left_alias = $first_reference_alias; + $table_references = array( + array( + 'alias' => $first_reference_alias, + 'alias_key' => strtolower( $first_reference_alias ), + 'table' => $first_table, + 'table_as' => $first_alias, + 'derived' => false, + 'sql' => $this->get_postgresql_dml_table_reference_sql( $first_table, $first_alias ), + ), + ); + + while ( $position < $set_position ) { + if ( WP_MySQL_Lexer::COMMA_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + $source_alias = null; + $source_reference = null; + if ( ! $this->append_mysql_joined_update_source_table( $tokens, $position, $set_position, $scope, $from_parts, $source_alias, $source_reference ) ) { + return null; + } + if ( null === $source_reference ) { + return null; + } + $table_references[] = $source_reference; + $current_join_left_alias = $source_alias; + continue; + } + + if ( + ! $this->is_mysql_supported_inner_join_separator_at( $tokens, $position, $set_position ) + || ! $this->append_mysql_joined_update_inner_join( + $tokens, + $position, + $set_position, + $scope, + $from_parts, + $join_predicates, + $current_join_left_alias, + $table_references + ) + ) { + return null; + } + } + + if ( empty( $from_parts ) ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $set_position + 1, $statement_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $set_position + 1, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $set_position + 1, $statement_end ); + $order_end = $limit_position ?? $statement_end; + if ( + ( null !== $order_position && null !== $where_position && $order_position < $where_position ) + || ( null !== $limit_position && null !== $where_position && $limit_position < $where_position ) + || ( null !== $limit_position && null !== $order_position && $limit_position < $order_position ) + || ( null !== $order_position && ! $this->is_nonempty_mysql_order_by_clause( $tokens, $order_position, $order_end ) ) + ) { + return null; + } + + $set_end = $where_position ?? $order_position ?? $limit_position ?? $statement_end; + if ( $set_position + 1 >= $set_end ) { + return null; + } + + $target_reference = $this->get_mysql_joined_update_target_reference( + $tokens, + $set_position + 1, + $set_end, + $table_references + ); + if ( null === $target_reference ) { + return null; + } + + $table_name = $target_reference['table']; + $alias = $target_reference['table_as']; + $target_reference_alias = $target_reference['alias']; + $from_parts = array(); + foreach ( $table_references as $table_reference ) { + if ( $table_reference['alias_key'] === $target_reference['alias_key'] ) { + continue; + } + $from_parts[] = $table_reference['sql']; + } + if ( empty( $from_parts ) ) { + return null; + } + + $predicates = $join_predicates; + if ( null !== $where_position ) { + $where_end = $order_position ?? $limit_position ?? $statement_end; + if ( + $where_position + 1 >= $where_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $where_end ) + ) { + return null; + } + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + $predicates[] = $where_sql['sql']; + } + + if ( null !== $order_position || null !== $limit_position ) { + $update_set_clause = $this->translate_mysql_joined_update_set_clause_for_derived_source( + $table_name, + $target_reference_alias, + $tokens, + $set_position + 1, + $set_end, + $scope + ); + if ( null === $update_set_clause ) { + return null; + } + + $order_sql = ''; + if ( null !== $order_position ) { + $order_sql = $this->translate_mysql_joined_dml_order_by_clause_to_postgresql( + $tokens, + $order_position, + $order_end, + $scope + ); + if ( null === $order_sql ) { + return null; + } + } + + $limit_sql = ''; + if ( null !== $limit_position ) { + $limit_sql = $this->translate_simple_dml_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end, true ); + if ( null === $limit_sql ) { + return null; + } + } + + $source_alias = 'mysql_update_values'; + $source_alias_sql = $this->connection->quote_identifier( $source_alias ); + $target_ctid_alias = 'mysql_update_target_ctid'; + $target_alias_sql = $this->connection->quote_identifier( $target_reference_alias ); + $select_values = array_merge( + array( + sprintf( + '%s.ctid AS %s', + $target_alias_sql, + $this->connection->quote_identifier( $target_ctid_alias ) + ), + ), + $update_set_clause['select_sql'] + ); + $source_where_sql = empty( $predicates ) ? '' : ' WHERE (' . implode( ') AND (', $predicates ) . ')'; + $source_sql = sprintf( + '(SELECT %s FROM %s%s%s%s) AS %s', + implode( ', ', $select_values ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $source_start, $set_position ), + $source_where_sql, + $order_sql, + $limit_sql, + $source_alias_sql + ); + + return sprintf( + 'UPDATE %s SET %s FROM %s WHERE (%s = %s.%s) AND (%s)', + $this->get_postgresql_dml_table_reference_sql( $table_name, $alias ), + $update_set_clause['set_sql'], + $source_sql, + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $source_alias_sql, + $this->connection->quote_identifier( $target_ctid_alias ), + $update_set_clause['changed_predicate_sql'] + ); + } + + $update_set_clause = $this->translate_simple_mysql_update_set_clause( + $table_name, + $target_reference_alias, + $tokens, + $set_position + 1, + $set_end, + $scope + ); + if ( null === $update_set_clause ) { + return null; + } + + $predicates[] = $update_set_clause['changed_predicate_sql']; + + return sprintf( + 'UPDATE %s SET %s FROM %s WHERE (%s)', + $this->get_postgresql_dml_table_reference_sql( $table_name, $alias ), + $update_set_clause['set_sql'], + implode( ', ', $from_parts ), + implode( ') AND (', $predicates ) + ); + } + + /** + * Translate a joined UPDATE that reads information_schema sources. + * + * @param string $query MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $statement_end Final statement token, exclusive. + * @param int $source_start First table-reference token after UPDATE modifiers. + * @param int $source_end SET token position. + * @param array $target_reference Parsed first, writable target reference. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_mysql_information_schema_join_update_query( string $query, array $tokens, int $statement_end, int $source_start, int $source_end, array $target_reference ): ?string { + $source_translation = $this->get_direct_information_schema_dml_source_translation( + $query, + $tokens, + $source_start, + $source_end + ); + if ( null === $source_translation ) { + return null; + } + + $table_name = $target_reference['table']; + $alias = $target_reference['alias']; + $target_reference_alias = null === $alias ? $table_name : $alias; + $target_alias_key = strtolower( $target_reference_alias ); + if ( + ! isset( $source_translation['scope']['aliases'][ $target_alias_key ] ) + || 0 !== strcasecmp( $table_name, (string) $source_translation['scope']['aliases'][ $target_alias_key ]['table'] ) + ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $source_end + 1, $statement_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $source_end + 1, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $source_end + 1, $statement_end ); + if ( + null !== $limit_position + || ( null !== $order_position && null !== $where_position && $order_position < $where_position ) + || ( null !== $order_position && ! $this->is_nonempty_mysql_order_by_clause( $tokens, $order_position, $statement_end ) ) + ) { + return null; + } + + $set_end = $where_position ?? $order_position ?? $statement_end; + if ( $source_end + 1 >= $set_end ) { + return null; + } + + $update_set_clause = $this->translate_mysql_joined_update_set_clause_for_derived_source( + $table_name, + $target_reference_alias, + $tokens, + $source_end + 1, + $set_end, + $source_translation['scope'], + $source_translation['context'] + ); + if ( null === $update_set_clause ) { + return null; + } + + $where_sql = ''; + if ( null !== $where_position ) { + $where_end = $order_position ?? $statement_end; + if ( $where_position + 1 >= $where_end ) { + return null; + } + + $where = $this->translate_direct_information_schema_dml_predicate_to_postgresql( + $query, + $tokens, + $where_position + 1, + $where_end, + $source_translation['context'] + ); + if ( null === $where ) { + return null; + } + + $where_sql = ' WHERE ' . $where; + } + + $order_sql = ''; + if ( null !== $order_position ) { + $order_sql = $this->translate_direct_information_schema_dml_order_by_clause_to_postgresql( + $tokens, + $order_position, + $statement_end, + $source_translation['context'] + ); + if ( null === $order_sql ) { + return null; + } + } + + $source_alias = 'mysql_update_values'; + $source_alias_sql = $this->connection->quote_identifier( $source_alias ); + $target_ctid_alias = 'mysql_update_target_ctid'; + $target_alias_sql = $this->connection->quote_identifier( $target_reference_alias ); + $select_values = array_merge( + array( + sprintf( + '%s.ctid AS %s', + $target_alias_sql, + $this->connection->quote_identifier( $target_ctid_alias ) + ), + ), + $update_set_clause['select_sql'] + ); + $source_sql = sprintf( + '(SELECT %s FROM %s%s%s) AS %s', + implode( ', ', $select_values ), + $source_translation['sql'], + $where_sql, + $order_sql, + $source_alias_sql + ); + + return sprintf( + 'UPDATE %s SET %s FROM %s WHERE (%s = %s.%s) AND (%s)', + $this->get_postgresql_dml_table_reference_sql( $table_name, $alias ), + $update_set_clause['set_sql'], + $source_sql, + $this->get_postgresql_dml_ctid_reference_sql( $alias ), + $source_alias_sql, + $this->connection->quote_identifier( $target_ctid_alias ), + $update_set_clause['changed_predicate_sql'] + ); + } + + /** + * Translate an ORDER BY clause for DML reading information_schema sources. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start ORDER token position. + * @param int $end Final clause token position, exclusive. + * @param array $context Direct information_schema context. + * @return string|null PostgreSQL ORDER BY clause SQL, or null when unsupported. + */ + private function translate_direct_information_schema_dml_order_by_clause_to_postgresql( array $tokens, int $start, int $end, array $context ): ?string { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || WP_MySQL_Lexer::BY_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + ) { + return null; + } + + $item_ranges = $this->split_top_level_mysql_arguments( $tokens, $start + 2, $end ); + if ( null === $item_ranges || empty( $item_ranges ) ) { + return null; + } + + $items = array(); + foreach ( $item_ranges as $item_range ) { + $item_start = $item_range['start']; + $item_end = $item_range['end']; + $direction = ''; + if ( + $item_start < $item_end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + || WP_MySQL_Lexer::DESC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + ) + ) { + $direction = ' ' . strtoupper( $tokens[ $item_end - 1 ]->get_bytes() ); + --$item_end; + } + + if ( $item_start >= $item_end ) { + return null; + } + + $item_sql = $this->translate_direct_information_schema_dml_order_by_item_to_postgresql( + $tokens, + $item_start, + $item_end, + $context + ); + if ( null === $item_sql ) { + return null; + } + + $items[] = $item_sql . $direction; + } + + return ' ORDER BY ' . implode( ', ', $items ); + } + + /** + * Translate one supported information_schema DML ORDER BY item. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First item token. + * @param int $end Final item token, exclusive. + * @param array $context Direct information_schema context. + * @return string|null PostgreSQL item SQL, or null when unsupported. + */ + private function translate_direct_information_schema_dml_order_by_item_to_postgresql( array $tokens, int $start, int $end, array $context ): ?string { + if ( + $start + 1 === $end + && isset( $tokens[ $start ] ) + && $this->is_mysql_unsigned_integer_token( $tokens[ $start ] ) + ) { + return $tokens[ $start ]->get_bytes(); + } + + if ( $start + 1 === $end && isset( $tokens[ $start ] ) ) { + $column_sql = $this->get_direct_information_schema_unqualified_column_sql( $tokens[ $start ], $context ); + return is_string( $column_sql ) ? $column_sql : null; + } + + if ( + $start + 3 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + ) { + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + $column = null === $source ? null : $this->get_direct_information_schema_column_name_for_token( $tokens[ $start + 2 ], $source['column_map'] ); + + return null === $source || null === $column + ? null + : $this->get_direct_information_schema_qualified_column_sql( $source, $column ); + } + + if ( + $start + 5 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ], $tokens[ $start + 4 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 3 ]->id + ) { + $schema = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start ] ); + if ( null === $schema || 0 !== strcasecmp( $schema, 'information_schema' ) ) { + return null; + } + + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start + 2 ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + $column = null === $source ? null : $this->get_direct_information_schema_column_name_for_token( $tokens[ $start + 4 ], $source['column_map'] ); + + return null === $source || null === $column + ? null + : $this->get_direct_information_schema_qualified_column_sql( $source, $column ); + } + + return null; + } + + /** + * Translate supported MySQL multi-target UPDATE statements. + * + * MySQL can update more than one table in a joined UPDATE. PostgreSQL cannot + * express that as one UPDATE ... FROM, so compute the original joined row set + * and assignment values once, then update each physical target by ctid. + * ORDER BY clauses are translated in that source so unsupported expressions + * cannot be silently discarded. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL writable-CTE query, or null when unsupported. + */ + private function translate_mysql_multi_target_update_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::UPDATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end || ! $this->is_at_mysql_query_end( $tokens, $statement_end ) ) { + return null; + } + + $set_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::SET_SYMBOL, 1, $statement_end ); + if ( null === $set_position ) { + return null; + } + + $position = 1; + $this->consume_mysql_update_modifiers( $tokens, $position, $set_position ); + + $first_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $set_position ); + if ( + null === $first_reference + || $position >= $set_position + ) { + return null; + } + + $first_table = $first_reference['table']; + $first_alias = $first_reference['alias']; + $first_reference_alias = null === $first_alias ? $first_table : $first_alias; + $scope = $this->get_mysql_single_table_scope( $first_table, $first_alias ); + $from_parts = array(); + $join_predicates = array(); + $current_join_left_alias = $first_reference_alias; + $table_references = array( + array( + 'alias' => $first_reference_alias, + 'alias_key' => strtolower( $first_reference_alias ), + 'table' => $first_table, + 'table_as' => $first_alias, + 'derived' => false, + 'sql' => $this->get_postgresql_dml_table_reference_sql( $first_table, $first_alias ), + ), + ); + + while ( $position < $set_position ) { + if ( WP_MySQL_Lexer::COMMA_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + $source_alias = null; + $source_reference = null; + if ( ! $this->append_mysql_joined_update_source_table( $tokens, $position, $set_position, $scope, $from_parts, $source_alias, $source_reference ) ) { + return null; + } + if ( null === $source_reference ) { + return null; + } + $table_references[] = $source_reference; + $current_join_left_alias = $source_alias; + continue; + } + + if ( + ! $this->is_mysql_supported_inner_join_separator_at( $tokens, $position, $set_position ) + || ! $this->append_mysql_joined_update_inner_join( + $tokens, + $position, + $set_position, + $scope, + $from_parts, + $join_predicates, + $current_join_left_alias, + $table_references + ) + ) { + return null; + } + } + + if ( empty( $from_parts ) ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $set_position + 1, $statement_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $set_position + 1, $statement_end ); + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $set_position + 1, $statement_end ); + $order_end = $limit_position ?? $statement_end; + if ( + ( null !== $order_position && null !== $where_position && $order_position < $where_position ) + || ( null !== $limit_position && null !== $where_position && $limit_position < $where_position ) + || ( null !== $limit_position && null !== $order_position && $limit_position < $order_position ) + || ( null !== $order_position && ! $this->is_nonempty_mysql_order_by_clause( $tokens, $order_position, $order_end ) ) + ) { + return null; + } + + $set_end = $where_position ?? $order_position ?? $limit_position ?? $statement_end; + if ( $set_position + 1 >= $set_end ) { + return null; + } + + $update_set_clause = $this->translate_mysql_multi_target_update_set_clause_for_derived_source( + $tokens, + $set_position + 1, + $set_end, + $table_references, + $scope + ); + if ( null === $update_set_clause || count( $update_set_clause['targets'] ) < 2 ) { + return null; + } + + $predicates = $join_predicates; + if ( null !== $where_position ) { + $where_end = $order_position ?? $limit_position ?? $statement_end; + if ( + $where_position + 1 >= $where_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $where_end ) + ) { + return null; + } + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + $predicates[] = $where_sql['sql']; + } + + $source_where_sql = empty( $predicates ) ? '' : ' WHERE (' . implode( ') AND (', $predicates ) . ')'; + $order_sql = ''; + if ( null !== $order_position ) { + $order_sql = $this->translate_mysql_joined_dml_order_by_clause_to_postgresql( $tokens, $order_position, $order_end, $scope ); + if ( null === $order_sql ) { + return null; + } + } + + $limit_sql = ''; + if ( null !== $limit_position ) { + $limit_sql = $this->translate_simple_dml_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end, true ); + if ( null === $limit_sql ) { + return null; + } + } + + $source_sql = sprintf( + 'mysql_update_rows AS MATERIALIZED (SELECT %s FROM %s%s%s%s)', + implode( ', ', $update_set_clause['select_sql'] ), + implode( ', ', array_column( $table_references, 'sql' ) ), + $source_where_sql, + $order_sql, + $limit_sql + ); + + $update_ctes = array(); + $count_parts = array(); + $index = 0; + foreach ( $update_set_clause['targets'] as $target ) { + $cte_name = 'mysql_update_target_' . $index; + $target_table_sql = $this->get_postgresql_dml_table_reference_sql( $target['table'], $target['table_as'] ); + $target_ctid_sql = $this->get_postgresql_dml_ctid_reference_sql( $target['table_as'] ); + $source_ctid_alias_sql = 'mysql_update_rows.' . $this->connection->quote_identifier( $target['ctid_alias'] ); + $update_ctes[] = sprintf( + '%s AS (UPDATE %s SET %s FROM mysql_update_rows WHERE (%s = %s) AND (%s) RETURNING 1)', + $cte_name, + $target_table_sql, + implode( ', ', $target['assignments'] ), + $target_ctid_sql, + $source_ctid_alias_sql, + implode( ' OR ', $target['changed_predicates'] ) + ); + $count_parts[] = sprintf( '(SELECT COUNT(*) FROM %s)', $cte_name ); + ++$index; + } + + return sprintf( + 'WITH %s, %s SELECT %s AS affected_rows', + $source_sql, + implode( ', ', $update_ctes ), + implode( ' + ', $count_parts ) + ); + } + + /** + * Translate a multi-target joined UPDATE SET clause for a writable-CTE rewrite. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First SET-clause token position. + * @param int $end Final SET-clause token position, exclusive. + * @param array[] $table_references Joined table references. + * @param array $scope Statement table scope. + * @return array{select_sql: string[], targets: array}|null PostgreSQL SET data, or null when unsupported. + */ + private function translate_mysql_multi_target_update_set_clause_for_derived_source( array $tokens, int $start, int $end, array $table_references, array $scope ): ?array { + $targets = array(); + $target_physical_keys = array(); + $select_expressions = array(); + $value_index = 0; + + for ( $position = $start; $position < $end; ) { + $target = $this->parse_mysql_joined_update_assignment_target( $tokens, $position, $end, $table_references ); + if ( null === $target ) { + return null; + } + + $target_reference = null; + foreach ( $table_references as $table_reference ) { + if ( $target['alias_key'] === $table_reference['alias_key'] ) { + $target_reference = $table_reference; + break; + } + } + + if ( + null === $target_reference + || ! empty( $target_reference['derived'] ) + || empty( $target_reference['table'] ) + ) { + return null; + } + + if ( ! isset( $tokens[ $target['end'] ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $target['end'] ]->id ) { + return null; + } + + $value_start = $target['end'] + 1; + $assignment_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $value_start, + $end + ) ?? $end; + + if ( $value_start >= $assignment_end ) { + return null; + } + + $table_name = (string) $target_reference['table']; + $target_column = $target['column']; + $column_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $target_metadata = $column_metadata[ strtolower( $target_column ) ] ?? null; + $coerced_default_sql = null; + + if ( null !== $target_metadata ) { + $this->validate_strict_mysql_dml_value_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + + if ( + null !== $target_metadata + && ! $this->is_mysql_strict_sql_mode_active() + && $this->is_mysql_null_token_sequence( $tokens, $value_start, $assignment_end ) + ) { + $coerced_default_sql = $this->get_non_strict_dml_default_sql_for_column( $target_metadata ); + } + + $value_sql = $coerced_default_sql; + if ( null === $value_sql && null !== $target_metadata ) { + $value_sql = $this->get_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + if ( null === $value_sql && null !== $target_metadata ) { + $value_sql = $this->get_non_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + if ( null === $value_sql ) { + $expression_sql = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $value_start, + $assignment_end, + $scope + ); + $value_sql = $expression_sql['sql']; + if ( + $expression_sql['changed'] + && null !== $target_metadata + && $this->is_mysql_text_family_column_type( (string) ( $target_metadata['column_type'] ?? '' ) ) + ) { + $value_sql = sprintf( 'CAST(%s AS text)', $value_sql ); + } + } + if ( null !== $target_metadata ) { + $guarded_value_sql = $this->get_strict_mysql_dml_temporal_expression_sql_for_column( + $target_metadata, + $tokens, + $value_start, + $assignment_end, + $value_sql + ); + if ( null !== $guarded_value_sql ) { + $value_sql = $guarded_value_sql; + } + } + + $target_alias_key = $target_reference['alias_key']; + if ( ! isset( $targets[ $target_alias_key ] ) ) { + $physical_key = strtolower( 'public.' . $table_name ); + if ( isset( $target_physical_keys[ $physical_key ] ) ) { + return null; + } + $target_physical_keys[ $physical_key ] = true; + + $ctid_alias = 'mysql_update_' . count( $targets ) . '_ctid'; + $targets[ $target_alias_key ] = array( + 'table' => $table_name, + 'table_as' => $target_reference['table_as'], + 'ctid_alias' => $ctid_alias, + 'assignments' => array(), + 'changed_predicates' => array(), + ); + $select_expressions[] = sprintf( + '%s.ctid AS %s', + $this->connection->quote_identifier( $target_reference['alias'] ), + $this->connection->quote_identifier( $ctid_alias ) + ); + } + + $value_alias = 'mysql_update_value_' . $value_index; + $value_alias_sql = $this->connection->quote_identifier( $value_alias ); + $source_value_sql = 'mysql_update_rows.' . $value_alias_sql; + + $select_expressions[] = sprintf( '%s AS %s', $value_sql, $value_alias_sql ); + $targets[ $target_alias_key ]['assignments'][] = sprintf( + '%s = %s', + $this->connection->quote_identifier( $target_column ), + $source_value_sql + ); + $targets[ $target_alias_key ]['changed_predicates'][] = sprintf( + '%s IS DISTINCT FROM (%s)', + $this->get_postgresql_dml_column_reference_sql( $target_column, $target_reference['table_as'] ), + $source_value_sql + ); + + ++$value_index; + $position = $assignment_end; + if ( $position === $end ) { + break; + } + + ++$position; + } + + if ( 0 === count( $targets ) ) { + return null; + } + + return array( + 'select_sql' => $select_expressions, + 'targets' => $targets, + ); + } + + /** + * Consume MySQL UPDATE modifiers that do not change row targeting. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $end Final token position, exclusive. + */ + private function consume_mysql_update_modifiers( array $tokens, int &$position, int $end ): void { + while ( + $position < $end + && isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[ $position ]->id + ) + ) { + ++$position; + } + } + + /** + * Check whether an UPDATE modifier sequence contains IGNORE. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position First possible modifier token. + * @param int $end Final token position, exclusive. + * @return bool Whether IGNORE is present before the table reference. + */ + private function mysql_update_modifiers_include_ignore( array $tokens, int $position, int $end ): bool { + while ( + $position < $end + && isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[ $position ]->id + ) + ) { + if ( WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[ $position ]->id ) { + return true; + } + ++$position; + } + + return false; + } + + /** + * Consume MySQL DELETE modifiers that do not change row targeting. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + */ + private function consume_mysql_delete_modifiers( array $tokens, int &$position ): void { + while ( + isset( $tokens[ $position ] ) + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL, + WP_MySQL_Lexer::QUICK_SYMBOL, + WP_MySQL_Lexer::IGNORE_SYMBOL, + ), + true + ) + ) { + ++$position; + } + } + + /** + * Consume one MySQL INSERT priority modifier that does not change row values. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + */ + private function consume_mysql_insert_priority_modifier( array $tokens, int &$position ): void { + if ( + isset( $tokens[ $position ] ) + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL, + WP_MySQL_Lexer::DELAYED_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + ), + true + ) + ) { + ++$position; + } + } + + /** + * Consume one MySQL REPLACE priority modifier that does not change row values. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + */ + private function consume_mysql_replace_priority_modifier( array $tokens, int &$position ): void { + if ( + isset( $tokens[ $position ] ) + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::LOW_PRIORITY_SYMBOL, + WP_MySQL_Lexer::DELAYED_SYMBOL, + ), + true + ) + ) { + ++$position; + } + } + + /** + * Resolve the single target table referenced by a joined UPDATE SET clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First SET-clause token position. + * @param int $end Final SET-clause token position, exclusive. + * @param array[] $table_references Joined table references. + * @return array|null Target table reference, or null when unsupported. + */ + private function get_mysql_joined_update_target_reference( array $tokens, int $start, int $end, array $table_references ): ?array { + $target_alias_key = null; + + for ( $position = $start; $position < $end; ) { + $target = $this->parse_mysql_joined_update_assignment_target( $tokens, $position, $end, $table_references ); + if ( null === $target ) { + return null; + } + + if ( null === $target_alias_key ) { + $target_alias_key = $target['alias_key']; + } elseif ( $target_alias_key !== $target['alias_key'] ) { + return null; + } + + if ( ! isset( $tokens[ $target['end'] ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $target['end'] ]->id ) { + return null; + } + + $value_start = $target['end'] + 1; + $assignment_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $value_start, + $end + ) ?? $end; + if ( $value_start >= $assignment_end ) { + return null; + } + + $position = $assignment_end; + if ( $position === $end ) { + break; + } + + ++$position; + } + + if ( null === $target_alias_key ) { + return null; + } + + foreach ( $table_references as $table_reference ) { + if ( $target_alias_key !== $table_reference['alias_key'] ) { + continue; + } + + return empty( $table_reference['derived'] ) ? $table_reference : null; + } + + return null; + } + + /** + * Parse a joined UPDATE assignment target and resolve its table reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Assignment target start. + * @param int $end Final SET-clause token position, exclusive. + * @param array[] $table_references Joined table references. + * @return array{alias_key: string, column: string, end: int}|null Target data, or null when unsupported. + */ + private function parse_mysql_joined_update_assignment_target( array $tokens, int $position, int $end, array $table_references ): ?array { + $first_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + if ( $position + 2 < $end && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + $column = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null === $column ) { + return null; + } + + $target_reference = $this->get_mysql_joined_update_reference_for_qualifier( $first_identifier, $table_references ); + if ( null === $target_reference ) { + return null; + } + + return array( + 'alias_key' => $target_reference['alias_key'], + 'column' => $column, + 'end' => $position + 3, + ); + } + + $target_reference = $this->get_mysql_joined_update_reference_for_unqualified_column( + $first_identifier, + $table_references + ); + if ( null === $target_reference ) { + return null; + } + + return array( + 'alias_key' => $target_reference['alias_key'], + 'column' => $first_identifier, + 'end' => $position + 1, + ); + } + + /** + * Resolve a joined UPDATE table qualifier to a table reference. + * + * @param string $qualifier MySQL table qualifier. + * @param array[] $table_references Joined table references. + * @return array|null Table reference, or null when ambiguous or unknown. + */ + private function get_mysql_joined_update_reference_for_qualifier( string $qualifier, array $table_references ): ?array { + $qualifier_key = strtolower( $qualifier ); + foreach ( $table_references as $table_reference ) { + if ( $qualifier_key === $table_reference['alias_key'] ) { + return $table_reference; + } + } + + $matched_reference = null; + foreach ( $table_references as $table_reference ) { + if ( + ! empty( $table_reference['derived'] ) + || null === $table_reference['table'] + || 0 !== strcasecmp( $qualifier, $table_reference['table'] ) + ) { + continue; + } + + if ( null !== $matched_reference ) { + return null; + } + + $matched_reference = $table_reference; + } + + return $matched_reference; + } + + /** + * Resolve an unqualified joined UPDATE assignment column to one table reference. + * + * @param string $column_name MySQL column name. + * @param array[] $table_references Joined table references. + * @return array|null Table reference, or null when ambiguous, derived, or unknown. + */ + private function get_mysql_joined_update_reference_for_unqualified_column( string $column_name, array $table_references ): ?array { + $real_table_references = array(); + foreach ( $table_references as $table_reference ) { + if ( + empty( $table_reference['derived'] ) + && ! empty( $table_reference['table'] ) + ) { + $real_table_references[] = $table_reference; + } + } + + if ( 1 === count( $real_table_references ) ) { + return $real_table_references[0]; + } + + $matched_reference = null; + foreach ( $real_table_references as $table_reference ) { + $table_name = (string) $table_reference['table']; + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + if ( ! $this->mysql_table_has_column_for_translation( $table_schema, $table_name, $column_name ) ) { + continue; + } + + if ( null !== $matched_reference ) { + return null; + } + + $matched_reference = $table_reference; + } + + return $matched_reference; + } + + /** + * Check whether a table contains a column for SQL translation decisions. + * + * @param string $table_schema Backend schema name. + * @param string $table_name Table name. + * @param string $column_name MySQL column name. + * @return bool Whether the table contains the column. + */ + private function mysql_table_has_column_for_translation( string $table_schema, string $table_name, string $column_name ): bool { + if ( $this->mysql_table_has_column_metadata( $table_schema, $table_name ) ) { + return null !== $this->get_mysql_table_column_type( $table_schema, $table_name, $column_name ); + } + + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + if ( 'sqlite' === $driver_name ) { + $schema_prefix = 'temp' === $table_schema ? 'temp.' : ''; + $stmt = $this->connection->query( + sprintf( + 'PRAGMA %stable_info(%s)', + $schema_prefix, + $this->connection->quote_identifier( $table_name ) + ) + ); + + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $column ) { + if ( 0 === strcasecmp( $column_name, (string) ( $column['name'] ?? '' ) ) ) { + return true; + } + } + + return false; + } + + $stmt = $this->connection->query( + 'SELECT 1 + FROM information_schema.columns + WHERE table_schema = ? + AND table_name = ? + AND LOWER(column_name) = LOWER(?) + LIMIT 1', + array( $table_schema, $table_name, $column_name ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Append a joined UPDATE source table to the UPDATE ... FROM list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current source table position, updated on success. + * @param int $end Final UPDATE table-reference-list token, exclusive. + * @param array $scope Statement table scope, mutated on success. + * @param string[] $from_parts PostgreSQL FROM items, mutated on success. + * @param string|null $appended_alias Joined table alias, mutated on success. + * @return bool Whether a table source was appended. + */ + private function append_mysql_joined_update_source_table( array $tokens, int &$position, int $end, array &$scope, array &$from_parts, ?string &$appended_alias, ?array &$appended_reference = null ): bool { + $derived_reference = $this->parse_mysql_joined_update_derived_table_source( $tokens, $position, $end ); + if ( null !== $derived_reference ) { + $joined_alias_key = strtolower( $derived_reference['alias'] ); + if ( isset( $scope['aliases'][ $joined_alias_key ] ) ) { + return false; + } + + $scope['aliases'][ $joined_alias_key ] = array( + 'schema' => null, + 'table' => null, + 'derived' => true, + ); + $scope['unknown'] = true; + $from_parts[] = $derived_reference['sql']; + $appended_alias = $derived_reference['alias']; + $appended_reference = array( + 'alias' => $derived_reference['alias'], + 'alias_key' => $joined_alias_key, + 'table' => null, + 'table_as' => null, + 'derived' => true, + 'sql' => $derived_reference['sql'], + ); + + return true; + } + + $joined_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $end ); + if ( null === $joined_reference ) { + return false; + } + + $joined_alias = null === $joined_reference['alias'] ? $joined_reference['table'] : $joined_reference['alias']; + $joined_alias_key = strtolower( $joined_alias ); + if ( isset( $scope['aliases'][ $joined_alias_key ] ) ) { + return false; + } + + $joined_table = array( + 'schema' => $this->resolve_mysql_table_schema_for_introspection( 'public', $joined_reference['table'] ), + 'table' => $joined_reference['table'], + ); + + $scope['tables'][] = $joined_table; + $scope['aliases'][ $joined_alias_key ] = $joined_table; + + $source_sql = $this->get_postgresql_dml_table_reference_sql( + $joined_reference['table'], + $joined_reference['alias'] + ); + $from_parts[] = $source_sql; + $appended_alias = $joined_alias; + $appended_reference = array( + 'alias' => $joined_alias, + 'alias_key' => $joined_alias_key, + 'table' => $joined_reference['table'], + 'table_as' => $joined_reference['alias'], + 'derived' => false, + 'sql' => $source_sql, + ); + + return true; + } + + /** + * Parse a parenthesized SELECT source for a joined UPDATE. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current source table position, updated on success. + * @param int $end Final UPDATE table-reference-list token, exclusive. + * @return array{alias: string, sql: string}|null Derived source data, or null when unsupported. + */ + private function parse_mysql_joined_update_derived_table_source( array $tokens, int &$position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close ) { + return null; + } + + $select_start = $position + 1; + $select_end = $after_close - 1; + $locking_start = $this->find_mysql_select_row_locking_clause_start( $tokens, $select_start + 1, $select_end ); + if ( null !== $locking_start ) { + $select_end = $locking_start; + } + if ( $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, $select_start, $select_end ) ) { + return null; + } + + $alias_position = $after_close; + if ( $alias_position < $end && WP_MySQL_Lexer::AS_SYMBOL === ( $tokens[ $alias_position ]->id ?? null ) ) { + ++$alias_position; + } + + $alias = $this->get_mysql_identifier_token_value( $tokens[ $alias_position ] ?? null ); + if ( null === $alias ) { + return null; + } + + $position = $alias_position + 1; + + return array( + 'alias' => $alias, + 'sql' => sprintf( + '(%s) AS %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $select_start, $select_end ), + $this->connection->quote_identifier( $alias ) + ), + ); + } + + /** + * Append a joined UPDATE inner join source and ON/USING predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current join separator position, updated on success. + * @param int $end Final UPDATE table-reference-list token, exclusive. + * @param array $scope Statement table scope, mutated on success. + * @param string[] $from_parts PostgreSQL FROM items, mutated on success. + * @param string[] $join_predicates PostgreSQL join predicates, mutated on success. + * @param string $left_alias Alias for the joined table expression's left side, mutated on success. + * @param array[] $table_references Joined table references, mutated on success. + * @return bool Whether an inner join source was appended. + */ + private function append_mysql_joined_update_inner_join( array $tokens, int &$position, int $end, array &$scope, array &$from_parts, array &$join_predicates, string &$left_alias, array &$table_references ): bool { + $predicate_optional = false; + if ( WP_MySQL_Lexer::INNER_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + if ( WP_MySQL_Lexer::JOIN_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return false; + } + ++$position; + } elseif ( WP_MySQL_Lexer::CROSS_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + if ( WP_MySQL_Lexer::JOIN_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return false; + } + ++$position; + $predicate_optional = true; + } elseif ( WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + $predicate_optional = true; + } else { + if ( WP_MySQL_Lexer::JOIN_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return false; + } + ++$position; + } + + $joined_alias = null; + $joined_reference = null; + if ( ! $this->append_mysql_joined_update_source_table( $tokens, $position, $end, $scope, $from_parts, $joined_alias, $joined_reference ) ) { + return false; + } + if ( null === $joined_reference ) { + return false; + } + $table_references[] = $joined_reference; + + if ( WP_MySQL_Lexer::ON_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $predicate_start = $position + 1; + $predicate_end = $this->find_mysql_join_separator( $tokens, $predicate_start, $end ) ?? $end; + if ( + $predicate_start >= $predicate_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $predicate_start, $predicate_end ) + ) { + return false; + } + + $predicate_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $predicate_start, + $predicate_end, + $scope + ); + $join_predicates[] = $predicate_sql['sql']; + $position = $predicate_end; + $left_alias = $joined_alias; + + return true; + } + + if ( WP_MySQL_Lexer::USING_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + if ( $predicate_optional ) { + $left_alias = $joined_alias; + return true; + } + return false; + } + + $using_position = $position + 1; + $using_columns = $this->parse_mysql_identifier_list( $tokens, $using_position ); + if ( null === $using_columns ) { + return false; + } + + foreach ( $using_columns as $using_column ) { + $left_alias_sql = $this->translate_mysql_identifier_value_to_postgresql( $left_alias ); + $joined_alias_sql = $this->translate_mysql_identifier_value_to_postgresql( $joined_alias ); + $using_column_sql = $this->translate_mysql_identifier_value_to_postgresql( $using_column ); + $join_predicates[] = sprintf( + '%s.%s = %s.%s', + $left_alias_sql, + $using_column_sql, + $joined_alias_sql, + $using_column_sql + ); + } + + $position = $using_position; + $left_alias = $joined_alias; + + return true; + } + + /** + * Find the next top-level MySQL join separator. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return int|null Join separator position, or null when none exists. + */ + private function find_mysql_join_separator( array $tokens, int $start, int $end ): ?int { + return $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::CROSS_SYMBOL, + WP_MySQL_Lexer::INNER_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LEFT_SYMBOL, + WP_MySQL_Lexer::NATURAL_SYMBOL, + WP_MySQL_Lexer::RIGHT_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ), + $start, + $end + ); + } + + /** + * Check whether a position starts a supported inner join separator. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate token position. + * @param int $end Final token position, exclusive. + * @return bool Whether the separator is a supported inner-style join. + */ + private function is_mysql_supported_inner_join_separator_at( array $tokens, int $position, int $end ): bool { + if ( $position >= $end ) { + return false; + } + + if ( + WP_MySQL_Lexer::JOIN_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === ( $tokens[ $position ]->id ?? null ) + ) { + return true; + } + + return $position + 1 < $end + && ( + WP_MySQL_Lexer::INNER_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || WP_MySQL_Lexer::CROSS_SYMBOL === ( $tokens[ $position ]->id ?? null ) + ) + && WP_MySQL_Lexer::JOIN_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ); + } + + /** + * Get metadata-derived defaults for omitted NOT NULL columns in non-strict DML. + * + * @param string $table_name Table name. + * @param string[] $columns Supplied DML columns. + * @param array|null $column_metadata Optional ordered column metadata rows. + * @return array[] Default column descriptors. + */ + private function get_non_strict_dml_defaults_for_omitted_columns( string $table_name, array $columns, ?array $column_metadata = null ): array { + if ( $this->is_mysql_strict_sql_mode_active() ) { + return array(); + } + + $supplied_columns = array(); + foreach ( $columns as $column ) { + $supplied_columns[ strtolower( (string) $column ) ] = true; + } + + if ( null === $column_metadata ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + } + + $defaults = array(); + foreach ( $column_metadata as $column_metadata_row ) { + $column_name = (string) ( $column_metadata_row['column_name'] ?? '' ); + if ( '' === $column_name || isset( $supplied_columns[ strtolower( $column_name ) ] ) ) { + continue; + } + + $default_sql = $this->get_non_strict_dml_default_sql_for_column( $column_metadata_row ); + if ( null === $default_sql ) { + continue; + } + + $defaults[] = array( + 'column' => $column_name, + 'sql' => $default_sql, + ); + + $supplied_columns[ strtolower( $column_name ) ] = true; + } + + return $defaults; + } + + /** + * Append metadata-derived defaults for omitted NOT NULL columns in non-strict DML. + * + * @param string $table_name Table name. + * @param string[] $columns DML columns, mutated when defaults are appended. + * @param string[] $values DML values, mutated when defaults are appended. + * @param array|null $column_metadata Optional ordered column metadata rows. + */ + private function append_non_strict_dml_defaults_for_omitted_columns( string $table_name, array &$columns, array &$values, ?array $column_metadata = null ): void { + if ( $this->is_mysql_strict_sql_mode_active() ) { + return; + } + + $supplied_columns = array(); + foreach ( $columns as $column ) { + $supplied_columns[ strtolower( (string) $column ) ] = true; + } + + if ( null === $column_metadata ) { + if ( null === $column_metadata ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + } + } + + foreach ( $column_metadata as $column_metadata_row ) { + $column_name = (string) ( $column_metadata_row['column_name'] ?? '' ); + if ( '' === $column_name || isset( $supplied_columns[ strtolower( $column_name ) ] ) ) { + continue; + } + + $default_sql = $this->get_non_strict_dml_default_sql_for_column( $column_metadata_row ); + if ( null === $default_sql ) { + continue; + } + + $columns[] = $column_name; + $values[] = $default_sql; + + $supplied_columns[ strtolower( $column_name ) ] = true; + } + } + + /** + * Append metadata-derived defaults to every VALUES row for omitted NOT NULL columns. + * + * @param string $table_name Table name. + * @param string[] $columns DML columns, mutated when defaults are appended. + * @param array[] $value_rows DML value rows, mutated when defaults are appended. + * @param array|null $column_metadata Optional ordered column metadata rows. + */ + private function append_non_strict_dml_defaults_for_omitted_value_rows( string $table_name, array &$columns, array &$value_rows, ?array $column_metadata = null ): void { + if ( $this->is_mysql_strict_sql_mode_active() ) { + return; + } + + $supplied_columns = array(); + foreach ( $columns as $column ) { + $supplied_columns[ strtolower( (string) $column ) ] = true; + } + + if ( null === $column_metadata ) { + $column_metadata = $this->get_mysql_dml_column_metadata( $table_name ); + } + + $default_sql_values = array(); + foreach ( $column_metadata as $column_metadata_row ) { + $column_name = (string) ( $column_metadata_row['column_name'] ?? '' ); + if ( '' === $column_name || isset( $supplied_columns[ strtolower( $column_name ) ] ) ) { + continue; + } + + $default_sql = $this->get_non_strict_dml_default_sql_for_column( $column_metadata_row ); + if ( null === $default_sql ) { + continue; + } + + $columns[] = $column_name; + $default_sql_values[] = $default_sql; + + $supplied_columns[ strtolower( $column_name ) ] = true; + } + + if ( empty( $default_sql_values ) ) { + return; + } + + foreach ( $value_rows as &$values ) { + foreach ( $default_sql_values as $default_sql ) { + $values[] = $default_sql; + } + } + unset( $values ); + } + + /** + * Translate a supported simple UPDATE SET clause with non-strict NULL coercion. + * + * @param string $table_name Table name. + * @param string|null $alias Optional table alias. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First SET-clause token position. + * @param int $end Final SET-clause token position, exclusive. + * @return array{set_sql: string, changed_predicate_sql: string}|null PostgreSQL SET data, or null when unsupported. + */ + private function translate_simple_mysql_update_set_clause( string $table_name, ?string $alias, array $tokens, int $start, int $end, ?array $scope = null ): ?array { + $column_metadata = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $assignments = array(); + $changed_predicates = array(); + $scope = $scope ?? $this->get_mysql_single_table_scope( $table_name, $alias ); + + for ( $position = $start; $position < $end; ) { + $target = $this->parse_simple_mysql_update_assignment_target( $table_name, $alias, $tokens, $position, $end ); + if ( null === $target ) { + return null; + } + + $target_column = $target['column']; + if ( ! isset( $tokens[ $target['end'] ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $target['end'] ]->id ) { + return null; + } + + $value_start = $target['end'] + 1; + $assignment_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $value_start, + $end + ) ?? $end; + + if ( $value_start >= $assignment_end ) { + return null; + } + + $target_column_key = strtolower( $target_column ); + $target_metadata = $column_metadata[ $target_column_key ] ?? null; + $coerced_default_sql = null; + + if ( null !== $target_metadata ) { + $this->validate_strict_mysql_dml_value_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + + if ( + null !== $target_metadata + && ! $this->is_mysql_strict_sql_mode_active() + && $this->is_mysql_null_token_sequence( $tokens, $value_start, $assignment_end ) + ) { + $coerced_default_sql = $this->get_non_strict_dml_default_sql_for_column( $target_metadata ); + } + + $value_sql = $coerced_default_sql; + if ( null === $value_sql && null !== $target_metadata ) { + $value_sql = $this->get_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + if ( null === $value_sql && null !== $target_metadata ) { + $value_sql = $this->get_non_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + if ( null === $value_sql ) { + $expression_sql = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $value_start, + $assignment_end, + $scope + ); + $value_sql = $expression_sql['sql']; + if ( + $expression_sql['changed'] + && null !== $target_metadata + && $this->is_mysql_text_family_column_type( (string) ( $target_metadata['column_type'] ?? '' ) ) + ) { + $value_sql = sprintf( 'CAST(%s AS text)', $value_sql ); + } + } + if ( null !== $target_metadata ) { + $guarded_value_sql = $this->get_strict_mysql_dml_temporal_expression_sql_for_column( + $target_metadata, + $tokens, + $value_start, + $assignment_end, + $value_sql + ); + if ( null !== $guarded_value_sql ) { + $value_sql = $guarded_value_sql; + } + } + + $quoted_target_column = $this->connection->quote_identifier( $target_column ); + $assignments[] = sprintf( + '%s = %s', + $quoted_target_column, + $value_sql + ); + $changed_predicates[] = sprintf( + '%s IS DISTINCT FROM (%s)', + $this->get_postgresql_dml_column_reference_sql( $target_column, $alias ), + $value_sql + ); + + $position = $assignment_end; + if ( $position === $end ) { + break; + } + + ++$position; + } + + if ( 0 === count( $assignments ) ) { + return null; + } + + return array( + 'set_sql' => implode( ', ', $assignments ), + 'changed_predicate_sql' => implode( ' OR ', $changed_predicates ), + ); + } + + /** + * Parse the target column for a simple UPDATE assignment. + * + * @param string $table_name Table name. + * @param string|null $alias Optional table alias. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Assignment target start. + * @param int $end Final SET-clause token position, exclusive. + * @return array{column: string, end: int}|null Assignment target, or null when unsupported. + */ + private function parse_simple_mysql_update_assignment_target( string $table_name, ?string $alias, array $tokens, int $position, int $end ): ?array { + $first_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + if ( $position + 2 < $end && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + $column = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null === $column || ! $this->is_mysql_dml_table_qualifier( $first_identifier, $table_name, $alias ) ) { + return null; + } + + return array( + 'column' => $column, + 'end' => $position + 3, + ); + } + + return array( + 'column' => $first_identifier, + 'end' => $position + 1, + ); + } + + /** + * Rewrite MySQL AUTO_INCREMENT zero literals to generated values when the mode permits it. + * + * @param string[] $columns DML columns. + * @param string[] $values Translated DML values, mutated when needed. + * @param array[] $value_ranges Original token ranges for each value. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array[] $metadata Ordered column metadata rows. + */ + private function normalize_mysql_auto_increment_zero_values_for_columns( array $columns, array &$values, array $value_ranges, array $tokens, array $metadata ): void { + if ( $this->is_mysql_sql_mode_active( 'NO_AUTO_VALUE_ON_ZERO' ) ) { + return; + } + + $column_metadata = $this->get_mysql_dml_column_metadata_lookup_from_rows( $metadata ); + foreach ( $columns as $index => $column ) { + $column_key = strtolower( (string) $column ); + if ( + ! isset( $column_metadata[ $column_key ], $value_ranges[ $index ] ) + || ! $this->is_mysql_auto_increment_column_metadata( $column_metadata[ $column_key ] ) + || ! isset( $value_ranges[ $index ]['start'], $value_ranges[ $index ]['end'] ) + || ! isset( $values[ $index ] ) + ) { + continue; + } + + if ( ! $this->is_mysql_zero_literal_range( $tokens, (int) $value_ranges[ $index ]['start'], (int) $value_ranges[ $index ]['end'] ) ) { + continue; + } + + $values[ $index ] = $this->get_mysql_auto_increment_generated_value_sql(); + } + } + + /** + * Get the SQL value that asks the backend to generate an AUTO_INCREMENT value. + * + * @return string Backend-compatible generated value marker. + */ + private function get_mysql_auto_increment_generated_value_sql(): string { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + return 'sqlite' === $driver_name ? 'NULL' : 'DEFAULT'; + } + + /** + * Get the SQL expression that generates an AUTO_INCREMENT value in INSERT ... SELECT. + * + * @param string $table_name Target table name. + * @param array $column_metadata Target column metadata. + * @return string Backend-compatible generated value expression. + */ + private function get_mysql_insert_select_auto_increment_generated_value_sql( string $table_name, array $column_metadata ): string { + $driver_name = (string) $this->connection->get_pdo()->getAttribute( PDO::ATTR_DRIVER_NAME ); + if ( 'sqlite' === $driver_name ) { + return 'NULL'; + } + + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + + return sprintf( + 'nextval(pg_get_serial_sequence(%s, %s))', + $this->connection->quote( $table_schema . '.' . $table_name ), + $this->connection->quote( $column_name ) + ); + } + + /** + * Check whether SQL asks the backend to generate an AUTO_INCREMENT value. + * + * @param string $value_sql Translated value SQL. + * @return bool Whether this value has no explicit conflict key. + */ + private function is_mysql_generated_auto_increment_value_sql( string $value_sql ): bool { + return in_array( strtoupper( trim( $value_sql ) ), array( 'DEFAULT', 'NULL' ), true ); + } + + /** + * Validate strict-mode DML values using MySQL column metadata. + * + * @param string[] $columns DML columns. + * @param array[] $value_ranges Original token ranges for each value. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array[] $metadata Ordered column metadata rows. + */ + private function validate_strict_mysql_dml_values_for_columns( array $columns, array $value_ranges, array $tokens, array $metadata ): void { + if ( ! $this->is_mysql_strict_sql_mode_active() ) { + return; + } + + $column_metadata = $this->get_mysql_dml_column_metadata_lookup_from_rows( $metadata ); + foreach ( $columns as $index => $column ) { + $column_key = strtolower( (string) $column ); + if ( + ! isset( $column_metadata[ $column_key ], $value_ranges[ $index ] ) + || ! isset( $value_ranges[ $index ]['start'], $value_ranges[ $index ]['end'] ) + ) { + continue; + } + + $this->validate_strict_mysql_dml_value_for_column( + $column_metadata[ $column_key ], + $tokens, + (int) $value_ranges[ $index ]['start'], + (int) $value_ranges[ $index ]['end'] + ); + } + } + + /** + * Validate a strict-mode DML value for one column. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + */ + private function validate_strict_mysql_dml_value_for_column( array $column_metadata, array $tokens, int $start, int $end ): void { + if ( ! $this->is_mysql_strict_sql_mode_active() ) { + return; + } + + $this->get_strict_mysql_dml_value_sql_for_column( $column_metadata, $tokens, $start, $end ); + } + + /** + * Get strict-mode SQL for a MySQL-compatible DML literal when normalization is needed. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null PostgreSQL value SQL, or null when generic translation is sufficient. + */ + private function get_strict_mysql_dml_value_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + if ( ! $this->is_mysql_strict_sql_mode_active() ) { + return null; + } + + $this->validate_strict_mysql_dml_text_length_for_column( $column_metadata, $tokens, $start, $end ); + $text_hex_sql = $this->get_mysql_text_hex_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + if ( null !== $text_hex_sql ) { + return $text_hex_sql; + } + + $base_type = $this->get_base_mysql_dml_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ); + + if ( in_array( $base_type, array( 'date', 'datetime', 'timestamp' ), true ) ) { + return $this->get_strict_mysql_dml_date_time_literal_sql_for_column( $base_type, $tokens, $start, $end ); + } + + if ( 'year' === $base_type ) { + return $this->get_mysql_dml_year_literal_sql_for_column( $tokens, $start, $end ); + } + + return $this->get_strict_mysql_dml_integer_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + } + + /** + * Get strict-mode SQL for DATE/DATETIME/TIMESTAMP literals. + * + * @param string $base_type Base MySQL column type. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null PostgreSQL value SQL, or null when the token range is not a simple literal. + */ + private function get_strict_mysql_dml_date_time_literal_sql_for_column( string $base_type, array $tokens, int $start, int $end ): ?string { + $literal = $this->get_mysql_dml_literal_value( $tokens, $start, $end ); + if ( null === $literal || null === $literal['value'] ) { + return null; + } + + $value = $literal['value']; + if ( 'string' !== $literal['type'] ) { + $this->throw_mysql_incorrect_temporal_value( $base_type, $value ); + } + + if ( 'date' === $base_type ) { + $storage_value = $this->get_strict_mysql_dml_date_storage_value( $value ); + } else { + $storage_value = $this->get_strict_mysql_dml_datetime_storage_value( $base_type, $value ); + } + + if ( $storage_value === $value ) { + return null; + } + + return $this->connection->quote( $storage_value ); + } + + /** + * Get strict-mode storage value for a MySQL DATE literal. + * + * @param string $value Unquoted literal value. + * @return string Normalized storage value. + */ + private function get_strict_mysql_dml_date_storage_value( string $value ): string { + $date_value = $this->normalize_mysql_dml_date_literal_format( $value ); + $parts = $this->get_mysql_dml_date_parts( $date_value ); + if ( null === $parts ) { + $this->throw_mysql_incorrect_temporal_value( 'date', $value ); + } + + $this->validate_strict_mysql_dml_date_parts( 'date', $value, $parts['year'], $parts['month'], $parts['day'] ); + return $date_value; + } + + /** + * Validate a strict-mode MySQL DATE literal. + * + * @param string $value Unquoted literal value. + */ + private function validate_strict_mysql_dml_date_value( string $value ): void { + $this->get_strict_mysql_dml_date_storage_value( $value ); + } + + /** + * Get strict-mode storage value for a MySQL DATETIME/TIMESTAMP literal. + * + * @param string $base_type Base MySQL column type. + * @param string $value Unquoted literal value. + * @return string Normalized storage value. + */ + private function get_strict_mysql_dml_datetime_storage_value( string $base_type, string $value ): string { + $normalized_value = $this->normalize_mysql_dml_datetime_literal_format( $value ); + $parts = $this->get_mysql_dml_datetime_parts( $normalized_value ); + if ( null === $parts ) { + $date_parts = $this->get_mysql_dml_date_parts( $normalized_value ); + if ( null === $date_parts ) { + $this->throw_mysql_incorrect_temporal_value( $base_type, $value ); + } + + $this->validate_strict_mysql_dml_date_parts( $base_type, $value, $date_parts['year'], $date_parts['month'], $date_parts['day'] ); + return $normalized_value . ' 00:00:00'; + } + + if ( ! $this->is_mysql_dml_time_value_valid( $parts['hour'], $parts['minute'], $parts['second'] ) ) { + $this->throw_mysql_incorrect_temporal_value( $base_type, $value ); + } + + $this->validate_strict_mysql_dml_date_parts( $base_type, $value, $parts['year'], $parts['month'], $parts['day'] ); + return $normalized_value; + } + + /** + * Validate a strict-mode MySQL DATETIME/TIMESTAMP literal. + * + * @param string $base_type Base MySQL column type. + * @param string $value Unquoted literal value. + */ + private function validate_strict_mysql_dml_datetime_value( string $base_type, string $value ): void { + $this->get_strict_mysql_dml_datetime_storage_value( $base_type, $value ); + } + + /** + * Validate strict-mode MySQL date parts for zero-date modes and calendar validity. + * + * @param string $type MySQL temporal type label. + * @param string $value Original unquoted literal value. + * @param string $year Four-digit year. + * @param string $month Two-digit month. + * @param string $day Two-digit day. + */ + private function validate_strict_mysql_dml_date_parts( string $type, string $value, string $year, string $month, string $day ): void { + if ( '0000' === $year && '00' === $month && '00' === $day ) { + if ( $this->is_mysql_sql_mode_active( 'NO_ZERO_DATE' ) ) { + $this->throw_mysql_incorrect_temporal_value( $type, $value ); + } + + return; + } + + if ( '0000' !== $year && ( '00' === $month || '00' === $day ) ) { + if ( $this->is_mysql_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) { + $this->throw_mysql_incorrect_temporal_value( $type, $value ); + } + + return; + } + + if ( ! checkdate( (int) $month, (int) $day, (int) $year ) ) { + $this->throw_mysql_incorrect_temporal_value( $type, $value ); + } + } + + /** + * Throw a MySQL-compatible incorrect temporal value error. + * + * @param string $type MySQL temporal type label. + * @param string $value Original unquoted literal value. + */ + private function throw_mysql_incorrect_temporal_value( string $type, string $value ): void { + throw new InvalidArgumentException( sprintf( "Incorrect %s value: '%s'", $type, $value ) ); + } + + /** + * Throw a MySQL-compatible incorrect integer value error. + * + * @param string $value Original literal value. + */ + private function throw_mysql_incorrect_integer_value( string $value ): void { + throw new InvalidArgumentException( sprintf( "Incorrect integer value: '%s'", $value ) ); + } + + /** + * Throw a MySQL-compatible out-of-range value error. + * + * @param string $value Original literal value. + */ + private function throw_mysql_out_of_range_value( string $value ): void { + throw new InvalidArgumentException( sprintf( "Out of range value: '%s'", $value ) ); + } + + /** + * Throw a MySQL-compatible string truncation error. + * + * @param string $column_name Column name. + */ + private function throw_mysql_data_too_long_for_column( string $column_name ): void { + throw new InvalidArgumentException( sprintf( "Data too long for column '%s'", $column_name ) ); + } + + /** + * Validate strict-mode text-family literal lengths. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + */ + private function validate_strict_mysql_dml_text_length_for_column( array $column_metadata, array $tokens, int $start, int $end ): void { + $column_type = (string) ( $column_metadata['column_type'] ?? '' ); + $max_length = $this->get_mysql_text_column_max_length( $column_type ); + if ( null === $max_length ) { + return; + } + + $value = $this->get_mysql_text_hex_literal_value( $tokens, $start, $end ); + if ( null === $value ) { + $literal = $this->get_mysql_dml_literal_value( $tokens, $start, $end ); + if ( null === $literal || null === $literal['value'] ) { + return; + } + + $value = $literal['value']; + } + + $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value, 'UTF-8' ) : strlen( $value ); + if ( $length > $max_length ) { + $this->throw_mysql_data_too_long_for_column( (string) ( $column_metadata['column_name'] ?? '' ) ); + } + } + + /** + * Get the maximum character length for a MySQL text-family type. + * + * @param string $column_type MySQL column type metadata. + * @return int|null Maximum length, or null when the type is unbounded for this check. + */ + private function get_mysql_text_column_max_length( string $column_type ): ?int { + $base_type = $this->get_base_mysql_dml_column_type( $column_type ); + if ( in_array( $base_type, array( 'char', 'varchar' ), true ) ) { + return $this->get_mysql_column_type_display_width( $column_type ); + } + + if ( 'tinytext' === $base_type ) { + return 255; + } + + if ( 'text' === $base_type ) { + return 65535; + } + + if ( 'mediumtext' === $base_type ) { + return 16777215; + } + + if ( 'longtext' === $base_type ) { + return 4294967295; + } + + return null; + } + + /** + * Get a literal value from a simple DML value token range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return array{type: string, value: string|null}|null Literal metadata, or null for expressions. + */ + private function get_mysql_dml_literal_value( array $tokens, int $start, int $end ): ?array { + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + return array( + 'type' => 'string', + 'value' => $tokens[ $start ]->get_value(), + ); + } + + if ( $start + 1 === $end && isset( $tokens[ $start ] ) ) { + if ( WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $start ]->id ) { + return array( + 'type' => 'null', + 'value' => null, + ); + } + + if ( WP_MySQL_Lexer::FALSE_SYMBOL === $tokens[ $start ]->id ) { + return array( + 'type' => 'boolean', + 'value' => '0', + ); + } + + if ( WP_MySQL_Lexer::TRUE_SYMBOL === $tokens[ $start ]->id ) { + return array( + 'type' => 'boolean', + 'value' => '1', + ); + } + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + if ( null !== $literal && $literal['start'] === $start && $literal['end'] === $end ) { + return array( + 'type' => 'numeric', + 'value' => $this->get_mysql_token_sequence_bytes( $tokens, $start, $end ), + ); + } + + return null; + } + + /** + * Get the original byte sequence for a token range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return string Token byte sequence. + */ + private function get_mysql_token_sequence_bytes( array $tokens, int $start, int $end ): string { + $bytes = ''; + for ( $i = $start; $i < $end; $i++ ) { + $bytes .= $tokens[ $i ]->get_bytes(); + } + + return $bytes; + } + + /** + * Normalize strict-mode literals that MySQL accepts but PostgreSQL rejects. + * + * @param string[] $columns DML columns. + * @param string[] $values Translated DML values, mutated when needed. + * @param array[] $value_ranges Original token ranges for each value. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array[] $metadata Ordered column metadata rows. + */ + private function normalize_strict_mysql_dml_values_for_columns( array $columns, array &$values, array $value_ranges, array $tokens, array $metadata ): void { + if ( ! $this->is_mysql_strict_sql_mode_active() ) { + return; + } + + $column_metadata = $this->get_mysql_dml_column_metadata_lookup_from_rows( $metadata ); + foreach ( $columns as $index => $column ) { + $column_key = strtolower( (string) $column ); + if ( + ! isset( $column_metadata[ $column_key ], $value_ranges[ $index ] ) + || ! isset( $value_ranges[ $index ]['start'], $value_ranges[ $index ]['end'] ) + ) { + continue; + } + + $value_sql = $this->get_strict_mysql_dml_value_sql_for_column( + $column_metadata[ $column_key ], + $tokens, + (int) $value_ranges[ $index ]['start'], + (int) $value_ranges[ $index ]['end'] + ); + if ( null === $value_sql && isset( $values[ $index ] ) ) { + $value_sql = $this->get_strict_mysql_dml_temporal_expression_sql_for_column( + $column_metadata[ $column_key ], + $tokens, + (int) $value_ranges[ $index ]['start'], + (int) $value_ranges[ $index ]['end'], + (string) $values[ $index ] + ); + } + if ( null !== $value_sql ) { + $values[ $index ] = $value_sql; + } + } + } + + /** + * Get runtime validation SQL for a strict-mode temporal DML expression. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @param string $value_sql Translated PostgreSQL value SQL. + * @return string|null Guarded PostgreSQL value SQL, or null when no guard is needed. + */ + private function get_strict_mysql_dml_temporal_expression_sql_for_column( array $column_metadata, array $tokens, int $start, int $end, string $value_sql ): ?string { + if ( ! $this->is_mysql_strict_sql_mode_active() ) { + return null; + } + + $base_type = $this->get_base_mysql_dml_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ); + if ( ! in_array( $base_type, array( 'date', 'datetime', 'timestamp' ), true ) ) { + return null; + } + + if ( + null !== $this->get_mysql_dml_literal_value( $tokens, $start, $end ) + || $this->is_mysql_default_value_token_sequence( $tokens, $start, $end ) + ) { + return null; + } + + return sprintf( + '%s(CAST(%s AS text), %s, %d, %d)', + $this->get_postgresql_mysql_validate_temporal_function_name(), + $value_sql, + $this->connection->quote( $base_type ), + $this->is_mysql_sql_mode_active( 'NO_ZERO_DATE' ) ? 1 : 0, + $this->is_mysql_sql_mode_active( 'NO_ZERO_IN_DATE' ) ? 1 : 0 + ); + } + + /** + * Check whether a value token range is the MySQL DEFAULT value keyword. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return bool Whether the range is DEFAULT. + */ + private function is_mysql_default_value_token_sequence( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::DEFAULT_SYMBOL === $tokens[ $start ]->id; + } + + /** + * Get strict-mode SQL for MySQL-compatible integer literals. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null PostgreSQL value SQL, or null when generic translation is sufficient. + */ + private function get_strict_mysql_dml_integer_literal_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + if ( + ! $this->is_mysql_strict_sql_mode_active() + || ! $this->is_mysql_integer_family_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ) + ) { + return null; + } + + $literal = $this->get_mysql_dml_literal_value( $tokens, $start, $end ); + if ( null === $literal || null === $literal['value'] ) { + return null; + } + + $value = trim( $literal['value'] ); + $integer = $this->get_strict_mysql_dml_integer_literal_value( $value ); + if ( null === $integer ) { + $this->throw_mysql_incorrect_integer_value( $value ); + } + + if ( ! $this->is_mysql_integer_value_in_column_range( $integer, (string) ( $column_metadata['column_type'] ?? '' ) ) ) { + $this->throw_mysql_out_of_range_value( $value ); + } + + if ( + '' === $value + || 1 === preg_match( '/^[+-]?[0-9]+$/', $value ) + || 1 !== preg_match( '/^[+-]?(?:[0-9]+\.0+|[0-9]*\.0+)$/', $value ) + ) { + if ( 'boolean' === $literal['type'] ) { + return $integer; + } + + return null; + } + + return $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ) + ); + } + + /** + * Get a normalized strict integer literal value. + * + * @param string $value Literal value. + * @return string|null Normalized integer value, or null when the value is not an integer literal. + */ + private function get_strict_mysql_dml_integer_literal_value( string $value ): ?string { + $value = trim( $value ); + if ( '' === $value ) { + return null; + } + + if ( 1 !== preg_match( '/^[+-]?(?:(?:[0-9]+)(?:\.0+)?|(?:[0-9]*\.0+))$/', $value ) ) { + return null; + } + + $integer = $value; + $dot = strpos( $integer, '.' ); + if ( false !== $dot ) { + $integer = substr( $integer, 0, $dot ); + } + + if ( '' === $integer || '+' === $integer || '-' === $integer ) { + $integer .= '0'; + } + + return $this->normalize_mysql_integer_string( $integer ); + } + + /** + * Normalize a signed integer string for comparisons. + * + * @param string $value Integer string. + * @return string Normalized integer string. + */ + private function normalize_mysql_integer_string( string $value ): string { + $value = trim( $value ); + $negative = false; + if ( isset( $value[0] ) && ( '+' === $value[0] || '-' === $value[0] ) ) { + $negative = '-' === $value[0]; + $value = substr( $value, 1 ); + } + + $value = ltrim( $value, '0' ); + if ( '' === $value ) { + return '0'; + } + + return $negative ? '-' . $value : $value; + } + + /** + * Check whether an integer value fits the MySQL column type range. + * + * @param string $value Normalized integer string. + * @param string $column_type MySQL column type metadata. + * @return bool Whether the value is in range. + */ + private function is_mysql_integer_value_in_column_range( string $value, string $column_type ): bool { + $bounds = $this->get_mysql_integer_column_bounds( $column_type ); + if ( null === $bounds ) { + return true; + } + + return $this->compare_mysql_integer_strings( $value, $bounds['min'] ) >= 0 + && $this->compare_mysql_integer_strings( $value, $bounds['max'] ) <= 0; + } + + /** + * Get MySQL integer column bounds. + * + * @param string $column_type MySQL column type metadata. + * @return array{min: string, max: string}|null Integer bounds, or null for unknown integer types. + */ + private function get_mysql_integer_column_bounds( string $column_type ): ?array { + $base_type = $this->get_base_mysql_dml_column_type( $column_type ); + $unsigned = false !== stripos( $column_type, 'unsigned' ); + + $signed_bounds = array( + 'tinyint' => array( '-128', '127' ), + 'smallint' => array( '-32768', '32767' ), + 'mediumint' => array( '-8388608', '8388607' ), + 'int' => array( '-2147483648', '2147483647' ), + 'integer' => array( '-2147483648', '2147483647' ), + 'bigint' => array( '-9223372036854775808', '9223372036854775807' ), + ); + $unsigned_max = array( + 'tinyint' => '255', + 'smallint' => '65535', + 'mediumint' => '16777215', + 'int' => '4294967295', + 'integer' => '4294967295', + 'bigint' => '18446744073709551615', + ); + + if ( ! isset( $signed_bounds[ $base_type ] ) ) { + return null; + } + + if ( $unsigned ) { + return array( + 'min' => '0', + 'max' => $unsigned_max[ $base_type ], + ); + } + + return array( + 'min' => $signed_bounds[ $base_type ][0], + 'max' => $signed_bounds[ $base_type ][1], + ); + } + + /** + * Compare two normalized integer strings. + * + * @param string $left Left integer. + * @param string $right Right integer. + * @return int Less than zero, zero, or greater than zero. + */ + private function compare_mysql_integer_strings( string $left, string $right ): int { + $left = $this->normalize_mysql_integer_string( $left ); + $right = $this->normalize_mysql_integer_string( $right ); + + $left_negative = isset( $left[0] ) && '-' === $left[0]; + $right_negative = isset( $right[0] ) && '-' === $right[0]; + if ( $left_negative !== $right_negative ) { + return $left_negative ? -1 : 1; + } + + $left_digits = $left_negative ? substr( $left, 1 ) : $left; + $right_digits = $right_negative ? substr( $right, 1 ) : $right; + + if ( strlen( $left_digits ) !== strlen( $right_digits ) ) { + $result = strlen( $left_digits ) <=> strlen( $right_digits ); + return $left_negative ? -$result : $result; + } + + $result = strcmp( $left_digits, $right_digits ); + return $left_negative ? -$result : $result; + } + + /** + * Get strict/non-strict SQL for a MySQL YEAR literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null PostgreSQL value SQL, or null when the token range is not a simple literal. + */ + private function get_mysql_dml_year_literal_sql_for_column( array $tokens, int $start, int $end ): ?string { + $literal = $this->get_mysql_dml_literal_value( $tokens, $start, $end ); + if ( null === $literal || null === $literal['value'] ) { + return null; + } + + $value = trim( $literal['value'] ); + $storage_year = $this->get_mysql_dml_year_storage_value( $value ); + if ( null === $storage_year ) { + $this->throw_mysql_incorrect_temporal_value( 'year', $value ); + } + + return $this->connection->quote( $storage_year ); + } + + /** + * Get a normalized MySQL YEAR storage value. + * + * @param string $value Literal value. + * @return string|null Four-digit YEAR value, or null when invalid. + */ + private function get_mysql_dml_year_storage_value( string $value ): ?string { + $value = trim( $value ); + if ( '' === $value ) { + return null; + } + + if ( 1 === preg_match( '/^([+-]?[0-9]+)(?:\.0+)?$/', $value, $matches ) ) { + $year = $this->normalize_mysql_integer_string( $matches[1] ); + } elseif ( 1 === preg_match( '/^([0-9]{4})-(?:[0-9]{2})-(?:[0-9]{2})(?:[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z?)?$/', $value, $matches ) ) { + $year = $this->normalize_mysql_integer_string( $matches[1] ); + } else { + return null; + } + + if ( $this->compare_mysql_integer_strings( $year, '0' ) < 0 ) { + $this->throw_mysql_out_of_range_value( $value ); + } + + if ( '0' === $year ) { + return '0000'; + } + + if ( $this->compare_mysql_integer_strings( $year, '1' ) >= 0 && $this->compare_mysql_integer_strings( $year, '69' ) <= 0 ) { + return sprintf( '%04d', 2000 + (int) $year ); + } + + if ( $this->compare_mysql_integer_strings( $year, '70' ) >= 0 && $this->compare_mysql_integer_strings( $year, '99' ) <= 0 ) { + return (string) ( 1900 + (int) $year ); + } + + if ( $this->compare_mysql_integer_strings( $year, '1901' ) < 0 || $this->compare_mysql_integer_strings( $year, '2155' ) > 0 ) { + $this->throw_mysql_out_of_range_value( $value ); + } + + return sprintf( '%04d', (int) $year ); + } + + /** + * Normalize non-strict DML values using MySQL column metadata. + * + * @param string[] $columns DML columns. + * @param string[] $values Translated DML values, mutated when needed. + * @param array[] $value_ranges Original token ranges for each value. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array[] $metadata Ordered column metadata rows. + */ + private function normalize_non_strict_mysql_dml_values_for_columns( array $columns, array &$values, array $value_ranges, array $tokens, array $metadata ): void { + if ( $this->is_mysql_strict_sql_mode_active() ) { + return; + } + + $column_metadata = $this->get_mysql_dml_column_metadata_lookup_from_rows( $metadata ); + foreach ( $columns as $index => $column ) { + $column_key = strtolower( (string) $column ); + if ( + ! isset( $column_metadata[ $column_key ], $value_ranges[ $index ] ) + || ! isset( $value_ranges[ $index ]['start'], $value_ranges[ $index ]['end'] ) + ) { + continue; + } + + $value_sql = $this->get_non_strict_mysql_dml_value_sql_for_column( + $column_metadata[ $column_key ], + $tokens, + (int) $value_ranges[ $index ]['start'], + (int) $value_ranges[ $index ]['end'] + ); + if ( null !== $value_sql ) { + $values[ $index ] = $value_sql; + } + } + } + + /** + * Get a non-strict MySQL-compatible DML value for a column when special handling is needed. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null PostgreSQL value SQL, or null when generic translation is sufficient. + */ + private function get_non_strict_mysql_dml_value_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + $text_hex_sql = $this->get_mysql_text_hex_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + if ( null !== $text_hex_sql ) { + return $text_hex_sql; + } + + $value_sql = $this->get_non_strict_mysql_dml_date_time_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + if ( null !== $value_sql ) { + return $value_sql; + } + + return $this->get_non_strict_mysql_dml_integer_literal_sql_for_column( $column_metadata, $tokens, $start, $end ); + } + + /** + * Get a text SQL literal for a MySQL hex literal assigned to a text column. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null PostgreSQL value SQL, or null when this is not a text hex literal. + */ + private function get_mysql_text_hex_literal_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + if ( ! $this->is_mysql_text_family_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ) ) { + return null; + } + + $value = $this->get_mysql_text_hex_literal_value( $tokens, $start, $end ); + return null === $value ? null : $this->connection->quote( $value ); + } + + /** + * Decode a single MySQL hex literal token as bytes. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null Decoded bytes, or null when the range is not a hex literal. + */ + private function get_mysql_text_hex_literal_value( array $tokens, int $start, int $end ): ?string { + if ( + $start + 1 !== $end + || ! isset( $tokens[ $start ] ) + || WP_MySQL_Lexer::HEX_NUMBER !== $tokens[ $start ]->id + ) { + return null; + } + + $bytes = $tokens[ $start ]->get_bytes(); + if ( 1 === preg_match( '/^0x([0-9a-fA-F]+)$/', $bytes, $matches ) ) { + $hex = $matches[1]; + } elseif ( 1 === preg_match( "/^[xX]'([0-9a-fA-F]*)'$/", $bytes, $matches ) ) { + $hex = $matches[1]; + } else { + return null; + } + + if ( 1 === strlen( $hex ) % 2 ) { + $hex = '0' . $hex; + } + + $decoded = hex2bin( $hex ); + return false === $decoded ? null : $decoded; + } + + /** + * Get a non-strict MySQL-compatible integer literal for a column. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null PostgreSQL value SQL, or null when the literal does not need normalization. + */ + private function get_non_strict_mysql_dml_integer_literal_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + if ( ! $this->is_mysql_integer_family_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ) ) { + return null; + } + + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + if ( 1 === preg_match( '/^[[:space:]]*[+-]?[0-9]+[[:space:]]*$/', $tokens[ $start ]->get_value() ) ) { + return null; + } + + return $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_to_postgresql( $tokens[ $start ] ) + ); + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + if ( null === $literal || $literal['start'] !== $start || $literal['end'] !== $end ) { + return null; + } + if ( $this->is_mysql_integer_numeric_literal_range( $tokens, $start, $end ) ) { + return null; + } + + return $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ) + ); + } + + /** + * Get a non-strict MySQL-compatible date/time literal for a column. + * + * @param array $column_metadata Column metadata row. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First value token position. + * @param int $end Final value token position, exclusive. + * @return string|null PostgreSQL value SQL, or null when the literal does not need normalization. + */ + private function get_non_strict_mysql_dml_date_time_literal_sql_for_column( array $column_metadata, array $tokens, int $start, int $end ): ?string { + $base_type = $this->get_base_mysql_dml_column_type( (string) ( $column_metadata['column_type'] ?? '' ) ); + if ( ! in_array( $base_type, array( 'date', 'datetime', 'timestamp' ), true ) ) { + return null; + } + + $literal = $this->get_mysql_dml_literal_value( $tokens, $start, $end ); + if ( null === $literal || null === $literal['value'] ) { + return null; + } + + if ( 'string' === $literal['type'] ) { + $value = $literal['value']; + $storage_value = $this->get_non_strict_mysql_dml_date_time_storage_value( $base_type, $value ); + if ( null === $storage_value || $storage_value === $value ) { + return null; + } + + return $this->connection->quote( $storage_value ); + } + + if ( + 'boolean' !== $literal['type'] + && ( 'numeric' !== $literal['type'] || ! $this->is_mysql_zero_numeric_literal_value( $literal['value'] ) ) + ) { + return null; + } + + return $this->connection->quote( $this->get_mysql_zero_date_time_storage_value_for_type( $base_type ) ); + } + + /** + * Get the MySQL zero storage value for a date/time type. + * + * @param string $base_type Base MySQL date/time column type. + * @return string Zero storage value. + */ + private function get_mysql_zero_date_time_storage_value_for_type( string $base_type ): string { + if ( 'date' === $base_type ) { + return '0000-00-00'; + } + + return '0000-00-00 00:00:00'; + } + + /** + * Check whether a parsed numeric literal represents MySQL zero. + * + * @param string $value Original numeric literal bytes. + * @return bool Whether the value is numerically zero. + */ + private function is_mysql_zero_numeric_literal_value( string $value ): bool { + $value = trim( $value ); + if ( '' === $value ) { + return false; + } + + if ( isset( $value[0] ) && '+' === $value[0] ) { + $value = substr( $value, 1 ); + } + + return 1 === preg_match( '/^-?(?:0+)(?:\.0+)?(?:[eE][+-]?0+)?$/', $value ); + } + + /** + * Get the non-strict MySQL storage value for a date/time literal. + * + * @param string $base_type Base MySQL date/time column type. + * @param string $value Unquoted literal value. + * @return string|null Storage value, or null when the literal is not date/time-shaped. + */ + private function get_non_strict_mysql_dml_date_time_storage_value( string $base_type, string $value ): ?string { + if ( 'date' === $base_type ) { + return $this->get_non_strict_mysql_dml_date_storage_value( $value ); + } + + return $this->get_non_strict_mysql_dml_datetime_storage_value( $value ); + } + + /** + * Get the non-strict MySQL storage value for a DATE literal. + * + * @param string $value Unquoted literal value. + * @return string|null Storage value, or null when the literal is not date-shaped. + */ + private function get_non_strict_mysql_dml_date_storage_value( string $value ): ?string { + $storage_value = $this->normalize_mysql_dml_date_literal_format( $value ); + $parts = $this->get_mysql_dml_date_parts( $storage_value ); + if ( null === $parts ) { + return null; + } + + if ( $this->is_non_strict_mysql_dml_zero_date_allowed( $parts['year'], $parts['month'], $parts['day'] ) ) { + return $storage_value; + } + + if ( checkdate( (int) $parts['month'], (int) $parts['day'], (int) $parts['year'] ) ) { + return $storage_value; + } + + return '0000-00-00'; + } + + /** + * Get the non-strict MySQL storage value for a DATETIME/TIMESTAMP literal. + * + * @param string $value Unquoted literal value. + * @return string|null Storage value, or null when the literal is not datetime-shaped. + */ + private function get_non_strict_mysql_dml_datetime_storage_value( string $value ): ?string { + $normalized_value = $this->normalize_mysql_dml_datetime_literal_format( $value ); + $parts = $this->get_mysql_dml_datetime_parts( $normalized_value ); + if ( null === $parts ) { + $date_parts = $this->get_mysql_dml_date_parts( $normalized_value ); + if ( null === $date_parts ) { + return null; + } + + if ( $this->is_non_strict_mysql_dml_zero_date_allowed( $date_parts['year'], $date_parts['month'], $date_parts['day'] ) ) { + return $normalized_value . ' 00:00:00'; + } + + if ( checkdate( (int) $date_parts['month'], (int) $date_parts['day'], (int) $date_parts['year'] ) ) { + return $normalized_value . ' 00:00:00'; + } + + return '0000-00-00 00:00:00'; + } + + $is_valid_time = $this->is_mysql_dml_time_value_valid( $parts['hour'], $parts['minute'], $parts['second'] ); + if ( + $is_valid_time + && $this->is_non_strict_mysql_dml_zero_date_allowed( $parts['year'], $parts['month'], $parts['day'] ) + ) { + return $normalized_value; + } + + if ( + $is_valid_time + && checkdate( (int) $parts['month'], (int) $parts['day'], (int) $parts['year'] ) + ) { + return $normalized_value; + } + + return '0000-00-00 00:00:00'; + } + + /** + * Normalize MySQL-accepted date/datetime literals to the stored MySQL DATE shape. + * + * @param string $value Unquoted literal value. + * @return string Normalized literal value. + */ + private function normalize_mysql_dml_date_literal_format( string $value ): string { + if ( 1 === preg_match( '/^([0-9]{4}-[0-9]{2}-[0-9]{2})(?:[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z?)?$/', $value, $matches ) ) { + return $matches[1]; + } + + return $value; + } + + /** + * Normalize MySQL-accepted ISO datetime literals to the stored MySQL text shape. + * + * @param string $value Unquoted literal value. + * @return string Normalized literal value. + */ + private function normalize_mysql_dml_datetime_literal_format( string $value ): string { + if ( 1 === preg_match( '/^([0-9]{4}-[0-9]{2}-[0-9]{2})[ T]([0-9]{2}:[0-9]{2}:[0-9]{2})(?:\.[0-9]+)?Z?$/', $value, $matches ) ) { + return $matches[1] . ' ' . $matches[2]; + } + + return $value; + } + + /** + * Check whether a zero or partial-zero date is permitted in non-strict mode. + * + * @param string $year Four-digit year. + * @param string $month Two-digit month. + * @param string $day Two-digit day. + * @return bool Whether MySQL permits storing the zero date parts. + */ + private function is_non_strict_mysql_dml_zero_date_allowed( string $year, string $month, string $day ): bool { + if ( '0000' === $year && '00' === $month && '00' === $day ) { + return true; + } + + return '0000' !== $year + && ( '00' === $month || '00' === $day ) + && ! $this->is_mysql_sql_mode_active( 'NO_ZERO_IN_DATE' ); + } + + /** + * Get date parts from a MySQL DATE literal. + * + * @param string $value Unquoted literal value. + * @return array{year: string, month: string, day: string}|null Date parts, or null when not date-shaped. + */ + private function get_mysql_dml_date_parts( string $value ): ?array { + if ( 1 !== preg_match( '/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/', $value, $matches ) ) { + return null; + } + + return array( + 'year' => $matches[1], + 'month' => $matches[2], + 'day' => $matches[3], + ); + } + + /** + * Get date and time parts from a MySQL DATETIME/TIMESTAMP literal. + * + * @param string $value Unquoted literal value. + * @return array{year: string, month: string, day: string, hour: string, minute: string, second: string}|null Date/time parts, or null when not datetime-shaped. + */ + private function get_mysql_dml_datetime_parts( string $value ): ?array { + if ( 1 !== preg_match( '/^([0-9]{4})-([0-9]{2})-([0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2})$/', $value, $matches ) ) { + return null; + } + + return array( + 'year' => $matches[1], + 'month' => $matches[2], + 'day' => $matches[3], + 'hour' => $matches[4], + 'minute' => $matches[5], + 'second' => $matches[6], + ); + } + + /** + * Check whether a MySQL DATETIME/TIMESTAMP time part is valid. + * + * @param string $hour Two-digit hour. + * @param string $minute Two-digit minute. + * @param string $second Two-digit second. + * @return bool Whether the time part is valid. + */ + private function is_mysql_dml_time_value_valid( string $hour, string $minute, string $second ): bool { + return (int) $hour <= 23 + && (int) $minute <= 59 + && (int) $second <= 59; + } + + /** + * Get DML column metadata keyed by lowercase column name. + * + * @param string $table_name Table name. + * @return array Column metadata lookup. + */ + private function get_mysql_dml_column_metadata_lookup( string $table_name ): array { + return $this->get_mysql_dml_column_metadata_lookup_from_rows( + $this->get_mysql_dml_column_metadata( $table_name ) + ); + } + + /** + * Get DML column metadata keyed by lowercase column name from existing rows. + * + * @param array[] $metadata Column metadata rows. + * @return array Column metadata lookup. + */ + private function get_mysql_dml_column_metadata_lookup_from_rows( array $metadata ): array { + $lookup = array(); + foreach ( $metadata as $column_metadata ) { + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( '' !== $column_name ) { + $lookup[ strtolower( $column_name ) ] = $column_metadata; + } + } + + return $lookup; + } + + /** + * Get ordered DML column names from table metadata. + * + * @param array[] $metadata Ordered column metadata rows. + * @return string[]|null Column names, or null when metadata is unavailable. + */ + private function get_mysql_dml_column_names_from_metadata( array $metadata ): ?array { + $columns = array(); + $seen = array(); + + foreach ( $metadata as $column_metadata ) { + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( '' === $column_name ) { + return null; + } + + $column_key = strtolower( $column_name ); + if ( isset( $seen[ $column_key ] ) ) { + return null; + } + + $columns[] = $column_name; + $seen[ $column_key ] = true; + } + + return count( $columns ) > 0 ? $columns : null; + } + + /** + * Get ordered MySQL column metadata for a DML target table. + * + * @param string $table_name Table name. + * @return array[] Column metadata rows. + */ + private function get_mysql_dml_column_metadata( string $table_name ): array { + $this->ensure_mysql_schema_metadata_tables(); + + $table_schema = $this->resolve_mysql_table_schema_for_introspection( 'public', $table_name ); + $cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + if ( array_key_exists( $cache_key, $this->mysql_dml_column_metadata_cache ) ) { + return $this->mysql_dml_column_metadata_cache[ $cache_key ]; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT column_name, ordinal_position, column_type, is_nullable, column_default, extra + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY ordinal_position', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name ) + ); + + $this->mysql_dml_column_metadata_cache[ $cache_key ] = $stmt->fetchAll( PDO::FETCH_ASSOC ); + return $this->mysql_dml_column_metadata_cache[ $cache_key ]; + } + + /** + * Get the default SQL expression for a non-strict NOT NULL DML column. + * + * @param array $column_metadata Column metadata row. + * @return string|null Default SQL, or null when the column should not be coerced. + */ + private function get_non_strict_dml_default_sql_for_column( array $column_metadata ): ?string { + if ( 'NO' !== strtoupper( (string) ( $column_metadata['is_nullable'] ?? '' ) ) ) { + return null; + } + + if ( $this->is_mysql_auto_increment_column_metadata( $column_metadata ) ) { + return null; + } + + $default_sql = $this->get_mysql_dml_default_sql_from_metadata( $column_metadata ); + if ( null !== $default_sql ) { + return $default_sql; + } + + return $this->get_mysql_implicit_dml_default_sql( (string) ( $column_metadata['column_type'] ?? '' ) ); + } + + /** + * Get SQL for a stored MySQL metadata default in DML contexts. + * + * @param array $column_metadata Column metadata row. + * @return string|null PostgreSQL SQL expression, or null when no explicit default exists. + */ + private function get_mysql_dml_default_sql_from_metadata( array $column_metadata ): ?string { + if ( null === ( $column_metadata['column_default'] ?? null ) ) { + return null; + } + + $default = (string) $column_metadata['column_default']; + if ( + $this->is_mysql_current_timestamp_default_metadata( $default ) + || $this->mysql_column_extra_has_default_generated( (string) ( $column_metadata['extra'] ?? '' ) ) + ) { + $translated_default = $this->translate_mysql_default_fragment( $default ); + if ( null !== $translated_default ) { + return $translated_default['sql']; + } + } + + return $this->connection->quote( $default ); + } + + /** + * Check whether column metadata describes a MySQL AUTO_INCREMENT column. + * + * @param array $column_metadata Column metadata row. + * @return bool Whether the column is AUTO_INCREMENT. + */ + private function is_mysql_auto_increment_column_metadata( array $column_metadata ): bool { + return 'auto_increment' === strtolower( (string) ( $column_metadata['extra'] ?? '' ) ); + } + + /** + * Get a MySQL-compatible implicit default for a column type. + * + * @param string $column_type MySQL column type metadata. + * @return string|null SQL default expression, or null for unsupported type metadata. + */ + private function get_mysql_implicit_dml_default_sql( string $column_type ): ?string { + $base_type = $this->get_base_mysql_dml_column_type( $column_type ); + + if ( + in_array( + $base_type, + array( + 'char', + 'varchar', + 'binary', + 'varbinary', + 'tinyblob', + 'blob', + 'mediumblob', + 'longblob', + 'tinytext', + 'text', + 'mediumtext', + 'longtext', + 'enum', + 'set', + ), + true + ) + ) { + return $this->connection->quote( '' ); + } + + if ( + in_array( + $base_type, + array( + 'bit', + 'tinyint', + 'smallint', + 'mediumint', + 'int', + 'integer', + 'bigint', + 'decimal', + 'numeric', + 'float', + 'double', + 'real', + ), + true + ) + ) { + return '0'; + } + + if ( 'date' === $base_type ) { + return $this->connection->quote( '0000-00-00' ); + } + + if ( 'datetime' === $base_type || 'timestamp' === $base_type ) { + return $this->connection->quote( '0000-00-00 00:00:00' ); + } + + if ( 'time' === $base_type ) { + return $this->connection->quote( '00:00:00' ); + } + + if ( 'year' === $base_type ) { + return $this->connection->quote( '0000' ); + } + + return null; + } + + /** + * Get the base MySQL column type from metadata. + * + * @param string $column_type MySQL column type metadata. + * @return string Base type. + */ + private function get_base_mysql_dml_column_type( string $column_type ): string { + $column_type = strtolower( trim( $column_type ) ); + $type_end = strlen( $column_type ); + + $length_position = strpos( $column_type, '(' ); + if ( false !== $length_position ) { + $type_end = min( $type_end, $length_position ); + } + + $space_position = strpos( $column_type, ' ' ); + if ( false !== $space_position ) { + $type_end = min( $type_end, $space_position ); + } + + return substr( $column_type, 0, $type_end ); + } + + /** + * Get a MySQL column type display width or length. + * + * @param string $column_type MySQL column type metadata. + * @return int|null Width, or null when absent. + */ + private function get_mysql_column_type_display_width( string $column_type ): ?int { + if ( 1 !== preg_match( '/\(([0-9]+)\)/', $column_type, $matches ) ) { + return null; + } + + return (int) $matches[1]; + } + + /** + * Check whether a token sequence is exactly the NULL literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether the token sequence is NULL. + */ + private function is_mysql_null_token_sequence( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $start ]->id; + } + + /** + * Check whether the emulated MySQL session is using a strict SQL mode. + * + * @return bool Whether strict DML behavior should be preserved. + */ + private function is_mysql_strict_sql_mode_active(): bool { + return $this->is_mysql_sql_mode_active( 'STRICT_TRANS_TABLES' ) + || $this->is_mysql_sql_mode_active( 'STRICT_ALL_TABLES' ); + } + + /** + * Check whether a MySQL session SQL mode is active. + * + * @param string $mode SQL mode name. + * @return bool Whether the mode is active. + */ + private function is_mysql_sql_mode_active( string $mode ): bool { + return $this->is_sql_mode_active( $mode ); + } + + /** + * Translate standalone SELECT LAST_INSERT_ID(integer) projections. + * + * This is intentionally limited to no-table scalar SELECTs where every + * LAST_INSERT_ID(expr) setter is the whole projection expression. Broader + * expression and table-backed forms can have evaluation-count side effects, + * so they remain unsupported and fail closed. + * + * @param string $query MySQL query. + * @return array{sql: string, last_insert_id: int}|null PostgreSQL query and staged insert ID, or null when unsupported. + */ + private function translate_mysql_last_insert_id_assignment_select_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + 1, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::FROM_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ) + ) + ) { + return null; + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, 1, $statement_end ); + if ( null === $projection_ranges ) { + return null; + } + + $has_assignment = false; + foreach ( $projection_ranges as $projection_range ) { + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( + $tokens, + $projection_range['start'], + $projection_range['end'] + ); + if ( null === $expression_bounds ) { + return null; + } + + $assignment_value = $this->get_mysql_last_insert_id_assignment_literal_value( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ); + if ( null !== $assignment_value ) { + $has_assignment = true; + continue; + } + + if ( + $this->contains_mysql_last_insert_id_function_call_with_arguments( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ) + ) { + return null; + } + } + + if ( ! $has_assignment ) { + return null; + } + + $previous_translation_enabled = $this->mysql_last_insert_id_assignment_translation_enabled; + $previous_assignment_value = $this->mysql_last_insert_id_assignment_value; + + $this->mysql_last_insert_id_assignment_translation_enabled = true; + $this->mysql_last_insert_id_assignment_value = null; + + try { + $sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $statement_end ); + $last_insert_id = $this->mysql_last_insert_id_assignment_value; + } finally { + $this->mysql_last_insert_id_assignment_translation_enabled = $previous_translation_enabled; + $this->mysql_last_insert_id_assignment_value = $previous_assignment_value; + } + + if ( null === $last_insert_id ) { + return null; + } + + return array( + 'sql' => $sql, + 'last_insert_id' => $last_insert_id, + ); + } + + /** + * Get the supported LAST_INSERT_ID(expr) assignment value. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return int|null Assignment value, or null when unsupported. + */ + private function get_mysql_last_insert_id_assignment_literal_value( array $tokens, int $start, int $end ): ?int { + $normalized = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $normalized['start']; + $end = $normalized['end']; + + $bounds = $this->get_mysql_common_function_bounds( $tokens, $start, $end ); + if ( + null === $bounds + || 'last_insert_id' !== $bounds['function'] + || $bounds['close'] + 1 !== $end + ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 1 !== count( $arguments ) ) { + return null; + } + + return $this->get_mysql_non_negative_php_integer_literal_value( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + } + + /** + * Check whether a range contains a nonzero-arg LAST_INSERT_ID(...) call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token. + * @param int $end Final token, exclusive. + * @return bool Whether LAST_INSERT_ID(...) with arguments appears in the range. + */ + private function contains_mysql_last_insert_id_function_call_with_arguments( array $tokens, int $start, int $end ): bool { + for ( $position = $start; $position < $end; $position++ ) { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( null === $bounds || 'last_insert_id' !== $bounds['function'] ) { + continue; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 0 !== count( $arguments ) ) { + return true; + } + } + + return false; + } + + /** + * Get a supported non-negative decimal integer literal value. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @param int $end Final literal token, exclusive. + * @return int|null Integer value, or null when unsupported. + */ + private function get_mysql_non_negative_php_integer_literal_value( array $tokens, int $start, int $end ): ?int { + if ( $start + 2 === $end && WP_MySQL_Lexer::PLUS_OPERATOR === ( $tokens[ $start ]->id ?? null ) ) { + ++$start; + } + + if ( + $start + 1 !== $end + || ! isset( $tokens[ $start ] ) + || ! in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ) + ) { + return null; + } + + $value = $tokens[ $start ]->get_value(); + if ( '' === $value || ! ctype_digit( $value ) ) { + return null; + } + + $value = ltrim( $value, '0' ); + if ( '' === $value ) { + return 0; + } + + $max = (string) PHP_INT_MAX; + if ( strlen( $value ) > strlen( $max ) || ( strlen( $value ) === strlen( $max ) && strcmp( $value, $max ) > 0 ) ) { + return null; + } + + return (int) $value; + } + + /** + * Translate SELECT VERSION() while preserving MySQL's visible output label. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_mysql_version_function_select_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, 1, $statement_end ); + if ( null === $projection_ranges || 1 !== count( $projection_ranges ) ) { + return null; + } + + $projection = $projection_ranges[0]; + $bounds = $this->get_mysql_common_function_bounds( $tokens, $projection['start'], $projection['end'] ); + if ( + null === $bounds + || 'version' !== $bounds['function'] + || $bounds['close'] + 1 !== $projection['end'] + ) { + return null; + } + + return sprintf( + 'SELECT %s AS %s', + $this->connection->quote( $this->get_mysql_version_string() ), + $this->connection->quote_identifier( 'VERSION()' ) + ); + } + + /** + * Translate WordPress's distinct postmeta-key lookup that uses HAVING without GROUP BY. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_wordpress_postmeta_distinct_meta_key_having_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::DISTINCT_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $projection_start = 2; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $projection_start ); + if ( null === $statement_end ) { + return null; + } + + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $projection_start, $statement_end ); + $select_end = $limit_position ?? $statement_end; + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $projection_start, $select_end ); + if ( + null === $order_position + || ! isset( $tokens[ $order_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + || $order_position + 2 >= $select_end + ) { + return null; + } + + $having_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::HAVING_SYMBOL, $projection_start, $order_position ); + if ( null === $having_position || $having_position + 1 >= $order_position ) { + return null; + } + + if ( + null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, $projection_start, $having_position ) + || $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $projection_start, $having_position ); + if ( null === $from_position || $projection_start === $from_position ) { + return null; + } + + $projection_items = $this->parse_mysql_select_projection_items( $tokens, $projection_start, $from_position ); + if ( null === $projection_items || 1 !== count( $projection_items ) ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $having_position ); + $from_end = $where_position ?? $having_position; + $table = $this->parse_mysql_table_reference( $tokens, $from_position + 1, $from_end ); + if ( + null === $table + || $table['position'] !== $from_end + || 'public' !== $table['schema'] + || ! $this->is_mysql_wordpress_table_name( $table['table'], 'postmeta' ) + || ! $this->is_mysql_postmeta_meta_key_reference( + $tokens, + $projection_items[0]['expression_start'], + $projection_items[0]['expression_end'], + $table + ) + || ! $this->is_mysql_postmeta_meta_key_not_like_predicate( $tokens, $having_position + 1, $order_position, $table ) + || ! $this->is_mysql_postmeta_meta_key_order_by_clause( $tokens, $order_position + 2, $select_end, $table ) + ) { + return null; + } + + $from_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $from_end ); + $having_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $having_position + 1, $order_position ); + $order_sql = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $order_position, $select_end ); + + if ( null !== $where_position ) { + $where_sql = sprintf( + '(%s) AND (%s)', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $where_position + 1, $having_position ), + $having_sql + ); + } else { + $where_sql = $having_sql; + } + + $sql = sprintf( + 'SELECT DISTINCT %s %s WHERE %s %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $from_position ), + $from_sql, + $where_sql, + $order_sql + ); + + if ( null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Check whether a token range is a postmeta.meta_key reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @param array $table Parsed postmeta table reference. + * @return bool Whether the range references meta_key. + */ + private function is_mysql_postmeta_meta_key_reference( array $tokens, int $start, int $end, array $table ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $reference = $this->parse_mysql_column_reference( $tokens, $bounds['start'], $bounds['end'] ); + if ( + null === $reference + || $reference['end'] !== $bounds['end'] + || 0 !== strcasecmp( $reference['column'], 'meta_key' ) + ) { + return false; + } + + return null === $reference['qualifier'] + || $this->is_mysql_dml_table_qualifier( $reference['qualifier'], $table['table'], $table['alias'] ); + } + + /** + * Check whether a HAVING predicate is meta_key NOT LIKE pattern. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First HAVING predicate token. + * @param int $end Final HAVING predicate token, exclusive. + * @param array $table Parsed postmeta table reference. + * @return bool Whether the predicate is supported. + */ + private function is_mysql_postmeta_meta_key_not_like_predicate( array $tokens, int $start, int $end, array $table ): bool { + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( + null === $reference + || ! $this->is_mysql_postmeta_meta_key_reference( $tokens, $reference['start'], $reference['end'], $table ) + || ! isset( $tokens[ $reference['end'] ], $tokens[ $reference['end'] + 1 ] ) + || WP_MySQL_Lexer::NOT_SYMBOL !== $tokens[ $reference['end'] ]->id + || WP_MySQL_Lexer::LIKE_SYMBOL !== $tokens[ $reference['end'] + 1 ]->id + || $reference['end'] + 2 >= $end + ) { + return false; + } + + return ! $this->contains_top_level_mysql_token( + $tokens, + $reference['end'] + 2, + $end, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + ) + ); + } + + /** + * Check whether an ORDER BY clause sorts by meta_key. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First ORDER BY item token. + * @param int $end Final ORDER BY token, exclusive. + * @param array $table Parsed postmeta table reference. + * @return bool Whether the ORDER BY clause is supported. + */ + private function is_mysql_postmeta_meta_key_order_by_clause( array $tokens, int $start, int $end, array $table ): bool { + $order_items = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $order_items || 1 !== count( $order_items ) ) { + return false; + } + + $item_end = $order_items[0]['end']; + if ( + isset( $tokens[ $item_end - 1 ] ) + && ( + WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $item_end - 1 ]->id + || WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $item_end - 1 ]->id + ) + ) { + --$item_end; + } + + return $this->is_mysql_postmeta_meta_key_reference( $tokens, $order_items[0]['start'], $item_end, $table ); + } + + /** + * Translate simple single-table MySQL SELECT statements to PostgreSQL. + * + * This intentionally covers only the WordPress read shapes that need + * identifier quoting for PostgreSQL. Joins, grouping, subqueries, most + * functions, and MySQL-only SELECT modifiers fall through unchanged. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_simple_mysql_select_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $unsupported_tokens = array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ); + if ( $this->contains_top_level_mysql_token( $tokens, 1, $statement_end, $unsupported_tokens ) ) { + return null; + } + + $select_end = $statement_end; + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 1, $statement_end ); + if ( null !== $limit_position ) { + if ( ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $select_end = $limit_position; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $select_end ); + if ( null === $from_position || 1 === $from_position ) { + return null; + } + + if ( ! $this->is_supported_simple_select_projection( $tokens, 1, $from_position ) ) { + return null; + } + + $source_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + $from_position + 1, + $select_end + ) ?? $select_end; + + $table_reference_start = $from_position + 1; + $table_name_end = $table_reference_start; + $table_name_for_sql = $this->parse_mysql_main_database_table_name( $tokens, $table_name_end ); + $position = $table_reference_start; + $table_reference = $this->parse_mysql_main_database_table_reference( $tokens, $position, $source_end ); + if ( + null === $table_name_for_sql + || null === $table_reference + || $table_name_for_sql !== $table_reference['table'] + || $position !== $source_end + ) { + return null; + } + $table_reference_sql = $this->get_mysql_main_database_table_reference_sql( + $tokens, + $table_reference_start, + $table_name_end + ); + if ( null !== $table_reference['alias'] ) { + $table_reference_sql .= ' AS ' . $this->connection->quote_identifier( $table_reference['alias'] ); + } + $table_name = $table_reference['table']; + $table_alias = $table_reference['alias']; + + $where_position = null; + $where_end = null; + $order_position = null; + + if ( $position < $select_end && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { + $where_position = $position; + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $position + 1, $select_end ); + $where_end = $order_position ?? $select_end; + + if ( + $where_position + 1 >= $where_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $where_end ) + ) { + return null; + } + + $position = $where_end; + } + + if ( $position < $select_end && WP_MySQL_Lexer::ORDER_SYMBOL === $tokens[ $position ]->id ) { + $order_position = $position; + if ( ! $this->is_supported_simple_select_order_by_clause( $tokens, $order_position, $select_end ) ) { + return null; + } + + $position = $select_end; + } + + if ( $position !== $select_end ) { + return null; + } + + $sql = sprintf( + 'SELECT %s FROM %s', + $this->translate_simple_select_projection_to_postgresql( $tokens, 1, $from_position ), + $table_reference_sql + ); + + $scope = $this->get_mysql_single_table_scope( $table_name, $table_alias ); + if ( null !== $where_position ) { + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + $sql .= ' WHERE ' . $where_sql['sql']; + } + + if ( null !== $order_position ) { + $order_sql = $this->translate_mysql_order_by_token_sequence_to_postgresql( + $tokens, + $order_position + 2, + $select_end, + $scope, + false + ); + $sql .= ' ORDER BY ' . $order_sql['sql']; + + $tiebreaker_sql = $this->get_simple_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( + $tokens, + $table_name, + $order_position, + $select_end + ); + if ( null !== $tiebreaker_sql ) { + $sql .= ', ' . $tiebreaker_sql; + } + + $tiebreaker_sql = $this->get_simple_wordpress_approved_comments_order_tiebreaker_sql( + $tokens, + $table_name, + $where_position, + $where_end, + $order_position, + $select_end + ); + if ( null !== $tiebreaker_sql ) { + $sql .= ', ' . $tiebreaker_sql; + } + } + + if ( null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Translate WordPress Site Health's MySQL information_schema.TABLES query. + * + * WordPress asks MySQL for TABLE_ROWS and data/index lengths, which + * PostgreSQL's information_schema.tables does not expose. Keep this rewrite + * constrained to the Site Health projection and predicates so other catalog + * shapes continue to fail visibly. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_information_schema_tables_site_health_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + if ( null === $from_position || 1 === $from_position ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $statement_end ); + $group_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, $from_position + 1, $statement_end ); + if ( + null === $where_position + || null === $group_position + || $where_position > $group_position + || ! isset( $tokens[ $group_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_position + 1 ]->id + ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + 1, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + if ( ! $this->is_information_schema_tables_reference( $tokens, $from_position + 1, $where_position ) ) { + return null; + } + + $projection_items = $this->parse_mysql_select_projection_items( $tokens, 1, $from_position ); + if ( null === $projection_items ) { + return null; + } + + $projection_sql = $this->get_information_schema_tables_site_health_projection_sql( $tokens, $projection_items ); + if ( null === $projection_sql ) { + return null; + } + + $where_clause = $this->parse_information_schema_tables_site_health_where_clause( $tokens, $where_position + 1, $group_position ); + if ( null === $where_clause ) { + return null; + } + + if ( ! $this->is_information_schema_tables_site_health_group_by_clause( $tokens, $group_position + 2, $statement_end ) ) { + return null; + } + + $existing_table_names = $this->get_information_schema_tables_site_health_existing_table_names( $where_clause['table_names'] ); + + return sprintf( + 'SELECT %s FROM (%s) AS %s WHERE %s GROUP BY %s', + implode( ', ', $projection_sql ), + $this->get_information_schema_tables_site_health_relation_sql( $existing_table_names ), + $this->connection->quote_identifier( '__wp_pg_information_schema_tables' ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $where_position + 1, $group_position ), + $this->connection->quote_identifier( 'table_name' ) + ); + } + + /** + * Check whether a token range is exactly information_schema.TABLES. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First table-reference token position. + * @param int $end Final table-reference token position, exclusive. + * @return bool Whether the range references information_schema.TABLES. + */ + private function is_information_schema_tables_reference( array $tokens, int $start, int $end ): bool { + return $start + 3 === $end + && $this->is_mysql_identifier_like_token_value( $tokens[ $start ] ?? null, 'information_schema' ) + && isset( $tokens[ $start + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + && $this->is_mysql_identifier_like_token_value( $tokens[ $start + 2 ] ?? null, 'tables' ); + } + + /** + * Build Site Health's supported information_schema.TABLES projection list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @return string[]|null PostgreSQL projection SQL, or null when unsupported. + */ + private function get_information_schema_tables_site_health_projection_sql( array $tokens, array $projection_items ): ?array { + if ( 3 !== count( $projection_items ) ) { + return null; + } + + $expected = array( + array( + 'alias' => 'table', + 'type' => 'table_name', + ), + array( + 'alias' => 'rows', + 'type' => 'table_rows', + ), + array( + 'alias' => 'bytes', + 'type' => 'data_index_sum', + ), + ); + + $projection_sql = array(); + foreach ( $expected as $index => $expected_projection ) { + $projection_item = $projection_items[ $index ]; + if ( strtolower( $projection_item['alias'] ) !== $expected_projection['alias'] ) { + return null; + } + + if ( + 'table_name' === $expected_projection['type'] + && $this->is_information_schema_tables_column_expression( + $tokens, + $projection_item['expression_start'], + $projection_item['expression_end'], + 'table_name' + ) + ) { + $projection_sql[] = sprintf( + '%s AS %s', + $this->connection->quote_identifier( 'table_name' ), + $this->connection->quote_identifier( $projection_item['alias'] ) + ); + continue; + } + + if ( + 'table_rows' === $expected_projection['type'] + && $this->is_information_schema_tables_column_expression( + $tokens, + $projection_item['expression_start'], + $projection_item['expression_end'], + 'table_rows' + ) + ) { + $projection_sql[] = sprintf( + 'MAX(%s) AS %s', + $this->connection->quote_identifier( 'TABLE_ROWS' ), + $this->connection->quote_identifier( $projection_item['alias'] ) + ); + continue; + } + + if ( + 'data_index_sum' === $expected_projection['type'] + && $this->is_information_schema_tables_data_index_sum_expression( + $tokens, + $projection_item['expression_start'], + $projection_item['expression_end'] + ) + ) { + $projection_sql[] = sprintf( + 'SUM(%s + %s) AS %s', + $this->connection->quote_identifier( 'data_length' ), + $this->connection->quote_identifier( 'index_length' ), + $this->connection->quote_identifier( $projection_item['alias'] ) + ); + continue; + } + + return null; + } + + return $projection_sql; + } + + /** + * Check whether a projection expression is a supported information_schema.TABLES column. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @param string $column Expected column name. + * @return bool Whether the expression is the expected column. + */ + private function is_information_schema_tables_column_expression( array $tokens, int $start, int $end, string $column ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + return $bounds['start'] + 1 === $bounds['end'] + && $this->is_mysql_identifier_like_token_value( $tokens[ $bounds['start'] ] ?? null, $column ); + } + + /** + * Check whether a projection expression is SUM(data_length + index_length). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @return bool Whether the expression is the supported size aggregate. + */ + private function is_information_schema_tables_data_index_sum_expression( array $tokens, int $start, int $end ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + return $start + 6 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ], $tokens[ $start + 4 ], $tokens[ $start + 5 ] ) + && WP_MySQL_Lexer::SUM_SYMBOL === $tokens[ $start ]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start + 1 ]->id + && $this->is_mysql_identifier_like_token_value( $tokens[ $start + 2 ], 'data_length' ) + && WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $start + 3 ]->id + && $this->is_mysql_identifier_like_token_value( $tokens[ $start + 4 ], 'index_length' ) + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $start + 5 ]->id; + } + + /** + * Parse a supported Site Health information_schema.TABLES WHERE clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First WHERE predicate token position. + * @param int $end Final WHERE predicate token position, exclusive. + * @return array{table_names: string[]}|null Parsed WHERE data, or null when unsupported. + */ + private function parse_information_schema_tables_site_health_where_clause( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $position = $start; + $seen_columns = array(); + $table_names = array(); + $required_seen = array( + 'table_schema' => false, + 'table_name' => false, + ); + + while ( $position < $end ) { + $term = $this->parse_information_schema_tables_site_health_where_term( $tokens, $position, $end ); + if ( null === $term || isset( $seen_columns[ $term['column'] ] ) ) { + return null; + } + + $seen_columns[ $term['column'] ] = true; + if ( isset( $required_seen[ $term['column'] ] ) ) { + $required_seen[ $term['column'] ] = true; + } + if ( 'table_name' === $term['column'] ) { + $table_names = $term['table_names']; + } + $position = $term['position']; + + if ( $position === $end ) { + break; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + ++$position; + } + + if ( ! $required_seen['table_schema'] || ! $required_seen['table_name'] || empty( $table_names ) ) { + return null; + } + + return array( + 'table_names' => array_values( array_unique( $table_names ) ), + ); + } + + /** + * Parse one supported information_schema.TABLES WHERE term. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @param int $end Final WHERE predicate token position, exclusive. + * @return array{column: string, position: int, table_names: string[]}|null Parsed term, or null when unsupported. + */ + private function parse_information_schema_tables_site_health_where_term( array $tokens, int $position, int $end ): ?array { + if ( $this->is_mysql_identifier_like_token_value( $tokens[ $position ] ?? null, 'table_schema' ) ) { + if ( + ! isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position + 1 ]->id + || ! $this->is_mysql_string_literal_token( $tokens[ $position + 2 ] ) + ) { + return null; + } + + return array( + 'column' => 'table_schema', + 'position' => $position + 3, + 'table_names' => array(), + ); + } + + if ( ! $this->is_mysql_identifier_like_token_value( $tokens[ $position ] ?? null, 'table_name' ) ) { + return null; + } + + if ( + isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position + 1 ]->id + && $this->is_mysql_string_literal_token( $tokens[ $position + 2 ] ) + ) { + return array( + 'column' => 'table_name', + 'position' => $position + 3, + 'table_names' => array( $tokens[ $position + 2 ]->get_value() ), + ); + } + + if ( + ! isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + || WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 2 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 2, $end ); + if ( null === $after_close ) { + return null; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $position + 3, $after_close - 1 ); + if ( null === $items || count( $items ) < 1 ) { + return null; + } + + $table_names = array(); + foreach ( $items as $item ) { + if ( ! $this->is_mysql_string_literal_range( $tokens, $item['start'], $item['end'] ) ) { + return null; + } + $table_names[] = $tokens[ $item['start'] ]->get_value(); + } + + return array( + 'column' => 'table_name', + 'position' => $after_close, + 'table_names' => $table_names, + ); + } + + /** + * Check whether the GROUP BY clause is exactly GROUP BY TABLE_NAME. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First GROUP BY expression token position. + * @param int $end Final GROUP BY token position, exclusive. + * @return bool Whether the grouping shape is supported. + */ + private function is_information_schema_tables_site_health_group_by_clause( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && $this->is_mysql_identifier_like_token_value( $tokens[ $start ] ?? null, 'table_name' ); + } + + /** + * Get requested Site Health table names that exist in the PostgreSQL catalog. + * + * @param string[] $table_names Table names from the validated TABLE_NAME predicate. + * @return string[] Existing table names in requested order. + */ + private function get_information_schema_tables_site_health_existing_table_names( array $table_names ): array { + if ( empty( $table_names ) ) { + return array(); + } + + $placeholders = implode( ', ', array_fill( 0, count( $table_names ), '?' ) ); + $stmt = $this->connection->query( + sprintf( + 'SELECT %1$s FROM %2$s WHERE %3$s = ? AND %4$s IN (?, ?) AND %1$s NOT IN (?, ?, ?, ?, ?, ?) AND %1$s IN (%5$s)', + $this->connection->quote_identifier( 'table_name' ), + $this->get_postgresql_qualified_identifier( 'information_schema', 'tables' ), + $this->connection->quote_identifier( 'table_schema' ), + $this->connection->quote_identifier( 'table_type' ), + $placeholders + ), + array_merge( + array( + 'public', + 'BASE TABLE', + 'VIEW', + self::MYSQL_COLUMN_METADATA_TABLE, + self::MYSQL_INDEX_METADATA_TABLE, + self::MYSQL_FOREIGN_KEY_METADATA_TABLE, + self::MYSQL_CHECK_METADATA_TABLE, + self::MYSQL_CHARSET_METADATA_TABLE, + self::MYSQL_TABLE_METADATA_TABLE, + ), + $table_names + ) + ); + + $existing_table_names = array(); + foreach ( $stmt->fetchAll( PDO::FETCH_COLUMN, 0 ) as $table_name ) { + $existing_table_names[ (string) $table_name ] = true; + } + + return array_values( + array_filter( + $table_names, + static function ( string $table_name ) use ( $existing_table_names ): bool { + return isset( $existing_table_names[ $table_name ] ); + } + ) + ); + } + + /** + * Build the derived relation that emulates MySQL information_schema.TABLES columns. + * + * @param string[] $existing_table_names Table names validated against information_schema.tables. + * @return string PostgreSQL relation SQL. + */ + private function get_information_schema_tables_site_health_relation_sql( array $existing_table_names ): string { + return sprintf( + 'SELECT %1$s AS %1$s, %2$s AS %3$s, %4$s, 0 AS %5$s, 0 AS %6$s FROM %7$s WHERE %8$s = %9$s AND %10$s IN (%11$s, %12$s) AND %1$s NOT IN (%13$s, %14$s, %15$s, %16$s, %17$s, %18$s)', + $this->connection->quote_identifier( 'table_name' ), + $this->connection->quote( $this->db_name ), + $this->connection->quote_identifier( 'TABLE_SCHEMA' ), + $this->get_information_schema_tables_site_health_table_rows_sql( $existing_table_names ), + $this->connection->quote_identifier( 'data_length' ), + $this->connection->quote_identifier( 'index_length' ), + $this->get_postgresql_qualified_identifier( 'information_schema', 'tables' ), + $this->connection->quote_identifier( 'table_schema' ), + $this->connection->quote( 'public' ), + $this->connection->quote_identifier( 'table_type' ), + $this->connection->quote( 'BASE TABLE' ), + $this->connection->quote( 'VIEW' ), + $this->connection->quote( self::MYSQL_COLUMN_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_INDEX_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_CHECK_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_CHARSET_METADATA_TABLE ), + $this->connection->quote( self::MYSQL_TABLE_METADATA_TABLE ) + ); + } + + /** + * Build a Site Health TABLE_ROWS expression for existing catalog tables. + * + * @param string[] $existing_table_names Table names validated against information_schema.tables. + * @return string PostgreSQL row-count expression SQL. + */ + private function get_information_schema_tables_site_health_table_rows_sql( array $existing_table_names ): string { + if ( empty( $existing_table_names ) ) { + return sprintf( + '0 AS %s', + $this->connection->quote_identifier( 'TABLE_ROWS' ) + ); + } + + $cases = array(); + foreach ( $existing_table_names as $table_name ) { + $cases[] = sprintf( + 'WHEN %s THEN (SELECT COUNT(*) FROM %s)', + $this->connection->quote( $table_name ), + $this->get_postgresql_qualified_identifier( 'public', $table_name ) + ); + } + + return sprintf( + 'CASE %s %s ELSE 0 END AS %s', + $this->connection->quote_identifier( 'table_name' ), + implode( ' ', $cases ), + $this->connection->quote_identifier( 'TABLE_ROWS' ) + ); + } + + /** + * Translate supported CTE SELECTs over direct MySQL information_schema relations. + * + * @param string $query MySQL WITH ... SELECT query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_direct_information_schema_cte_select_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::WITH_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::RECURSIVE_SYMBOL === $tokens[ $position ]->id ) { + return null; + } + + $replacements = array(); + $cte_sources = array(); + while ( $position < $statement_end ) { + $cte_name_position = $position; + $cte_name = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $cte_name ) { + return null; + } + + $replacements[] = array( + 'start' => $cte_name_position, + 'end' => $cte_name_position + 1, + 'sql' => $this->connection->quote_identifier( $cte_name ), + ); + + ++$position; + $column_list_insert_position = $position; + $has_column_list = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_column_list = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $statement_end ); + if ( null === $after_column_list ) { + return null; + } + $columns = $this->get_direct_information_schema_cte_column_list( $tokens, $position + 1, $after_column_list - 1 ); + if ( empty( $columns ) ) { + return null; + } + $has_column_list = true; + $position = $after_column_list; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::AS_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_select_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $statement_end ); + if ( null === $after_select_close ) { + return null; + } + + $select_start = $position + 2; + $select_end = $after_select_close - 1; + if ( ! isset( $tokens[ $select_start ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $select_start ]->id ) { + return null; + } + + $select_query = $this->get_mysql_token_range_bytes( $query, $tokens, $select_start, $select_end ); + if ( '' === $select_query ) { + return null; + } + + $translated_select = $this->translate_direct_information_schema_select_query( $select_query ); + if ( null === $translated_select ) { + return null; + } + + if ( ! $has_column_list ) { + $select_tokens = $this->get_mysql_tokens( $select_query ); + $select_statement_end = $this->get_mysql_statement_end_position( $select_tokens, 1 ); + if ( null === $select_statement_end ) { + return null; + } + + $columns = $this->get_direct_information_schema_cte_output_columns( $select_query, $select_tokens, $select_statement_end ); + if ( empty( $columns ) ) { + return null; + } + + $replacements[] = array( + 'start' => $column_list_insert_position, + 'end' => $column_list_insert_position, + 'sql' => '(' . implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ) . ')', + ); + } + + $cte_sources[ strtolower( $cte_name ) ] = array( + 'name' => $cte_name, + 'columns' => $columns, + ); + + $replacements[] = array( + 'start' => $select_start, + 'end' => $select_end, + 'sql' => $translated_select, + ); + + $position = $after_select_close; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $final_select_needs_direct_translation = $this->select_references_direct_information_schema_relation( $tokens, $position + 1, $statement_end ) + || ( + 0 === strcasecmp( $this->db_name, 'information_schema' ) + && $this->information_schema_cte_final_select_has_non_cte_table_reference( $tokens, $position, $statement_end, $cte_sources ) + ); + + if ( $final_select_needs_direct_translation ) { + $select_query = $this->get_mysql_token_range_bytes( $query, $tokens, $position, $statement_end ); + if ( '' === $select_query ) { + return null; + } + + $translated_select = $this->translate_direct_information_schema_select_query( $select_query, $cte_sources ); + if ( null === $translated_select ) { + return null; + } + + $replacements[] = array( + 'start' => $position, + 'end' => $statement_end, + 'sql' => $translated_select, + ); + } + + usort( + $replacements, + static function ( array $left, array $right ): int { + return $left['start'] <=> $right['start']; + } + ); + + return $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 0, + $statement_end, + $replacements + ); + } + + /** + * Check whether a final CTE SELECT under USE information_schema reads outside known CTEs. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start SELECT token position. + * @param int $end Final statement token, exclusive. + * @param array $cte_sources Known CTE sources keyed by lowercase name. + * @return bool Whether the final SELECT must use direct information_schema routing. + */ + private function information_schema_cte_final_select_has_non_cte_table_reference( array $tokens, int $start, int $end, array $cte_sources ): bool { + $position = $start; + while ( $position < $end ) { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position ]->id ) { + return true; + } + + $segment_end = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::UNION_SYMBOL, $position + 1, $end ) ?? $end; + if ( $this->information_schema_cte_select_segment_has_non_cte_table_reference( $tokens, $position, $segment_end, $cte_sources ) ) { + return true; + } + + if ( $segment_end >= $end ) { + return false; + } + + $position = $segment_end + 1; + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::ALL_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $position ]->id + ) + ) { + ++$position; + } + } + + return false; + } + + /** + * Check whether one SELECT segment reads a non-CTE source. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start SELECT token position. + * @param int $end Segment end, exclusive. + * @param array $cte_sources Known CTE sources keyed by lowercase name. + * @return bool Whether the segment has a non-CTE table source. + */ + private function information_schema_cte_select_segment_has_non_cte_table_reference( array $tokens, int $start, int $end, array $cte_sources ): bool { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $start + 1, $end ); + if ( null === $from_position ) { + return false; + } + + $source_end = $this->find_direct_information_schema_source_end( $tokens, $from_position + 1, $end ); + $position = $from_position + 1; + while ( $position < $source_end ) { + if ( ! isset( $tokens[ $position ] ) ) { + return true; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + return true; + } + + $identifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ); + if ( null === $identifier ) { + ++$position; + continue; + } + + if ( ! isset( $cte_sources[ strtolower( $identifier ) ] ) ) { + return true; + } + + ++$position; + while ( $position < $source_end ) { + if ( + WP_MySQL_Lexer::COMMA_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || WP_MySQL_Lexer::JOIN_SYMBOL === ( $tokens[ $position ]->id ?? null ) + ) { + ++$position; + break; + } + + if ( + WP_MySQL_Lexer::INNER_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || WP_MySQL_Lexer::CROSS_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || WP_MySQL_Lexer::LEFT_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || WP_MySQL_Lexer::RIGHT_SYMBOL === ( $tokens[ $position ]->id ?? null ) + ) { + while ( $position < $source_end && WP_MySQL_Lexer::JOIN_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + ++$position; + } + if ( $position < $source_end ) { + ++$position; + } + break; + } + + ++$position; + } + } + + return false; + } + + /** + * Get explicit CTE output columns from a parenthesized column list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First column token. + * @param int $end Final column token, exclusive. + * @return string[]|null Column names, or null when unsupported. + */ + private function get_direct_information_schema_cte_column_list( array $tokens, int $start, int $end ): ?array { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || array() === $ranges ) { + return null; + } + + $columns = array(); + $seen = array(); + foreach ( $ranges as $range ) { + if ( $range['start'] + 1 !== $range['end'] || ! isset( $tokens[ $range['start'] ] ) ) { + return null; + } + + $column = $this->get_direct_information_schema_identifier_token_value( $tokens[ $range['start'] ] ); + if ( null === $column ) { + return null; + } + + $column_key = strtolower( $column ); + if ( isset( $seen[ $column_key ] ) ) { + return null; + } + + $seen[ $column_key ] = true; + $columns[] = $column; + } + + return $columns; + } + + /** + * Get CTE output columns for a supported direct information_schema SELECT body. + * + * @param string $query MySQL SELECT query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $statement_end Final statement token position, exclusive. + * @return string[]|null Output column names, or null when unsupported. + */ + private function get_direct_information_schema_cte_output_columns( string $query, array $tokens, int $statement_end ): ?array { + if ( $this->contains_top_level_mysql_token( $tokens, 1, $statement_end, array( WP_MySQL_Lexer::UNION_SYMBOL ) ) ) { + return $this->get_direct_information_schema_select_or_union_output_columns( $query, $tokens, $statement_end ); + } + + $context = $this->get_direct_information_schema_select_context( $query, $tokens, $statement_end ); + if ( null === $context ) { + return null; + } + + $projection_start = 1; + if ( isset( $tokens[ $projection_start ] ) && WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $projection_start ]->id ) { + ++$projection_start; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $context['from_position'] ); + if ( null === $ranges || array() === $ranges ) { + return null; + } + + $columns = array(); + foreach ( $ranges as $range ) { + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( $tokens, $range['start'], $range['end'] ); + if ( null === $expression_bounds ) { + return null; + } + + $star_columns = $this->get_direct_information_schema_star_projection_output_columns( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'], + $context + ); + if ( null !== $star_columns ) { + if ( null !== $this->get_mysql_select_projection_explicit_or_implicit_alias( $tokens, $range['start'], $range['end'] ) ) { + return null; + } + $columns = array_merge( $columns, $star_columns ); + continue; + } + + $alias = $this->get_mysql_select_projection_explicit_or_implicit_alias( $tokens, $range['start'], $range['end'] ); + if ( null !== $alias ) { + $columns[] = $alias; + continue; + } + + if ( $this->is_direct_information_schema_count_star_projection( $tokens, $expression_bounds['start'], $expression_bounds['end'] ) ) { + $columns[] = 'COUNT(*)'; + continue; + } + + $column = $this->get_direct_information_schema_cte_projection_column_name( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'], + $context + ); + if ( null === $column ) { + return null; + } + $columns[] = $column; + } + + return $columns; + } + + /** + * Get a projection column name preserving the original token spelling. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $context Direct information_schema SELECT context. + * @return string|null Output column name, or null. + */ + private function get_direct_information_schema_cte_projection_column_name( array $tokens, int $start, int $end, array $context ): ?string { + if ( $start + 1 === $end && isset( $tokens[ $start ] ) ) { + return null === $this->get_direct_information_schema_unqualified_column_name( $tokens[ $start ], $context ) + ? null + : $this->get_direct_information_schema_identifier_token_value( $tokens[ $start ] ); + } + + if ( + $start + 3 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + ) { + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + if ( null === $source || null === $this->get_direct_information_schema_column_name_for_token( $tokens[ $start + 2 ], $source['column_map'] ) ) { + return null; + } + + return $this->get_direct_information_schema_identifier_token_value( $tokens[ $start + 2 ] ); + } + + if ( + $start + 5 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ], $tokens[ $start + 4 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 3 ]->id + ) { + $schema = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start ] ); + if ( null === $schema || 0 !== strcasecmp( $schema, 'information_schema' ) ) { + return null; + } + + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start + 2 ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + if ( null === $source || null === $this->get_direct_information_schema_column_name_for_token( $tokens[ $start + 4 ], $source['column_map'] ) ) { + return null; + } + + return $this->get_direct_information_schema_identifier_token_value( $tokens[ $start + 4 ] ); + } + + return null; + } + + /** + * Translate common direct MySQL information_schema SELECT statements. + * + * This is intentionally limited to supported information_schema relations as + * FROM/JOIN sources, main-database tables with MySQL metadata, and derived + * subqueries that themselves use supported direct information_schema shapes, + * and caller-provided CTE sources. Unsupported relation shapes and nested + * application-table subqueries fail closed rather than receiving a partial + * rewrite. + * + * @param string $query MySQL query. + * @param array $cte_sources Caller-provided CTE sources keyed by lowercase name. + * @return string|null PostgreSQL query, or null when the shape is unsupported. + */ + private function translate_direct_information_schema_select_query( string $query, array $cte_sources = array() ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + if ( $this->contains_top_level_mysql_token( $tokens, 1, $statement_end, array( WP_MySQL_Lexer::UNION_SYMBOL ) ) ) { + return $this->translate_direct_information_schema_union_select_query( $query, $tokens, $statement_end ); + } + + $projection_start = 1; + if ( isset( $tokens[ $projection_start ] ) && WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $projection_start ]->id ) { + ++$projection_start; + } + + $context = $this->get_direct_information_schema_select_context( $query, $tokens, $statement_end, $cte_sources ); + if ( null === $context ) { + return $this->translate_direct_information_schema_no_from_select_query( $query, $tokens, $statement_end ); + } + + $source_cover_ranges = array(); + foreach ( $context['sources'] as $source ) { + $source_cover_ranges[] = array( + 'start' => $source['source_start'], + 'end' => $source['source_end'], + ); + } + + $nested_select_ranges = array_merge( + array( + array( + 'start' => $projection_start, + 'end' => $context['from_position'], + ), + ), + $context['clause_ranges'] + ); + + $nested_select_replacements = $this->get_direct_information_schema_nested_select_replacements( + $query, + $tokens, + $nested_select_ranges + ); + if ( null === $nested_select_replacements ) { + return null; + } + + if ( + ! $this->direct_information_schema_nested_selects_are_covered( + $tokens, + 1, + $statement_end, + array_merge( $source_cover_ranges, $nested_select_replacements ) + ) + ) { + return null; + } + + $replacements = array(); + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $context['from_position'] ); + if ( null === $projection_ranges || array() === $projection_ranges ) { + return null; + } + + foreach ( $projection_ranges as $range ) { + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( $tokens, $range['start'], $range['end'] ); + if ( null === $expression_bounds ) { + return null; + } + + $star_select_list = $this->get_direct_information_schema_star_projection_select_list( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'], + $context + ); + if ( null !== $star_select_list ) { + $replacements[] = array( + 'start' => $expression_bounds['start'], + 'end' => $expression_bounds['end'], + 'sql' => $star_select_list, + ); + continue; + } + + if ( + $range['start'] === $expression_bounds['start'] + && $range['end'] === $expression_bounds['end'] + && $this->is_direct_information_schema_count_star_projection( $tokens, $expression_bounds['start'], $expression_bounds['end'] ) + ) { + $replacements[] = array( + 'start' => $expression_bounds['start'], + 'end' => $expression_bounds['end'], + 'sql' => 'COUNT(*) AS ' . $this->connection->quote_identifier( 'COUNT(*)' ), + ); + continue; + } + + $current_database_function_replacements = $this->get_direct_information_schema_current_database_function_replacements( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'], + $nested_select_replacements + ); + if ( null === $current_database_function_replacements ) { + return null; + } + + $column_replacements = $this->get_direct_information_schema_column_replacements( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'], + $context, + array_merge( $nested_select_replacements, $current_database_function_replacements ) + ); + if ( null === $column_replacements ) { + return null; + } + + foreach ( $current_database_function_replacements as $replacement ) { + $replacements[] = $replacement; + } + + foreach ( $column_replacements as $replacement ) { + $replacements[] = $replacement; + } + } + + foreach ( $context['join_predicate_replacements'] as $replacement ) { + $replacements[] = $replacement; + } + + foreach ( array_merge( $context['join_predicate_ranges'], $context['clause_ranges'] ) as $range ) { + $current_database_function_replacements = $this->get_direct_information_schema_current_database_function_replacements( + $tokens, + $range['start'], + $range['end'], + $nested_select_replacements + ); + if ( null === $current_database_function_replacements ) { + return null; + } + + $column_replacements = $this->get_direct_information_schema_column_replacements( + $tokens, + $range['start'], + $range['end'], + $context, + array_merge( $nested_select_replacements, $current_database_function_replacements ) + ); + if ( null === $column_replacements ) { + return null; + } + + $binary_operator_replacements = $this->get_direct_information_schema_binary_operator_replacements( + $tokens, + $range['start'], + $range['end'], + array_merge( $nested_select_replacements, $current_database_function_replacements, $column_replacements ) + ); + + foreach ( $current_database_function_replacements as $replacement ) { + $replacements[] = $replacement; + } + + foreach ( $column_replacements as $replacement ) { + $replacements[] = $replacement; + } + + foreach ( $binary_operator_replacements as $replacement ) { + $replacements[] = $replacement; + } + } + + $source_replacements = $this->get_direct_information_schema_source_replacements( $context ); + if ( null === $source_replacements ) { + return null; + } + + foreach ( array_merge( $source_replacements, $nested_select_replacements ) as $replacement ) { + $replacements[] = $replacement; + } + + usort( + $replacements, + static function ( array $left, array $right ): int { + return $left['start'] <=> $right['start']; + } + ); + + return 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 1, + $statement_end, + $replacements + ); + } + + /** + * Translate no-table SELECTs whose nested subqueries need information_schema routing. + * + * @param string $query MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $statement_end Final statement token position, exclusive. + * @return string|null PostgreSQL query, or null when no direct rewrite is needed or supported. + */ + private function translate_direct_information_schema_no_from_select_query( string $query, array $tokens, int $statement_end ): ?string { + if ( + null !== $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + 1, + $statement_end + ) + || ! $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, 0, $statement_end ) + ) { + return null; + } + + $nested_select_replacements = $this->get_direct_information_schema_nested_select_replacements( + $query, + $tokens, + array( + array( + 'start' => 1, + 'end' => $statement_end, + ), + ) + ); + if ( null === $nested_select_replacements || array() === $nested_select_replacements ) { + return null; + } + + if ( ! $this->direct_information_schema_nested_selects_are_covered( $tokens, 1, $statement_end, $nested_select_replacements ) ) { + return null; + } + + return 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 1, + $statement_end, + $nested_select_replacements + ); + } + + /** + * Translate direct information_schema UNION SELECT statements. + * + * @param string $query MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $statement_end Final statement token position, exclusive. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_direct_information_schema_union_select_query( string $query, array $tokens, int $statement_end ): ?string { + $segments = array(); + $operators = array(); + $position = 0; + $tail_start = $statement_end; + + while ( $position < $statement_end ) { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $union_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::UNION_SYMBOL, $position + 1, $statement_end ); + $select_end = $union_position ?? $this->get_direct_information_schema_union_tail_start( $tokens, $position + 1, $statement_end ); + if ( null === $union_position ) { + $tail_start = $select_end; + } + if ( + $this->contains_top_level_mysql_token( + $tokens, + $position + 1, + $select_end, + array( + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + ) + ) + ) { + return null; + } + + $select_query = $this->get_mysql_token_range_bytes( $query, $tokens, $position, $select_end ); + if ( '' === $select_query ) { + return null; + } + + $translated_select = $this->translate_direct_information_schema_select_query( $select_query ); + if ( null === $translated_select ) { + return null; + } + $segments[] = $translated_select; + + if ( null === $union_position ) { + break; + } + + $operator_position = $union_position + 1; + if ( ! isset( $tokens[ $operator_position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::ALL_SYMBOL === $tokens[ $operator_position ]->id ) { + $operators[] = 'UNION ALL'; + $position = $operator_position + 1; + } elseif ( WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $operator_position ]->id ) { + $operators[] = 'UNION'; + $position = $operator_position + 1; + } else { + $operators[] = 'UNION'; + $position = $operator_position; + } + } + + if ( count( $segments ) < 2 || count( $operators ) + 1 !== count( $segments ) ) { + return null; + } + + $sql = $segments[0]; + foreach ( $operators as $index => $operator ) { + $sql .= ' ' . $operator . ' ' . $segments[ $index + 1 ]; + } + + if ( $tail_start < $statement_end ) { + $columns = $this->get_direct_information_schema_union_select_output_columns( $query, $tokens, $tail_start ); + if ( null === $columns || array() === $columns ) { + return null; + } + + $tail_sql = $this->translate_direct_information_schema_union_tail_to_postgresql( + $tokens, + $tail_start, + $statement_end, + $columns + ); + if ( null === $tail_sql ) { + return null; + } + + $sql .= $tail_sql; + } + + return $sql; + } + + /** + * Get the start of a top-level ORDER BY/LIMIT tail for an information_schema UNION. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token to inspect. + * @param int $end Final token, exclusive. + * @return int Tail start, or $end when absent. + */ + private function get_direct_information_schema_union_tail_start( array $tokens, int $start, int $end ): int { + $tail_start = $end; + foreach ( array( WP_MySQL_Lexer::ORDER_SYMBOL, WP_MySQL_Lexer::LIMIT_SYMBOL ) as $token_id ) { + $position = $this->find_top_level_mysql_token( $tokens, $token_id, $start, $end ); + if ( null !== $position ) { + $tail_start = min( $tail_start, $position ); + } + } + + return $tail_start; + } + + /** + * Translate a top-level ORDER BY/LIMIT tail for an information_schema UNION. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First tail token. + * @param int $end Final tail token, exclusive. + * @param string[] $columns UNION output column names. + * @return string|null PostgreSQL tail SQL, or null when unsupported. + */ + private function translate_direct_information_schema_union_tail_to_postgresql( array $tokens, int $start, int $end, array $columns ): ?string { + $position = $start; + $sql = ''; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::ORDER_SYMBOL === $tokens[ $position ]->id ) { + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $position + 1, $end ); + $order_end = $limit_position ?? $end; + $order_sql = $this->translate_direct_information_schema_union_order_by_clause_to_postgresql( + $tokens, + $position, + $order_end, + $columns + ); + if ( null === $order_sql ) { + return null; + } + + $sql .= $order_sql; + $position = $order_end; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::LIMIT_SYMBOL === $tokens[ $position ]->id ) { + $limit_sql = $this->translate_simple_dml_limit_clause_to_postgresql( $tokens, $position, $end, true ); + if ( null === $limit_sql ) { + return null; + } + + $sql .= $limit_sql; + $position = $end; + } + + return $position === $end && '' !== $sql ? $sql : null; + } + + /** + * Translate an information_schema UNION ORDER BY clause. + * + * PostgreSQL UNION ORDER BY can only reference output columns. Keep this + * intentionally bounded to MySQL output aliases/ordinals and simple + * directions so unsupported expressions still fail explicitly. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start ORDER token position. + * @param int $end Final ORDER BY token, exclusive. + * @param string[] $columns UNION output column names. + * @return string|null PostgreSQL ORDER BY clause, or null when unsupported. + */ + private function translate_direct_information_schema_union_order_by_clause_to_postgresql( array $tokens, int $start, int $end, array $columns ): ?string { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || WP_MySQL_Lexer::BY_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + ) { + return null; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start + 2, $end ); + if ( null === $ranges || array() === $ranges ) { + return null; + } + + $column_lookup = array(); + foreach ( $columns as $column ) { + $column_lookup[ strtolower( $column ) ] = $column; + } + + $items = array(); + foreach ( $ranges as $range ) { + $item_start = $range['start']; + $item_end = $range['end']; + $direction = ''; + if ( + $item_start < $item_end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + || WP_MySQL_Lexer::DESC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + ) + ) { + $direction = ' ' . strtoupper( $tokens[ $item_end - 1 ]->get_bytes() ); + --$item_end; + } + + if ( $item_start >= $item_end ) { + return null; + } + + if ( + $item_start + 1 === $item_end + && isset( $tokens[ $item_start ] ) + && $this->is_mysql_unsigned_integer_token( $tokens[ $item_start ] ) + ) { + $ordinal = (int) $tokens[ $item_start ]->get_value(); + if ( $ordinal < 1 || $ordinal > count( $columns ) ) { + return null; + } + + $items[] = $tokens[ $item_start ]->get_bytes() . $direction; + continue; + } + + if ( $item_start + 1 !== $item_end || ! isset( $tokens[ $item_start ] ) ) { + return null; + } + + $column = $this->get_direct_information_schema_identifier_token_value( $tokens[ $item_start ] ); + if ( null === $column ) { + return null; + } + + $column_key = strtolower( $column ); + if ( ! isset( $column_lookup[ $column_key ] ) ) { + return null; + } + + $items[] = $this->connection->quote_identifier( $column_lookup[ $column_key ] ) . $direction; + } + + return empty( $items ) ? null : ' ORDER BY ' . implode( ', ', $items ); + } + + /** + * Get the direct information_schema SELECT context for supported sources. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $statement_end Final statement token position, exclusive. + * @return array{sources: array[], from_position: int, source_start: int, source_end: int, join_predicate_ranges: array[], join_predicate_replacements: array[], using_columns: array[], clause_ranges: array[]}|null Context, or null. + */ + private function get_direct_information_schema_select_context( string $query, array $tokens, int $statement_end, array $cte_sources = array() ): ?array { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + if ( null === $from_position || 1 === $from_position ) { + return null; + } + + $source_start = $from_position + 1; + $source_end = $this->find_direct_information_schema_source_end( $tokens, $source_start, $statement_end ); + if ( $source_start >= $source_end ) { + return null; + } + + $sources = $this->parse_direct_information_schema_select_sources( $query, $tokens, $source_start, $source_end, $cte_sources ); + if ( null === $sources ) { + return null; + } + + $clause_ranges = array(); + $clause_starts = array_filter( + array( + $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $source_end, $statement_end ), + $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, $source_end, $statement_end ), + $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::HAVING_SYMBOL, $source_end, $statement_end ), + $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $source_end, $statement_end ), + $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $source_end, $statement_end ), + ), + 'is_int' + ); + sort( $clause_starts ); + foreach ( $clause_starts as $index => $start ) { + $end = $clause_starts[ $index + 1 ] ?? $statement_end; + $clause_ranges[] = array( + 'start' => $start, + 'end' => $end, + ); + } + + return array( + 'sources' => $sources['sources'], + 'from_position' => $from_position, + 'source_start' => $source_start, + 'source_end' => $source_end, + 'join_predicate_ranges' => $sources['join_predicate_ranges'], + 'join_predicate_replacements' => $sources['join_predicate_replacements'], + 'using_columns' => $sources['using_columns'], + 'clause_ranges' => $clause_ranges, + ); + } + + /** + * Find the end of the information_schema FROM source range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First source token position. + * @param int $statement_end Final statement token position, exclusive. + * @return int Source end position, exclusive. + */ + private function find_direct_information_schema_source_end( array $tokens, int $start, int $statement_end ): int { + $source_end = $statement_end; + foreach ( + 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, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + ) as $token_id + ) { + $position = $this->find_top_level_mysql_token( $tokens, $token_id, $start, $statement_end ); + if ( null !== $position ) { + $source_end = min( $source_end, $position ); + } + } + + return $source_end; + } + + /** + * Parse supported direct information_schema FROM/JOIN sources. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First source token position. + * @param int $end Source end position, exclusive. + * @return array{sources: array[], join_predicate_ranges: array[], join_predicate_replacements: array[], using_columns: array[]}|null Parsed sources, or null. + */ + private function parse_direct_information_schema_select_sources( string $query, array $tokens, int $start, int $end, array $cte_sources = array() ): ?array { + if ( + 0 !== strcasecmp( $this->db_name, 'information_schema' ) + && ! $this->direct_information_schema_source_range_references_information_schema( $tokens, $start, $end ) + ) { + return null; + } + + $sources = array(); + $aliases = array(); + $join_predicate_ranges = array(); + $join_predicate_replacements = array(); + $using_columns = array(); + $position = $start; + + while ( $position < $end ) { + $source_start = $position; + $source = $this->parse_direct_information_schema_select_source( $query, $tokens, $position, $end, $cte_sources ); + if ( null === $source ) { + return null; + } + + $alias_key = strtolower( $source['alias'] ); + if ( isset( $aliases[ $alias_key ] ) ) { + return null; + } + + $columns = $source['columns'] ?? $this->get_direct_information_schema_relation_columns( $source['view'] ); + if ( null === $columns ) { + return null; + } + + $source['columns'] = $columns; + $source['column_map'] = $this->get_direct_information_schema_relation_column_map( $columns ); + $source['source_start'] = $source_start; + $source['source_end'] = $source['position']; + $sources[] = $source; + $aliases[ $alias_key ] = true; + $position = $source['position']; + + if ( count( $sources ) > 6 ) { + return null; + } + + $separator = $this->find_next_direct_information_schema_source_separator( $tokens, $position, $end ); + if ( null === $separator ) { + $predicate = $this->get_direct_information_schema_join_predicate_range_data( + $tokens, + $position, + $end, + $sources, + $using_columns + ); + if ( null === $predicate ) { + return null; + } + if ( isset( $predicate['range'] ) ) { + $join_predicate_ranges[] = $predicate['range']; + } + if ( isset( $predicate['replacement'] ) ) { + $join_predicate_replacements[] = $predicate['replacement']; + } + if ( isset( $predicate['using_columns'] ) ) { + $using_columns = $this->merge_direct_information_schema_using_columns( $using_columns, $predicate['using_columns'] ); + } + $position = $end; + break; + } + + if ( 'comma' === $separator['type'] ) { + if ( $position !== $separator['start'] ) { + return null; + } + + $position = $separator['source_start']; + continue; + } + + $predicate = $this->get_direct_information_schema_join_predicate_range_data( + $tokens, + $position, + $separator['start'], + $sources, + $using_columns + ); + if ( null === $predicate ) { + return null; + } + if ( isset( $predicate['range'] ) ) { + $join_predicate_ranges[] = $predicate['range']; + } + if ( isset( $predicate['replacement'] ) ) { + $join_predicate_replacements[] = $predicate['replacement']; + } + if ( isset( $predicate['using_columns'] ) ) { + $using_columns = $this->merge_direct_information_schema_using_columns( $using_columns, $predicate['using_columns'] ); + } + + $position = $separator['source_start']; + } + + if ( empty( $sources ) ) { + return null; + } + + if ( ! $this->direct_information_schema_sources_include_information_schema_relation( $sources ) ) { + return null; + } + + if ( count( $sources ) > 1 && ! $this->direct_information_schema_sources_are_joinable( $sources ) ) { + return null; + } + + return array( + 'sources' => $sources, + 'join_predicate_ranges' => $join_predicate_ranges, + 'join_predicate_replacements' => $join_predicate_replacements, + 'using_columns' => $using_columns, + ); + } + + /** + * Parse one supported direct information_schema source at the current token. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Source token position. + * @param int $end Source range end position, exclusive. + * @return array{view?: string, alias: string, position: int, relation_sql?: string, columns?: string[]}|null Parsed source, or null. + */ + private function parse_direct_information_schema_select_source( string $query, array $tokens, int $position, int $end, array $cte_sources = array() ): ?array { + if ( isset( $tokens[ $position ], $tokens[ $position + 1 ] ) && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + return $this->parse_direct_information_schema_derived_select_source( $query, $tokens, $position, $end ); + } + + $source_start = $position; + $first = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first ) { + return null; + } + + if ( isset( $cte_sources[ strtolower( $first ) ] ) ) { + return $this->parse_direct_information_schema_cte_select_source( $tokens, $position, $end, $cte_sources[ strtolower( $first ) ] ); + } + + ++$position; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id + ) { + if ( 0 !== strcasecmp( $first, 'information_schema' ) ) { + if ( + 0 === strcasecmp( $first, $this->main_db_name ) + || 0 === strcasecmp( $first, 'public' ) + ) { + return $this->parse_direct_information_schema_main_table_source( $tokens, $source_start, $end ); + } + + return null; + } + + $view = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position + 1 ] ); + if ( null === $view ) { + return null; + } + $position += 2; + } else { + if ( 0 !== strcasecmp( $this->db_name, 'information_schema' ) ) { + return $this->parse_direct_information_schema_main_table_source( $tokens, $source_start, $end ); + } + + $view = $first; + } + + $view = strtolower( $view ); + if ( null === $this->get_direct_information_schema_relation_columns( $view ) ) { + return null; + } + + $alias = $view; + if ( $position < $end ) { + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + + $parsed_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $parsed_alias ) { + return null; + } + + $alias = $parsed_alias; + ++$position; + } else { + $parsed_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $parsed_alias ) { + $alias = $parsed_alias; + ++$position; + } + } + } + + return array( + 'view' => $view, + 'alias' => $alias, + 'position' => $position, + ); + } + + /** + * Parse a CTE source used by a final direct information_schema SELECT. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Source token position. + * @param int $end Source range end position, exclusive. + * @param array $cte_source CTE source metadata. + * @return array{cte:string,alias:string,position:int,columns:string[]}|null Parsed source, or null. + */ + private function parse_direct_information_schema_cte_select_source( array $tokens, int $position, int $end, array $cte_source ): ?array { + if ( ! isset( $cte_source['name'], $cte_source['columns'] ) || ! is_array( $cte_source['columns'] ) ) { + return null; + } + + $cte_name = (string) $cte_source['name']; + $alias = $cte_name; + ++$position; + + if ( $position < $end ) { + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + + $parsed_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $parsed_alias ) { + return null; + } + + $alias = $parsed_alias; + ++$position; + } else { + $parsed_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $parsed_alias ) { + $alias = $parsed_alias; + ++$position; + } + } + } + + return array( + 'cte' => $cte_name, + 'alias' => $alias, + 'position' => $position, + 'columns' => $cte_source['columns'], + ); + } + + /** + * Parse a main-database table source used in a mixed information_schema SELECT. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Source token position. + * @param int $end Source range end position, exclusive. + * @return array{table:string,alias:string,position:int,columns:string[]}|null Parsed source, or null. + */ + private function parse_direct_information_schema_main_table_source( array $tokens, int $position, int $end ): ?array { + $reference = $this->parse_mysql_table_reference( $tokens, $position, $end ); + if ( + null === $reference + || ( + 0 !== strcasecmp( $reference['schema'], $this->main_db_name ) + && 0 !== strcasecmp( $reference['schema'], 'public' ) + ) + ) { + return null; + } + + $columns = $this->get_direct_information_schema_main_table_columns( $reference['table'] ); + if ( null === $columns ) { + return null; + } + + return array( + 'table' => $reference['table'], + 'alias' => null === $reference['alias'] ? $reference['table'] : $reference['alias'], + 'position' => $reference['position'], + 'columns' => $columns, + ); + } + + /** + * Get MySQL-facing column names for a main-database table source. + * + * @param string $table_name Table name. + * @return string[]|null Ordered column names, or null when unavailable. + */ + private function get_direct_information_schema_main_table_columns( string $table_name ): ?array { + $columns = array(); + foreach ( $this->get_mysql_dml_column_metadata( $table_name ) as $column ) { + if ( ! isset( $column['column_name'] ) ) { + return null; + } + $columns[] = (string) $column['column_name']; + } + + return empty( $columns ) ? null : $columns; + } + + /** + * Parse a derived information_schema SELECT source. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Source token position. + * @param int $end Source range end position, exclusive. + * @return array{alias:string,position:int,relation_sql:string,columns:string[]}|null Parsed source, or null. + */ + private function parse_direct_information_schema_derived_select_source( string $query, array $tokens, int $position, int $end ): ?array { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( + null === $after_close + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $select_start = $position + 1; + $select_end = $after_close - 1; + $select_query = $this->get_mysql_token_range_bytes( $query, $tokens, $select_start, $select_end ); + if ( '' === $select_query ) { + return null; + } + + $translated_select = $this->translate_direct_information_schema_select_query( $select_query ); + if ( null === $translated_select ) { + return null; + } + + $select_tokens = $this->get_mysql_tokens( $select_query ); + $select_statement_end = $this->get_mysql_statement_end_position( $select_tokens, 1 ); + if ( null === $select_statement_end ) { + return null; + } + + $columns = $this->get_direct_information_schema_select_or_union_output_columns( + $select_query, + $select_tokens, + $select_statement_end + ); + if ( null === $columns || array() === $columns ) { + return null; + } + + $position = $after_close; + $alias = 'derived'; + if ( $position < $end ) { + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $parsed_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $parsed_alias ) { + return null; + } + $alias = $parsed_alias; + ++$position; + } else { + $parsed_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $parsed_alias ) { + $alias = $parsed_alias; + ++$position; + } + } + } + + return array( + 'alias' => $alias, + 'position' => $position, + 'relation_sql' => $translated_select, + 'columns' => $columns, + ); + } + + /** + * Get output column names for a supported information_schema SELECT or UNION SELECT. + * + * @param string $query MySQL SELECT query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $statement_end Final statement token position, exclusive. + * @return string[]|null Output column names, or null when unsupported. + */ + private function get_direct_information_schema_select_or_union_output_columns( string $query, array $tokens, int $statement_end ): ?array { + if ( ! $this->contains_top_level_mysql_token( $tokens, 1, $statement_end, array( WP_MySQL_Lexer::UNION_SYMBOL ) ) ) { + $context = $this->get_direct_information_schema_select_context( $query, $tokens, $statement_end ); + return null === $context ? null : $this->get_direct_information_schema_select_output_columns( $tokens, $context ); + } + + return $this->get_direct_information_schema_union_select_output_columns( $query, $tokens, $statement_end ); + } + + /** + * Get output column names for a supported information_schema UNION SELECT. + * + * MySQL exposes the first SELECT branch's output names for a UNION result. + * Each branch still has to be a supported direct information_schema SELECT + * with the same number of columns. + * + * @param string $query MySQL UNION query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $statement_end Final statement token position, exclusive. + * @return string[]|null Output column names, or null when unsupported. + */ + private function get_direct_information_schema_union_select_output_columns( string $query, array $tokens, int $statement_end ): ?array { + $columns = null; + $position = 0; + $segment_count = 0; + + while ( $position < $statement_end ) { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $union_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::UNION_SYMBOL, $position + 1, $statement_end ); + $select_end = $union_position ?? $statement_end; + if ( + $this->contains_top_level_mysql_token( + $tokens, + $position + 1, + $select_end, + array( + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + ) + ) + ) { + return null; + } + + $select_query = $this->get_mysql_token_range_bytes( $query, $tokens, $position, $select_end ); + if ( '' === $select_query ) { + return null; + } + + $select_tokens = $this->get_mysql_tokens( $select_query ); + $select_statement_end = $this->get_mysql_statement_end_position( $select_tokens, 1 ); + if ( null === $select_statement_end ) { + return null; + } + + $context = $this->get_direct_information_schema_select_context( $select_query, $select_tokens, $select_statement_end ); + if ( null === $context ) { + return null; + } + + $branch_columns = $this->get_direct_information_schema_select_output_columns( $select_tokens, $context ); + if ( null === $branch_columns || array() === $branch_columns ) { + return null; + } + + if ( null === $columns ) { + $columns = $branch_columns; + } elseif ( count( $columns ) !== count( $branch_columns ) ) { + return null; + } + + ++$segment_count; + if ( null === $union_position ) { + break; + } + + $operator_position = $union_position + 1; + if ( ! isset( $tokens[ $operator_position ] ) ) { + return null; + } + + if ( + WP_MySQL_Lexer::ALL_SYMBOL === $tokens[ $operator_position ]->id + || WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $operator_position ]->id + ) { + $position = $operator_position + 1; + } else { + $position = $operator_position; + } + } + + return 2 <= $segment_count ? $columns : null; + } + + /** + * Build source replacement ranges for a direct information_schema context. + * + * @param array $context Direct information_schema SELECT context. + * @return array[]|null Replacement ranges, or null when a source is unsupported. + */ + private function get_direct_information_schema_source_replacements( array $context ): ?array { + $replacements = array(); + foreach ( $context['sources'] as $source ) { + if ( isset( $source['cte'] ) ) { + $replacements[] = array( + 'start' => $source['source_start'], + 'end' => $source['source_end'], + 'sql' => sprintf( + '%s AS %s', + $this->connection->quote_identifier( $source['cte'] ), + $this->connection->quote_identifier( $source['alias'] ) + ), + ); + continue; + } + + if ( isset( $source['table'] ) ) { + $replacements[] = array( + 'start' => $source['source_start'], + 'end' => $source['source_end'], + 'sql' => $this->get_postgresql_dml_table_reference_sql( $source['table'], $source['alias'] ), + ); + continue; + } + + $relation_sql = $source['relation_sql'] ?? $this->get_direct_information_schema_relation_sql( $source['view'] ?? '' ); + if ( null === $relation_sql ) { + return null; + } + + $replacements[] = array( + 'start' => $source['source_start'], + 'end' => $source['source_end'], + 'sql' => sprintf( + '(%s) AS %s', + $relation_sql, + $this->connection->quote_identifier( $source['alias'] ) + ), + ); + } + + return $replacements; + } + + /** + * Get output column names for a supported direct information_schema SELECT. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $context Direct information_schema SELECT context. + * @return string[]|null Output column names, or null when unsupported. + */ + private function get_direct_information_schema_select_output_columns( array $tokens, array $context ): ?array { + $projection_start = 1; + if ( isset( $tokens[ $projection_start ] ) && WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $projection_start ]->id ) { + ++$projection_start; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $context['from_position'] ); + if ( null === $ranges || array() === $ranges ) { + return null; + } + + $columns = array(); + foreach ( $ranges as $range ) { + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( $tokens, $range['start'], $range['end'] ); + if ( null === $expression_bounds ) { + return null; + } + + $star_columns = $this->get_direct_information_schema_star_projection_output_columns( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'], + $context + ); + if ( null !== $star_columns ) { + if ( null !== $this->get_mysql_select_projection_explicit_or_implicit_alias( $tokens, $range['start'], $range['end'] ) ) { + return null; + } + $columns = array_merge( $columns, $star_columns ); + continue; + } + + $alias = $this->get_mysql_select_projection_explicit_or_implicit_alias( $tokens, $range['start'], $range['end'] ); + if ( null !== $alias ) { + $columns[] = $alias; + continue; + } + + if ( $this->is_direct_information_schema_count_star_projection( $tokens, $expression_bounds['start'], $expression_bounds['end'] ) ) { + $columns[] = 'COUNT(*)'; + continue; + } + + $column = $this->get_direct_information_schema_projection_column_name( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'], + $context + ); + if ( null === $column ) { + return null; + } + $columns[] = $column; + } + + return $columns; + } + + /** + * Get an explicit or implicit SELECT projection alias. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token. + * @param int $end Final projection token, exclusive. + * @return string|null Alias, or null. + */ + private function get_mysql_select_projection_explicit_or_implicit_alias( array $tokens, int $start, int $end ): ?string { + $as_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $start, $end ); + if ( null !== $as_position ) { + if ( $as_position <= $start || $as_position + 2 !== $end ) { + return null; + } + + return $this->get_mysql_projection_alias_token_value( $tokens[ $as_position + 1 ] ?? null ); + } + + return $this->get_mysql_implicit_projection_alias( $tokens, $start, $end ); + } + + /** + * Get output columns for a star projection. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $context Direct information_schema SELECT context. + * @return string[]|null Output columns, or null when not a supported star. + */ + private function get_direct_information_schema_star_projection_output_columns( array $tokens, int $start, int $end, array $context ): ?array { + if ( $start + 1 === $end && isset( $tokens[ $start ] ) && '*' === $tokens[ $start ]->get_bytes() ) { + $columns = array(); + foreach ( $context['sources'] as $source ) { + $columns = array_merge( $columns, $source['columns'] ); + } + return $columns; + } + + if ( + $start + 3 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + && '*' === $tokens[ $start + 2 ]->get_bytes() + ) { + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + return null === $source ? null : $source['columns']; + } + + if ( + $start + 5 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ], $tokens[ $start + 4 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 3 ]->id + && '*' === $tokens[ $start + 4 ]->get_bytes() + ) { + $schema = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start ] ); + if ( null === $schema || 0 !== strcasecmp( $schema, 'information_schema' ) ) { + return null; + } + + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start + 2 ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + return null === $source ? null : $source['columns']; + } + + return null; + } + + /** + * Get the visible column name for a simple information_schema projection. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $context Direct information_schema SELECT context. + * @return string|null Output column name, or null. + */ + private function get_direct_information_schema_projection_column_name( array $tokens, int $start, int $end, array $context ): ?string { + if ( $start + 1 === $end && isset( $tokens[ $start ] ) ) { + return $this->get_direct_information_schema_unqualified_column_name( $tokens[ $start ], $context ); + } + + if ( + $start + 3 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + ) { + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + return null === $source ? null : $this->get_direct_information_schema_column_name_for_token( $tokens[ $start + 2 ], $source['column_map'] ); + } + + if ( + $start + 5 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ], $tokens[ $start + 4 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 3 ]->id + ) { + $schema = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start ] ); + if ( null === $schema || 0 !== strcasecmp( $schema, 'information_schema' ) ) { + return null; + } + + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start + 2 ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + return null === $source ? null : $this->get_direct_information_schema_column_name_for_token( $tokens[ $start + 4 ], $source['column_map'] ); + } + + return null; + } + + /** + * Find the next supported explicit source separator in a FROM source range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return array{type: string, start: int, source_start: int}|null Separator bounds, or null. + */ + private function find_next_direct_information_schema_source_separator( array $tokens, int $start, int $end ): ?array { + $depth = 0; + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 !== $depth ) { + continue; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + return array( + 'type' => 'comma', + 'start' => $position, + 'source_start' => $position + 1, + ); + } + + if ( WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $position ]->id ) { + return array( + 'type' => 'join', + 'start' => $position, + 'source_start' => $position + 1, + ); + } + + if ( + isset( $tokens[ $position + 1 ] ) + && ( + WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::CROSS_SYMBOL === $tokens[ $position ]->id + ) + && WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $position + 1 ]->id + ) { + return array( + 'type' => 'join', + 'start' => $position, + 'source_start' => $position + 2, + ); + } + + if ( + WP_MySQL_Lexer::LEFT_SYMBOL !== $tokens[ $position ]->id + && WP_MySQL_Lexer::RIGHT_SYMBOL !== $tokens[ $position ]->id + ) { + continue; + } + + $join_position = $position + 1; + if ( isset( $tokens[ $join_position ] ) && WP_MySQL_Lexer::OUTER_SYMBOL === $tokens[ $join_position ]->id ) { + ++$join_position; + } + + if ( isset( $tokens[ $join_position ] ) && WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $join_position ]->id ) { + return array( + 'type' => 'join', + 'start' => $position, + 'source_start' => $join_position + 1, + ); + } + } + + return null; + } + + /** + * Check whether a JOIN predicate range is simple enough for source rewriting. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether the range is supported. + */ + private function is_direct_information_schema_join_predicate_range_supported( array $tokens, int $start, int $end ): bool { + return null !== $this->get_direct_information_schema_join_predicate_range_data( $tokens, $start, $end, array(), array() ); + } + + /** + * Get validation and replacement data for a supported information_schema JOIN predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @param array[] $sources Parsed sources through the right-hand join source. + * @param array $using_columns Previously merged USING columns. + * @return array{range?: array{start:int,end:int}, replacement?: array{start:int,end:int,sql:string}, using_columns?: array}|null Predicate data, or null when unsupported. + */ + private function get_direct_information_schema_join_predicate_range_data( array $tokens, int $start, int $end, array $sources, array $using_columns ): ?array { + if ( $start === $end ) { + return array(); + } + + if ( + isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::USING_SYMBOL === $tokens[ $start ]->id + ) { + $using = $this->get_direct_information_schema_join_using_replacement( $tokens, $start, $end, $sources, $using_columns ); + if ( null === $using ) { + return null; + } + + return $using; + } + + if ( + ! isset( $tokens[ $start ] ) + || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $start ]->id + ) { + return null; + } + + $using = $this->get_direct_information_schema_join_on_using_replacement( $tokens, $start, $end, $sources, $using_columns ); + if ( null !== $using ) { + return $using; + } + + if ( $this->contains_mysql_token( + $tokens, + $start + 1, + $end, + array( + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) ) { + return null; + } + + return array( + 'range' => array( + 'start' => $start, + 'end' => $end, + ), + ); + } + + /** + * Get a PostgreSQL USING predicate replacement for an information_schema JOIN. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start USING token position. + * @param int $end Final token position, exclusive. + * @param array[] $sources Parsed sources through the right-hand join source. + * @param array $using_columns Previously merged USING columns. + * @return array{replacement: array{start:int,end:int,sql:string}, using_columns: array}|null Replacement data, or null when unsupported. + */ + private function get_direct_information_schema_join_using_replacement( array $tokens, int $start, int $end, array $sources, array $using_columns ): ?array { + if ( + count( $sources ) < 2 + || ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::USING_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return null; + } + + $current_source = $sources[ count( $sources ) - 1 ]; + $previous_sources = array_slice( $sources, 0, -1 ); + $position = $start + 2; + $columns = array(); + $seen_columns = array(); + $new_using = array(); + + while ( $position < $end && isset( $tokens[ $position ] ) ) { + $current_column = $this->get_direct_information_schema_column_name_for_token( $tokens[ $position ], $current_source['column_map'] ); + if ( null === $current_column ) { + return null; + } + + $column_key = strtolower( $current_column ); + if ( isset( $seen_columns[ $column_key ] ) ) { + return null; + } + $seen_columns[ $column_key ] = true; + + $previous_matches = array(); + foreach ( $previous_sources as $source ) { + if ( isset( $source['column_map'][ $column_key ] ) ) { + $previous_matches[] = $source; + } + } + + if ( empty( $previous_matches ) ) { + return null; + } + + $matched_aliases = array(); + foreach ( $previous_matches as $source ) { + $matched_aliases[ strtolower( $source['alias'] ) ] = true; + } + + if ( count( $previous_matches ) > 1 ) { + $merged_aliases = $using_columns[ $column_key ]['aliases'] ?? array(); + foreach ( $matched_aliases as $alias => $_ ) { + if ( ! isset( $merged_aliases[ $alias ] ) ) { + return null; + } + } + } + + $matched_aliases[ strtolower( $current_source['alias'] ) ] = true; + if ( isset( $using_columns[ $column_key ]['aliases'] ) ) { + $matched_aliases = array_merge( $using_columns[ $column_key ]['aliases'], $matched_aliases ); + } + + $new_using[ $column_key ] = array( + 'column' => $current_column, + 'aliases' => $matched_aliases, + ); + $columns[] = $this->connection->quote_identifier( $current_column ); + ++$position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + return $position === $end && ! empty( $columns ) + ? array( + 'replacement' => array( + 'start' => $start, + 'end' => $end, + 'sql' => 'USING (' . implode( ', ', $columns ) . ')', + ), + 'using_columns' => $new_using, + ) + : null; + } + + return null; + } + + return null; + } + + /** + * Get a PostgreSQL USING replacement for same-name information_schema ON predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start ON token position. + * @param int $end Final token position, exclusive. + * @param array[] $sources Parsed sources through the right-hand join source. + * @param array $using_columns Previously merged USING columns. + * @return array{replacement: array{start:int,end:int,sql:string}, using_columns: array}|null Replacement data, or null when unsupported. + */ + private function get_direct_information_schema_join_on_using_replacement( array $tokens, int $start, int $end, array $sources, array $using_columns ): ?array { + if ( + count( $sources ) < 2 + || ! isset( $tokens[ $start ] ) + || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $start ]->id + ) { + return null; + } + + $current_source = $sources[ count( $sources ) - 1 ]; + $current_alias = strtolower( $current_source['alias'] ); + $position = $start + 1; + $columns = array(); + $seen_columns = array(); + $new_using = array(); + + while ( $position < $end ) { + $left = $this->parse_direct_information_schema_qualified_column_reference( $tokens, $position, $end, $sources ); + if ( null === $left || ! isset( $tokens[ $left['end'] ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $left['end'] ]->id ) { + return null; + } + + $right = $this->parse_direct_information_schema_qualified_column_reference( $tokens, $left['end'] + 1, $end, $sources ); + if ( null === $right ) { + return null; + } + + $left_alias = strtolower( $left['source']['alias'] ); + $right_alias = strtolower( $right['source']['alias'] ); + if ( 0 !== strcasecmp( $left['column'], $right['column'] ) ) { + return null; + } + + if ( $left_alias === $current_alias && $right_alias !== $current_alias ) { + $previous_source = $right['source']; + $column = $left['column']; + } elseif ( $right_alias === $current_alias && $left_alias !== $current_alias ) { + $previous_source = $left['source']; + $column = $right['column']; + } else { + return null; + } + + $column_key = strtolower( $column ); + if ( isset( $seen_columns[ $column_key ] ) ) { + return null; + } + $seen_columns[ $column_key ] = true; + + $matched_aliases = array( + strtolower( $previous_source['alias'] ) => true, + $current_alias => true, + ); + if ( isset( $using_columns[ $column_key ]['aliases'] ) ) { + $matched_aliases = array_merge( $using_columns[ $column_key ]['aliases'], $matched_aliases ); + } + + $new_using[ $column_key ] = array( + 'column' => $column, + 'aliases' => $matched_aliases, + ); + $columns[] = $this->connection->quote_identifier( $column ); + $position = $right['end']; + + if ( $position === $end ) { + return ! empty( $columns ) + ? array( + 'replacement' => array( + 'start' => $start, + 'end' => $end, + 'sql' => 'USING (' . implode( ', ', $columns ) . ')', + ), + 'using_columns' => $new_using, + ) + : null; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + ++$position; + } + + return null; + } + + /** + * Parse a qualified information_schema source column reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Reference start position. + * @param int $end Final token position, exclusive. + * @param array[] $sources Parsed direct information_schema sources. + * @return array{source: array, column: string, end: int}|null Parsed reference, or null. + */ + private function parse_direct_information_schema_qualified_column_reference( array $tokens, int $position, int $end, array $sources ): ?array { + if ( + $position + 2 >= $end + || ! isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ); + if ( null === $qualifier ) { + return null; + } + + $source = $this->get_direct_information_schema_source_for_qualifier( + $qualifier, + array( + 'sources' => $sources, + ) + ); + if ( null === $source ) { + return null; + } + + $column = $this->get_direct_information_schema_column_name_for_token( $tokens[ $position + 2 ], $source['column_map'] ); + if ( null === $column ) { + return null; + } + + return array( + 'source' => $source, + 'column' => $column, + 'end' => $position + 3, + ); + } + + /** + * Merge newly parsed USING columns into the direct information_schema context. + * + * @param array $columns Existing USING column metadata. + * @param array $new_columns New USING column metadata. + * @return array Merged USING column metadata. + */ + private function merge_direct_information_schema_using_columns( array $columns, array $new_columns ): array { + foreach ( $new_columns as $key => $metadata ) { + if ( isset( $columns[ $key ] ) ) { + $metadata['aliases'] = array_merge( $columns[ $key ]['aliases'], $metadata['aliases'] ); + } + $columns[ $key ] = $metadata; + } + + return $columns; + } + + /** + * Check whether parsed sources include at least one information_schema relation. + * + * @param array[] $sources Parsed direct information_schema sources. + * @return bool Whether at least one source is a catalog source. + */ + private function direct_information_schema_sources_include_information_schema_relation( array $sources ): bool { + foreach ( $sources as $source ) { + if ( isset( $source['view'] ) || isset( $source['relation_sql'] ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a FROM source range directly names information_schema. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First source token. + * @param int $end Final source token, exclusive. + * @return bool Whether the source range directly references information_schema. + */ + private function direct_information_schema_source_range_references_information_schema( array $tokens, int $start, int $end ): bool { + for ( $position = $start; $position + 1 < $end; $position++ ) { + $identifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ?? null ); + if ( + null !== $identifier + && 0 === strcasecmp( $identifier, 'information_schema' ) + && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether direct information_schema sources are safe for a multi-source rewrite. + * + * @param array[] $sources Parsed direct information_schema sources. + * @return bool Whether the sources are safe to rewrite together. + */ + private function direct_information_schema_sources_are_joinable( array $sources ): bool { + $has_information_schema_source = false; + $main_table_count = 0; + + foreach ( $sources as $source ) { + if ( + isset( $source['relation_sql'] ) + || ( isset( $source['view'] ) && $this->is_direct_information_schema_join_relation( $source['view'] ) ) + ) { + $has_information_schema_source = true; + continue; + } + + if ( isset( $source['cte'] ) ) { + continue; + } + + if ( isset( $source['table'] ) ) { + ++$main_table_count; + if ( $main_table_count > 1 ) { + return false; + } + continue; + } + + return false; + } + + return $has_information_schema_source; + } + + /** + * Check whether a relation is allowed in multi-source direct information_schema rewrites. + * + * @param string $view Information schema view name. + * @return bool Whether the view may be used in a JOIN rewrite. + */ + private function is_direct_information_schema_join_relation( string $view ): bool { + return in_array( + strtolower( $view ), + array( + 'tables', + 'columns', + 'schemata', + 'statistics', + 'table_constraints', + 'key_column_usage', + 'referential_constraints', + 'check_constraints', + 'character_sets', + 'collations', + 'engines', + 'session_variables', + 'global_variables', + 'session_status', + 'global_status', + 'plugins', + 'user_privileges', + 'schema_privileges', + 'table_privileges', + 'column_privileges', + 'applicable_roles', + 'administrable_role_authorizations', + 'enabled_roles', + 'role_column_grants', + 'role_routine_grants', + 'role_table_grants', + 'processlist', + 'views', + 'triggers', + 'routines', + 'parameters', + ), + true + ); + } + + /** + * Get an identifier-ish token value for information_schema sources/columns. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Identifier value, or null. + */ + private function get_direct_information_schema_identifier_token_value( ?WP_MySQL_Token $token ): ?string { + if ( null === $token ) { + return null; + } + + $identifier = $this->get_mysql_identifier_token_value( $token ); + if ( null !== $identifier ) { + return $identifier; + } + + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::EOF, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + ), + true + ) + ) { + return null; + } + + $value = $token->get_value(); + return 1 === preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $value ) ? $value : null; + } + + /** + * Get MySQL information_schema columns for a supported relation. + * + * @param string $view Information schema view name. + * @return string[]|null Uppercase MySQL column names, or null. + */ + private function get_direct_information_schema_relation_columns( string $view ): ?array { + switch ( strtolower( $view ) ) { + case 'schemata': + return array( + 'CATALOG_NAME', + 'SCHEMA_NAME', + 'DEFAULT_CHARACTER_SET_NAME', + 'DEFAULT_COLLATION_NAME', + 'SQL_PATH', + 'DEFAULT_ENCRYPTION', + ); + + case 'tables': + return array( + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'TABLE_TYPE', + 'ENGINE', + 'VERSION', + 'ROW_FORMAT', + 'TABLE_ROWS', + 'AVG_ROW_LENGTH', + 'DATA_LENGTH', + 'MAX_DATA_LENGTH', + 'INDEX_LENGTH', + 'DATA_FREE', + 'AUTO_INCREMENT', + 'CREATE_TIME', + 'UPDATE_TIME', + 'CHECK_TIME', + 'TABLE_COLLATION', + 'CHECKSUM', + 'CREATE_OPTIONS', + 'TABLE_COMMENT', + ); + + case 'columns': + return array( + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'COLUMN_NAME', + 'ORDINAL_POSITION', + 'COLUMN_DEFAULT', + 'IS_NULLABLE', + 'DATA_TYPE', + 'CHARACTER_MAXIMUM_LENGTH', + 'CHARACTER_OCTET_LENGTH', + 'NUMERIC_PRECISION', + 'NUMERIC_SCALE', + 'DATETIME_PRECISION', + 'CHARACTER_SET_NAME', + 'COLLATION_NAME', + 'COLUMN_TYPE', + 'COLUMN_KEY', + 'EXTRA', + 'PRIVILEGES', + 'COLUMN_COMMENT', + 'GENERATION_EXPRESSION', + 'SRS_ID', + ); + + case 'statistics': + return array( + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'NON_UNIQUE', + 'INDEX_SCHEMA', + 'INDEX_NAME', + 'SEQ_IN_INDEX', + 'COLUMN_NAME', + 'COLLATION', + 'CARDINALITY', + 'SUB_PART', + 'PACKED', + 'NULLABLE', + 'INDEX_TYPE', + 'COMMENT', + 'INDEX_COMMENT', + 'IS_VISIBLE', + 'EXPRESSION', + ); + + case 'table_constraints': + return array( + 'CONSTRAINT_CATALOG', + 'CONSTRAINT_SCHEMA', + 'CONSTRAINT_NAME', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'CONSTRAINT_TYPE', + 'ENFORCED', + ); + + case 'key_column_usage': + return array( + 'CONSTRAINT_CATALOG', + 'CONSTRAINT_SCHEMA', + 'CONSTRAINT_NAME', + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'COLUMN_NAME', + 'ORDINAL_POSITION', + 'POSITION_IN_UNIQUE_CONSTRAINT', + 'REFERENCED_TABLE_SCHEMA', + 'REFERENCED_TABLE_NAME', + 'REFERENCED_COLUMN_NAME', + ); + + case 'referential_constraints': + return 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', + ); + + case 'check_constraints': + return array( + 'CONSTRAINT_CATALOG', + 'CONSTRAINT_SCHEMA', + 'CONSTRAINT_NAME', + 'CHECK_CLAUSE', + ); + + case 'character_sets': + return array( + 'CHARACTER_SET_NAME', + 'DEFAULT_COLLATE_NAME', + 'DESCRIPTION', + 'MAXLEN', + ); + + case 'collations': + return array( + 'COLLATION_NAME', + 'CHARACTER_SET_NAME', + 'ID', + 'IS_DEFAULT', + 'IS_COMPILED', + 'SORTLEN', + 'PAD_ATTRIBUTE', + ); + + case 'engines': + return array( + 'ENGINE', + 'SUPPORT', + 'COMMENT', + 'TRANSACTIONS', + 'XA', + 'SAVEPOINTS', + ); + + case 'session_variables': + case 'global_variables': + case 'session_status': + case 'global_status': + return array( + 'VARIABLE_NAME', + 'VARIABLE_VALUE', + ); + + case 'processlist': + return array( + 'ID', + 'USER', + 'HOST', + 'DB', + 'COMMAND', + 'TIME', + 'STATE', + 'INFO', + ); + + case 'plugins': + return array( + 'PLUGIN_NAME', + 'PLUGIN_VERSION', + 'PLUGIN_STATUS', + 'PLUGIN_TYPE', + 'PLUGIN_TYPE_VERSION', + 'PLUGIN_LIBRARY', + 'PLUGIN_LIBRARY_VERSION', + 'PLUGIN_AUTHOR', + 'PLUGIN_DESCRIPTION', + 'PLUGIN_LICENSE', + 'LOAD_OPTION', + ); + + case 'user_privileges': + return array( + 'GRANTEE', + 'TABLE_CATALOG', + 'PRIVILEGE_TYPE', + 'IS_GRANTABLE', + ); + + case 'schema_privileges': + return array( + 'GRANTEE', + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'PRIVILEGE_TYPE', + 'IS_GRANTABLE', + ); + + case 'table_privileges': + return array( + 'GRANTEE', + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'PRIVILEGE_TYPE', + 'IS_GRANTABLE', + ); + + case 'column_privileges': + return array( + 'GRANTEE', + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'COLUMN_NAME', + 'PRIVILEGE_TYPE', + 'IS_GRANTABLE', + ); + + case 'applicable_roles': + case 'administrable_role_authorizations': + return array( + 'USER', + 'HOST', + 'GRANTEE', + 'GRANTEE_HOST', + 'ROLE_NAME', + 'ROLE_HOST', + 'IS_GRANTABLE', + 'IS_DEFAULT', + 'IS_MANDATORY', + ); + + case 'enabled_roles': + return array( + 'ROLE_NAME', + 'ROLE_HOST', + 'IS_DEFAULT', + 'IS_MANDATORY', + ); + + case 'role_table_grants': + return array( + 'GRANTOR', + 'GRANTOR_HOST', + 'GRANTEE', + 'GRANTEE_HOST', + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'PRIVILEGE_TYPE', + 'IS_GRANTABLE', + ); + + case 'role_column_grants': + return array( + 'GRANTOR', + 'GRANTOR_HOST', + 'GRANTEE', + 'GRANTEE_HOST', + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'COLUMN_NAME', + 'PRIVILEGE_TYPE', + 'IS_GRANTABLE', + ); + + case 'role_routine_grants': + return array( + 'GRANTOR', + 'GRANTOR_HOST', + 'GRANTEE', + 'GRANTEE_HOST', + 'SPECIFIC_CATALOG', + 'SPECIFIC_SCHEMA', + 'SPECIFIC_NAME', + 'ROUTINE_CATALOG', + 'ROUTINE_SCHEMA', + 'ROUTINE_NAME', + 'PRIVILEGE_TYPE', + 'IS_GRANTABLE', + ); + + case 'views': + return array( + 'TABLE_CATALOG', + 'TABLE_SCHEMA', + 'TABLE_NAME', + 'VIEW_DEFINITION', + 'CHECK_OPTION', + 'IS_UPDATABLE', + 'DEFINER', + 'SECURITY_TYPE', + 'CHARACTER_SET_CLIENT', + 'COLLATION_CONNECTION', + ); + + case 'triggers': + return array( + 'TRIGGER_CATALOG', + 'TRIGGER_SCHEMA', + 'TRIGGER_NAME', + 'EVENT_MANIPULATION', + 'EVENT_OBJECT_CATALOG', + 'EVENT_OBJECT_SCHEMA', + 'EVENT_OBJECT_TABLE', + 'ACTION_ORDER', + 'ACTION_CONDITION', + 'ACTION_STATEMENT', + 'ACTION_ORIENTATION', + 'ACTION_TIMING', + 'ACTION_REFERENCE_OLD_TABLE', + 'ACTION_REFERENCE_NEW_TABLE', + 'ACTION_REFERENCE_OLD_ROW', + 'ACTION_REFERENCE_NEW_ROW', + 'CREATED', + 'SQL_MODE', + 'DEFINER', + 'CHARACTER_SET_CLIENT', + 'COLLATION_CONNECTION', + 'DATABASE_COLLATION', + ); + + case 'routines': + return array( + 'SPECIFIC_NAME', + 'ROUTINE_CATALOG', + 'ROUTINE_SCHEMA', + 'ROUTINE_NAME', + 'ROUTINE_TYPE', + 'DATA_TYPE', + 'CHARACTER_MAXIMUM_LENGTH', + 'CHARACTER_OCTET_LENGTH', + 'NUMERIC_PRECISION', + 'NUMERIC_SCALE', + 'DATETIME_PRECISION', + 'CHARACTER_SET_NAME', + 'COLLATION_NAME', + 'DTD_IDENTIFIER', + 'ROUTINE_BODY', + 'ROUTINE_DEFINITION', + 'EXTERNAL_NAME', + 'EXTERNAL_LANGUAGE', + 'PARAMETER_STYLE', + 'IS_DETERMINISTIC', + 'SQL_DATA_ACCESS', + 'SQL_PATH', + 'SECURITY_TYPE', + 'CREATED', + 'LAST_ALTERED', + 'SQL_MODE', + 'ROUTINE_COMMENT', + 'DEFINER', + 'CHARACTER_SET_CLIENT', + 'COLLATION_CONNECTION', + 'DATABASE_COLLATION', + ); + + case 'parameters': + return array( + 'SPECIFIC_CATALOG', + 'SPECIFIC_SCHEMA', + 'SPECIFIC_NAME', + 'ORDINAL_POSITION', + 'PARAMETER_MODE', + 'PARAMETER_NAME', + 'DATA_TYPE', + 'CHARACTER_MAXIMUM_LENGTH', + 'CHARACTER_OCTET_LENGTH', + 'NUMERIC_PRECISION', + 'NUMERIC_SCALE', + 'DATETIME_PRECISION', + 'CHARACTER_SET_NAME', + 'COLLATION_NAME', + 'DTD_IDENTIFIER', + 'ROUTINE_TYPE', + ); + } + + return null; + } + + /** + * Get a lookup map keyed by lowercase information_schema column name. + * + * @param string[] $columns Uppercase column names. + * @return array Column map. + */ + private function get_direct_information_schema_relation_column_map( array $columns ): array { + $map = array(); + foreach ( $columns as $column ) { + $map[ strtolower( $column ) ] = $column; + } + return $map; + } + + /** + * Build a MySQL-shaped SHOW CREATE TABLE statement for a supported information_schema view. + * + * @param string $view Information schema view name. + * @return string|null CREATE TABLE statement, or null when unsupported. + */ + private function get_direct_information_schema_create_table_statement( string $view ): ?string { + $columns = $this->get_direct_information_schema_relation_columns( $view ); + if ( null === $columns ) { + return null; + } + + $definitions = array(); + foreach ( $columns as $column ) { + $definitions[] = sprintf( + ' %s %s', + $this->quote_mysql_identifier( $column ), + $this->get_direct_information_schema_create_column_type( $column ) + ); + } + + return sprintf( + "CREATE TEMPORARY TABLE %s (\n%s\n) ENGINE=MEMORY DEFAULT CHARSET=%s COLLATE=%s", + $this->quote_mysql_identifier( strtolower( $view ) ), + implode( ",\n", $definitions ), + self::DEFAULT_MYSQL_CHARSET, + self::DEFAULT_MYSQL_COLLATION + ); + } + + /** + * Get a MySQL column definition type for an information_schema output column. + * + * @param string $column Uppercase information_schema column name. + * @return string MySQL column definition fragment. + */ + private function get_direct_information_schema_create_column_type( string $column ): string { + if ( + in_array( + $column, + array( + 'VERSION', + 'TABLE_ROWS', + 'AVG_ROW_LENGTH', + 'DATA_LENGTH', + 'MAX_DATA_LENGTH', + 'INDEX_LENGTH', + 'DATA_FREE', + 'AUTO_INCREMENT', + 'ORDINAL_POSITION', + 'CHARACTER_MAXIMUM_LENGTH', + 'CHARACTER_OCTET_LENGTH', + 'NUMERIC_PRECISION', + 'NUMERIC_SCALE', + 'DATETIME_PRECISION', + 'SRS_ID', + 'NON_UNIQUE', + 'SEQ_IN_INDEX', + 'CARDINALITY', + 'SUB_PART', + 'POSITION_IN_UNIQUE_CONSTRAINT', + 'MAXLEN', + 'ID', + 'SORTLEN', + 'TIME', + 'ACTION_ORDER', + ), + true + ) + ) { + return 'bigint DEFAULT NULL'; + } + + if ( in_array( $column, array( 'COLUMN_DEFAULT', 'CHECK_CLAUSE', 'GENERATION_EXPRESSION', 'EXPRESSION', 'VIEW_DEFINITION', 'ACTION_CONDITION', 'ACTION_STATEMENT', 'ROUTINE_DEFINITION' ), true ) ) { + return 'longtext DEFAULT NULL'; + } + + if ( in_array( $column, array( 'CREATE_TIME', 'UPDATE_TIME', 'CHECK_TIME', 'CREATED', 'LAST_ALTERED' ), true ) ) { + return 'datetime DEFAULT NULL'; + } + + return 'varchar(512) DEFAULT NULL'; + } + + /** + * Get relation SQL for a supported direct information_schema view. + * + * @param string $view Information schema view name. + * @return string|null Relation SQL, or null. + */ + private function get_direct_information_schema_relation_sql( string $view ): ?string { + switch ( strtolower( $view ) ) { + case 'schemata': + return $this->get_direct_information_schema_schemata_relation_sql(); + case 'tables': + return $this->get_direct_information_schema_tables_relation_sql(); + case 'columns': + return $this->get_direct_information_schema_columns_relation_sql(); + case 'statistics': + return $this->get_direct_information_schema_statistics_relation_sql(); + case 'table_constraints': + return $this->get_direct_information_schema_table_constraints_relation_sql(); + case 'key_column_usage': + return $this->get_direct_information_schema_key_column_usage_relation_sql(); + case 'referential_constraints': + return $this->get_direct_information_schema_referential_constraints_relation_sql(); + case 'check_constraints': + return $this->get_direct_information_schema_check_constraints_relation_sql(); + case 'character_sets': + return $this->get_direct_information_schema_character_sets_relation_sql(); + case 'collations': + return $this->get_direct_information_schema_collations_relation_sql(); + case 'engines': + return $this->get_direct_information_schema_engines_relation_sql(); + case 'session_variables': + case 'global_variables': + return $this->get_direct_information_schema_variables_relation_sql( + 'global_variables' === strtolower( $view ) ? 'global' : 'session' + ); + case 'session_status': + case 'global_status': + return $this->get_direct_information_schema_status_relation_sql(); + case 'processlist': + return $this->get_direct_information_schema_processlist_relation_sql(); + case 'plugins': + case 'user_privileges': + case 'schema_privileges': + case 'table_privileges': + case 'column_privileges': + case 'applicable_roles': + case 'administrable_role_authorizations': + case 'enabled_roles': + case 'role_column_grants': + case 'role_routine_grants': + case 'role_table_grants': + return $this->get_direct_information_schema_empty_relation_sql( $view ); + case 'views': + case 'triggers': + case 'routines': + case 'parameters': + return $this->get_direct_information_schema_empty_relation_sql( $view ); + } + + return null; + } + + /** + * Get an explicit SELECT list for a supported information_schema star. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $context Direct information_schema SELECT context. + * @return string|null SELECT list SQL, or null when the expression is not a rewritable star. + */ + private function get_direct_information_schema_star_projection_select_list( array $tokens, int $start, int $end, array $context ): ?string { + if ( $start + 1 === $end && isset( $tokens[ $start ] ) && '*' === $tokens[ $start ]->get_bytes() ) { + $select_lists = array(); + foreach ( $context['sources'] as $source ) { + $select_lists[] = $this->get_direct_information_schema_column_select_list( $source['columns'], $source['alias'] ); + } + return implode( ', ', $select_lists ); + } + + if ( + $start + 3 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + && '*' === $tokens[ $start + 2 ]->get_bytes() + ) { + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + return null === $source ? null : $this->get_direct_information_schema_column_select_list( $source['columns'], $source['alias'] ); + } + + if ( + $start + 5 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ], $tokens[ $start + 4 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 3 ]->id + && '*' === $tokens[ $start + 4 ]->get_bytes() + ) { + $schema = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start ] ); + if ( null === $schema || 0 !== strcasecmp( $schema, 'information_schema' ) ) { + return null; + } + + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $start + 2 ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + return null === $source ? null : $this->get_direct_information_schema_column_select_list( $source['columns'], $source['alias'] ); + } + + return null; + } + + /** + * Check whether a projection expression is unaliased COUNT(*). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether this is COUNT(*). + */ + private function is_direct_information_schema_count_star_projection( array $tokens, int $start, int $end ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + return $start + 4 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ] ) + && $this->is_mysql_token_value( $tokens[ $start ], 'count' ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start + 1 ]->id + && '*' === $tokens[ $start + 2 ]->get_bytes() + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $start + 3 ]->id; + } + + /** + * Get DATABASE()/SCHEMA() replacements for a direct information_schema range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token. + * @param int $end Final token, exclusive. + * @param array[] $protected_ranges Ranges already handled by larger replacements. + * @return array[]|null Replacement ranges, or null when a current-database function has unsupported arguments. + */ + private function get_direct_information_schema_current_database_function_replacements( array $tokens, int $start, int $end, array $protected_ranges = array() ): ?array { + $replacements = array(); + for ( $position = $start; $position < $end; $position++ ) { + $protected_end = $this->get_covering_mysql_replacement_range_end( $position, $protected_ranges ); + if ( null !== $protected_end ) { + $position = $protected_end - 1; + continue; + } + + $bounds = $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( null === $bounds || ! in_array( $bounds['function'], array( 'database', 'schema' ), true ) ) { + continue; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || array() !== $arguments ) { + return null; + } + + $replacements[] = array( + 'start' => $position, + 'end' => $bounds['close'] + 1, + 'sql' => $this->connection->quote( $this->db_name ), + ); + $position = $bounds['close']; + } + + return $replacements; + } + + /** + * Get column-reference replacements for a direct information_schema range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token. + * @param int $end Final token, exclusive. + * @param array $context Direct information_schema SELECT context. + * @return array[]|null Replacement ranges, or null when a source-qualified reference is unsupported. + */ + private function get_direct_information_schema_column_replacements( array $tokens, int $start, int $end, array $context, array $protected_ranges = array() ): ?array { + $replacements = array(); + for ( $position = $start; $position < $end; $position++ ) { + $protected_end = $this->get_covering_mysql_replacement_range_end( $position, $protected_ranges ); + if ( null !== $protected_end ) { + $position = $protected_end - 1; + continue; + } + + if ( + isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ], $tokens[ $position + 4 ] ) + && $position + 4 < $end + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 3 ]->id + ) { + $schema = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ); + if ( null !== $schema && 0 === strcasecmp( $schema, 'information_schema' ) ) { + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position + 2 ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + $column = null === $source ? null : $this->get_direct_information_schema_column_name_for_token( $tokens[ $position + 4 ], $source['column_map'] ); + if ( null === $source || null === $column ) { + return null; + } + + $replacements[] = array( + 'start' => $position, + 'end' => $position + 5, + 'sql' => $this->get_direct_information_schema_qualified_column_sql( $source, $column ), + ); + $position += 4; + continue; + } + } + + if ( + isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && $position + 2 < $end + && ( ! isset( $tokens[ $position - 1 ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position - 1 ]->id ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $qualifier = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ); + $source = null === $qualifier ? null : $this->get_direct_information_schema_source_for_qualifier( $qualifier, $context ); + if ( null !== $source ) { + $column = $this->get_direct_information_schema_column_name_for_token( $tokens[ $position + 2 ], $source['column_map'] ); + if ( null === $column ) { + return null; + } + + $replacements[] = array( + 'start' => $position, + 'end' => $position + 3, + 'sql' => $this->get_direct_information_schema_qualified_column_sql( $source, $column ), + ); + $position += 2; + continue; + } + } + + if ( + ( isset( $tokens[ $position - 1 ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position - 1 ]->id ) + || ( isset( $tokens[ $position + 1 ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id ) + ) { + continue; + } + + $column_sql = $this->get_direct_information_schema_unqualified_column_sql( $tokens[ $position ], $context ); + if ( false === $column_sql ) { + return null; + } + if ( null === $column_sql ) { + continue; + } + + $replacements[] = array( + 'start' => $position, + 'end' => $position + 1, + 'sql' => $column_sql, + ); + } + + return $replacements; + } + + /** + * Get safe unary BINARY operator replacements for direct information_schema ranges. + * + * PostgreSQL text comparisons are already byte-sensitive in these rewritten + * catalog relations. Strip only the standalone MySQL unary operator while + * leaving CAST(... AS BINARY) and CONVERT(..., BINARY) intact for their + * dedicated translators. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token. + * @param int $end Final token, exclusive. + * @param array[] $protected_ranges Ranges already handled by larger replacements. + * @return array[] Replacement ranges. + */ + private function get_direct_information_schema_binary_operator_replacements( array $tokens, int $start, int $end, array $protected_ranges = array() ): array { + $replacements = array(); + for ( $position = $start; $position < $end; $position++ ) { + $protected_end = $this->get_covering_mysql_replacement_range_end( $position, $protected_ranges ); + if ( null !== $protected_end ) { + $position = $protected_end - 1; + continue; + } + + if ( + ! isset( $tokens[ $position ] ) + || WP_MySQL_Lexer::BINARY_SYMBOL !== $tokens[ $position ]->id + || ! $this->is_direct_information_schema_unary_binary_operator_position( $tokens, $position, $start, $end ) + ) { + continue; + } + + $replacements[] = array( + 'start' => $position, + 'end' => $position + 1, + 'sql' => '', + ); + } + + return $replacements; + } + + /** + * Check whether BINARY is being used as a standalone unary operator. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position BINARY token position. + * @param int $start First token in the range. + * @param int $end Final token, exclusive. + * @return bool Whether the token can be stripped safely. + */ + private function is_direct_information_schema_unary_binary_operator_position( array $tokens, int $position, int $start, int $end ): bool { + if ( $position + 1 >= $end || ! isset( $tokens[ $position + 1 ] ) ) { + return false; + } + + $next_token_id = $tokens[ $position + 1 ]->id; + if ( + in_array( + $next_token_id, + array( + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::EOF, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + ), + true + ) + ) { + return false; + } + + if ( $position > $start && isset( $tokens[ $position - 1 ] ) ) { + $previous_token_id = $tokens[ $position - 1 ]->id; + if ( + in_array( + $previous_token_id, + array( + WP_MySQL_Lexer::AS_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::USING_SYMBOL, + ), + true + ) + ) { + return false; + } + + if ( + ! in_array( + $previous_token_id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::BETWEEN_SYMBOL, + WP_MySQL_Lexer::BY_SYMBOL, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_THAN_OPERATOR, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::LIKE_SYMBOL, + WP_MySQL_Lexer::NOT_SYMBOL, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::REGEXP_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + WP_MySQL_Lexer::XOR_SYMBOL, + ), + true + ) + ) { + return false; + } + } + + return true; + } + + /** + * Get the end of a replacement range that covers a token position. + * + * @param int $position Token position. + * @param array[] $replacements Replacement ranges. + * @return int|null Range end, or null. + */ + private function get_covering_mysql_replacement_range_end( int $position, array $replacements ): ?int { + foreach ( $replacements as $replacement ) { + if ( $position >= $replacement['start'] && $position < $replacement['end'] ) { + return $replacement['end']; + } + } + + return null; + } + + /** + * Get nested SELECT replacements for supported direct information_schema clause subqueries. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array[] $clause_ranges Clause token ranges. + * @return array[]|null Replacement ranges, or null when a nested SELECT is unsupported. + */ + private function get_direct_information_schema_nested_select_replacements( string $query, array $tokens, array $clause_ranges ): ?array { + $replacements = array(); + foreach ( $clause_ranges as $range ) { + for ( $position = $range['start']; $position < $range['end']; $position++ ) { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + continue; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $range['end'] ); + if ( null === $after_close ) { + return null; + } + + $select_start = $position + 1; + $select_end = $after_close - 1; + $select_query = $this->get_mysql_token_range_bytes( $query, $tokens, $select_start, $select_end ); + if ( '' === $select_query ) { + return null; + } + + $translated_select = $this->translate_direct_information_schema_select_query( $select_query ); + if ( null === $translated_select ) { + if ( $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, $select_start, $select_end ) ) { + return null; + } + + $translated_select = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $select_start, $select_end ); + } + + $replacements[] = array( + 'start' => $select_start, + 'end' => $select_end, + 'sql' => $translated_select, + ); + $position = $after_close - 1; + } + } + + return $replacements; + } + + /** + * Check whether every nested SELECT is covered by a complete replacement range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @param array[] $replacements Replacement ranges. + * @return bool Whether nested SELECTs are fully handled. + */ + private function direct_information_schema_nested_selects_are_covered( array $tokens, int $start, int $end, array $replacements ): bool { + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + if ( null === $this->get_covering_mysql_replacement_range_end( $position, $replacements ) ) { + return false; + } + } + + return true; + } + + /** + * Get direct information_schema nested SELECT replacements for simple DML predicates. + * + * Simple application-table UPDATE/DELETE predicates can safely read supported + * information_schema subqueries, but ordinary application-table subqueries + * should remain unsupported until they have a deliberate translation path. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token position. + * @param int $end Final predicate token position, exclusive. + * @param array $cte_names Known CTE names keyed lowercase. + * @return array[]|null Replacement ranges, empty when no nested SELECT is present, or null when unsupported. + */ + private function get_simple_mysql_dml_predicate_nested_select_replacements( string $query, array $tokens, int $start, int $end, array $cte_names = array() ): ?array { + $has_nested_select = false; + $has_information_schema_select = false; + $cte_replacements = array(); + for ( $position = $start; $position < $end; $position++ ) { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + continue; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close ) { + return null; + } + + $select_start = $position + 1; + $select_end = $after_close - 1; + if ( $this->mysql_select_range_requires_direct_information_schema_rewrite( $tokens, $select_start, $select_end ) ) { + $has_information_schema_select = true; + } elseif ( $this->mysql_select_range_references_only_cte_sources( $tokens, $select_start, $select_end, $cte_names ) ) { + $cte_replacements[] = array( + 'start' => $position, + 'end' => $after_close, + 'sql' => '(' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $select_start, $select_end ) . ')', + ); + } else { + return null; + } + + $has_nested_select = true; + $position = $after_close - 1; + } + + if ( ! $has_nested_select ) { + return array(); + } + + if ( $has_information_schema_select && ! empty( $cte_replacements ) ) { + return null; + } + + $replacements = $cte_replacements; + if ( $has_information_schema_select ) { + $information_schema_replacements = $this->get_direct_information_schema_nested_select_replacements( + $query, + $tokens, + array( + array( + 'start' => $start, + 'end' => $end, + ), + ) + ); + if ( + null === $information_schema_replacements + || ! $this->direct_information_schema_nested_selects_are_covered( + $tokens, + $start, + $end, + array_merge( $information_schema_replacements, $cte_replacements ) + ) + ) { + return null; + } + + $replacements = array_merge( $replacements, $information_schema_replacements ); + } elseif ( ! $this->direct_information_schema_nested_selects_are_covered( $tokens, $start, $end, $cte_replacements ) ) { + return null; + } + + usort( + $replacements, + static function ( array $left, array $right ): int { + return $left['start'] <=> $right['start']; + } + ); + + return $replacements; + } + + /** + * Check whether a SELECT range reads only CTE sources declared by the statement prefix. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start SELECT token position. + * @param int $end Final SELECT token position, exclusive. + * @param array $cte_names Known CTE names keyed lowercase. + * @return bool Whether all top-level FROM sources are known CTEs. + */ + private function mysql_select_range_references_only_cte_sources( array $tokens, int $start, int $end, array $cte_names ): bool { + if ( empty( $cte_names ) || ! isset( $tokens[ $start ] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $start ]->id ) { + return false; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $start + 1, $end ); + if ( null === $from_position ) { + return false; + } + + $from_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + $from_position + 1, + $end + ) ?? $end; + + $position = $from_position + 1; + $expect_source = true; + $source_count = 0; + while ( $position < $from_end ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + return false; + } + + if ( $expect_source ) { + $reference = $this->parse_mysql_table_reference( $tokens, $position, $from_end ); + if ( + null === $reference + || 'public' !== $reference['schema'] + || ! isset( $cte_names[ strtolower( $reference['table'] ) ] ) + ) { + return false; + } + + $position = $reference['position']; + $expect_source = false; + ++$source_count; + continue; + } + + if ( + WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id + || $this->is_mysql_join_token( $tokens[ $position ] ) + ) { + $expect_source = true; + } + + ++$position; + } + + return $source_count > 0 && ! $expect_source; + } + + /** + * Get a direct information_schema source for a qualifier. + * + * @param string $qualifier Qualifier token value. + * @param array $context Direct information_schema SELECT context. + * @return array|null Source, or null when the qualifier is not unique. + */ + private function get_direct_information_schema_source_for_qualifier( string $qualifier, array $context ): ?array { + $matches = array(); + foreach ( $context['sources'] as $source ) { + if ( 0 === strcasecmp( $qualifier, $source['alias'] ) ) { + return $source; + } + + if ( isset( $source['view'] ) && 0 === strcasecmp( $qualifier, $source['view'] ) ) { + $matches[] = $source; + } + } + + return 1 === count( $matches ) ? $matches[0] : null; + } + + /** + * Get SQL for an unqualified information_schema column token. + * + * @param WP_MySQL_Token $token MySQL token. + * @param array $context Direct information_schema SELECT context. + * @return string|false|null SQL, false when ambiguous, or null when not a known column. + */ + private function get_direct_information_schema_unqualified_column_sql( WP_MySQL_Token $token, array $context ) { + $column = $this->get_direct_information_schema_unqualified_column_name( $token, $context ); + if ( false === $column ) { + return false; + } + if ( null === $column ) { + return null; + } + + if ( isset( $context['using_columns'][ strtolower( $column ) ] ) ) { + return $this->connection->quote_identifier( $column ); + } + + if ( 1 === count( $context['sources'] ) ) { + return $this->connection->quote_identifier( $column ); + } + + foreach ( $context['sources'] as $source ) { + if ( isset( $source['column_map'][ strtolower( $column ) ] ) ) { + return $this->get_direct_information_schema_qualified_column_sql( $source, $column ); + } + } + + return false; + } + + /** + * Get an unqualified information_schema column name. + * + * @param WP_MySQL_Token $token MySQL token. + * @param array $context Direct information_schema SELECT context. + * @return string|false|null Column name, false when ambiguous, or null when unknown. + */ + private function get_direct_information_schema_unqualified_column_name( WP_MySQL_Token $token, array $context ) { + $value = $this->get_direct_information_schema_identifier_token_value( $token ); + if ( null === $value ) { + return null; + } + + $matches = array(); + $key = strtolower( $value ); + foreach ( $context['sources'] as $source ) { + if ( isset( $source['column_map'][ $key ] ) ) { + $matches[] = array( + 'source' => $source, + 'column' => $source['column_map'][ $key ], + ); + } + } + + if ( 0 === count( $matches ) ) { + return null; + } + + if ( count( $matches ) > 1 ) { + $using_column = $context['using_columns'][ $key ] ?? null; + if ( is_array( $using_column ) && isset( $using_column['column'], $using_column['aliases'] ) ) { + foreach ( $matches as $match ) { + if ( ! isset( $using_column['aliases'][ strtolower( $match['source']['alias'] ) ] ) ) { + return false; + } + } + + return $using_column['column']; + } + + return false; + } + + return $matches[0]['column']; + } + + /** + * Get SQL for a source-qualified information_schema column. + * + * @param array $source Direct information_schema source. + * @param string $column Uppercase column name. + * @return string SQL expression. + */ + private function get_direct_information_schema_qualified_column_sql( array $source, string $column ): string { + return $this->connection->quote_identifier( $source['alias'] ) . '.' . $this->connection->quote_identifier( $column ); + } + + /** + * Get a supported information_schema column name for a token. + * + * @param WP_MySQL_Token $token MySQL token. + * @param array $column_map Supported columns keyed by lowercase name. + * @return string|null Uppercase column name, or null. + */ + private function get_direct_information_schema_column_name_for_token( WP_MySQL_Token $token, array $column_map ): ?string { + $value = $this->get_direct_information_schema_identifier_token_value( $token ); + if ( null === $value ) { + return null; + } + + return $column_map[ strtolower( $value ) ] ?? null; + } + + /** + * Build an explicit SELECT list for information_schema star expansion. + * + * @param string[] $columns Uppercase column names. + * @param string $alias Relation alias. + * @return string SELECT list SQL. + */ + private function get_direct_information_schema_column_select_list( array $columns, string $alias ): string { + $select = array(); + foreach ( $columns as $column ) { + $select[] = $this->connection->quote_identifier( $alias ) . '.' . $this->connection->quote_identifier( $column ) . ' AS ' . $this->connection->quote_identifier( $column ); + } + return implode( ', ', $select ); + } + + /** + * Build a literal relation from PHP-computed information_schema rows. + * + * @param string[] $columns Uppercase column names. + * @param array[] $rows Rows keyed by uppercase column name. + * @return string Relation SQL. + */ + private function get_direct_information_schema_literal_relation_sql( array $columns, array $rows ): string { + if ( empty( $rows ) ) { + $select = array(); + foreach ( $columns as $column ) { + $select[] = 'NULL AS ' . $this->connection->quote_identifier( $column ); + } + return 'SELECT ' . implode( ', ', $select ) . ' WHERE 1 = 0'; + } + + $selects = array(); + foreach ( $rows as $row ) { + $select = array(); + foreach ( $columns as $column ) { + $select[] = $this->get_direct_information_schema_literal_sql( $row[ $column ] ?? null ) . ' AS ' . $this->connection->quote_identifier( $column ); + } + $selects[] = 'SELECT ' . implode( ', ', $select ); + } + + return implode( ' UNION ALL ', $selects ); + } + + /** + * Convert a PHP value into relation-literal SQL. + * + * @param mixed $value Value. + * @return string SQL literal. + */ + private function get_direct_information_schema_literal_sql( $value ): string { + if ( null === $value ) { + return 'NULL'; + } + + if ( is_int( $value ) || is_float( $value ) || ( is_string( $value ) && 1 === preg_match( '/^-?[0-9]+(?:\.[0-9]+)?$/', $value ) ) ) { + return (string) $value; + } + + return $this->connection->quote( (string) $value ); + } + + /** + * Get MySQL-facing display schema SQL for a backend schema expression. + * + * @param string $schema_sql SQL expression returning a backend schema. + * @return string SQL expression returning a MySQL-facing schema. + */ + private function get_direct_information_schema_display_schema_sql( string $schema_sql ): string { + return sprintf( + 'CASE WHEN %1$s = %2$s THEN %3$s ELSE %1$s END', + $schema_sql, + $this->connection->quote( 'public' ), + $this->connection->quote( $this->main_db_name ) + ); + } + + /** + * Get MySQL-facing display schema for a backend schema value. + * + * @param string $schema Backend schema. + * @return string MySQL-facing schema. + */ + private function get_direct_information_schema_display_schema( string $schema ): string { + return 0 === strcasecmp( $schema, 'public' ) ? $this->main_db_name : $schema; + } + + /** + * Get hidden PostgreSQL metadata table names. + * + * @return string[] Table names. + */ + private function get_direct_information_schema_hidden_table_names(): array { + return array( + self::MYSQL_COLUMN_METADATA_TABLE, + self::MYSQL_INDEX_METADATA_TABLE, + self::MYSQL_FOREIGN_KEY_METADATA_TABLE, + self::MYSQL_CHECK_METADATA_TABLE, + self::MYSQL_CHARSET_METADATA_TABLE, + self::MYSQL_TABLE_METADATA_TABLE, + ); + } + + /** + * Get SQL for the hidden metadata table exclusion list. + * + * @return string SQL literal list. + */ + private function get_direct_information_schema_hidden_table_list_sql(): string { + return implode( + ', ', + array_map( + array( $this->connection, 'quote' ), + $this->get_direct_information_schema_hidden_table_names() + ) + ); + } + + /** + * Build the MySQL-shaped information_schema.SCHEMATA relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_schemata_relation_sql(): string { + $columns = $this->get_direct_information_schema_relation_columns( 'schemata' ); + return $this->get_direct_information_schema_literal_relation_sql( + $columns, + array( + array( + 'CATALOG_NAME' => 'def', + 'SCHEMA_NAME' => 'information_schema', + 'DEFAULT_CHARACTER_SET_NAME' => self::DEFAULT_MYSQL_CHARSET, + 'DEFAULT_COLLATION_NAME' => self::DEFAULT_MYSQL_COLLATION, + 'SQL_PATH' => null, + 'DEFAULT_ENCRYPTION' => 'NO', + ), + array( + 'CATALOG_NAME' => 'def', + 'SCHEMA_NAME' => $this->main_db_name, + 'DEFAULT_CHARACTER_SET_NAME' => self::DEFAULT_MYSQL_CHARSET, + 'DEFAULT_COLLATION_NAME' => self::DEFAULT_MYSQL_COLLATION, + 'SQL_PATH' => null, + 'DEFAULT_ENCRYPTION' => 'NO', + ), + ) + ); + } + + /** + * Build the MySQL-shaped information_schema.CHARACTER_SETS relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_character_sets_relation_sql(): string { + return $this->get_direct_information_schema_literal_relation_sql( + $this->get_direct_information_schema_relation_columns( 'character_sets' ), + $this->get_mysql_static_character_set_rows() + ); + } + + /** + * Build the MySQL-shaped information_schema.COLLATIONS relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_collations_relation_sql(): string { + return $this->get_direct_information_schema_literal_relation_sql( + $this->get_direct_information_schema_relation_columns( 'collations' ), + $this->get_mysql_static_collation_rows() + ); + } + + /** + * Build the MySQL-shaped information_schema.ENGINES relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_engines_relation_sql(): string { + $rows = array(); + foreach ( $this->get_mysql_static_show_engine_rows() as $row ) { + $rows[] = array( + 'ENGINE' => $row['Engine'], + 'SUPPORT' => $row['Support'], + 'COMMENT' => $row['Comment'], + 'TRANSACTIONS' => $row['Transactions'], + 'XA' => $row['XA'], + 'SAVEPOINTS' => $row['Savepoints'], + ); + } + + return $this->get_direct_information_schema_literal_relation_sql( + $this->get_direct_information_schema_relation_columns( 'engines' ), + $rows + ); + } + + /** + * Build the MySQL-shaped information_schema.SESSION_VARIABLES/GLOBAL_VARIABLES relation. + * + * @param string $scope Variable scope. + * @return string Relation SQL. + */ + private function get_direct_information_schema_variables_relation_sql( string $scope ): string { + $rows = array(); + $variables = 'global' === $scope ? $this->get_mysql_global_variables() : $this->get_mysql_session_variables(); + foreach ( $variables as $name => $value ) { + $rows[] = array( + 'VARIABLE_NAME' => $name, + 'VARIABLE_VALUE' => $value, + ); + } + + return $this->get_direct_information_schema_literal_relation_sql( + $this->get_direct_information_schema_relation_columns( 'session_variables' ), + $rows + ); + } + + /** + * Build the MySQL-shaped information_schema.SESSION_STATUS/GLOBAL_STATUS relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_status_relation_sql(): string { + $rows = array(); + foreach ( $this->get_mysql_status_variables() as $name => $value ) { + $rows[] = array( + 'VARIABLE_NAME' => $name, + 'VARIABLE_VALUE' => $value, + ); + } + + return $this->get_direct_information_schema_literal_relation_sql( + $this->get_direct_information_schema_relation_columns( 'session_status' ), + $rows + ); + } + + /** + * Build the MySQL-shaped information_schema.PROCESSLIST relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_processlist_relation_sql(): string { + return $this->get_direct_information_schema_literal_relation_sql( + $this->get_direct_information_schema_relation_columns( 'processlist' ), + array( + array( + 'ID' => '1', + 'USER' => 'root', + 'HOST' => 'localhost', + 'DB' => $this->db_name, + 'COMMAND' => 'Query', + 'TIME' => '0', + 'STATE' => '', + 'INFO' => (string) $this->last_mysql_query, + ), + ) + ); + } + + /** + * Build an empty MySQL-shaped information_schema relation. + * + * @param string $view information_schema relation name. + * @return string Relation SQL. + */ + private function get_direct_information_schema_empty_relation_sql( string $view ): string { + return $this->get_direct_information_schema_literal_relation_sql( + $this->get_direct_information_schema_relation_columns( $view ), + array() + ); + } + + /** + * Build the MySQL-shaped information_schema.TABLES relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_tables_relation_sql(): string { + $columns = $this->get_direct_information_schema_relation_columns( 'tables' ); + return $this->get_direct_information_schema_literal_relation_sql( + $columns, + $this->get_direct_information_schema_table_rows() + ); + } + + /** + * Get MySQL-shaped information_schema.TABLES rows. + * + * @return array[] Rows keyed by uppercase column name. + */ + private function get_direct_information_schema_table_rows(): array { + $this->ensure_mysql_schema_metadata_tables(); + + $sql = sprintf( + 'SELECT + t.table_schema, + t.table_name, + t.table_type, + COALESCE(tm.table_comment, \'\') AS table_comment, + ( + SELECT c.column_name + FROM information_schema.columns c + WHERE c.table_schema = t.table_schema + AND c.table_name = t.table_name + AND ( + c.is_identity = \'YES\' + OR LOWER(COALESCE(c.column_default, \'\')) LIKE \'nextval(%%\' + ) + ORDER BY c.ordinal_position + LIMIT 1 + ) AS identity_column + FROM information_schema.tables t + LEFT JOIN %s tm + ON tm.table_schema = t.table_schema + AND tm.table_name = t.table_name + WHERE t.table_schema NOT IN (\'information_schema\', \'pg_catalog\') + AND t.table_type IN (\'BASE TABLE\', \'VIEW\') + AND t.table_name NOT IN (' . $this->get_direct_information_schema_hidden_table_list_sql() . ') + ORDER BY t.table_schema, t.table_name', + $this->connection->quote_identifier( self::MYSQL_TABLE_METADATA_TABLE ) + ); + + try { + $stmt = $this->connection->query( $sql ); + } catch ( PDOException $e ) { + return array(); + } + + $rows = array(); + $create_time = gmdate( 'Y-m-d H:i:s' ); + foreach ( $stmt->fetchAll( PDO::FETCH_ASSOC ) as $row ) { + $table_schema = (string) $row['table_schema']; + $table_name = (string) $row['table_name']; + $table_type = 'VIEW' === strtoupper( (string) $row['table_type'] ) ? 'VIEW' : 'BASE TABLE'; + $identity_column = null === $row['identity_column'] ? null : (string) $row['identity_column']; + $auto_increment = null; + + if ( null !== $identity_column ) { + try { + $auto_increment = $this->get_show_table_status_auto_increment_value( $table_name, $identity_column, $table_schema ); + } catch ( PDOException $e ) { + $auto_increment = null; + } + } + + $rows[] = array( + 'TABLE_CATALOG' => 'def', + 'TABLE_SCHEMA' => $this->get_direct_information_schema_display_schema( $table_schema ), + 'TABLE_NAME' => $table_name, + 'TABLE_TYPE' => $table_type, + 'ENGINE' => 'InnoDB', + 'VERSION' => 10, + 'ROW_FORMAT' => 'Dynamic', + 'TABLE_ROWS' => $this->get_direct_information_schema_table_row_count( $table_schema, $table_name ), + 'AVG_ROW_LENGTH' => 0, + 'DATA_LENGTH' => 0, + 'MAX_DATA_LENGTH' => 0, + 'INDEX_LENGTH' => 0, + 'DATA_FREE' => 0, + 'AUTO_INCREMENT' => $auto_increment, + 'CREATE_TIME' => $create_time, + 'UPDATE_TIME' => null, + 'CHECK_TIME' => null, + 'TABLE_COLLATION' => $this->collation, + 'CHECKSUM' => null, + 'CREATE_OPTIONS' => '', + 'TABLE_COMMENT' => (string) ( $row['table_comment'] ?? '' ), + ); + } + + return $rows; + } + + /** + * Count rows for an information_schema.TABLES row. + * + * @param string $table_schema Backend schema. + * @param string $table_name Table name. + * @return int Row count, or zero when unavailable. + */ + private function get_direct_information_schema_table_row_count( string $table_schema, string $table_name ): int { + try { + $stmt = $this->connection->query( + 'SELECT COUNT(*) FROM ' . $this->get_postgresql_schema_identifier( $table_schema, $table_name ) + ); + return (int) $stmt->fetchColumn(); + } catch ( PDOException $e ) { + return 0; + } + } + + /** + * Build the MySQL-shaped information_schema.COLUMNS relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_columns_relation_sql(): string { + $this->ensure_mysql_schema_metadata_tables(); + + $column_metadata_table = $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ); + $type_expression = $this->get_direct_information_schema_catalog_data_type_expression( 'c' ); + $column_type = $this->get_direct_information_schema_column_type_expression( 'c', 'cm' ); + $data_type = $this->get_direct_information_schema_metadata_data_type_expression( 'cm.column_type', $type_expression ); + $charset = $this->get_direct_information_schema_character_set_expression( $column_type, 'cm.character_set_name' ); + $collation = $this->get_direct_information_schema_collation_expression( $column_type, 'COALESCE(cm.collation_name, c.collation_name)' ); + $column_key = $this->get_direct_information_schema_column_key_expression( 'c.table_schema', 'c.table_name', 'c.column_name' ); + $metadata_type = $this->get_direct_information_schema_metadata_data_type_expression( 'cm.column_type', 'cm.column_type' ); + $metadata_charset = $this->get_direct_information_schema_character_set_expression( 'cm.column_type', 'cm.character_set_name' ); + $metadata_collation = $this->get_direct_information_schema_collation_expression( 'cm.column_type', 'cm.collation_name' ); + $metadata_key = $this->get_direct_information_schema_column_key_expression( 'cm.table_schema', 'cm.table_name', 'cm.column_name' ); + + return sprintf( + 'WITH catalog_columns AS ( + SELECT + \'def\' AS "TABLE_CATALOG", + %1$s AS "TABLE_SCHEMA", + c.table_name AS "TABLE_NAME", + c.column_name AS "COLUMN_NAME", + c.ordinal_position AS "ORDINAL_POSITION", + CASE WHEN cm.column_name IS NOT NULL THEN cm.column_default ELSE c.column_default END AS "COLUMN_DEFAULT", + COALESCE(cm.is_nullable, c.is_nullable) AS "IS_NULLABLE", + %2$s AS "DATA_TYPE", + c.character_maximum_length AS "CHARACTER_MAXIMUM_LENGTH", + CASE WHEN c.character_maximum_length IS NULL THEN NULL ELSE c.character_maximum_length * 4 END AS "CHARACTER_OCTET_LENGTH", + c.numeric_precision AS "NUMERIC_PRECISION", + c.numeric_scale AS "NUMERIC_SCALE", + c.datetime_precision AS "DATETIME_PRECISION", + %3$s AS "CHARACTER_SET_NAME", + %4$s AS "COLLATION_NAME", + %5$s AS "COLUMN_TYPE", + %6$s AS "COLUMN_KEY", + COALESCE(cm.extra, %7$s) AS "EXTRA", + \'select,insert,update,references\' AS "PRIVILEGES", + COALESCE(cm.column_comment, \'\') AS "COLUMN_COMMENT", + \'\' AS "GENERATION_EXPRESSION", + NULL AS "SRS_ID" + FROM information_schema.columns c + LEFT JOIN %8$s cm + ON cm.table_schema = c.table_schema + AND cm.table_name = c.table_name + AND cm.column_name = c.column_name + WHERE c.table_schema NOT IN (\'information_schema\', \'pg_catalog\') + AND c.table_name NOT IN (%9$s) +), +metadata_columns AS ( + SELECT + \'def\' AS "TABLE_CATALOG", + %10$s AS "TABLE_SCHEMA", + cm.table_name AS "TABLE_NAME", + cm.column_name AS "COLUMN_NAME", + cm.ordinal_position AS "ORDINAL_POSITION", + cm.column_default AS "COLUMN_DEFAULT", + cm.is_nullable AS "IS_NULLABLE", + %11$s AS "DATA_TYPE", + NULL AS "CHARACTER_MAXIMUM_LENGTH", + NULL AS "CHARACTER_OCTET_LENGTH", + NULL AS "NUMERIC_PRECISION", + NULL AS "NUMERIC_SCALE", + NULL AS "DATETIME_PRECISION", + %12$s AS "CHARACTER_SET_NAME", + %13$s AS "COLLATION_NAME", + cm.column_type AS "COLUMN_TYPE", + %14$s AS "COLUMN_KEY", + cm.extra AS "EXTRA", + \'select,insert,update,references\' AS "PRIVILEGES", + cm.column_comment AS "COLUMN_COMMENT", + \'\' AS "GENERATION_EXPRESSION", + NULL AS "SRS_ID" + FROM %8$s cm + WHERE NOT %15$s + AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns c + WHERE c.table_schema = cm.table_schema + AND c.table_name = cm.table_name + AND c.column_name = cm.column_name + ) +) +SELECT * FROM catalog_columns +UNION ALL +SELECT * FROM metadata_columns', + $this->get_direct_information_schema_display_schema_sql( 'c.table_schema' ), + $data_type, + $charset, + $collation, + $column_type, + $column_key, + $this->get_direct_information_schema_column_extra_expression( 'c' ), + $column_metadata_table, + $this->get_direct_information_schema_hidden_table_list_sql(), + $this->get_direct_information_schema_display_schema_sql( 'cm.table_schema' ), + $metadata_type, + $metadata_charset, + $metadata_collation, + $metadata_key, + $this->get_mysql_temporary_schema_sql_condition( 'cm.table_schema' ) + ); + } + + /** + * Get a SQL condition that matches temporary metadata schemas. + * + * @param string $schema_sql SQL expression for a backend schema name. + * @return string SQL condition. + */ + private function get_mysql_temporary_schema_sql_condition( string $schema_sql ): string { + return sprintf( + '(LOWER(%1$s) IN (\'temp\', \'pg_temp\') OR LOWER(%1$s) LIKE \'pg_temp_%%\')', + $schema_sql + ); + } + + /** + * Get a MySQL data type expression from PostgreSQL catalog metadata. + * + * @param string $alias Catalog column table alias. + * @return string SQL expression. + */ + private function get_direct_information_schema_catalog_data_type_expression( string $alias ): string { + return sprintf( + 'CASE + WHEN %1$s.data_type = \'character varying\' THEN \'varchar\' + WHEN %1$s.data_type = \'character\' THEN \'char\' + WHEN %1$s.data_type = \'integer\' THEN \'int\' + WHEN %1$s.data_type = \'timestamp without time zone\' THEN \'datetime\' + ELSE %1$s.data_type +END', + $alias + ); + } + + /** + * Get a MySQL column type expression. + * + * @param string $catalog_alias Catalog column table alias. + * @param string $metadata_alias MySQL metadata table alias. + * @return string SQL expression. + */ + private function get_direct_information_schema_column_type_expression( string $catalog_alias, string $metadata_alias ): string { + return sprintf( + 'COALESCE(%2$s.column_type, CASE + WHEN %1$s.data_type = \'character varying\' THEN + \'varchar\' || CASE WHEN %1$s.character_maximum_length IS NULL THEN \'\' ELSE \'(\' || CAST(%1$s.character_maximum_length AS text) || \')\' END + WHEN %1$s.data_type = \'character\' THEN + \'char\' || CASE WHEN %1$s.character_maximum_length IS NULL THEN \'\' ELSE \'(\' || CAST(%1$s.character_maximum_length AS text) || \')\' END + WHEN %1$s.data_type = \'integer\' THEN \'int\' + WHEN %1$s.data_type = \'timestamp without time zone\' THEN \'datetime\' + ELSE %1$s.data_type +END)', + $catalog_alias, + $metadata_alias + ); + } + + /** + * Get a MySQL DATA_TYPE expression from MySQL column_type metadata. + * + * @param string $column_type_sql SQL expression for column_type. + * @param string $fallback_sql Fallback SQL expression. + * @return string SQL expression. + */ + private function get_direct_information_schema_metadata_data_type_expression( string $column_type_sql, string $fallback_sql ): string { + return sprintf( + 'CASE + WHEN %1$s IS NULL THEN %2$s + WHEN LOWER(%1$s) LIKE \'bigint%%\' THEN \'bigint\' + WHEN LOWER(%1$s) LIKE \'mediumint%%\' THEN \'mediumint\' + WHEN LOWER(%1$s) LIKE \'smallint%%\' THEN \'smallint\' + WHEN LOWER(%1$s) LIKE \'tinyint%%\' THEN \'tinyint\' + WHEN LOWER(%1$s) LIKE \'int%%\' THEN \'int\' + WHEN LOWER(%1$s) LIKE \'integer%%\' THEN \'int\' + WHEN LOWER(%1$s) LIKE \'varchar%%\' THEN \'varchar\' + WHEN LOWER(%1$s) LIKE \'char%%\' THEN \'char\' + WHEN LOWER(%1$s) LIKE \'decimal%%\' THEN \'decimal\' + WHEN LOWER(%1$s) LIKE \'numeric%%\' THEN \'decimal\' + WHEN LOWER(%1$s) LIKE \'datetime%%\' THEN \'datetime\' + WHEN LOWER(%1$s) LIKE \'timestamp%%\' THEN \'timestamp\' + WHEN LOWER(%1$s) LIKE \'double%%\' THEN \'double\' + WHEN LOWER(%1$s) LIKE \'float%%\' THEN \'float\' + WHEN LOWER(%1$s) LIKE \'longtext%%\' THEN \'longtext\' + WHEN LOWER(%1$s) LIKE \'mediumtext%%\' THEN \'mediumtext\' + WHEN LOWER(%1$s) LIKE \'tinytext%%\' THEN \'tinytext\' + WHEN LOWER(%1$s) LIKE \'text%%\' THEN \'text\' + ELSE LOWER(%1$s) +END', + $column_type_sql, + $fallback_sql + ); + } + + /** + * Get CHARACTER_SET_NAME expression for a MySQL column type. + * + * @param string $column_type_sql SQL expression for column_type. + * @param string $metadata_sql SQL expression for stored charset. + * @return string SQL expression. + */ + private function get_direct_information_schema_character_set_expression( string $column_type_sql, string $metadata_sql ): string { + return sprintf( + 'CASE + WHEN LOWER(%1$s) LIKE \'char%%\' + OR LOWER(%1$s) LIKE \'varchar%%\' + OR LOWER(%1$s) LIKE \'%%text%%\' THEN COALESCE(%2$s, %3$s) + ELSE NULL +END', + $column_type_sql, + $metadata_sql, + $this->connection->quote( $this->charset ) + ); + } + + /** + * Get COLLATION_NAME expression for a MySQL column type. + * + * @param string $column_type_sql SQL expression for column_type. + * @param string $metadata_sql SQL expression for stored collation. + * @return string SQL expression. + */ + private function get_direct_information_schema_collation_expression( string $column_type_sql, string $metadata_sql ): string { + return sprintf( + 'CASE + WHEN LOWER(%1$s) LIKE \'char%%\' + OR LOWER(%1$s) LIKE \'varchar%%\' + OR LOWER(%1$s) LIKE \'%%text%%\' THEN COALESCE(%2$s, %3$s) + ELSE NULL +END', + $column_type_sql, + $metadata_sql, + $this->connection->quote( $this->collation ) + ); + } + + /** + * Get COLUMN_KEY expression for a table column. + * + * @param string $schema_sql SQL expression for backend schema. + * @param string $table_sql SQL expression for table name. + * @param string $column_sql SQL expression for column name. + * @return string SQL expression. + */ + private function get_direct_information_schema_column_key_expression( string $schema_sql, string $table_sql, string $column_sql ): string { + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + + return sprintf( + 'CASE + WHEN EXISTS ( + SELECT 1 FROM %1$s im + WHERE im.table_schema = %2$s + AND im.table_name = %3$s + AND im.column_name = %4$s + AND UPPER(im.key_name) = \'PRIMARY\' + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 FROM %1$s im + WHERE im.table_schema = %2$s + AND im.table_name = %3$s + AND im.column_name = %4$s + AND im.non_unique = \'0\' + ) THEN \'UNI\' + WHEN EXISTS ( + SELECT 1 FROM %1$s im + WHERE im.table_schema = %2$s + AND im.table_name = %3$s + AND im.column_name = %4$s + ) THEN \'MUL\' + WHEN EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + INNER JOIN information_schema.key_column_usage kcu + ON kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = %2$s + AND tc.table_name = %3$s + AND tc.constraint_type = \'PRIMARY KEY\' + AND kcu.column_name = %4$s + ) THEN \'PRI\' + WHEN EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + INNER JOIN information_schema.key_column_usage kcu + ON kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.table_name = tc.table_name + WHERE tc.table_schema = %2$s + AND tc.table_name = %3$s + AND tc.constraint_type = \'UNIQUE\' + AND kcu.column_name = %4$s + ) THEN \'UNI\' + ELSE \'\' +END', + $index_metadata_table, + $schema_sql, + $table_sql, + $column_sql + ); + } + + /** + * Get EXTRA expression for a catalog column. + * + * @param string $alias Catalog column table alias. + * @return string SQL expression. + */ + private function get_direct_information_schema_column_extra_expression( string $alias ): string { + return sprintf( + 'CASE + WHEN %1$s.is_identity = \'YES\' THEN \'auto_increment\' + WHEN LOWER(COALESCE(%1$s.column_default, \'\')) LIKE \'nextval(%%\' THEN \'auto_increment\' + ELSE \'\' +END', + $alias + ); + } + + /** + * Build the MySQL-shaped information_schema.STATISTICS relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_statistics_relation_sql(): string { + $this->ensure_mysql_schema_metadata_tables(); + + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + + return sprintf( + 'SELECT + \'def\' AS "TABLE_CATALOG", + %1$s AS "TABLE_SCHEMA", + im.table_name AS "TABLE_NAME", + CAST(im.non_unique AS integer) AS "NON_UNIQUE", + %1$s AS "INDEX_SCHEMA", + im.key_name AS "INDEX_NAME", + im.seq_in_index AS "SEQ_IN_INDEX", + im.column_name AS "COLUMN_NAME", + CASE WHEN im.index_type = \'FULLTEXT\' THEN NULL ELSE COALESCE(im."collation", \'A\') END AS "COLLATION", + 0 AS "CARDINALITY", + im.sub_part AS "SUB_PART", + NULL AS "PACKED", + im.nullable AS "NULLABLE", + im.index_type AS "INDEX_TYPE", + \'\' AS "COMMENT", + im.index_comment AS "INDEX_COMMENT", + \'YES\' AS "IS_VISIBLE", + NULL AS "EXPRESSION" +FROM %2$s im', + $this->get_direct_information_schema_display_schema_sql( 'im.table_schema' ), + $index_metadata_table + ); + } + + /** + * Build the MySQL-shaped information_schema.TABLE_CONSTRAINTS relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_table_constraints_relation_sql(): string { + $this->ensure_mysql_schema_metadata_tables(); + + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + $foreign_key_metadata_table = $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ); + $check_metadata_table = $this->connection->quote_identifier( self::MYSQL_CHECK_METADATA_TABLE ); + + return sprintf( + 'WITH index_constraints AS ( + SELECT DISTINCT + im.table_schema, + im.table_name, + im.key_name AS constraint_name, + CASE WHEN UPPER(im.key_name) = \'PRIMARY\' THEN \'PRIMARY KEY\' ELSE \'UNIQUE\' END AS constraint_type + FROM %1$s im + WHERE im.non_unique = \'0\' +), +foreign_key_constraints AS ( + SELECT DISTINCT + fk.table_schema, + fk.table_name, + fk.constraint_name, + \'FOREIGN KEY\' AS constraint_type + FROM %2$s fk +), +check_constraints AS ( + SELECT + cm.table_schema, + cm.table_name, + cm.constraint_name, + \'CHECK\' AS constraint_type, + cm.enforced + FROM %3$s cm +), +catalog_constraints AS ( + SELECT + tc.table_schema, + tc.table_name, + CASE WHEN tc.constraint_type = \'PRIMARY KEY\' THEN \'PRIMARY\' ELSE tc.constraint_name END AS constraint_name, + tc.constraint_type, + \'YES\' AS enforced + FROM information_schema.table_constraints tc + WHERE tc.table_schema NOT IN (\'information_schema\', \'pg_catalog\') + AND tc.table_name NOT IN (%4$s) + AND tc.constraint_type IN (\'PRIMARY KEY\', \'UNIQUE\', \'FOREIGN KEY\', \'CHECK\') + AND NOT EXISTS ( + SELECT 1 + FROM index_constraints ic + WHERE ic.table_schema = tc.table_schema + AND ic.table_name = tc.table_name + AND ic.constraint_type = tc.constraint_type + ) + AND NOT EXISTS ( + SELECT 1 + FROM foreign_key_constraints fkc + WHERE fkc.table_schema = tc.table_schema + AND fkc.table_name = tc.table_name + AND fkc.constraint_name = tc.constraint_name + ) + AND NOT EXISTS ( + SELECT 1 + FROM check_constraints cc + WHERE cc.table_schema = tc.table_schema + AND cc.table_name = tc.table_name + AND cc.constraint_name = tc.constraint_name + ) +) +SELECT + \'def\' AS "CONSTRAINT_CATALOG", + %5$s AS "CONSTRAINT_SCHEMA", + constraint_name AS "CONSTRAINT_NAME", + %5$s AS "TABLE_SCHEMA", + table_name AS "TABLE_NAME", + constraint_type AS "CONSTRAINT_TYPE", + enforced AS "ENFORCED" +FROM ( + SELECT table_schema, table_name, constraint_name, constraint_type, \'YES\' AS enforced FROM index_constraints + UNION ALL + SELECT table_schema, table_name, constraint_name, constraint_type, \'YES\' AS enforced FROM foreign_key_constraints + UNION ALL + SELECT * FROM check_constraints + UNION ALL + SELECT * FROM catalog_constraints +) constraints', + $index_metadata_table, + $foreign_key_metadata_table, + $check_metadata_table, + $this->get_direct_information_schema_hidden_table_list_sql(), + $this->get_direct_information_schema_display_schema_sql( 'table_schema' ) + ); + } + + /** + * Build the MySQL-shaped information_schema.KEY_COLUMN_USAGE relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_key_column_usage_relation_sql(): string { + $this->ensure_mysql_schema_metadata_tables(); + + $index_metadata_table = $this->connection->quote_identifier( self::MYSQL_INDEX_METADATA_TABLE ); + $foreign_key_metadata_table = $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ); + + return sprintf( + 'SELECT + \'def\' AS "CONSTRAINT_CATALOG", + %1$s AS "CONSTRAINT_SCHEMA", + im.key_name AS "CONSTRAINT_NAME", + \'def\' AS "TABLE_CATALOG", + %1$s AS "TABLE_SCHEMA", + im.table_name AS "TABLE_NAME", + im.column_name AS "COLUMN_NAME", + im.seq_in_index AS "ORDINAL_POSITION", + NULL AS "POSITION_IN_UNIQUE_CONSTRAINT", + NULL AS "REFERENCED_TABLE_SCHEMA", + NULL AS "REFERENCED_TABLE_NAME", + NULL AS "REFERENCED_COLUMN_NAME" +FROM %2$s im +WHERE im.non_unique = \'0\' +UNION ALL +SELECT + \'def\' AS "CONSTRAINT_CATALOG", + %3$s AS "CONSTRAINT_SCHEMA", + fk.constraint_name AS "CONSTRAINT_NAME", + \'def\' AS "TABLE_CATALOG", + %3$s AS "TABLE_SCHEMA", + fk.table_name AS "TABLE_NAME", + fk.column_name AS "COLUMN_NAME", + fk.seq_in_index AS "ORDINAL_POSITION", + fk.seq_in_index AS "POSITION_IN_UNIQUE_CONSTRAINT", + %4$s AS "REFERENCED_TABLE_SCHEMA", + fk.referenced_table_name AS "REFERENCED_TABLE_NAME", + fk.referenced_column_name AS "REFERENCED_COLUMN_NAME" +FROM %5$s fk', + $this->get_direct_information_schema_display_schema_sql( 'im.table_schema' ), + $index_metadata_table, + $this->get_direct_information_schema_display_schema_sql( 'fk.table_schema' ), + $this->get_direct_information_schema_display_schema_sql( 'fk.referenced_table_schema' ), + $foreign_key_metadata_table + ) . sprintf( + ' +UNION ALL +SELECT + \'def\' AS "CONSTRAINT_CATALOG", + %1$s AS "CONSTRAINT_SCHEMA", + CASE WHEN tc.constraint_type = \'PRIMARY KEY\' THEN \'PRIMARY\' ELSE kcu.constraint_name END AS "CONSTRAINT_NAME", + \'def\' AS "TABLE_CATALOG", + %1$s AS "TABLE_SCHEMA", + kcu.table_name AS "TABLE_NAME", + kcu.column_name AS "COLUMN_NAME", + kcu.ordinal_position AS "ORDINAL_POSITION", + kcu.position_in_unique_constraint AS "POSITION_IN_UNIQUE_CONSTRAINT", + %2$s AS "REFERENCED_TABLE_SCHEMA", + ccu.table_name AS "REFERENCED_TABLE_NAME", + ccu.column_name AS "REFERENCED_COLUMN_NAME" +FROM information_schema.key_column_usage kcu +LEFT JOIN information_schema.table_constraints tc + ON tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + AND tc.table_name = kcu.table_name +LEFT JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_schema = kcu.constraint_schema + AND ccu.constraint_name = kcu.constraint_name +WHERE kcu.table_schema NOT IN (\'information_schema\', \'pg_catalog\') + AND kcu.table_name NOT IN (%3$s) + AND NOT EXISTS ( + SELECT 1 + FROM %4$s im + WHERE im.table_schema = kcu.table_schema + AND im.table_name = kcu.table_name + AND im.column_name = kcu.column_name + AND im.seq_in_index = kcu.ordinal_position + AND im.non_unique = \'0\' + ) + AND NOT EXISTS ( + SELECT 1 + FROM %5$s fk + WHERE fk.table_schema = kcu.table_schema + AND fk.table_name = kcu.table_name + AND fk.constraint_name = kcu.constraint_name + AND fk.column_name = kcu.column_name + )', + $this->get_direct_information_schema_display_schema_sql( 'kcu.table_schema' ), + $this->get_direct_information_schema_display_schema_sql( 'ccu.table_schema' ), + $this->get_direct_information_schema_hidden_table_list_sql(), + $index_metadata_table, + $foreign_key_metadata_table + ); + } + + /** + * Build the MySQL-shaped information_schema.REFERENTIAL_CONSTRAINTS relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_referential_constraints_relation_sql(): string { + $this->ensure_mysql_schema_metadata_tables(); + + $foreign_key_metadata_table = $this->connection->quote_identifier( self::MYSQL_FOREIGN_KEY_METADATA_TABLE ); + + return sprintf( + 'WITH metadata_constraints AS ( + SELECT + fk.table_schema, + fk.constraint_name, + MIN(fk.constraint_ordinal) AS constraint_ordinal, + MIN(fk.referenced_table_schema) AS referenced_table_schema, + MIN(fk.referenced_table_name) AS referenced_table_name, + MIN(fk.update_rule) AS update_rule, + MIN(fk.delete_rule) AS delete_rule, + MIN(fk.table_name) AS table_name + FROM %1$s fk + GROUP BY fk.table_schema, fk.constraint_name +), +catalog_constraints AS ( + SELECT + rc.constraint_schema AS table_schema, + rc.constraint_name, + 0 AS constraint_ordinal, + rc.unique_constraint_schema AS referenced_table_schema, + ccu.table_name AS referenced_table_name, + rc.update_rule, + rc.delete_rule, + tc.table_name + FROM information_schema.referential_constraints rc + LEFT JOIN information_schema.table_constraints tc + ON tc.constraint_schema = rc.constraint_schema + AND tc.constraint_name = rc.constraint_name + LEFT JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_schema = rc.unique_constraint_schema + AND ccu.constraint_name = rc.unique_constraint_name + WHERE rc.constraint_schema NOT IN (\'information_schema\', \'pg_catalog\') + AND NOT EXISTS ( + SELECT 1 + FROM metadata_constraints mc + WHERE mc.table_schema = rc.constraint_schema + AND mc.constraint_name = rc.constraint_name + ) +) +SELECT + \'def\' AS "CONSTRAINT_CATALOG", + %2$s AS "CONSTRAINT_SCHEMA", + constraint_name AS "CONSTRAINT_NAME", + \'def\' AS "UNIQUE_CONSTRAINT_CATALOG", + %3$s AS "UNIQUE_CONSTRAINT_SCHEMA", + \'PRIMARY\' AS "UNIQUE_CONSTRAINT_NAME", + \'NONE\' AS "MATCH_OPTION", + update_rule AS "UPDATE_RULE", + delete_rule AS "DELETE_RULE", + table_name AS "TABLE_NAME", + referenced_table_name AS "REFERENCED_TABLE_NAME" +FROM ( + SELECT * FROM metadata_constraints + UNION ALL + SELECT * FROM catalog_constraints +) constraints', + $foreign_key_metadata_table, + $this->get_direct_information_schema_display_schema_sql( 'table_schema' ), + $this->get_direct_information_schema_display_schema_sql( 'referenced_table_schema' ) + ); + } + + /** + * Build the MySQL-shaped information_schema.CHECK_CONSTRAINTS relation. + * + * @return string Relation SQL. + */ + private function get_direct_information_schema_check_constraints_relation_sql(): string { + $this->ensure_mysql_schema_metadata_tables(); + + $check_metadata_table = $this->connection->quote_identifier( self::MYSQL_CHECK_METADATA_TABLE ); + + return sprintf( + 'WITH metadata_checks AS ( + SELECT + cm.table_schema AS constraint_schema, + cm.constraint_name, + cm.check_clause + FROM %2$s cm +), +catalog_checks AS ( + SELECT + cc.constraint_schema, + cc.constraint_name, + cc.check_clause + FROM information_schema.check_constraints cc + WHERE cc.constraint_schema NOT IN (\'information_schema\', \'pg_catalog\') + AND NOT EXISTS ( + SELECT 1 + FROM metadata_checks mc + WHERE mc.constraint_schema = cc.constraint_schema + AND mc.constraint_name = cc.constraint_name + ) +) +SELECT + \'def\' AS "CONSTRAINT_CATALOG", + %1$s AS "CONSTRAINT_SCHEMA", + constraint_name AS "CONSTRAINT_NAME", + check_clause AS "CHECK_CLAUSE" +FROM ( + SELECT * FROM metadata_checks + UNION ALL + SELECT * FROM catalog_checks +) checks', + $this->get_direct_information_schema_display_schema_sql( 'constraint_schema' ), + $check_metadata_table + ); + } + + /** + * Translate SELECT DISTINCT queries whose ORDER BY expression is not selected. + * + * PostgreSQL requires ORDER BY expressions in SELECT DISTINCT statements to + * appear in the projection. Grouping by the visible projection and ordering + * by a hidden aggregate keeps the MySQL-visible result shape and avoids + * changing DISTINCT cardinality. + * + * @param string $query MySQL query. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_distinct_order_by_query( string $query, bool $include_limit = true ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( WP_MySQL_Lexer::DISTINCT_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $has_sql_calc_found_rows = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $tokens[ $position ]->id ) { + $has_sql_calc_found_rows = true; + ++$position; + } + + if ( isset( $tokens[ $position ] ) && $this->is_unsupported_distinct_select_modifier( $tokens[ $position ] ) ) { + return null; + } + + $projection_start = $position; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $projection_start ); + if ( null === $statement_end ) { + return null; + } + + $limit_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::LIMIT_SYMBOL, + $projection_start, + $statement_end + ); + $select_end = $limit_position ?? $statement_end; + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $order_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::ORDER_SYMBOL, + $projection_start, + $select_end + ); + if ( null === $order_position ) { + if ( ! $has_sql_calc_found_rows ) { + return null; + } + + $sql = 'SELECT DISTINCT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $select_end ); + if ( $include_limit && null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $order_position + ); + if ( + null === $from_position + || null === $order_position + || $order_position + 2 >= $statement_end + || ! isset( $tokens[ $order_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + if ( + $this->contains_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::AVG_SYMBOL, + WP_MySQL_Lexer::COUNT_SYMBOL, + WP_MySQL_Lexer::GROUP_CONCAT_SYMBOL, + WP_MySQL_Lexer::MAX_SYMBOL, + WP_MySQL_Lexer::MIN_SYMBOL, + WP_MySQL_Lexer::SUM_SYMBOL, + ) + ) + ) { + return null; + } + + $projection_items = $this->parse_mysql_select_projection_items( $tokens, $projection_start, $from_position ); + if ( null === $projection_items ) { + return null; + } + + $scope_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::WHERE_SYMBOL, + $from_position + 1, + $order_position + ) ?? $order_position; + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $scope_end ); + if ( null === $scope ) { + return null; + } + + $order_items = $this->parse_mysql_select_order_by_items( + $tokens, + $order_position + 2, + $select_end, + $projection_items, + $scope + ); + if ( null === $order_items ) { + return null; + } + + $has_hidden_order_expression = false; + foreach ( $order_items as $order_item ) { + if ( null === $order_item['projection_index'] ) { + $has_hidden_order_expression = true; + break; + } + } + + if ( ! $has_hidden_order_expression ) { + if ( ! $has_sql_calc_found_rows ) { + return null; + } + + $sql = 'SELECT DISTINCT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $select_end ); + if ( $include_limit && null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + return $this->build_distinct_order_by_grouped_query( + $tokens, + $projection_items, + $order_items, + $from_position, + $order_position, + $limit_position, + $statement_end, + $include_limit + ); + } + + /** + * Translate WordPress's available post MIME type lookup with MySQL order. + * + * WordPress issues this query without ORDER BY, but MySQL returns MIME types + * in first matching posts.ID order for the posts table shape. Keep this + * constrained to the exact get_available_post_mime_types() query so generic + * unordered DISTINCT queries remain unchanged. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_wordpress_available_post_mime_types_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[12] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::DISTINCT_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( 13 !== $statement_end ) { + return null; + } + + if ( + ! $this->is_mysql_identifier_like_token_value( $tokens[2], 'post_mime_type' ) + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[3]->id + || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[5]->id + || ! $this->is_mysql_identifier_like_token_value( $tokens[6], 'post_type' ) + || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[7]->id + || ! $this->is_mysql_string_literal_token( $tokens[8] ) + || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[9]->id + || ! $this->is_mysql_identifier_like_token_value( $tokens[10], 'post_mime_type' ) + || WP_MySQL_Lexer::NOT_EQUAL_OPERATOR !== $tokens[11]->id + || ! $this->is_mysql_string_literal_token( $tokens[12] ) + || '' !== $tokens[12]->get_value() + ) { + return null; + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[4] ); + if ( null === $table_name || ! $this->is_mysql_wordpress_table_name( $table_name, 'posts' ) ) { + return null; + } + + $scope = $this->get_mysql_single_table_scope( $table_name ); + $table = $scope['tables'][0]; + foreach ( array( 'ID', 'post_mime_type', 'post_type' ) as $column_name ) { + if ( null === $this->get_mysql_table_column_type( $table['schema'], $table['table'], $column_name ) ) { + return null; + } + } + + $projection_sql = $this->translate_mysql_token_to_postgresql( $tokens[2] ); + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + 6, + $statement_end, + $scope + ); + + return sprintf( + 'SELECT %1$s %2$s WHERE %3$s GROUP BY %1$s ORDER BY MIN(%4$s) ASC', + $projection_sql, + 'FROM ' . $this->translate_mysql_token_to_postgresql( $tokens[4] ), + $where_sql['sql'], + $this->connection->quote_identifier( 'ID' ) + ); + } + + /** + * Translate WordPress term cache priming with MySQL-compatible shared-term order. + * + * WordPress primes term objects with a join query that has no ORDER BY. The + * cache is keyed by term_id, so legacy shared terms rely on MySQL returning + * rows in term_taxonomy_id order and letting the last taxonomy row win. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_wordpress_term_cache_priming_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + 1, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + if ( null === $from_position || 1 === $from_position ) { + return null; + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, 1, $from_position ); + if ( + null === $projection_ranges + || 2 !== count( $projection_ranges ) + || ! $this->is_mysql_qualified_star_projection( $tokens, $projection_ranges[0]['start'], $projection_ranges[0]['end'], 't' ) + || ! $this->is_mysql_qualified_star_projection( $tokens, $projection_ranges[1]['start'], $projection_ranges[1]['end'], 'tt' ) + ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $statement_end ); + if ( + null === $where_position + || ! $this->is_mysql_distinct_term_taxonomy_from_shape( $tokens, $from_position, $where_position ) + || ! $this->is_mysql_term_cache_priming_where_clause( $tokens, $where_position + 1, $statement_end ) + ) { + return null; + } + + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $statement_end ) . ' ORDER BY tt.term_taxonomy_id ASC'; + } + + /** + * Translate WordPress's approved comments lookup with MySQL-compatible ties. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_wordpress_approved_comments_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + return null; + } + + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 2, $statement_end ); + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $select_end = $limit_position ?? $statement_end; + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 2, $select_end ); + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, 2, $select_end ); + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 2, $select_end ); + if ( + null === $from_position + || null === $where_position + || null === $order_position + || $from_position + 2 !== $where_position + || $where_position >= $order_position + || $order_position + 2 >= $select_end + || ! isset( $tokens[ $order_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + ) { + return null; + } + + $table_token = $tokens[ $from_position + 1 ] ?? null; + $table_name = $this->get_mysql_identifier_token_value( $table_token ); + if ( + null === $table_name + || ! $this->is_mysql_wordpress_table_name( $table_name, 'comments' ) + || ! $this->is_supported_wordpress_approved_comments_select_projection( $tokens, 1, $from_position, $table_name ) + ) { + return null; + } + + $tiebreaker_sql = $this->get_simple_wordpress_approved_comments_order_tiebreaker_sql( + $tokens, + $table_name, + $where_position, + $order_position, + $order_position, + $select_end + ); + if ( null === $tiebreaker_sql ) { + return null; + } + + $scope = $this->get_mysql_single_table_scope( $table_name ); + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $order_position, + $scope + ); + $order_sql = $this->translate_mysql_order_by_token_sequence_to_postgresql( + $tokens, + $order_position + 2, + $select_end, + $scope, + false + ); + + $sql = sprintf( + 'SELECT %s FROM %s WHERE %s ORDER BY %s, %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, 1, $from_position ), + $this->translate_mysql_identifier_token_to_postgresql( $table_token ), + $where_sql['sql'], + $order_sql['sql'], + $tiebreaker_sql + ); + if ( null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Validate the approved-comments SELECT projection. + * + * This translator appends comment_ID to ORDER BY, so it must stay limited + * to row-returning projections. Aggregate/function/expression projections + * can become invalid when the tie-breaker is appended. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @param string $table_name Selected comments table name. + * @return bool Whether the projection is supported. + */ + private function is_supported_wordpress_approved_comments_select_projection( + array $tokens, + int $start, + int $end, + string $table_name + ): bool { + if ( $start + 1 === $end && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[ $start ]->id ) { + return true; + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $projection_ranges ) { + return false; + } + + foreach ( $projection_ranges as $projection_range ) { + $reference = $this->parse_mysql_column_reference( + $tokens, + $projection_range['start'], + $projection_range['end'] + ); + if ( + null === $reference + || $reference['end'] !== $projection_range['end'] + || ( + null !== $reference['qualifier'] + && strtolower( $reference['qualifier'] ) !== strtolower( $table_name ) + ) + ) { + return false; + } + } + + return true; + } + + /** + * Check whether a projection item is alias.*. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token. + * @param int $end Final projection token, exclusive. + * @param string $alias Expected table alias. + * @return bool Whether the projection item is the qualified star. + */ + private function is_mysql_qualified_star_projection( array $tokens, int $start, int $end, string $alias ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + return $start + 3 === $end + && $this->is_mysql_identifier_like_token_value( $tokens[ $start ] ?? null, $alias ) + && isset( $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[ $start + 2 ]->id; + } + + /** + * Check whether a WHERE clause is t.term_id IN (integer list). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First WHERE predicate token. + * @param int $end Final WHERE predicate token, exclusive. + * @return bool Whether the WHERE clause matches term cache priming. + */ + private function is_mysql_term_cache_priming_where_clause( array $tokens, int $start, int $end ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( + null === $reference + || $reference['end'] + 3 > $end + || 't' !== strtolower( (string) $reference['qualifier'] ) + || 'term_id' !== strtolower( $reference['column'] ) + || ! isset( $tokens[ $reference['end'] ], $tokens[ $reference['end'] + 1 ] ) + || WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $reference['end'] ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $reference['end'] + 1 ]->id + ) { + return false; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $reference['end'] + 1, $end ); + if ( $after_close !== $end ) { + return false; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $reference['end'] + 2, $end - 1 ); + if ( null === $items || empty( $items ) ) { + return false; + } + + foreach ( $items as $item ) { + if ( + $item['start'] + 1 !== $item['end'] + || ! isset( $tokens[ $item['start'] ] ) + || ! in_array( + $tokens[ $item['start'] ]->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ) + ) { + return false; + } + } + + return true; + } + + /** + * Check whether a token is an unsupported SELECT modifier for this rewrite. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is an unsupported modifier. + */ + private function is_unsupported_distinct_select_modifier( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + 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_NO_CACHE_SYMBOL, + WP_MySQL_Lexer::SQL_SMALL_RESULT_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ), + true + ); + } + + /** + * Parse SELECT projection items with expression bounds and visible aliases. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @return array|null Projection items. + */ + private function parse_mysql_select_projection_items( array $tokens, int $start, int $end ): ?array { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || count( $ranges ) < 1 ) { + return null; + } + + $items = array(); + $alias_lookup = array(); + foreach ( $ranges as $range ) { + $item = $this->parse_mysql_select_projection_item( $tokens, $range['start'], $range['end'] ); + if ( null === $item ) { + return null; + } + + $alias_key = strtolower( $item['alias'] ); + if ( isset( $alias_lookup[ $alias_key ] ) ) { + return null; + } + + $alias_lookup[ $alias_key ] = true; + $items[] = $item; + } + + return $items; + } + + /** + * Parse one SELECT projection item. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection item token position. + * @param int $end Final projection item token position, exclusive. + * @return array{expression_start: int, expression_end: int, sql: string, alias: string}|null Projection item. + */ + private function parse_mysql_select_projection_item( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $expression_start = $start; + $expression_end = $end; + $alias = null; + $as_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $start, $end ); + + if ( null !== $as_position ) { + if ( $as_position <= $start || $as_position + 2 !== $end ) { + return null; + } + + $alias = $this->get_mysql_projection_alias_token_value( $tokens[ $as_position + 1 ] ?? null ); + if ( null === $alias ) { + return null; + } + + $expression_end = $as_position; + } else { + $implicit_alias = $this->get_mysql_implicit_projection_alias( $tokens, $start, $end ); + if ( null !== $implicit_alias ) { + $alias = $implicit_alias; + $expression_end = $end - 1; + } + } + + if ( $expression_start >= $expression_end ) { + return null; + } + + if ( null === $alias ) { + $alias = $this->get_mysql_select_expression_default_output_name( $tokens, $expression_start, $expression_end ); + if ( null === $alias ) { + return null; + } + } + + return array( + 'expression_start' => $expression_start, + 'expression_end' => $expression_end, + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( $tokens, $expression_start, $expression_end ), + 'alias' => $alias, + ); + } + + /** + * Get an explicit projection alias token value. + * + * MySQL permits string-literal aliases in projection context. Keep that + * context local so predicate string literals continue to render as values. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Alias value, or null when unsupported. + */ + private function get_mysql_projection_alias_token_value( ?WP_MySQL_Token $token ): ?string { + if ( null === $token ) { + return null; + } + + $identifier = $this->get_mysql_identifier_token_value( $token ); + if ( null !== $identifier ) { + return $identifier; + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $token->get_value(); + } + + $value = $token->get_value(); + if ( $this->is_mysql_unquoted_projection_alias_value( $value ) ) { + return $value; + } + + return null; + } + + /** + * Check whether a token value is safe as an unquoted MySQL projection alias. + * + * @param string $value Token value. + * @return bool Whether the value is identifier-shaped. + */ + private function is_mysql_unquoted_projection_alias_value( string $value ): bool { + if ( '' === $value ) { + return false; + } + + $first_character = $value[0]; + if ( '_' !== $first_character && ! ctype_alpha( $first_character ) ) { + return false; + } + + for ( $i = 1, $length = strlen( $value ); $i < $length; $i++ ) { + $character = $value[ $i ]; + if ( '_' !== $character && '$' !== $character && ! ctype_alnum( $character ) ) { + return false; + } + } + + return true; + } + + /** + * Get an implicit projection alias when a complex expression is followed by a name. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection item token position. + * @param int $end Final projection item token position, exclusive. + * @return string|null Alias value, or null when absent. + */ + private function get_mysql_implicit_projection_alias( array $tokens, int $start, int $end ): ?string { + if ( $start + 1 >= $end ) { + return null; + } + + $alias = $this->get_mysql_identifier_token_value( $tokens[ $end - 1 ] ?? null ); + if ( null === $alias ) { + return null; + } + + if ( isset( $tokens[ $end - 2 ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $end - 2 ]->id ) { + return null; + } + + return $alias; + } + + /** + * Infer the default visible name for a projected expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @return string|null Output column name, or null when unsupported. + */ + private function get_mysql_select_expression_default_output_name( array $tokens, int $start, int $end ): ?string { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + if ( $start + 1 === $end ) { + return $this->get_mysql_identifier_token_value( $tokens[ $start ] ?? null ); + } + + if ( + $start + 3 <= $end + && isset( $tokens[ $end - 2 ], $tokens[ $end - 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $end - 2 ]->id + ) { + return $this->get_mysql_identifier_token_value( $tokens[ $end - 1 ] ); + } + + return null; + } + + /** + * Parse ORDER BY items and connect them to projected expressions when possible. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First ORDER BY item token position. + * @param int $end Final ORDER BY token position, exclusive. + * @param array $projection_items Parsed projection items. + * @param array|null $scope Optional statement table scope for contextual expression coercions. + * @return array|null + * ORDER BY items. + */ + private function parse_mysql_select_order_by_items( + array $tokens, + int $start, + int $end, + array $projection_items, + ?array $scope = null + ): ?array { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || count( $ranges ) < 1 ) { + return null; + } + + $items = array(); + foreach ( $ranges as $range ) { + $expression_end = $range['end']; + $direction = 'ASC'; + $direction_explicit = false; + + if ( + isset( $tokens[ $expression_end - 1 ] ) + && ( + WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $expression_end - 1 ]->id + || WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $expression_end - 1 ]->id + ) + ) { + $direction = WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $expression_end - 1 ]->id ? 'DESC' : 'ASC'; + $direction_explicit = true; + --$expression_end; + } + + if ( $range['start'] >= $expression_end ) { + return null; + } + + $expression_sql = null === $scope + ? array( + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $range['start'], + $expression_end + ), + 'changed' => false, + ) + : $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $range['start'], + $expression_end, + $scope + ); + + $items[] = array( + 'expression_start' => $range['start'], + 'expression_end' => $expression_end, + 'sql' => $expression_sql['sql'], + 'direction' => $direction, + 'direction_explicit' => $direction_explicit, + 'projection_index' => $this->find_mysql_projection_for_order_expression( + $tokens, + $range['start'], + $expression_end, + $projection_items + ), + 'changed' => $expression_sql['changed'], + ); + } + + return $items; + } + + /** + * Find a projection item that satisfies an ORDER BY expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First ORDER BY expression token. + * @param int $end Final ORDER BY expression token, exclusive. + * @param array $projection_items Parsed projection items. + * @return int|null Projection item index, or null when not projected. + */ + private function find_mysql_projection_for_order_expression( array $tokens, int $start, int $end, array $projection_items ): ?int { + foreach ( $projection_items as $index => $projection_item ) { + if ( + $this->are_mysql_token_ranges_equivalent( + $tokens, + $start, + $end, + $projection_item['expression_start'], + $projection_item['expression_end'] + ) + ) { + return $index; + } + } + + if ( $start + 1 === $end ) { + $ordinal = $this->get_mysql_order_by_ordinal_projection_index( $tokens[ $start ], count( $projection_items ) ); + if ( null !== $ordinal ) { + return $ordinal; + } + + $alias = $this->get_mysql_order_by_alias_token_value( $tokens[ $start ] ); + if ( null !== $alias ) { + foreach ( $projection_items as $index => $projection_item ) { + if ( strtolower( $alias ) === strtolower( $projection_item['alias'] ) ) { + return $index; + } + } + } + } + + return null; + } + + /** + * Get a one-token ORDER BY alias reference. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Alias value, or null when unsupported. + */ + private function get_mysql_order_by_alias_token_value( ?WP_MySQL_Token $token ): ?string { + if ( null === $token ) { + return null; + } + + $identifier = $this->get_mysql_identifier_token_value( $token ); + if ( null !== $identifier ) { + return $identifier; + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return null; + } + + $value = $token->get_value(); + if ( $this->is_mysql_unquoted_projection_alias_value( $value ) ) { + return $value; + } + + return null; + } + + /** + * Resolve a positional ORDER BY item to a projection index. + * + * @param WP_MySQL_Token $token ORDER BY token. + * @param int $projection_count Number of projected columns. + * @return int|null Zero-based projection index, or null when unsupported. + */ + private function get_mysql_order_by_ordinal_projection_index( WP_MySQL_Token $token, int $projection_count ): ?int { + if ( + ! in_array( $token->id, array( WP_MySQL_Lexer::INT_NUMBER, WP_MySQL_Lexer::LONG_NUMBER ), true ) + || ! ctype_digit( $token->get_value() ) + ) { + return null; + } + + $ordinal = (int) $token->get_value(); + if ( $ordinal < 1 || $ordinal > $projection_count ) { + return null; + } + + return $ordinal - 1; + } + + /** + * Check whether a bounded token range contains any token IDs. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @param int[] $token_ids Token IDs to detect. + * @return bool Whether any token ID was found. + */ + private function contains_mysql_token( array $tokens, int $start, int $end, array $token_ids ): bool { + $lookup = array(); + foreach ( $token_ids as $token_id ) { + $lookup[ $token_id ] = true; + } + + for ( $i = $start; $i < $end; $i++ ) { + if ( isset( $lookup[ $tokens[ $i ]->id ] ) ) { + return true; + } + } + + return false; + } + + /** + * Build a grouped derived-table rewrite for DISTINCT ORDER BY queries. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $order_items Parsed ORDER BY items. + * @param int $from_position FROM token position. + * @param int $order_position ORDER token position. + * @param int|null $limit_position LIMIT token position, or null. + * @param int $statement_end Final statement token position, exclusive. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. + * @return string PostgreSQL query. + */ + private function build_distinct_order_by_grouped_query( + array $tokens, + array $projection_items, + array $order_items, + int $from_position, + int $order_position, + ?int $limit_position, + int $statement_end, + bool $include_limit = true + ): string { + $derived_table_alias = '__wp_pg_distinct'; + $quoted_derived_table_alias = $this->connection->quote_identifier( $derived_table_alias ); + $inner_projection_sql = array(); + $outer_projection_sql = array(); + $group_by_sql = array(); + + foreach ( $projection_items as $projection_item ) { + $quoted_alias = $this->connection->quote_identifier( $projection_item['alias'] ); + $inner_projection_sql[] = $projection_item['sql'] . ' AS ' . $quoted_alias; + $outer_projection_sql[] = sprintf( + '%s.%s AS %s', + $quoted_derived_table_alias, + $quoted_alias, + $quoted_alias + ); + $group_by_sql[] = $projection_item['sql']; + } + + foreach ( $order_items as $index => $order_item ) { + if ( null !== $order_item['projection_index'] ) { + continue; + } + + $aggregate_function = 'DESC' === $order_item['direction'] ? 'MAX' : 'MIN'; + $quoted_order_alias = $this->connection->quote_identifier( $this->get_distinct_order_by_hidden_alias( $index ) ); + $inner_projection_sql[] = sprintf( + '%s(%s) AS %s', + $aggregate_function, + $order_item['sql'], + $quoted_order_alias + ); + } + + $sql = sprintf( + 'SELECT %s FROM (SELECT %s %s GROUP BY %s) AS %s ORDER BY %s', + implode( ', ', $outer_projection_sql ), + implode( ', ', $inner_projection_sql ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $order_position ), + implode( ', ', $group_by_sql ), + $quoted_derived_table_alias, + $this->get_distinct_order_by_outer_order_sql( $projection_items, $order_items, $quoted_derived_table_alias ) + ); + + if ( $include_limit && null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Get the hidden ORDER BY alias for a parsed order item. + * + * @param int $index ORDER BY item index. + * @return string Hidden alias. + */ + private function get_distinct_order_by_hidden_alias( int $index ): string { + return '__wp_pg_order_' . $index; + } + + /** + * Build the outer ORDER BY clause for a grouped DISTINCT rewrite. + * + * @param array $projection_items Parsed projection items. + * @param array $order_items Parsed ORDER BY items. + * @param string $quoted_derived_table_alias Quoted derived table alias. + * @return string Outer ORDER BY SQL. + */ + private function get_distinct_order_by_outer_order_sql( array $projection_items, array $order_items, string $quoted_derived_table_alias ): string { + $order_sql = array(); + + foreach ( $order_items as $index => $order_item ) { + if ( null !== $order_item['projection_index'] ) { + $order_alias = $projection_items[ $order_item['projection_index'] ]['alias']; + } else { + $order_alias = $this->get_distinct_order_by_hidden_alias( $index ); + } + + $order_sql[] = sprintf( + '%s.%s %s', + $quoted_derived_table_alias, + $this->connection->quote_identifier( $order_alias ), + $order_item['direction'] + ); + } + + return implode( ', ', $order_sql ); + } + + /** + * Translate aggregate/grouped SELECT ORDER BY clauses that PostgreSQL rejects. + * + * MySQL permits non-grouped ORDER BY expressions in grouped queries. Keep + * this rewrite limited to WordPress's scalar count and grouped archive/comment + * ID query shapes so unsupported grouping semantics still fail visibly. + * + * @param string $query MySQL query. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_strict_aggregate_grouped_order_by_query( string $query, bool $include_limit = true ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $projection_start = 1; + $has_distinct = false; + if ( isset( $tokens[ $projection_start ] ) && WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[ $projection_start ]->id ) { + $has_distinct = true; + ++$projection_start; + } + + if ( isset( $tokens[ $projection_start ] ) && WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $tokens[ $projection_start ]->id ) { + ++$projection_start; + } + + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $projection_start, $statement_end ); + $select_end = $limit_position ?? $statement_end; + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $projection_start, $select_end ); + if ( + null === $order_position + || ! isset( $tokens[ $order_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + || $order_position + 2 >= $select_end + ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $group_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, $projection_start, $order_position ); + if ( null === $group_position ) { + return $this->translate_strict_aggregate_only_order_by_query( + $tokens, + $projection_start, + $order_position, + $limit_position, + $statement_end, + $include_limit + ); + } + + if ( + ! isset( $tokens[ $group_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_position + 1 ]->id + || $group_position + 2 >= $order_position + ) { + return null; + } + + return $this->translate_strict_grouped_order_by_query( + $tokens, + $projection_start, + $group_position, + $order_position, + $limit_position, + $statement_end, + $has_distinct, + $include_limit + ); + } + + /** + * Drop ORDER BY from scalar COUNT-only aggregate queries. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First projection token position. + * @param int $order_position ORDER token position. + * @param int|null $limit_position LIMIT token position, or null. + * @param int $statement_end Final statement token position, exclusive. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_strict_aggregate_only_order_by_query( + array $tokens, + int $projection_start, + int $order_position, + ?int $limit_position, + int $statement_end, + bool $include_limit = true + ): ?string { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $projection_start, $order_position ); + if ( null === $from_position || $projection_start === $from_position ) { + return null; + } + + if ( ! $this->is_mysql_count_only_projection( $tokens, $projection_start, $from_position ) ) { + return null; + } + + $sql = 'SELECT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, $projection_start, $order_position ); + if ( $include_limit && null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Translate targeted grouped ORDER BY expressions to aggregate-safe forms. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First projection token position. + * @param int $group_position GROUP token position. + * @param int $order_position ORDER token position. + * @param int|null $limit_position LIMIT token position, or null. + * @param int $statement_end Final statement token position, exclusive. + * @param bool $has_distinct Whether the original SELECT used DISTINCT. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_strict_grouped_order_by_query( + array $tokens, + int $projection_start, + int $group_position, + int $order_position, + ?int $limit_position, + int $statement_end, + bool $has_distinct, + bool $include_limit = true + ): ?string { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $projection_start, $group_position ); + if ( null === $from_position || $projection_start === $from_position ) { + return null; + } + + $projection_items = $this->parse_mysql_select_projection_items( $tokens, $projection_start, $from_position ); + if ( null === $projection_items ) { + return null; + } + + $group_items = $this->split_top_level_mysql_arguments( $tokens, $group_position + 2, $order_position ); + if ( null === $group_items || count( $group_items ) < 1 ) { + return null; + } + + $scope_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::WHERE_SYMBOL, + $from_position + 1, + $group_position + ) ?? $group_position; + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $scope_end ); + if ( null === $scope ) { + return null; + } + + $select_end = $limit_position ?? $statement_end; + $order_items = $this->parse_mysql_select_order_by_items( + $tokens, + $order_position + 2, + $select_end, + $projection_items, + $scope + ); + if ( null === $order_items ) { + return null; + } + + $archive_date_expression = $this->get_mysql_archive_grouped_date_expression_bounds( $tokens, $group_items ); + $is_comment_id_group = $this->is_mysql_comment_id_grouped_select_shape( $tokens, $projection_items, $group_items ); + $is_post_id_group = $this->is_mysql_post_id_grouped_select_shape( $tokens, $projection_items, $group_items ); + + if ( + $has_distinct + && ( + null === $archive_date_expression + || ! $this->is_mysql_redundant_distinct_week_archive_select_shape( + $tokens, + $projection_items, + $group_items, + $archive_date_expression + ) + ) + ) { + return $this->translate_distinct_strict_grouped_order_by_query( + $tokens, + $projection_start, + $projection_items, + $group_items, + $order_items, + $from_position, + $group_position, + $order_position, + $limit_position, + $statement_end, + $include_limit + ); + } + + if ( null === $archive_date_expression && ! $is_comment_id_group && ! $is_post_id_group ) { + return null; + } + + $order_sql = array(); + $rewritten = false; + foreach ( $order_items as $order_item ) { + if ( + null !== $order_item['projection_index'] + || $this->is_mysql_grouped_order_expression( $tokens, $order_item, $group_items ) + ) { + $order_sql[] = $order_item['sql'] . ' ' . $order_item['direction']; + continue; + } + + if ( + null !== $archive_date_expression + && $this->is_mysql_archive_post_date_order_expression( $tokens, $order_item, $archive_date_expression ) + ) { + $order_sql[] = $this->get_strict_grouped_aggregate_order_sql( $order_item ); + $rewritten = true; + continue; + } + + if ( + ( + $is_comment_id_group + && $this->is_mysql_comment_id_grouped_order_expression( $tokens, $order_item ) + ) + || ( + $is_post_id_group + && $this->is_mysql_post_id_grouped_order_expression( $tokens, $order_item ) + ) + ) { + $order_sql[] = $this->get_strict_grouped_aggregate_order_sql( $order_item ); + $rewritten = true; + continue; + } + + return null; + } + + $tiebreaker_sql = $this->get_strict_grouped_posts_post_date_desc_order_id_tiebreaker_sql( + $tokens, + $order_items, + $group_items, + $is_post_id_group + ); + if ( null !== $tiebreaker_sql ) { + $order_sql[] = $tiebreaker_sql; + $rewritten = true; + } + + if ( ! $rewritten ) { + return null; + } + + $replacements = array(); + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $projection_start, $group_position ); + if ( null !== $archive_date_expression ) { + $replacements = array_merge( + $replacements, + $this->get_mysql_archive_grouped_projection_replacements( + $tokens, + $projection_items, + $archive_date_expression + ) + ); + } + + if ( null !== $where_position ) { + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $group_position, + $scope + ); + if ( $where_sql['changed'] ) { + $replacements[] = array( + 'start' => $where_position + 1, + 'end' => $group_position, + 'sql' => $where_sql['sql'], + ); + } + } + + $sql = 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $projection_start, + $order_position, + $replacements + ) + . ' ORDER BY ' . implode( ', ', $order_sql ); + if ( $include_limit && null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Translate DISTINCT grouped queries that need hidden ORDER BY projections. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First projection token position. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @param array $order_items Parsed ORDER BY items. + * @param int $from_position FROM token position. + * @param int $group_position GROUP token position. + * @param int $order_position ORDER token position. + * @param int|null $limit_position LIMIT token position, or null. + * @param int $statement_end Final statement token position, exclusive. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_distinct_strict_grouped_order_by_query( + array $tokens, + int $projection_start, + array $projection_items, + array $group_items, + array $order_items, + int $from_position, + int $group_position, + int $order_position, + ?int $limit_position, + int $statement_end, + bool $include_limit = true + ): ?string { + $select_end = $limit_position ?? $statement_end; + if ( + $this->contains_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::SELECT_SYMBOL, + ) + ) + ) { + return null; + } + + $has_hidden_order_expression = false; + foreach ( $order_items as $order_item ) { + if ( null === $order_item['projection_index'] ) { + $has_hidden_order_expression = true; + break; + } + } + + if ( ! $has_hidden_order_expression ) { + return null; + } + + if ( + ! $this->contains_mysql_aggregate_call( $tokens, $projection_start, $select_end ) + && $this->is_mysql_distinct_grouped_projection_shape( $tokens, $projection_items, $group_items ) + ) { + return $this->build_distinct_strict_grouped_order_by_query( + $tokens, + $projection_items, + $this->get_mysql_group_by_item_sql( $tokens, $group_items ), + $order_items, + $from_position, + $group_position, + $order_position, + $limit_position, + $statement_end, + $include_limit + ); + } + + $group_by_sql = $this->get_mysql_distinct_term_taxonomy_group_by_sql( + $tokens, + $projection_items, + $group_items, + $order_items, + $from_position, + $group_position + ); + if ( null === $group_by_sql ) { + return null; + } + + return $this->build_distinct_strict_grouped_order_by_query( + $tokens, + $projection_items, + $group_by_sql, + $order_items, + $from_position, + $group_position, + $order_position, + $limit_position, + $statement_end, + $include_limit + ); + } + + /** + * Translate parsed GROUP BY items to PostgreSQL SQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $group_items Parsed GROUP BY item ranges. + * @return string[] PostgreSQL GROUP BY expressions. + */ + private function get_mysql_group_by_item_sql( array $tokens, array $group_items ): array { + $group_by_sql = array(); + foreach ( $group_items as $group_item ) { + $group_by_sql[] = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $group_item['start'], + $group_item['end'] + ); + } + + return $group_by_sql; + } + + /** + * Check whether DISTINCT projection expressions exactly match GROUP BY. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether grouping already preserves DISTINCT cardinality. + */ + private function is_mysql_distinct_grouped_projection_shape( array $tokens, array $projection_items, array $group_items ): bool { + if ( count( $projection_items ) !== count( $group_items ) ) { + return false; + } + + $matched_group_items = array(); + foreach ( $projection_items as $projection_item ) { + $matched = false; + foreach ( $group_items as $group_index => $group_item ) { + if ( isset( $matched_group_items[ $group_index ] ) ) { + continue; + } + + if ( + $this->are_mysql_token_ranges_equivalent( + $tokens, + $projection_item['expression_start'], + $projection_item['expression_end'], + $group_item['start'], + $group_item['end'] + ) + ) { + $matched_group_items[ $group_index ] = true; + $matched = true; + break; + } + } + + if ( ! $matched ) { + return false; + } + } + + return true; + } + + /** + * Get GROUP BY expressions for the supported single-taxonomy term query. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @param array $order_items Parsed ORDER BY items. + * @param int $from_position FROM token position. + * @param int $group_position GROUP token position. + * @return string[]|null PostgreSQL GROUP BY expressions, or null when unsupported. + */ + private function get_mysql_distinct_term_taxonomy_group_by_sql( + array $tokens, + array $projection_items, + array $group_items, + array $order_items, + int $from_position, + int $group_position + ): ?array { + if ( + ! $this->is_mysql_distinct_term_taxonomy_projection_shape( $tokens, $projection_items ) + || ! $this->is_mysql_distinct_term_taxonomy_group_shape( $tokens, $group_items ) + || ! $this->is_mysql_distinct_term_taxonomy_order_shape( $tokens, $order_items ) + ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $group_position ); + if ( + null === $where_position + || ! $this->is_mysql_distinct_term_taxonomy_from_shape( $tokens, $from_position, $where_position ) + || ! $this->has_mysql_single_term_taxonomy_predicate( $tokens, $where_position + 1, $group_position ) + ) { + return null; + } + + return array( + 't.term_id', + 'tt.term_taxonomy_id', + 'tt.taxonomy', + 'tt.description', + 'tt.parent', + ); + } + + /** + * Check whether the projection is WordPress's term query result shape. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @return bool Whether the projection shape is supported. + */ + private function is_mysql_distinct_term_taxonomy_projection_shape( array $tokens, array $projection_items ): bool { + if ( 6 !== count( $projection_items ) ) { + return false; + } + + $expected_columns = array( + array( 't', 'term_id', 'term_id' ), + array( 'tt', 'term_taxonomy_id', 'term_taxonomy_id' ), + array( 'tt', 'taxonomy', 'taxonomy' ), + array( 'tt', 'description', 'description' ), + array( 'tt', 'parent', 'parent' ), + ); + foreach ( $expected_columns as $index => $expected_column ) { + if ( + ! $this->is_mysql_projection_item_qualified_column( + $tokens, + $projection_items[ $index ], + $expected_column[0], + $expected_column[1], + $expected_column[2] + ) + ) { + return false; + } + } + + return $this->is_mysql_count_post_type_projection_item( $tokens, $projection_items[5] ); + } + + /** + * Check whether a projection item is a specific qualified column. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $item Parsed projection item. + * @param string $alias Expected table alias. + * @param string $column Expected column name. + * @param string $name Expected output name. + * @return bool Whether the projection item matches. + */ + private function is_mysql_projection_item_qualified_column( array $tokens, array $item, string $alias, string $column, string $name ): bool { + return strtolower( $item['alias'] ) === $name + && $this->is_mysql_exact_qualified_column_expression( + $tokens, + $item['expression_start'], + $item['expression_end'], + $alias, + $column + ); + } + + /** + * Check whether a projection item is COUNT(p.post_type) AS count. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $item Parsed projection item. + * @return bool Whether the projection item matches. + */ + private function is_mysql_count_post_type_projection_item( array $tokens, array $item ): bool { + if ( 'count' !== strtolower( $item['alias'] ) ) { + return false; + } + + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $item['expression_start'], $item['expression_end'] ); + if ( + ! isset( $tokens[ $bounds['start'] ], $tokens[ $bounds['start'] + 1 ] ) + || ! $this->is_mysql_token_value( $tokens[ $bounds['start'] ], 'count' ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $bounds['start'] + 1 ]->id + || $this->get_mysql_parenthesized_sequence_end( $tokens, $bounds['start'] + 1, $bounds['end'] ) !== $bounds['end'] + ) { + return false; + } + + return $this->is_mysql_exact_qualified_column_expression( + $tokens, + $bounds['start'] + 2, + $bounds['end'] - 1, + 'p', + 'post_type' + ); + } + + /** + * Check whether GROUP BY is exactly t.term_id. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether the group shape is supported. + */ + private function is_mysql_distinct_term_taxonomy_group_shape( array $tokens, array $group_items ): bool { + return 1 === count( $group_items ) + && $this->is_mysql_exact_qualified_column_expression( + $tokens, + $group_items[0]['start'], + $group_items[0]['end'], + 't', + 'term_id' + ); + } + + /** + * Check whether ORDER BY can be hidden for the supported term query. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_items Parsed ORDER BY items. + * @return bool Whether the order shape is supported. + */ + private function is_mysql_distinct_term_taxonomy_order_shape( array $tokens, array $order_items ): bool { + if ( 1 !== count( $order_items ) || null !== $order_items[0]['projection_index'] ) { + return false; + } + + return $this->is_mysql_exact_qualified_column_expression( + $tokens, + $order_items[0]['expression_start'], + $order_items[0]['expression_end'], + 't', + 'name' + ); + } + + /** + * Check whether FROM begins with terms t joined to term_taxonomy tt. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $from_position FROM token position. + * @param int $from_end Final FROM-clause token, exclusive. + * @return bool Whether the FROM shape is supported. + */ + private function is_mysql_distinct_term_taxonomy_from_shape( array $tokens, int $from_position, int $from_end ): bool { + $terms_reference = $this->parse_mysql_table_reference( $tokens, $from_position + 1, $from_end ); + if ( + null === $terms_reference + || ! $this->is_mysql_wordpress_table_reference( $terms_reference, 'terms', 't' ) + ) { + return false; + } + + $position = $terms_reference['position']; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + $term_taxonomy_reference = $this->parse_mysql_table_reference( $tokens, $position + 1, $from_end ); + if ( + null === $term_taxonomy_reference + || ! $this->is_mysql_wordpress_table_reference( $term_taxonomy_reference, 'term_taxonomy', 'tt' ) + ) { + return false; + } + + $position = $term_taxonomy_reference['position']; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + $predicate_end = $this->find_mysql_join_predicate_end( $tokens, $position + 1, $from_end ); + $pair = $this->get_mysql_top_level_simple_column_equality_pair( $tokens, $position + 1, $predicate_end ); + return null !== $pair && $this->is_mysql_wordpress_term_split_column_equality_pair( $pair ); + } + + /** + * Check whether WHERE constrains tt.taxonomy to one string literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First WHERE predicate token. + * @param int $end Final WHERE predicate token, exclusive. + * @return bool Whether a single taxonomy predicate is present. + */ + private function has_mysql_single_term_taxonomy_predicate( array $tokens, int $start, int $end ): bool { + $conjuncts = $this->split_mysql_top_level_boolean_conjuncts( $tokens, $start, $end ); + if ( null === $conjuncts ) { + return false; + } + + $matched = false; + foreach ( $conjuncts as $conjunct ) { + if ( ! $this->is_mysql_single_term_taxonomy_predicate( $tokens, $conjunct['start'], $conjunct['end'] ) ) { + continue; + } + + if ( $matched ) { + return false; + } + + $matched = true; + } + + return $matched; + } + + /** + * Check whether a predicate is tt.taxonomy = literal or IN (single literal). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token. + * @param int $end Final predicate token, exclusive. + * @return bool Whether the predicate constrains one taxonomy value. + */ + private function is_mysql_single_term_taxonomy_predicate( array $tokens, int $start, int $end ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( + null === $reference + || $reference['end'] >= $end + || 'tt' !== strtolower( (string) $reference['qualifier'] ) + || 'taxonomy' !== strtolower( $reference['column'] ) + ) { + return false; + } + + if ( + WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $reference['end'] ]->id + && $this->is_mysql_string_literal_range( $tokens, $reference['end'] + 1, $end ) + ) { + return true; + } + + if ( + WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $reference['end'] ]->id + || ! isset( $tokens[ $reference['end'] + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $reference['end'] + 1 ]->id + ) { + return false; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $reference['end'] + 1, $end ); + if ( $after_close !== $end ) { + return false; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $reference['end'] + 2, $end - 1 ); + return null !== $items + && 1 === count( $items ) + && $this->is_mysql_string_literal_range( $tokens, $items[0]['start'], $items[0]['end'] ); + } + + /** + * Check whether an expression is exactly a qualified column reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param string $alias Expected table alias. + * @param string $column Expected column name. + * @return bool Whether the expression matches. + */ + private function is_mysql_exact_qualified_column_expression( array $tokens, int $start, int $end, string $alias, string $column ): bool { + $column_expression = $this->get_mysql_simple_qualified_column_expression( $tokens, $start, $end ); + return null !== $column_expression + && strtolower( $alias ) === $column_expression['qualifier'] + && strtolower( $column ) === $column_expression['column']; + } + + /** + * Build a derived-table rewrite for DISTINCT grouped ORDER BY queries. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param string[] $group_by_sql PostgreSQL GROUP BY expressions. + * @param array $order_items Parsed ORDER BY items. + * @param int $from_position FROM token position. + * @param int $group_position GROUP token position. + * @param int $order_position ORDER token position. + * @param int|null $limit_position LIMIT token position, or null. + * @param int $statement_end Final statement token position, exclusive. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. + * @return string PostgreSQL query. + */ + private function build_distinct_strict_grouped_order_by_query( + array $tokens, + array $projection_items, + array $group_by_sql, + array $order_items, + int $from_position, + int $group_position, + int $order_position, + ?int $limit_position, + int $statement_end, + bool $include_limit = true + ): string { + $derived_table_alias = '__wp_pg_distinct'; + $quoted_derived_table_alias = $this->connection->quote_identifier( $derived_table_alias ); + $inner_projection_sql = array(); + $outer_projection_sql = array(); + + foreach ( $projection_items as $projection_item ) { + $quoted_alias = $this->connection->quote_identifier( $projection_item['alias'] ); + $inner_projection_sql[] = $projection_item['sql'] . ' AS ' . $quoted_alias; + $outer_projection_sql[] = sprintf( + '%s.%s AS %s', + $quoted_derived_table_alias, + $quoted_alias, + $quoted_alias + ); + } + + foreach ( $order_items as $index => $order_item ) { + if ( null !== $order_item['projection_index'] ) { + continue; + } + + $aggregate_function = 'DESC' === $order_item['direction'] ? 'MAX' : 'MIN'; + $quoted_order_alias = $this->connection->quote_identifier( $this->get_distinct_order_by_hidden_alias( $index ) ); + $inner_projection_sql[] = sprintf( + '%s(%s) AS %s', + $aggregate_function, + $order_item['sql'], + $quoted_order_alias + ); + } + + $sql = sprintf( + 'SELECT %s FROM (SELECT DISTINCT %s %s GROUP BY %s) AS %s ORDER BY %s', + implode( ', ', $outer_projection_sql ), + implode( ', ', $inner_projection_sql ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $group_position ), + implode( ', ', $group_by_sql ), + $quoted_derived_table_alias, + $this->get_distinct_order_by_outer_order_sql( $projection_items, $order_items, $quoted_derived_table_alias ) + ); + + if ( $include_limit && null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Translate grouped SELECT queries that reference projection aliases in HAVING. + * + * MySQL resolves SELECT aliases in HAVING, but PostgreSQL does not. Keep this + * rewrite limited to aliases whose projected expression is valid in a grouped + * HAVING clause so unsupported grouping shapes still fail visibly. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_grouped_having_alias_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + 1, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 1, $statement_end ); + $select_end = $limit_position ?? $statement_end; + if ( null !== $limit_position && ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + + $order_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, 1, $select_end ); + if ( + null !== $order_position + && ( + ! isset( $tokens[ $order_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + || $order_position + 2 >= $select_end + ) + ) { + return null; + } + + $having_end = $order_position ?? $select_end; + $having_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::HAVING_SYMBOL, 1, $having_end ); + if ( null === $having_position || $having_position + 1 >= $having_end ) { + return null; + } + + $group_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, 1, $having_position ); + if ( + null === $group_position + || ! isset( $tokens[ $group_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $group_position + 1 ]->id + || $group_position + 2 >= $having_position + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $group_position ); + if ( null === $from_position || 1 === $from_position ) { + return null; + } + + $group_items = $this->split_top_level_mysql_arguments( $tokens, $group_position + 2, $having_position ); + if ( null === $group_items || count( $group_items ) < 1 ) { + return null; + } + + $alias_expressions = $this->get_mysql_grouped_having_projection_alias_expressions( + $tokens, + 1, + $from_position, + $group_items + ); + if ( null === $alias_expressions || empty( $alias_expressions ) ) { + return null; + } + + $having_sql = $this->translate_mysql_having_alias_predicate_to_postgresql( + $tokens, + $having_position + 1, + $having_end, + $alias_expressions + ); + if ( null === $having_sql ) { + return null; + } + + $replacements = array(); + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $group_position ); + if ( null !== $where_position ) { + $scope_end = $where_position; + } else { + $scope_end = $group_position; + } + + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $scope_end ); + if ( null === $scope ) { + return null; + } + + if ( null !== $where_position ) { + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $group_position, + $scope + ); + if ( $where_sql['changed'] ) { + $replacements[] = array( + 'start' => $where_position + 1, + 'end' => $group_position, + 'sql' => $where_sql['sql'], + ); + } + } + + $group_by_extensions = $this->get_mysql_grouped_having_group_by_projection_extensions( + $tokens, + 1, + $from_position, + $group_position, + $having_position, + $group_items + ); + if ( null === $group_by_extensions ) { + return null; + } + + if ( ! empty( $group_by_extensions ) ) { + $replacements[] = array( + 'start' => $group_position + 2, + 'end' => $having_position, + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( $tokens, $group_position + 2, $having_position ) + . ', ' . implode( ', ', $group_by_extensions ), + ); + } + + $replacements[] = array( + 'start' => $having_position + 1, + 'end' => $having_end, + 'sql' => $having_sql, + ); + + return 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 1, + $statement_end, + $replacements + ); + } + + /** + * Get projection aliases that can be substituted safely in grouped HAVING. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @param array $group_items Parsed GROUP BY items. + * @return array|null Alias expressions keyed by lowercase alias. + */ + private function get_mysql_grouped_having_projection_alias_expressions( array $tokens, int $start, int $end, array $group_items ): ?array { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || count( $ranges ) < 1 ) { + return null; + } + + $aliases = array(); + foreach ( $ranges as $range ) { + $item = $this->parse_mysql_aliased_projection_expression( $tokens, $range['start'], $range['end'] ); + if ( null === $item ) { + continue; + } + + $alias_key = strtolower( $item['alias'] ); + if ( isset( $aliases[ $alias_key ] ) ) { + return null; + } + + if ( + ! $this->contains_mysql_aggregate_call( $tokens, $item['expression_start'], $item['expression_end'] ) + && ! $this->is_mysql_grouped_projection_expression( $tokens, $item, $group_items ) + ) { + continue; + } + + $aliases[ $alias_key ] = array( + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $item['expression_start'], + $item['expression_end'] + ), + ); + } + + return $aliases; + } + + /** + * Get GROUP BY extensions for selected columns equivalent to grouped columns. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First projection token position. + * @param int $from_position FROM token position. + * @param int $group_position GROUP token position. + * @param int $having_position HAVING token position. + * @param array $group_items Parsed GROUP BY items. + * @return string[]|null PostgreSQL GROUP BY expressions to append, or null when unsupported. + */ + private function get_mysql_grouped_having_group_by_projection_extensions( + array $tokens, + int $projection_start, + int $from_position, + int $group_position, + int $having_position, + array $group_items + ): ?array { + $projection_items = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $from_position ); + if ( null === $projection_items ) { + return null; + } + + $grouped_columns = array(); + foreach ( $group_items as $group_item ) { + $grouped_column = $this->get_mysql_simple_qualified_column_expression( + $tokens, + $group_item['start'], + $group_item['end'] + ); + if ( null !== $grouped_column ) { + $grouped_columns[] = $grouped_column; + } + } + + if ( empty( $grouped_columns ) ) { + return array(); + } + + $extensions = array(); + $extension_keys = array(); + $equivalent_columns = null; + foreach ( $projection_items as $projection_item ) { + $bounds = $this->get_mysql_projection_expression_bounds( $tokens, $projection_item['start'], $projection_item['end'] ); + if ( null === $bounds ) { + continue; + } + + $projection_column = $this->get_mysql_simple_qualified_column_expression( $tokens, $bounds['start'], $bounds['end'] ); + if ( null === $projection_column ) { + continue; + } + + if ( $this->is_mysql_projection_column_grouped( $projection_column, $grouped_columns ) ) { + continue; + } + + if ( null === $equivalent_columns ) { + $equivalent_columns = $this->get_mysql_safe_grouped_having_column_equality_pairs( + $tokens, + $from_position, + $group_position + ); + if ( null === $equivalent_columns || empty( $equivalent_columns ) ) { + return null; + } + } + + $extended = false; + foreach ( $grouped_columns as $grouped_column ) { + if ( ! $this->are_mysql_simple_columns_equivalent( $projection_column, $grouped_column, $equivalent_columns ) ) { + continue; + } + + $extension_key = $projection_column['key']; + if ( isset( $extension_keys[ $extension_key ] ) ) { + $extended = true; + break; + } + + $extensions[] = $projection_column['sql']; + $extension_keys[ $extension_key ] = true; + $extended = true; + break; + } + + if ( ! $extended ) { + return null; + } + } + + return $extensions; + } + + /** + * Get expression bounds for a SELECT projection item. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection item token position. + * @param int $end Final projection item token position, exclusive. + * @return array{start: int, end: int}|null Expression bounds, or null when malformed. + */ + private function get_mysql_projection_expression_bounds( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $expression_end = $end; + $as_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $start, $end ); + if ( null !== $as_position ) { + if ( $as_position <= $start || $as_position + 2 !== $end ) { + return null; + } + + $expression_end = $as_position; + } elseif ( null !== $this->get_mysql_implicit_projection_alias( $tokens, $start, $end ) ) { + $expression_end = $end - 1; + } + + return $start >= $expression_end + ? null + : array( + 'start' => $start, + 'end' => $expression_end, + ); + } + + /** + * Parse a simple qualified column expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @return array{qualifier: string, column: string, key: string, sql: string}|null Column data, or null when unsupported. + */ + private function get_mysql_simple_qualified_column_expression( array $tokens, int $start, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + return $this->get_mysql_unwrapped_simple_qualified_column_expression( $tokens, $bounds['start'], $bounds['end'] ); + } + + /** + * Parse a simple qualified column expression without removing wrapper parentheses. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @return array{qualifier: string, column: string, key: string, sql: string}|null Column data, or null when unsupported. + */ + private function get_mysql_unwrapped_simple_qualified_column_expression( array $tokens, int $start, int $end ): ?array { + if ( + $start + 3 !== $end + || ! isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return null; + } + + $qualifier = $this->get_mysql_identifier_token_value( $tokens[ $start ] ); + $column = $this->get_mysql_identifier_token_value( $tokens[ $start + 2 ] ); + if ( null === $qualifier || null === $column ) { + return null; + } + + $key = strtolower( $qualifier ) . '.' . strtolower( $column ); + return array( + 'qualifier' => strtolower( $qualifier ), + 'column' => strtolower( $column ), + 'key' => $key, + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ), + ); + } + + /** + * Get safe qualified column equality pairs for grouped HAVING rewrites. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $from_position FROM token position. + * @param int $group_position GROUP token position. + * @return array>|null Column equality adjacency map, or null when unsupported. + */ + private function get_mysql_safe_grouped_having_column_equality_pairs( array $tokens, int $from_position, int $group_position ): ?array { + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_position + 1, $group_position ); + $from_end = $where_position ?? $group_position; + + $pairs = $this->get_mysql_inner_join_column_equality_pairs( $tokens, $from_position + 1, $from_end ); + if ( null === $pairs ) { + return null === $where_position + ? $this->get_mysql_wordpress_term_split_left_join_column_equality_pairs( $tokens, $from_position + 1, $from_end ) + : null; + } + + if ( null === $where_position ) { + return $pairs; + } + + $where_pairs = $this->get_mysql_top_level_conjunct_column_equality_pairs( $tokens, $where_position + 1, $group_position ); + if ( null === $where_pairs ) { + return null; + } + + $this->merge_mysql_column_equality_pairs( $pairs, $where_pairs ); + return $pairs; + } + + /** + * Get qualified column equality pairs from supported inner JOIN predicates. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First FROM-clause token position. + * @param int $end Final FROM-clause token position, exclusive. + * @return array>|null Column equality adjacency map, or null when unsupported. + */ + private function get_mysql_inner_join_column_equality_pairs( array $tokens, int $start, int $end ): ?array { + $pairs = array(); + + for ( $position = $start; $position < $end; $position++ ) { + $token_id = $tokens[ $position ]->id; + if ( + WP_MySQL_Lexer::LEFT_SYMBOL === $token_id + || WP_MySQL_Lexer::NATURAL_SYMBOL === $token_id + || WP_MySQL_Lexer::OUTER_SYMBOL === $token_id + || WP_MySQL_Lexer::RIGHT_SYMBOL === $token_id + || WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === $token_id + || WP_MySQL_Lexer::USING_SYMBOL === $token_id + ) { + return null; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $token_id ) { + return null; + } + + if ( WP_MySQL_Lexer::ON_SYMBOL !== $token_id ) { + continue; + } + + $predicate_end = $this->find_mysql_join_predicate_end( $tokens, $position + 1, $end ); + $join_pairs = $this->get_mysql_top_level_conjunct_column_equality_pairs( $tokens, $position + 1, $predicate_end ); + if ( null === $join_pairs ) { + return null; + } + + $this->merge_mysql_column_equality_pairs( $pairs, $join_pairs ); + $position = $predicate_end - 1; + } + + return $pairs; + } + + /** + * Get equality pairs for WordPress core's legacy shared-term split query. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First FROM-clause token position. + * @param int $end Final FROM-clause token position, exclusive. + * @return array>|null Column equality adjacency map, or null when unsupported. + */ + private function get_mysql_wordpress_term_split_left_join_column_equality_pairs( array $tokens, int $start, int $end ): ?array { + $term_taxonomy_reference = $this->parse_mysql_table_reference( $tokens, $start, $end ); + if ( + null === $term_taxonomy_reference + || ! $this->is_mysql_wordpress_table_reference( $term_taxonomy_reference, 'term_taxonomy', 'tt' ) + ) { + return null; + } + + $position = $term_taxonomy_reference['position']; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::LEFT_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OUTER_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::JOIN_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $terms_reference = $this->parse_mysql_table_reference( $tokens, $position + 1, $end ); + if ( + null === $terms_reference + || ! $this->is_mysql_wordpress_table_reference( $terms_reference, 'terms', 't' ) + ) { + return null; + } + + $position = $terms_reference['position']; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $predicate_end = $this->find_mysql_join_predicate_end( $tokens, $position + 1, $end ); + if ( $predicate_end !== $end ) { + return null; + } + + $pair = $this->get_mysql_top_level_simple_column_equality_pair( $tokens, $position + 1, $predicate_end ); + if ( null === $pair || ! $this->is_mysql_wordpress_term_split_column_equality_pair( $pair ) ) { + return null; + } + + return array( + 't.term_id' => array( + 'tt.term_id' => true, + ), + 'tt.term_id' => array( + 't.term_id' => true, + ), + ); + } + + /** + * Check whether a table reference matches a WordPress core table and alias. + * + * @param array $reference Parsed table reference. + * @param string $table_base Expected unprefixed table name. + * @param string $alias Expected alias. + * @return bool Whether the reference matches. + */ + private function is_mysql_wordpress_table_reference( array $reference, string $table_base, string $alias ): bool { + $reference_alias = strtolower( null === $reference['alias'] ? $reference['table'] : $reference['alias'] ); + if ( $alias !== $reference_alias ) { + return false; + } + + return $this->is_mysql_wordpress_table_name( $reference['table'], $table_base ); + } + + /** + * Check whether a table name matches a WordPress core table base name. + * + * @param string $table_name Table name. + * @param string $table_base Expected unprefixed table name. + * @return bool Whether the table name matches. + */ + private function is_mysql_wordpress_table_name( string $table_name, string $table_base ): bool { + $table_name = strtolower( $table_name ); + $table_base = strtolower( $table_base ); + return $table_base === $table_name + || substr( $table_name, -strlen( '_' . $table_base ) ) === '_' . $table_base; + } + + /** + * Check whether an equality pair is t.term_id = tt.term_id. + * + * @param array $pair Parsed equality pair. + * @return bool Whether this is the WordPress shared-term split equality. + */ + private function is_mysql_wordpress_term_split_column_equality_pair( array $pair ): bool { + return ( + 't.term_id' === $pair['left']['key'] + && 'tt.term_id' === $pair['right']['key'] + ) || ( + 'tt.term_id' === $pair['left']['key'] + && 't.term_id' === $pair['right']['key'] + ); + } + + /** + * Find the end of a JOIN ON predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First ON predicate token position. + * @param int $end Final FROM-clause token position, exclusive. + * @return int Final ON predicate token position, exclusive. + */ + private function find_mysql_join_predicate_end( array $tokens, int $start, int $end ): int { + $depth = 0; + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + --$depth; + continue; + } + + if ( + 0 === $depth + && ( + WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::INNER_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::LEFT_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::NATURAL_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::RIGHT_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === $tokens[ $position ]->id + ) + ) { + return $position; + } + } + + return $end; + } + + /** + * Get column equality pairs from a top-level AND predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token position. + * @param int $end Final predicate token position, exclusive. + * @return array>|null Column equality adjacency map, or null when unsupported. + */ + private function get_mysql_top_level_conjunct_column_equality_pairs( array $tokens, int $start, int $end ): ?array { + $conjuncts = $this->split_mysql_top_level_boolean_conjuncts( $tokens, $start, $end ); + if ( null === $conjuncts ) { + return null; + } + + $pairs = array(); + foreach ( $conjuncts as $conjunct ) { + $pair = $this->get_mysql_top_level_simple_column_equality_pair( + $tokens, + $conjunct['start'], + $conjunct['end'] + ); + if ( null === $pair ) { + continue; + } + + $pairs[ $pair['left']['key'] ][ $pair['right']['key'] ] = true; + $pairs[ $pair['right']['key'] ][ $pair['left']['key'] ] = true; + } + + return $pairs; + } + + /** + * Split a boolean predicate into top-level AND conjuncts. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token position. + * @param int $end Final predicate token position, exclusive. + * @return array|null Conjunct bounds, or null when unsupported. + */ + private function split_mysql_top_level_boolean_conjuncts( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $conjuncts = array(); + $conjunct_start = $start; + $depth = 0; + + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 !== $depth ) { + continue; + } + + if ( WP_MySQL_Lexer::OR_SYMBOL === $tokens[ $position ]->id || WP_MySQL_Lexer::XOR_SYMBOL === $tokens[ $position ]->id ) { + return null; + } + + if ( WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + if ( $conjunct_start === $position ) { + return null; + } + + $conjuncts[] = array( + 'start' => $conjunct_start, + 'end' => $position, + ); + $conjunct_start = $position + 1; + } + + if ( 0 !== $depth || $conjunct_start >= $end ) { + return null; + } + + $conjuncts[] = array( + 'start' => $conjunct_start, + 'end' => $end, + ); + + return $conjuncts; + } + + /** + * Parse a top-level simple qualified-column equality predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token position. + * @param int $end Final predicate token position, exclusive. + * @return array{left: array, right: array}|null Equality pair, or null when unsupported. + */ + private function get_mysql_top_level_simple_column_equality_pair( array $tokens, int $start, int $end ): ?array { + $equal_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::EQUAL_OPERATOR, $start, $end ); + if ( + null === $equal_position + || null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::EQUAL_OPERATOR, $equal_position + 1, $end ) + ) { + return null; + } + + $left_column = $this->get_mysql_unwrapped_simple_qualified_column_expression( $tokens, $start, $equal_position ); + $right_column = $this->get_mysql_unwrapped_simple_qualified_column_expression( $tokens, $equal_position + 1, $end ); + if ( null === $left_column || null === $right_column ) { + return null; + } + + return array( + 'left' => $left_column, + 'right' => $right_column, + ); + } + + /** + * Merge column equality adjacency maps. + * + * @param array $target Target adjacency map. + * @param array $source Source adjacency map. + */ + private function merge_mysql_column_equality_pairs( array &$target, array $source ): void { + foreach ( $source as $left_key => $right_columns ) { + foreach ( $right_columns as $right_key => $_ ) { + $target[ $left_key ][ $right_key ] = true; + } + } + } + + /** + * Check whether a selected column is already grouped. + * + * @param array $projection_column Selected column data. + * @param array $grouped_columns Grouped column data. + * @return bool Whether the selected column is grouped. + */ + private function is_mysql_projection_column_grouped( array $projection_column, array $grouped_columns ): bool { + foreach ( $grouped_columns as $grouped_column ) { + if ( $projection_column['key'] === $grouped_column['key'] ) { + return true; + } + } + + return false; + } + + /** + * Check whether two simple columns are connected by a parsed equality. + * + * @param array $left_column Left column data. + * @param array $right_column Right column data. + * @param array $equivalent_columns Column equality adjacency map. + * @return bool Whether the columns are equivalent. + */ + private function are_mysql_simple_columns_equivalent( array $left_column, array $right_column, array $equivalent_columns ): bool { + return $left_column['key'] === $right_column['key'] + || isset( $equivalent_columns[ $left_column['key'] ][ $right_column['key'] ] ); + } + + /** + * Parse a projection item that has an explicit or implicit alias. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection item token position. + * @param int $end Final projection item token position, exclusive. + * @return array{expression_start: int, expression_end: int, alias: string}|null Parsed alias expression, or null when absent. + */ + private function parse_mysql_aliased_projection_expression( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $expression_end = $end; + $alias = null; + $as_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $start, $end ); + + if ( null !== $as_position ) { + if ( $as_position <= $start || $as_position + 2 !== $end ) { + return null; + } + + $alias = $this->get_mysql_projection_alias_token_value( $tokens[ $as_position + 1 ] ?? null ); + if ( null === $alias ) { + return null; + } + + $expression_end = $as_position; + } else { + $alias = $this->get_mysql_implicit_projection_alias( $tokens, $start, $end ); + if ( null === $alias ) { + return null; + } + + $expression_end = $end - 1; + } + + if ( $start >= $expression_end ) { + return null; + } + + return array( + 'expression_start' => $start, + 'expression_end' => $expression_end, + 'alias' => $alias, + ); + } + + /** + * Check whether a projection expression is already present in GROUP BY. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $item Parsed projection item. + * @param array $group_items Parsed GROUP BY items. + * @return bool Whether the projection expression is grouped. + */ + private function is_mysql_grouped_projection_expression( array $tokens, array $item, array $group_items ): bool { + foreach ( $group_items as $group_item ) { + if ( + $this->are_mysql_token_ranges_equivalent( + $tokens, + $item['expression_start'], + $item['expression_end'], + $group_item['start'], + $group_item['end'] + ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a token range contains a MySQL aggregate function call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @return bool Whether an aggregate call is present. + */ + private function contains_mysql_aggregate_call( array $tokens, int $start, int $end ): bool { + $aggregate_token_ids = array( + WP_MySQL_Lexer::AVG_SYMBOL, + WP_MySQL_Lexer::BIT_AND_SYMBOL, + WP_MySQL_Lexer::BIT_OR_SYMBOL, + WP_MySQL_Lexer::BIT_XOR_SYMBOL, + WP_MySQL_Lexer::COUNT_SYMBOL, + WP_MySQL_Lexer::GROUP_CONCAT_SYMBOL, + WP_MySQL_Lexer::MAX_SYMBOL, + WP_MySQL_Lexer::MIN_SYMBOL, + WP_MySQL_Lexer::STD_SYMBOL, + WP_MySQL_Lexer::STDDEV_POP_SYMBOL, + WP_MySQL_Lexer::STDDEV_SAMP_SYMBOL, + WP_MySQL_Lexer::STDDEV_SYMBOL, + WP_MySQL_Lexer::SUM_SYMBOL, + WP_MySQL_Lexer::VAR_POP_SYMBOL, + WP_MySQL_Lexer::VAR_SAMP_SYMBOL, + WP_MySQL_Lexer::VARIANCE_SYMBOL, + ); + + for ( $position = $start; $position < $end; $position++ ) { + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null !== $after_subquery ) { + $position = $after_subquery - 1; + continue; + } + } + + if ( + isset( $tokens[ $position + 1 ] ) + && in_array( $tokens[ $position ]->id, $aggregate_token_ids, true ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position + 1 ]->id + ) { + return true; + } + } + + return false; + } + + /** + * Translate HAVING predicate aliases to their projection expressions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First HAVING predicate token. + * @param int $end Final HAVING predicate token, exclusive. + * @param array $alias_expressions Projection alias SQL keyed by lowercase alias. + * @return string|null Translated HAVING SQL, or null when no alias was changed. + */ + private function translate_mysql_having_alias_predicate_to_postgresql( array $tokens, int $start, int $end, array $alias_expressions ): ?string { + $chunks = array(); + $segment_start = $start; + $changed = false; + + for ( $position = $start; $position < $end; $position++ ) { + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null !== $after_subquery ) { + $position = $after_subquery - 1; + continue; + } + } + + $alias = $this->get_mysql_order_by_alias_token_value( $tokens[ $position ] ?? null ); + if ( + null === $alias + || ! $this->is_unqualified_mysql_having_alias_reference( $tokens, $position, $end ) + || ! isset( $alias_expressions[ strtolower( $alias ) ] ) + ) { + continue; + } + + if ( $segment_start < $position ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $position ); + } + + $chunks[] = '(' . $alias_expressions[ strtolower( $alias ) ]['sql'] . ')'; + $segment_start = $position + 1; + $changed = true; + } + + if ( ! $changed ) { + return null; + } + + if ( $segment_start < $end ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $end ); + } + + return implode( ' ', array_filter( $chunks, 'strlen' ) ); + } + + /** + * Check whether a HAVING token is an unqualified alias reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate alias token position. + * @param int $end Final HAVING predicate token, exclusive. + * @return bool Whether the token can be replaced as an alias. + */ + private function is_unqualified_mysql_having_alias_reference( array $tokens, int $position, int $end ): bool { + if ( isset( $tokens[ $position - 1 ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position - 1 ]->id ) { + return false; + } + + if ( + isset( $tokens[ $position + 1 ] ) + && ( + WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position + 1 ]->id + ) + ) { + return false; + } + + return $position < $end; + } + + /** + * Get projection replacements needed by grouped archive queries. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $archive_date_expression Shared date expression bounds. + * @return array Replacement ranges. + */ + private function get_mysql_archive_grouped_projection_replacements( array $tokens, array $projection_items, array $archive_date_expression ): array { + $replacements = array(); + + foreach ( $projection_items as $projection_item ) { + $bounds = $this->get_mysql_date_format_bounds( + $tokens, + $projection_item['expression_start'], + $projection_item['expression_end'] + ); + if ( + null === $bounds + || '%Y-%m-%d' !== $bounds['format'] + || $bounds['close'] + 1 !== $projection_item['expression_end'] + || ! $this->are_mysql_token_ranges_equivalent( + $tokens, + $archive_date_expression['start'], + $archive_date_expression['end'], + $bounds['expression_start'], + $bounds['expression_end'] + ) + ) { + continue; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + $sql = $this->get_postgresql_mysql_date_format_sql( + $bounds['format'], + sprintf( 'MAX(%s)', $expression_sql ) + ); + if ( null === $sql ) { + continue; + } + + $replacements[] = array( + 'start' => $projection_item['expression_start'], + 'end' => $projection_item['expression_end'], + 'sql' => $sql, + ); + } + + return $replacements; + } + + /** + * Check whether DISTINCT is redundant for the supported weekly archive shape. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether this is the supported weekly archive projection. + */ + private function is_mysql_redundant_distinct_week_archive_select_shape( + array $tokens, + array $projection_items, + array $group_items, + array $archive_date_expression + ): bool { + if ( 4 !== count( $projection_items ) || 2 !== count( $group_items ) ) { + return false; + } + + $expected_aliases = array( 'week', 'yr', 'yyyymmdd', 'posts' ); + foreach ( $expected_aliases as $index => $alias ) { + if ( strtolower( $projection_items[ $index ]['alias'] ) !== $alias ) { + return false; + } + } + + return $this->is_mysql_week_expression_for_archive_date( + $tokens, + $projection_items[0]['expression_start'], + $projection_items[0]['expression_end'], + $archive_date_expression + ) + && $this->is_mysql_year_expression_for_archive_date( + $tokens, + $projection_items[1]['expression_start'], + $projection_items[1]['expression_end'], + $archive_date_expression + ) + && $this->is_mysql_year_month_day_format_expression_for_archive_date( + $tokens, + $projection_items[2]['expression_start'], + $projection_items[2]['expression_end'], + $archive_date_expression + ) + && $this->is_mysql_count_aggregate_expression( + $tokens, + $projection_items[3]['expression_start'], + $projection_items[3]['expression_end'] + ) + && $this->do_mysql_group_items_include_week_and_year_for_archive_date( + $tokens, + $group_items, + $archive_date_expression + ); + } + + /** + * Check whether an expression is WEEK(archive_date, 1). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether the expression matches the archive week. + */ + private function is_mysql_week_expression_for_archive_date( array $tokens, int $start, int $end, array $archive_date_expression ): bool { + $expression = $this->get_mysql_week_argument_expression_bounds( $tokens, $start, $end ); + + return null !== $expression + && $this->are_mysql_token_ranges_equivalent( + $tokens, + $archive_date_expression['start'], + $archive_date_expression['end'], + $expression['start'], + $expression['end'] + ); + } + + /** + * Check whether an expression is YEAR(archive_date). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether the expression matches the archive year. + */ + private function is_mysql_year_expression_for_archive_date( array $tokens, int $start, int $end, array $archive_date_expression ): bool { + $expression = $this->get_mysql_extract_argument_expression_bounds( $tokens, $start, $end, 'YEAR' ); + + return null !== $expression + && $this->are_mysql_token_ranges_equivalent( + $tokens, + $archive_date_expression['start'], + $archive_date_expression['end'], + $expression['start'], + $expression['end'] + ); + } + + /** + * Check whether an expression is DATE_FORMAT(archive_date, '%Y-%m-%d'). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether the expression matches the archive date format. + */ + private function is_mysql_year_month_day_format_expression_for_archive_date( array $tokens, int $start, int $end, array $archive_date_expression ): bool { + $bounds = $this->get_mysql_date_format_bounds( $tokens, $start, $end ); + + return null !== $bounds + && '%Y-%m-%d' === $bounds['format'] + && $bounds['close'] + 1 === $end + && $this->are_mysql_token_ranges_equivalent( + $tokens, + $archive_date_expression['start'], + $archive_date_expression['end'], + $bounds['expression_start'], + $bounds['expression_end'] + ); + } + + /** + * Check whether GROUP BY contains WEEK(archive_date, 1) and YEAR(archive_date). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $group_items Parsed GROUP BY item ranges. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether both grouped date keys are present. + */ + private function do_mysql_group_items_include_week_and_year_for_archive_date( array $tokens, array $group_items, array $archive_date_expression ): bool { + $has_week = false; + $has_year = false; + + foreach ( $group_items as $group_item ) { + $has_week = $has_week || $this->is_mysql_week_expression_for_archive_date( + $tokens, + $group_item['start'], + $group_item['end'], + $archive_date_expression + ); + $has_year = $has_year || $this->is_mysql_year_expression_for_archive_date( + $tokens, + $group_item['start'], + $group_item['end'], + $archive_date_expression + ); + } + + return $has_week && $has_year; + } + + /** + * Check whether a projection is exactly one COUNT aggregate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @return bool Whether the projection is COUNT-only. + */ + private function is_mysql_count_only_projection( array $tokens, int $start, int $end ): bool { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || 1 !== count( $ranges ) ) { + return false; + } + + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( + $tokens, + $ranges[0]['start'], + $ranges[0]['end'] + ); + if ( null === $expression_bounds ) { + return false; + } + + return $this->is_mysql_count_aggregate_expression( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ); + } + + /** + * Get expression bounds for a projection item, excluding any alias. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection item token position. + * @param int $end Final projection item token position, exclusive. + * @return array{start: int, end: int}|null Expression bounds, or null when malformed. + */ + private function get_mysql_select_projection_expression_bounds( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $expression_end = $end; + $as_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::AS_SYMBOL, $start, $end ); + if ( null !== $as_position ) { + if ( + $as_position <= $start + || $as_position + 2 !== $end + || null === $this->get_mysql_projection_alias_token_value( $tokens[ $as_position + 1 ] ?? null ) + ) { + return null; + } + + $expression_end = $as_position; + } else { + $implicit_alias = $this->get_mysql_implicit_projection_alias( $tokens, $start, $end ); + if ( null !== $implicit_alias ) { + $expression_end = $end - 1; + } + } + + if ( $start >= $expression_end ) { + return null; + } + + return array( + 'start' => $start, + 'end' => $expression_end, + ); + } + + /** + * Check whether an expression is a COUNT aggregate call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether the expression is COUNT(...). + */ + private function is_mysql_count_aggregate_expression( array $tokens, int $start, int $end ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + return isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + && $this->is_mysql_token_value( $tokens[ $start ], 'count' ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start + 1 ]->id + && $this->get_mysql_parenthesized_sequence_end( $tokens, $start + 1, $end ) === $end; + } + + /** + * Get the shared post_date expression from archive date grouping. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $group_items Parsed GROUP BY item ranges. + * @return array{start: int, end: int}|null Shared post_date expression bounds, or null. + */ + private function get_mysql_archive_grouped_date_expression_bounds( array $tokens, array $group_items ): ?array { + $group_count = count( $group_items ); + if ( 1 > $group_count || 3 < $group_count ) { + return null; + } + + $year_expression = null; + $week_expression = null; + $month_expression = null; + $dayofmonth_expression = null; + $supported_expressions = 0; + foreach ( $group_items as $group_item ) { + $expression = $this->get_mysql_extract_argument_expression_bounds( + $tokens, + $group_item['start'], + $group_item['end'], + 'YEAR' + ); + if ( null !== $expression ) { + $year_expression = $expression; + ++$supported_expressions; + continue; + } + + $expression = $this->get_mysql_week_argument_expression_bounds( + $tokens, + $group_item['start'], + $group_item['end'] + ); + if ( null !== $expression ) { + $week_expression = $expression; + ++$supported_expressions; + continue; + } + + $expression = $this->get_mysql_extract_argument_expression_bounds( + $tokens, + $group_item['start'], + $group_item['end'], + 'MONTH' + ); + if ( null !== $expression ) { + $month_expression = $expression; + ++$supported_expressions; + continue; + } + + $expression = $this->get_mysql_extract_argument_expression_bounds( + $tokens, + $group_item['start'], + $group_item['end'], + 'DAY' + ); + if ( null !== $expression ) { + $dayofmonth_expression = $expression; + ++$supported_expressions; + } + } + + if ( + $group_count !== $supported_expressions + || null === $year_expression + ) { + return null; + } + + if ( null !== $week_expression ) { + if ( + 2 !== $group_count + || null !== $month_expression + || null !== $dayofmonth_expression + || ! $this->are_mysql_token_ranges_equivalent( + $tokens, + $year_expression['start'], + $year_expression['end'], + $week_expression['start'], + $week_expression['end'] + ) + ) { + return null; + } + } elseif ( + ( + 2 <= $group_count + && null === $month_expression + ) + || ( + 3 === $group_count + && null === $dayofmonth_expression + ) + || ( + null !== $month_expression + && ! $this->are_mysql_token_ranges_equivalent( + $tokens, + $year_expression['start'], + $year_expression['end'], + $month_expression['start'], + $month_expression['end'] + ) + ) + || ( + null !== $dayofmonth_expression + && ! $this->are_mysql_token_ranges_equivalent( + $tokens, + $year_expression['start'], + $year_expression['end'], + $dayofmonth_expression['start'], + $dayofmonth_expression['end'] + ) + ) + ) { + return null; + } + + if ( + ! $this->is_mysql_column_reference_expression( + $tokens, + $year_expression['start'], + $year_expression['end'], + 'post_date', + 'posts', + true + ) + ) { + return null; + } + + return $year_expression; + } + + /** + * Get the argument expression for a supported date/time extract function. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param string $unit Expected date/time unit. + * @return array{start: int, end: int}|null Argument bounds, or null. + */ + private function get_mysql_extract_argument_expression_bounds( array $tokens, int $start, int $end, string $unit ): ?array { + $expression_bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $bounds = $this->get_mysql_extract_function_bounds( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ); + if ( + null === $bounds + || $bounds['unit'] !== $unit + || $bounds['close'] + 1 !== $expression_bounds['end'] + ) { + return null; + } + + return array( + 'start' => $bounds['expression_start'], + 'end' => $bounds['expression_end'], + ); + } + + /** + * Get the argument expression for a supported WEEK() function. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return array{start: int, end: int}|null Argument bounds, or null. + */ + private function get_mysql_week_argument_expression_bounds( array $tokens, int $start, int $end ): ?array { + $expression_bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $bounds = $this->get_mysql_week_function_bounds( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ); + if ( + null === $bounds + || $bounds['close'] + 1 !== $expression_bounds['end'] + ) { + return null; + } + + return array( + 'start' => $bounds['expression_start'], + 'end' => $bounds['expression_end'], + ); + } + + /** + * Check whether ORDER BY references the archive post_date expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_item Parsed ORDER BY item. + * @param array $archive_date_expression Shared date expression bounds. + * @return bool Whether the ORDER BY expression is supported. + */ + private function is_mysql_archive_post_date_order_expression( array $tokens, array $order_item, array $archive_date_expression ): bool { + return $this->are_mysql_token_ranges_equivalent( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + $archive_date_expression['start'], + $archive_date_expression['end'] + ) || $this->is_mysql_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'post_date', + 'posts', + true + ); + } + + /** + * Check whether a SELECT is grouped by the selected comments.comment_ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether this is the supported comment ID grouped shape. + */ + private function is_mysql_comment_id_grouped_select_shape( array $tokens, array $projection_items, array $group_items ): bool { + return 1 === count( $projection_items ) + && 1 === count( $group_items ) + && $this->is_mysql_comment_id_expression( + $tokens, + $projection_items[0]['expression_start'], + $projection_items[0]['expression_end'] + ) + && $this->is_mysql_comment_id_expression( + $tokens, + $group_items[0]['start'], + $group_items[0]['end'] + ); + } + + /** + * Check whether an expression references comments.comment_ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether the expression is comments.comment_ID. + */ + private function is_mysql_comment_id_expression( array $tokens, int $start, int $end ): bool { + return $this->is_mysql_column_reference_expression( $tokens, $start, $end, 'comment_ID', 'comments', true ); + } + + /** + * Check whether a grouped comment ID ORDER BY expression can be aggregated. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_item Parsed ORDER BY item. + * @return bool Whether the ORDER BY expression is supported. + */ + private function is_mysql_comment_id_grouped_order_expression( array $tokens, array $order_item ): bool { + if ( + $this->is_mysql_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'comment_date', + 'comments', + false + ) + || $this->is_mysql_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'comment_date_gmt', + 'comments', + false + ) + || $this->is_mysql_qualified_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'meta_value' + ) + ) { + return true; + } + + $cast_bounds = $this->get_mysql_character_cast_bounds( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'] + ); + if ( null === $cast_bounds || $cast_bounds['close'] + 1 !== $order_item['expression_end'] ) { + return false; + } + + return $this->is_mysql_qualified_column_reference_expression( + $tokens, + $cast_bounds['expression_start'], + $cast_bounds['expression_end'], + 'meta_value' + ); + } + + /** + * Check whether a SELECT is grouped by the selected posts.ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $projection_items Parsed projection items. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether this is the supported post ID grouped shape. + */ + private function is_mysql_post_id_grouped_select_shape( array $tokens, array $projection_items, array $group_items ): bool { + return 1 === count( $projection_items ) + && 1 === count( $group_items ) + && $this->is_mysql_post_id_expression( + $tokens, + $projection_items[0]['expression_start'], + $projection_items[0]['expression_end'] + ) + && $this->is_mysql_post_id_expression( + $tokens, + $group_items[0]['start'], + $group_items[0]['end'] + ); + } + + /** + * Check whether an expression references posts.ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether the expression is posts.ID. + */ + private function is_mysql_post_id_expression( array $tokens, int $start, int $end ): bool { + return $this->is_mysql_column_reference_expression( $tokens, $start, $end, 'ID', 'posts', true ); + } + + /** + * Check whether a grouped post ID ORDER BY expression can be aggregated. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_item Parsed ORDER BY item. + * @return bool Whether the ORDER BY expression is supported. + */ + private function is_mysql_post_id_grouped_order_expression( array $tokens, array $order_item ): bool { + if ( + $this->is_mysql_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'post_date', + 'posts', + false + ) + || $this->is_mysql_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'post_date_gmt', + 'posts', + false + ) + || $this->is_mysql_qualified_column_reference_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + 'meta_value' + ) + ) { + return true; + } + + return $this->is_mysql_meta_value_cast_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'] + ) || $this->is_mysql_meta_value_plus_zero_expression( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'] + ); + } + + /** + * Check whether an expression is a supported metadata value CAST. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether the expression casts a qualified meta_value reference. + */ + private function is_mysql_meta_value_cast_expression( array $tokens, int $start, int $end ): bool { + $cast_bounds = $this->get_mysql_character_cast_bounds( $tokens, $start, $end ); + if ( null === $cast_bounds ) { + $cast_bounds = $this->get_mysql_integer_cast_bounds( $tokens, $start, $end ); + } + if ( null === $cast_bounds ) { + $cast_bounds = $this->get_mysql_decimal_cast_bounds( $tokens, $start, $end ); + } + if ( null === $cast_bounds ) { + $cast_bounds = $this->get_mysql_date_time_cast_bounds( $tokens, $start, $end ); + } + + return null !== $cast_bounds + && $cast_bounds['close'] + 1 === $end + && $this->is_mysql_qualified_column_reference_expression( + $tokens, + $cast_bounds['expression_start'], + $cast_bounds['expression_end'], + 'meta_value' + ); + } + + /** + * Check whether an expression is metadata value plus zero numeric ordering. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether the expression numerically orders meta_value. + */ + private function is_mysql_meta_value_plus_zero_expression( array $tokens, int $start, int $end ): bool { + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( + null !== $reference + && null !== $reference['qualifier'] + && isset( $tokens[ $reference['end'] ] ) + && WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $reference['end'] ]->id + ) { + $literal = $this->parse_mysql_numeric_literal( $tokens, $reference['end'] + 1, $end ); + return null !== $literal + && $literal['end'] === $end + && $this->is_mysql_zero_numeric_literal_range( $tokens, $literal['start'], $literal['end'] ) + && $this->is_mysql_qualified_column_reference_expression( $tokens, $start, $reference['end'], 'meta_value' ); + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + if ( + null === $literal + || ! $this->is_mysql_zero_numeric_literal_range( $tokens, $literal['start'], $literal['end'] ) + || ! isset( $tokens[ $literal['end'] ] ) + || WP_MySQL_Lexer::PLUS_OPERATOR !== $tokens[ $literal['end'] ]->id + ) { + return false; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $literal['end'] + 1, $end ); + return null !== $reference + && $reference['end'] === $end + && null !== $reference['qualifier'] + && $this->is_mysql_qualified_column_reference_expression( $tokens, $reference['start'], $reference['end'], 'meta_value' ); + } + + /** + * Check whether an ORDER BY expression is already valid for the GROUP BY. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_item Parsed ORDER BY item. + * @param array $group_items Parsed GROUP BY item ranges. + * @return bool Whether the expression is grouped. + */ + private function is_mysql_grouped_order_expression( array $tokens, array $order_item, array $group_items ): bool { + foreach ( $group_items as $group_item ) { + if ( + $this->are_mysql_token_ranges_equivalent( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'], + $group_item['start'], + $group_item['end'] + ) + ) { + return true; + } + } + + return false; + } + + /** + * Build an aggregate-safe ORDER BY item for grouped SELECTs. + * + * @param array $order_item Parsed ORDER BY item. + * @return string PostgreSQL ORDER BY item SQL. + */ + private function get_strict_grouped_aggregate_order_sql( array $order_item ): string { + $aggregate_function = 'DESC' === $order_item['direction'] ? 'MAX' : 'MIN'; + + return sprintf( + '%s(%s) %s', + $aggregate_function, + $order_item['sql'], + $order_item['direction'] + ); + } + + /** + * Get the MySQL-compatible ID tie-breaker for grouped posts date ordering. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_items Parsed ORDER BY items. + * @param array $group_items Parsed GROUP BY item ranges. + * @param bool $is_post_id_group Whether the query groups by posts.ID. + * @return string|null PostgreSQL ORDER BY item SQL, or null when not applicable. + */ + private function get_strict_grouped_posts_post_date_desc_order_id_tiebreaker_sql( + array $tokens, + array $order_items, + array $group_items, + bool $is_post_id_group + ): ?string { + if ( + ! $is_post_id_group + || 1 !== count( $order_items ) + || 1 !== count( $group_items ) + || 'DESC' !== $order_items[0]['direction'] + || ! $this->is_mysql_column_reference_expression( + $tokens, + $order_items[0]['expression_start'], + $order_items[0]['expression_end'], + 'post_date', + 'posts', + false + ) + ) { + return null; + } + + return $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $group_items[0]['start'], + $group_items[0]['end'] + ) . ' DESC'; + } + + /** + * Check whether an expression is a supported column reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param string $column_name Expected column name. + * @param string|null $qualifier_suffix Optional table-name suffix for qualified references. + * @param bool $allow_bare Whether unqualified references are allowed. + * @return bool Whether the expression is a supported column reference. + */ + private function is_mysql_column_reference_expression( + array $tokens, + int $start, + int $end, + string $column_name, + ?string $qualifier_suffix, + bool $allow_bare + ): bool { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + if ( $allow_bare && $start + 1 === $end ) { + $identifier = $this->get_mysql_identifier_token_value( $tokens[ $start ] ?? null ); + return null !== $identifier && strtolower( $identifier ) === strtolower( $column_name ); + } + + if ( + $start + 3 !== $end + || ! isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return false; + } + + $qualifier = $this->get_mysql_identifier_token_value( $tokens[ $start ] ); + $column = $this->get_mysql_identifier_token_value( $tokens[ $start + 2 ] ); + if ( null === $qualifier || null === $column || strtolower( $column ) !== strtolower( $column_name ) ) { + return false; + } + + return null === $qualifier_suffix + || strtolower( $qualifier ) === strtolower( $qualifier_suffix ) + || '_' . strtolower( $qualifier_suffix ) === substr( strtolower( $qualifier ), -1 * ( strlen( $qualifier_suffix ) + 1 ) ); + } + + /** + * Check whether an expression is a qualified column reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param string $column_name Expected column name. + * @return bool Whether the expression is a qualified column reference. + */ + private function is_mysql_qualified_column_reference_expression( array $tokens, int $start, int $end, string $column_name ): bool { + return $this->is_mysql_column_reference_expression( $tokens, $start, $end, $column_name, null, false ); + } + + /** + * Check whether two expression token ranges are structurally equivalent. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $left_start First left expression token. + * @param int $left_end Final left expression token, exclusive. + * @param int $right_start First right expression token. + * @param int $right_end Final right expression token, exclusive. + * @return bool Whether the token ranges are equivalent. + */ + private function are_mysql_token_ranges_equivalent( + array $tokens, + int $left_start, + int $left_end, + int $right_start, + int $right_end + ): bool { + $left_bounds = $this->normalize_mysql_expression_bounds( $tokens, $left_start, $left_end ); + $right_bounds = $this->normalize_mysql_expression_bounds( $tokens, $right_start, $right_end ); + + $left_start = $left_bounds['start']; + $left_end = $left_bounds['end']; + $right_start = $right_bounds['start']; + $right_end = $right_bounds['end']; + + if ( $left_end - $left_start !== $right_end - $right_start ) { + return false; + } + + for ( $left = $left_start, $right = $right_start; $left < $left_end; $left++, $right++ ) { + if ( ! $this->are_mysql_tokens_equivalent( $tokens[ $left ], $tokens[ $right ] ) ) { + return false; + } + } + + return true; + } + + /** + * Normalize expression bounds by removing full-range wrapper parentheses. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return array{start: int, end: int} Normalized bounds. + */ + private function normalize_mysql_expression_bounds( array $tokens, int $start, int $end ): array { + while ( + $start + 2 <= $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start ]->id + && $this->get_mysql_parenthesized_sequence_end( $tokens, $start, $end ) === $end + ) { + ++$start; + --$end; + } + + return array( + 'start' => $start, + 'end' => $end, + ); + } + + /** + * Check whether two individual MySQL tokens are structurally equivalent. + * + * @param WP_MySQL_Token $left Left token. + * @param WP_MySQL_Token $right Right token. + * @return bool Whether the tokens are equivalent. + */ + private function are_mysql_tokens_equivalent( WP_MySQL_Token $left, WP_MySQL_Token $right ): bool { + $left_identifier = $this->get_mysql_identifier_token_value( $left ); + $right_identifier = $this->get_mysql_identifier_token_value( $right ); + if ( null !== $left_identifier || null !== $right_identifier ) { + return null !== $left_identifier + && null !== $right_identifier + && strtolower( $left_identifier ) === strtolower( $right_identifier ); + } + + if ( $left->id !== $right->id ) { + return false; + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $left->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $left->id ) { + return $left->get_value() === $right->get_value(); + } + + return strtolower( $left->get_bytes() ) === strtolower( $right->get_bytes() ); + } + + /** + * Check whether a SELECT query uses the MySQL SQL_CALC_FOUND_ROWS modifier. + * + * @param string $query MySQL query. + * @return bool Whether the query asks for FOUND_ROWS tracking. + */ + private function is_sql_calc_found_rows_select_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0], $tokens[1] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + if ( null === $this->get_mysql_statement_end_position( $tokens, 1 ) ) { + return false; + } + + if ( WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $tokens[1]->id ) { + return true; + } + + return isset( $tokens[2] ) + && WP_MySQL_Lexer::DISTINCT_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL === $tokens[2]->id; + } + + /** + * Translate WordPress SELECT SQL_CALC_FOUND_ROWS queries. + * + * PostgreSQL has no SQL_CALC_FOUND_ROWS modifier. WordPress issues these + * queries for pagination, followed by SELECT FOUND_ROWS(); this first pass + * executes the paginated query itself while preserving compatible clauses. + * + * @param string $query MySQL query. + * @param bool $include_limit Whether to preserve the LIMIT/OFFSET clause. + * @return string|null PostgreSQL query, or null when the query is unsupported. + */ + private function translate_sql_calc_found_rows_select_query( string $query, bool $include_limit = true ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + return null; + } + + $select_end = $statement_end; + $limit_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, 2, $statement_end ); + if ( null !== $limit_position ) { + if ( ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $statement_end ) ) { + return null; + } + $select_end = $limit_position; + } + + $contextual_sql = $this->translate_mysql_select_statement_with_integer_string_coercion( + $tokens, + 2, + $select_end, + false + ); + if ( null !== $contextual_sql ) { + if ( $include_limit && null !== $limit_position ) { + $contextual_sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $contextual_sql; + } + + $sql = 'SELECT ' . $this->translate_mysql_token_sequence_to_postgresql( $tokens, 2, $select_end ); + if ( $include_limit && null !== $limit_position ) { + $sql .= $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $statement_end ); + } + + return $sql; + } + + /** + * Apply conservative MySQL-to-PostgreSQL token compatibility rewrites. + * + * Complex WordPress queries often use PostgreSQL-compatible SQL except for + * MySQL identifier casing. This fallback quotes backticked and mixed-case + * identifiers without trying to emulate unsupported MySQL-only syntax. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when no compatibility rewrite applies. + */ + private function translate_mysql_compatible_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return null; + } + + if ( + ! in_array( + $tokens[0]->id, + array( + WP_MySQL_Lexer::DELETE_SYMBOL, + WP_MySQL_Lexer::INSERT_SYMBOL, + WP_MySQL_Lexer::REPLACE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::UPDATE_SYMBOL, + ), + true + ) + ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + if ( WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[0]->id ) { + $contextual_sql = $this->translate_mysql_select_statement_with_integer_string_coercion( + $tokens, + 1, + $statement_end, + true + ); + if ( null !== $contextual_sql ) { + return $contextual_sql; + } + + $contextual_sql = $this->translate_mysql_count_aggregate_projection_alias_query( + $tokens, + 1, + $statement_end + ); + if ( null !== $contextual_sql ) { + return $contextual_sql; + } + } + + if ( $this->contains_unsupported_mysql_date_arithmetic_function( $tokens, 0, $statement_end ) ) { + return null; + } + + if ( $this->contains_unsupported_mysql_date_format_function( $tokens, 0, $statement_end ) ) { + return null; + } + + if ( $this->contains_unsupported_mysql_rand_function( $tokens, 0, $statement_end ) ) { + return null; + } + + if ( $this->contains_unsupported_mysql_week_function( $tokens, 0, $statement_end ) ) { + return null; + } + + if ( $this->contains_unsupported_mysql_convert_function( $tokens, 0, $statement_end ) ) { + return null; + } + + if ( $this->contains_unsupported_mysql_common_function( $tokens, 0, $statement_end ) ) { + return null; + } + + if ( ! $this->needs_mysql_compatible_rewrite( $tokens, 0, $statement_end ) ) { + return null; + } + + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $statement_end ); + } + + /** + * Check whether a query must fail closed while information_schema is selected. + * + * The PostgreSQL backend does not use MySQL database state for unqualified + * names. Without broad information_schema routing, table-scoped statements + * under USE information_schema would otherwise target public tables. + * + * @param string $query MySQL query. + * @return bool Whether the query should be rejected before backend execution. + */ + private function should_reject_information_schema_backend_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[0]->id ) { + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null !== $statement_end ) { + if ( null !== $this->translate_direct_information_schema_select_query( $query ) ) { + return false; + } + + if ( $this->select_references_direct_information_schema_relation( $tokens, 1, $statement_end ) ) { + return true; + } + + if ( $this->information_schema_select_query_targets_main_database_explicitly( $query, $tokens, $statement_end ) ) { + return false; + } + } + + return 0 === strcasecmp( $this->db_name, 'information_schema' ) + && $this->information_schema_select_has_table_reference( $tokens ); + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( + null !== $statement_end + && WP_MySQL_Lexer::INSERT_SYMBOL === $tokens[0]->id + && $this->select_references_direct_information_schema_relation( $tokens, 1, $statement_end ) + ) { + if ( null !== $this->translate_simple_mysql_insert_select_query( $query ) ) { + return false; + } + + return true; + } + + if ( 0 !== strcasecmp( $this->db_name, 'information_schema' ) ) { + return false; + } + + if ( + WP_MySQL_Lexer::DESCRIBE_SYMBOL === $tokens[0]->id + || WP_MySQL_Lexer::DESC_SYMBOL === $tokens[0]->id + ) { + $position = 1; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( + null !== $table_reference + && $this->is_at_mysql_query_end( $tokens, $position ) + && $this->is_explicit_main_database_table_reference( $table_reference ) + ) { + return false; + } + + if ( + null !== $table_reference + && $this->is_at_mysql_query_end( $tokens, $position ) + && ( + null === $table_reference['schema'] + || 0 === strcasecmp( $table_reference['schema'], 'information_schema' ) + ) + && null !== $this->get_direct_information_schema_relation_columns( $table_reference['table'] ) + ) { + return false; + } + } + + if ( + in_array( + $tokens[0]->id, + array( + WP_MySQL_Lexer::EXPLAIN_SYMBOL, + WP_MySQL_Lexer::WITH_SYMBOL, + ), + true + ) + ) { + return true; + } + + if ( $this->information_schema_write_query_targets_main_database_explicitly( $query, $tokens ) ) { + return false; + } + + return in_array( + $tokens[0]->id, + array( + WP_MySQL_Lexer::ALTER_SYMBOL, + WP_MySQL_Lexer::ANALYZE_SYMBOL, + WP_MySQL_Lexer::CHECK_SYMBOL, + WP_MySQL_Lexer::CREATE_SYMBOL, + WP_MySQL_Lexer::DESCRIBE_SYMBOL, + WP_MySQL_Lexer::DELETE_SYMBOL, + WP_MySQL_Lexer::DESC_SYMBOL, + WP_MySQL_Lexer::DROP_SYMBOL, + WP_MySQL_Lexer::INSERT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::OPTIMIZE_SYMBOL, + WP_MySQL_Lexer::REPLACE_SYMBOL, + WP_MySQL_Lexer::REPAIR_SYMBOL, + WP_MySQL_Lexer::TRUNCATE_SYMBOL, + WP_MySQL_Lexer::UPDATE_SYMBOL, + ), + true + ); + } + + /** + * Check whether a write/admin query under USE information_schema explicitly targets the main database. + * + * Unqualified write targets should continue to resolve as information_schema + * while that database is selected. Explicit main-database or public targets + * can safely continue into the existing PostgreSQL translators. + * + * @param string $query MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the guarded query targets only the main database explicitly. + */ + private function information_schema_write_query_targets_main_database_explicitly( string $query, array $tokens ): bool { + if ( ! isset( $tokens[0] ) ) { + return false; + } + + switch ( $tokens[0]->id ) { + case WP_MySQL_Lexer::INSERT_SYMBOL: + return $this->insert_or_replace_query_targets_main_database_explicitly( $tokens, true ); + + case WP_MySQL_Lexer::REPLACE_SYMBOL: + return $this->insert_or_replace_query_targets_main_database_explicitly( $tokens, false ); + + case WP_MySQL_Lexer::UPDATE_SYMBOL: + return $this->simple_update_query_targets_main_database_explicitly( $tokens ); + + case WP_MySQL_Lexer::DELETE_SYMBOL: + return $this->simple_delete_query_targets_main_database_explicitly( $tokens ); + + case WP_MySQL_Lexer::TRUNCATE_SYMBOL: + return $this->truncate_query_targets_main_database_explicitly( $tokens ); + + case WP_MySQL_Lexer::CREATE_SYMBOL: + return $this->create_query_targets_main_database_explicitly( $tokens ); + + case WP_MySQL_Lexer::ALTER_SYMBOL: + return $this->alter_table_query_targets_main_database_explicitly( $tokens ); + + case WP_MySQL_Lexer::DROP_SYMBOL: + return $this->drop_query_targets_main_database_explicitly( $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: + return $this->table_administration_query_targets_main_database_explicitly( $query ); + + case WP_MySQL_Lexer::LOCK_SYMBOL: + return $this->lock_tables_query_targets_main_database_explicitly( $query ); + } + + return false; + } + + /** + * Check whether a parsed table reference explicitly names the main database. + * + * @param array{schema: string|null, table: string}|null $table_reference Parsed table reference. + * @return bool Whether the reference is explicitly main-database qualified. + */ + private function is_explicit_main_database_table_reference( ?array $table_reference ): bool { + if ( null === $table_reference || null === $table_reference['schema'] ) { + return false; + } + + return 0 === strcasecmp( $table_reference['schema'], $this->main_db_name ) + || 0 === strcasecmp( $table_reference['schema'], 'public' ); + } + + /** + * Check whether an INSERT/REPLACE query explicitly targets the main database. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param bool $is_insert Whether the query is INSERT. + * @return bool Whether the target table is explicitly main-database qualified. + */ + private function insert_or_replace_query_targets_main_database_explicitly( array $tokens, bool $is_insert ): bool { + $position = 1; + if ( $is_insert ) { + $this->consume_mysql_insert_priority_modifier( $tokens, $position ); + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::IGNORE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + } else { + $this->consume_mysql_replace_priority_modifier( $tokens, $position ); + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::INTO_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + return $this->is_explicit_main_database_table_reference( $table_reference ); + } + + /** + * Check whether a simple UPDATE query explicitly targets the main database. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the target table is explicitly main-database qualified. + */ + private function simple_update_query_targets_main_database_explicitly( array $tokens ): bool { + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return false; + } + + $position = 1; + $this->consume_mysql_update_modifiers( $tokens, $position, $statement_end ); + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( ! $this->is_explicit_main_database_table_reference( $table_reference ) ) { + return false; + } + + return $this->consume_optional_simple_table_alias( $tokens, $position, $statement_end ) + && isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::SET_SYMBOL === $tokens[ $position ]->id; + } + + /** + * Check whether a simple DELETE query explicitly targets the main database. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the target table is explicitly main-database qualified. + */ + private function simple_delete_query_targets_main_database_explicitly( array $tokens ): bool { + $position = 1; + $this->consume_mysql_delete_modifiers( $tokens, $position ); + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return false; + } + + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + return $this->is_explicit_main_database_table_reference( $table_reference ) + && $this->consume_optional_simple_table_alias( $tokens, $position, $statement_end ); + } + + /** + * Consume a simple table alias when present. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated when an alias is consumed. + * @param int $statement_end Final statement token, exclusive. + * @return bool Whether the alias shape is valid. + */ + private function consume_optional_simple_table_alias( array $tokens, int &$position, int $statement_end ): bool { + if ( $position + 1 < $statement_end && WP_MySQL_Lexer::AS_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + if ( null === $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ) ) { + return false; + } + + $position += 2; + return true; + } + + $implicit_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $implicit_alias ) { + ++$position; + } + + return true; + } + + /** + * Check whether a TRUNCATE query explicitly targets the main database. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the target table is explicitly main-database qualified. + */ + private function truncate_query_targets_main_database_explicitly( array $tokens ): bool { + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + return $this->is_explicit_main_database_table_reference( $table_reference ); + } + + /** + * Check whether a CREATE query explicitly targets the main database. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the target table is explicitly main-database qualified. + */ + private function create_query_targets_main_database_explicitly( array $tokens ): bool { + $position = 1; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::REPLACE_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + $view_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + return $this->is_explicit_main_database_table_reference( $view_reference ); + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position += 3; + } + + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + return $this->is_explicit_main_database_table_reference( $table_reference ); + } + + while ( + isset( $tokens[ $position ] ) + && in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::UNIQUE_SYMBOL, + WP_MySQL_Lexer::FULLTEXT_SYMBOL, + WP_MySQL_Lexer::SPATIAL_SYMBOL, + ), + true + ) + ) { + ++$position; + } + + if ( + ! isset( $tokens[ $position ] ) + || WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[ $position ]->id + ) { + return false; + } + + ++$position; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position += 3; + } + + if ( null === $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null, true ) ) { + return false; + } + ++$position; + + while ( + isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::USING_SYMBOL, WP_MySQL_Lexer::TYPE_SYMBOL ), true ) + ) { + $position += 2; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + return $this->is_explicit_main_database_table_reference( $table_reference ); + } + + /** + * Check whether an ALTER TABLE query explicitly targets the main database. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the target table is explicitly main-database qualified. + */ + private function alter_table_query_targets_main_database_explicitly( array $tokens ): bool { + if ( isset( $tokens[1] ) && WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[1]->id ) { + $position = 2; + $view_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + return $this->is_explicit_main_database_table_reference( $view_reference ); + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null !== $statement_end && $this->contains_mysql_unsupported_view_prefix_clause( $tokens, 1, $statement_end ) ) { + for ( $position = 1; $position < $statement_end; $position++ ) { + if ( WP_MySQL_Lexer::VIEW_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + ++$position; + $view_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + return $this->is_explicit_main_database_table_reference( $view_reference ); + } + } + + if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id ) { + return false; + } + + $position = 2; + $table_reference = $this->get_mysql_dbdelta_alter_table_target_reference( $tokens, $position ); + return $this->is_explicit_main_database_table_reference( $table_reference ); + } + + /** + * Check whether a DROP query explicitly targets the main database. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether all target tables are explicitly main-database qualified. + */ + private function drop_query_targets_main_database_explicitly( array $tokens ): bool { + if ( + isset( $tokens[1] ) + && in_array( $tokens[1]->id, array( WP_MySQL_Lexer::TABLE_SYMBOL, WP_MySQL_Lexer::VIEW_SYMBOL ), true ) + ) { + $statement_end = $this->get_mysql_statement_end_position( $tokens, 2 ); + if ( null === $statement_end ) { + return false; + } + + $position = 2; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + } + + $matched = false; + while ( $position < $statement_end ) { + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( ! $this->is_explicit_main_database_table_reference( $table_reference ) ) { + return false; + } + + $matched = true; + if ( $position === $statement_end ) { + break; + } + + if ( + WP_MySQL_Lexer::VIEW_SYMBOL === $tokens[1]->id + && isset( $tokens[ $position ] ) + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::CASCADE_SYMBOL, WP_MySQL_Lexer::RESTRICT_SYMBOL ), true ) + ) { + ++$position; + return $position === $statement_end; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + ++$position; + } + + return $matched; + } + + if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::INDEX_SYMBOL !== $tokens[1]->id ) { + return false; + } + + $position = 2; + if ( null === $this->get_mysql_index_identifier_token_value( $tokens[ $position ] ?? null ) ) { + return false; + } + ++$position; + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::ON_SYMBOL !== $tokens[ $position ]->id ) { + return false; + } + + ++$position; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + return $this->is_explicit_main_database_table_reference( $table_reference ); + } + + /** + * Check whether table administration targets explicitly name the main database. + * + * @param string $query MySQL query. + * @return bool Whether all target tables are explicitly main-database qualified. + */ + private function table_administration_query_targets_main_database_explicitly( string $query ): bool { + try { + $table_administration_query = $this->get_mysql_table_administration_query( $query ); + } catch ( InvalidArgumentException $e ) { + return false; + } + + foreach ( $table_administration_query['tables'] ?? array() as $table_reference ) { + if ( ! $this->is_explicit_main_database_table_reference( $table_reference ) ) { + return false; + } + } + + return ! empty( $table_administration_query['tables'] ); + } + + /** + * Check whether LOCK TABLE targets explicitly name the main database. + * + * @param string $query MySQL query. + * @return bool Whether all lock targets are explicitly main-database qualified. + */ + private function lock_tables_query_targets_main_database_explicitly( string $query ): bool { + try { + $lock_tables_query = $this->get_mysql_lock_tables_query( $query ); + } catch ( InvalidArgumentException $e ) { + return false; + } + + if ( null === $lock_tables_query || 'lock' !== ( $lock_tables_query['operation'] ?? null ) ) { + return false; + } + + foreach ( $lock_tables_query['tables'] ?? array() as $table_reference ) { + if ( ! $this->is_explicit_main_database_table_reference( $table_reference ) ) { + return false; + } + } + + return ! empty( $lock_tables_query['tables'] ); + } + + /** + * Check whether an untranslated SELECT must not fall through to PostgreSQL. + * + * @param string $query MySQL query. + * @return bool Whether the query names information_schema in a table-scoped way. + */ + private function should_reject_unsupported_direct_information_schema_select_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return false; + } + + if ( $this->select_references_direct_information_schema_relation( $tokens, 1, $statement_end ) ) { + return true; + } + + if ( $this->information_schema_select_query_targets_main_database_explicitly( $query, $tokens, $statement_end ) ) { + return false; + } + + return 0 === strcasecmp( $this->db_name, 'information_schema' ) + && $this->information_schema_select_has_table_reference( $tokens ); + } + + /** + * Translate SELECTs that explicitly target only main-database tables while information_schema is selected. + * + * @param string $query MySQL query. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_information_schema_main_database_select_query( string $query ): ?string { + if ( 0 !== strcasecmp( $this->db_name, 'information_schema' ) ) { + return null; + } + + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + if ( + $this->select_references_direct_information_schema_relation( $tokens, 1, $statement_end ) + || $this->contains_top_level_mysql_token( + $tokens, + 1, + $statement_end, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + if ( + $this->contains_unsupported_mysql_date_arithmetic_function( $tokens, 0, $statement_end ) + || $this->contains_unsupported_mysql_date_format_function( $tokens, 0, $statement_end ) + || $this->contains_unsupported_mysql_rand_function( $tokens, 0, $statement_end ) + || $this->contains_unsupported_mysql_week_function( $tokens, 0, $statement_end ) + || $this->contains_unsupported_mysql_convert_function( $tokens, 0, $statement_end ) + || $this->contains_unsupported_mysql_common_function( $tokens, 0, $statement_end ) + || $this->contains_unsupported_mysql_group_concat_function( $tokens, 0, $statement_end ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + if ( null === $from_position ) { + $nested_select_replacements = $this->get_information_schema_main_database_nested_select_replacements( + $query, + $tokens, + array( + array( + 'start' => 1, + 'end' => $statement_end, + ), + ) + ); + if ( + null === $nested_select_replacements + || array() === $nested_select_replacements + || ! $this->direct_information_schema_nested_selects_are_covered( $tokens, 1, $statement_end, $nested_select_replacements ) + ) { + return null; + } + + return 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 1, + $statement_end, + $nested_select_replacements + ); + } + + if ( 1 === $from_position ) { + return null; + } + + $from_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + $from_position + 1, + $statement_end + ) ?? $statement_end; + + $replacements = $this->get_information_schema_main_database_select_table_replacements( + $query, + $tokens, + $from_position + 1, + $from_end + ); + if ( null === $replacements ) { + return null; + } + + $nested_select_ranges = array( + array( + 'start' => 1, + 'end' => $from_position, + ), + ); + $clause_starts = array_filter( + array( + $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $from_end, $statement_end ), + $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::GROUP_SYMBOL, $from_end, $statement_end ), + $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::HAVING_SYMBOL, $from_end, $statement_end ), + $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::ORDER_SYMBOL, $from_end, $statement_end ), + $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::LIMIT_SYMBOL, $from_end, $statement_end ), + ), + 'is_int' + ); + sort( $clause_starts ); + foreach ( $clause_starts as $index => $start ) { + $nested_select_ranges[] = array( + 'start' => $start, + 'end' => $clause_starts[ $index + 1 ] ?? $statement_end, + ); + } + + $nested_select_replacements = $this->get_information_schema_main_database_nested_select_replacements( + $query, + $tokens, + $nested_select_ranges + ); + if ( null === $nested_select_replacements ) { + return null; + } + + $replacements = array_merge( $replacements, $nested_select_replacements ); + usort( + $replacements, + static function ( array $left, array $right ): int { + return $left['start'] <=> $right['start']; + } + ); + + if ( ! $this->direct_information_schema_nested_selects_are_covered( $tokens, 1, $statement_end, $replacements ) ) { + return null; + } + + return $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 0, + $statement_end, + $replacements + ); + } + + /** + * Get replacement ranges for explicitly main-database-qualified SELECT table sources. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First FROM-clause token after FROM. + * @param int $end Final FROM-clause token, exclusive. + * @return array[]|null Replacement ranges, or null when the source list is unsupported. + */ + private function get_information_schema_main_database_select_table_replacements( string $query, array $tokens, int $start, int $end ): ?array { + $position = $start; + $expect_next = true; + $replacements = array(); + + while ( $position < $end ) { + if ( $expect_next ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $derived_replacement = $this->get_information_schema_main_database_derived_select_replacement( + $query, + $tokens, + $position, + $end + ); + if ( null === $derived_replacement ) { + return null; + } + + $replacements[] = $derived_replacement['replacement']; + $position = $derived_replacement['position']; + $expect_next = false; + continue; + } + + $reference_start = $position; + $reference = $this->parse_mysql_table_reference( $tokens, $position, $end ); + if ( + null === $reference + || ! $this->is_information_schema_explicit_main_database_select_table_reference( $tokens, $reference_start ) + ) { + return null; + } + + $replacements[] = array( + 'start' => $reference_start, + 'end' => $reference_start + 3, + 'sql' => $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $reference_start + 2 ] ?? null ), + ); + $position = $reference['position']; + $expect_next = false; + continue; + } + + if ( + WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id + || $this->is_mysql_join_token( $tokens[ $position ] ) + ) { + $expect_next = true; + } + + ++$position; + } + + return empty( $replacements ) || $expect_next ? null : $replacements; + } + + /** + * Check whether a SELECT source explicitly targets the main database. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start Table reference start token. + * @return bool Whether the source starts with a main-database-qualified table. + */ + private function is_information_schema_explicit_main_database_select_table_reference( array $tokens, int $start ): bool { + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return false; + } + + $schema = $this->get_mysql_identifier_token_value( $tokens[ $start ] ); + if ( null === $schema ) { + return false; + } + + return 0 === strcasecmp( $schema, $this->main_db_name ) + || 0 === strcasecmp( $schema, 'public' ); + } + + /** + * Get a replacement for an explicitly main-database derived SELECT source. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Opening parenthesis position. + * @param int $end Final FROM-clause token, exclusive. + * @return array{replacement: array, position: int}|null Replacement and next token position. + */ + private function get_information_schema_main_database_derived_select_replacement( string $query, array $tokens, int $position, int $end ): ?array { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( + null === $after_close + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $select_start = $position + 1; + $select_end = $after_close - 1; + $select_query = $this->get_mysql_token_range_sql( $query, $tokens, $select_start, $select_end ); + if ( null === $select_query ) { + return null; + } + + $translated_select = $this->translate_information_schema_main_database_select_query( $select_query ); + if ( null === $translated_select ) { + return null; + } + + return array( + 'replacement' => array( + 'start' => $select_start, + 'end' => $select_end, + 'sql' => $translated_select, + ), + 'position' => $this->skip_mysql_table_alias( $tokens, $after_close, $end ), + ); + } + + /** + * Get replacements for nested explicitly main-database SELECTs. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array[] $ranges Token ranges to scan. + * @return array[]|null Replacement ranges, or null when a nested SELECT is unsupported. + */ + private function get_information_schema_main_database_nested_select_replacements( string $query, array $tokens, array $ranges ): ?array { + $replacements = array(); + foreach ( $ranges as $range ) { + for ( $position = $range['start']; $position < $range['end']; $position++ ) { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + continue; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $range['end'] ); + if ( null === $after_close ) { + return null; + } + + $select_start = $position + 1; + $select_end = $after_close - 1; + $select_query = $this->get_mysql_token_range_sql( $query, $tokens, $select_start, $select_end ); + if ( null === $select_query ) { + return null; + } + + $translated_select = $this->translate_information_schema_main_database_select_query( $select_query ); + if ( null === $translated_select ) { + return null; + } + + $replacements[] = array( + 'start' => $select_start, + 'end' => $select_end, + 'sql' => $translated_select, + ); + $position = $after_close - 1; + } + } + + return $replacements; + } + + /** + * Check whether a SELECT under USE information_schema explicitly reads one main database table. + * + * This mirrors the existing simple SELECT translator, which strips the current + * MySQL database qualifier only for a single top-level table source. + * + * @param string $query MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $statement_end Final statement token position, exclusive. + * @return bool Whether this SELECT can safely continue to the simple SELECT translator. + */ + private function information_schema_select_query_targets_main_database_explicitly( string $query, array $tokens, int $statement_end ): bool { + if ( 0 !== strcasecmp( $this->db_name, 'information_schema' ) ) { + return false; + } + + if ( null !== $this->translate_information_schema_main_database_select_query( $query ) ) { + return true; + } + + if ( null === $this->translate_simple_mysql_select_query( $query ) ) { + return false; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, 1, $statement_end ); + if ( null === $from_position ) { + return false; + } + + $source_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + $from_position + 1, + $statement_end + ) ?? $statement_end; + + $position = $from_position + 1; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position ); + if ( ! $this->is_explicit_main_database_table_reference( $table_reference ) ) { + return false; + } + + return $this->consume_optional_simple_table_alias( $tokens, $position, $source_end ) + && $position === $source_end; + } + + /** + * Check whether a SELECT under USE information_schema reaches a table source. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the SELECT should be rejected. + */ + private function information_schema_select_has_table_reference( array $tokens ): bool { + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return true; + } + + return $this->mysql_select_range_has_non_dual_table_reference( $tokens, 0, $statement_end ); + } + + /** + * Check whether a SELECT token range names information_schema directly. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether the range references an information_schema relation. + */ + private function select_references_direct_information_schema_relation( array $tokens, int $start, int $end ): bool { + for ( $position = $start; $position + 2 < $end; $position++ ) { + if ( + WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) + || 0 !== strcasecmp( + (string) $this->get_direct_information_schema_identifier_token_value( $tokens[ $position ] ?? null ), + 'information_schema' + ) + ) { + continue; + } + + $relation = $this->get_direct_information_schema_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null !== $relation ) { + return true; + } + } + + return false; + } + + /** + * Add explicit aliases to multi-expression COUNT aggregate projections. + * + * PostgreSQL labels every unaliased COUNT expression as "count". WordPress + * later converts fetched objects to ARRAY_N by reading object properties, so + * duplicate labels collapse the result row before ARRAY_N can preserve order. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First projection token position. + * @param int $statement_end Final statement token position, exclusive. + * @return string|null PostgreSQL query, or null when unsupported. + */ + private function translate_mysql_count_aggregate_projection_alias_query( array $tokens, int $projection_start, int $statement_end ): ?string { + if ( + ! isset( $tokens[0] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::HIGH_PRIORITY_SYMBOL, + WP_MySQL_Lexer::INTO_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::SQL_CALC_FOUND_ROWS_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $statement_end + ); + if ( null === $from_position || $projection_start === $from_position ) { + return null; + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $from_position ); + if ( null === $projection_ranges || count( $projection_ranges ) < 2 ) { + return null; + } + + $projection_sql = array(); + $alias_lookup = array(); + foreach ( $projection_ranges as $range ) { + $expression_bounds = $this->get_mysql_select_projection_expression_bounds( + $tokens, + $range['start'], + $range['end'] + ); + if ( + null === $expression_bounds + || $expression_bounds['start'] !== $range['start'] + || $expression_bounds['end'] !== $range['end'] + || ! $this->is_mysql_count_aggregate_expression( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ) + ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $expression_bounds['start'], + $expression_bounds['end'] + ); + $alias_key = strtolower( $expression_sql ); + if ( isset( $alias_lookup[ $alias_key ] ) ) { + return null; + } + + $alias_lookup[ $alias_key ] = true; + $projection_sql[] = sprintf( + '%s AS %s', + $expression_sql, + $this->connection->quote_identifier( $expression_sql ) + ); + } + + return sprintf( + 'SELECT %s %s', + implode( ', ', $projection_sql ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $from_position, $statement_end ) + ); + } + + /** + * Tokenize a MySQL query with the configured lexer implementation. + * + * @param string $query MySQL query. + * @return WP_MySQL_Token[] MySQL lexer token stream. + */ + private function get_mysql_tokens( string $query ): array { + $sql_mode = $this->get_sql_mode(); + if ( $query === $this->mysql_token_cache_query && $sql_mode === $this->mysql_token_cache_sql_mode ) { + return $this->mysql_token_cache_tokens; + } + + $lexer = new WP_MySQL_Lexer( $query, $this->mysql_version, $this->active_sql_modes ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + $this->mysql_token_cache_query = $query; + $this->mysql_token_cache_sql_mode = $sql_mode; + $this->mysql_token_cache_tokens = $tokens; + + return $tokens; + } + + /** + * Check whether a table name is a WordPress options table. + * + * @param string $table_name Table identifier value. + * @return bool Whether the table is an options table. + */ + private function is_wordpress_options_table_name( string $table_name ): bool { + $table_name = strtolower( $table_name ); + return 'options' === $table_name || '_options' === substr( $table_name, -8 ); + } + + /** + * Parse a parenthesized MySQL identifier list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return string[]|null Identifier values, or null when unsupported. + */ + private function parse_mysql_identifier_list( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $identifiers = array(); + + while ( isset( $tokens[ $position ] ) ) { + $identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ); + if ( null === $identifier ) { + return null; + } + + $identifiers[] = $identifier; + ++$position; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + return $identifiers; + } + + return null; + } + + return null; + } + + /** + * Parse a parenthesized single-row MySQL VALUES list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return array{values: string[], ranges: array[]}|null Translated SQL values and token ranges, or null when unsupported. + */ + private function parse_mysql_value_list_with_ranges( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $values = array(); + $ranges = array(); + $value_start = $position; + $depth = 0; + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + ++$depth; + ++$position; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $position ]->id ) { + if ( 0 === $depth ) { + if ( $value_start === $position ) { + return null; + } + + $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $position ); + $ranges[] = array( + 'start' => $value_start, + 'end' => $position, + ); + ++$position; + return array( + 'values' => $values, + 'ranges' => $ranges, + ); + } + + --$depth; + ++$position; + continue; + } + + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + if ( $value_start === $position ) { + return null; + } + + $values[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $value_start, $position ); + $ranges[] = array( + 'start' => $value_start, + 'end' => $position, + ); + $value_start = $position + 1; + } + + ++$position; + } + + return null; + } + + /** + * Locate the top-level ON DUPLICATE KEY UPDATE clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Token position where scanning starts. + * @return int|null Token position of ON, or null when not found. + */ + private function find_on_duplicate_key_update_clause( array $tokens, int $position ): ?int { + $depth = 0; + for ( $i = $position; isset( $tokens[ $i ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $i ]->id; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( + 0 === $depth + && WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $i ]->id + && isset( $tokens[ $i + 3 ] ) + && WP_MySQL_Lexer::DUPLICATE_SYMBOL === $tokens[ $i + 1 ]->id + && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[ $i + 2 ]->id + && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $i + 3 ]->id + ) { + return $i; + } + } + + return null; + } + + /** + * Parse ON DUPLICATE KEY UPDATE assignments for the supported upsert shape. + * + * @param string $table_name Target table name. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param int $end Final assignment token position, exclusive. + * @param array $column_lookup Insert-column lookup by lowercase name. + * @param array $table_column_lookup Table-column metadata lookup by lowercase name. + * @param array $source_aliases Optional VALUES-row alias lookup. + * @param array|null $assignment_effects Optional side effects detected while parsing. + * @return string[]|null PostgreSQL SET assignments, or null when unsupported. + */ + private function parse_upsert_update_assignments( string $table_name, array $tokens, int &$position, int $end, array $column_lookup, array $table_column_lookup, array $source_aliases = array(), ?array &$assignment_effects = null ): ?array { + $assignment_effects = array(); + $assignments = array(); + $scope = $this->get_mysql_single_table_scope( $table_name ); + $values_column_lookup = $this->get_mysql_upsert_values_column_lookup( $column_lookup, $table_column_lookup ); + + while ( $position < $end ) { + $target = $this->parse_mysql_upsert_assignment_target( $table_name, $tokens, $position, $end ); + if ( + null === $target + || ! isset( $table_column_lookup[ strtolower( $target['column'] ) ] ) + ) { + return null; + } + + $target_column = $target['column']; + $assignment_effects['assigned_columns'][ strtolower( $target_column ) ] = $target_column; + $position = $target['end']; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $value_start = $position; + $assignment_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $value_start, + $end + ) ?? $end; + + if ( $value_start >= $assignment_end ) { + return null; + } + + $target_metadata = $table_column_lookup[ strtolower( $target_column ) ]; + $source_column = $this->get_mysql_upsert_values_assignment_source_column( $tokens, $value_start, $assignment_end, $values_column_lookup, $source_aliases ); + if ( $this->is_mysql_default_keyword_expression( $tokens, $value_start, $assignment_end ) ) { + $value_sql = $this->get_mysql_dml_default_assignment_sql_for_column( $target_metadata ); + } else { + $default_function_sql = $this->get_mysql_dml_default_function_assignment_sql( $tokens, $value_start, $assignment_end, $table_column_lookup ); + if ( null !== $default_function_sql ) { + $value_sql = $default_function_sql; + } elseif ( null !== $source_column ) { + $value_sql = sprintf( + 'excluded.%s', + $this->connection->quote_identifier( $source_column ) + ); + } else { + $last_insert_id_assignment = $this->get_mysql_upsert_last_insert_id_assignment( + $table_name, + $target_column, + $tokens, + $value_start, + $assignment_end, + $scope, + $table_column_lookup + ); + if ( false === $last_insert_id_assignment ) { + return null; + } + if ( is_array( $last_insert_id_assignment ) ) { + if ( + isset( $assignment_effects['last_insert_id_column'] ) + && 0 !== strcasecmp( (string) $assignment_effects['last_insert_id_column'], $last_insert_id_assignment['column'] ) + ) { + return null; + } + + $value_sql = $last_insert_id_assignment['sql']; + $assignment_effects['last_insert_id_column'] = $last_insert_id_assignment['column']; + } else { + $scalar_subquery_sql = null; + + $value_replacements = $this->get_mysql_upsert_values_expression_replacements( + $tokens, + $value_start, + $assignment_end, + $values_column_lookup, + $source_aliases + ); + + $default_replacements = null; + if ( null !== $value_replacements ) { + $default_replacements = $this->get_mysql_upsert_default_expression_replacements( + $tokens, + $value_start, + $assignment_end, + $table_column_lookup + ); + } + + $subquery_replacements = null; + if ( null !== $value_replacements && null !== $default_replacements ) { + $subquery_replacements = $this->get_mysql_upsert_scalar_subquery_expression_replacements( + $tokens, + $value_start, + $assignment_end, + $scope + ); + } + + $target_column_replacements = null; + if ( null !== $value_replacements && null !== $default_replacements && null !== $subquery_replacements ) { + $target_column_replacements = $this->get_mysql_upsert_target_column_expression_replacements( + $table_name, + $tokens, + $value_start, + $assignment_end, + $scope, + $table_column_lookup, + array_merge( $value_replacements, $default_replacements, $subquery_replacements ) + ); + } + + $expression_replacements = null !== $value_replacements && null !== $default_replacements && null !== $subquery_replacements && null !== $target_column_replacements + ? array_merge( $value_replacements, $default_replacements, $subquery_replacements, $target_column_replacements ) + : null; + if ( null !== $expression_replacements ) { + usort( + $expression_replacements, + static function ( array $a, array $b ): int { + return ( $a['start'] ?? 0 ) <=> ( $b['start'] ?? 0 ); + } + ); + } + if ( + null === $scalar_subquery_sql + && is_array( $subquery_replacements ) + && 1 === count( $subquery_replacements ) + && $value_start === $subquery_replacements[0]['start'] + && $assignment_end === $subquery_replacements[0]['end'] + ) { + $scalar_subquery_sql = $subquery_replacements[0]['sql']; + $expression_replacements = array(); + } + if ( + null === $expression_replacements + || ! $this->is_supported_simple_mysql_upsert_expression_fragment( + $tokens, + $value_start, + $assignment_end, + $expression_replacements + ) + || $this->contains_unsupported_mysql_convert_function_outside_replacements( $tokens, $value_start, $assignment_end, $expression_replacements ) + || $this->contains_unsupported_mysql_common_function_outside_replacements( $tokens, $value_start, $assignment_end, $expression_replacements ) + || ! $this->mysql_upsert_expression_column_references_resolve_to_scope( + $tokens, + $value_start, + $assignment_end, + $expression_replacements, + $scope + ) + ) { + $scalar_subquery_sql = $this->get_mysql_upsert_scalar_subquery_assignment_sql( + $tokens, + $value_start, + $assignment_end, + $scope + ); + if ( null === $scalar_subquery_sql ) { + return null; + } + $expression_replacements = array(); + } + + $this->validate_strict_mysql_dml_value_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + + $value_sql = $scalar_subquery_sql ?? null; + if ( + null !== $value_sql + && $this->is_mysql_text_family_column_type( (string) ( $target_metadata['column_type'] ?? '' ) ) + ) { + $value_sql = sprintf( 'CAST(%s AS text)', $value_sql ); + } elseif ( + null !== $value_sql + && ! $this->is_mysql_strict_sql_mode_active() + && $this->is_mysql_integer_family_column_type( (string) ( $target_metadata['column_type'] ?? '' ) ) + ) { + $value_sql = $this->get_postgresql_mysql_integer_cast_sql( $value_sql ); + } + if ( null === $value_sql ) { + $value_sql = $this->get_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + if ( null === $value_sql ) { + $value_sql = $this->get_non_strict_mysql_dml_value_sql_for_column( $target_metadata, $tokens, $value_start, $assignment_end ); + } + if ( null === $value_sql ) { + if ( empty( $expression_replacements ) ) { + $expression_sql = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $value_start, + $assignment_end, + $scope + ); + $value_sql = $expression_sql['sql']; + $changed = $expression_sql['changed']; + } else { + $value_sql = $this->translate_mysql_upsert_expression_token_sequence_with_replacements_to_postgresql( + $tokens, + $value_start, + $assignment_end, + $expression_replacements + ); + if ( null === $value_sql ) { + return null; + } + $changed = true; + } + if ( + $changed + && $this->is_mysql_text_family_column_type( (string) ( $target_metadata['column_type'] ?? '' ) ) + ) { + $value_sql = sprintf( 'CAST(%s AS text)', $value_sql ); + } + } + $guarded_value_sql = $this->get_strict_mysql_dml_temporal_expression_sql_for_column( + $target_metadata, + $tokens, + $value_start, + $assignment_end, + $value_sql + ); + if ( null !== $guarded_value_sql ) { + $value_sql = $guarded_value_sql; + } + } + } + } + + $assignments[] = sprintf( + '%s = %s', + $this->connection->quote_identifier( $target_column ), + $value_sql + ); + $position = $assignment_end; + + if ( $position === $end ) { + break; + } + + ++$position; + } + + return count( $assignments ) > 0 ? $assignments : null; + } + + /** + * Parse the supported LAST_INSERT_ID(column) upsert assignment side effect. + * + * MySQL plugins commonly use "id = LAST_INSERT_ID(id)" so mysqli_insert_id() + * returns the existing row id on duplicate-key updates. Only the no-op + * AUTO_INCREMENT self-assignment is safe to emulate here. + * + * @param string $table_name Target table name. + * @param string $target_column Assignment target column. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $scope Statement table scope. + * @param array $table_column_lookup Table-column metadata lookup by lowercase name. + * @return array{column: string, sql: string}|false|null Assignment data, false for unsupported LAST_INSERT_ID usage, or null when not applicable. + */ + private function get_mysql_upsert_last_insert_id_assignment( string $table_name, string $target_column, array $tokens, int $start, int $end, array $scope, array $table_column_lookup ) { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $start, $end ); + if ( null === $bounds || 'last_insert_id' !== $bounds['function'] ) { + return $this->contains_mysql_last_insert_id_function_call( $tokens, $start, $end ) ? false : null; + } + + if ( $bounds['close'] + 1 !== $end ) { + return false; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 1 !== count( $arguments ) ) { + return false; + } + + $argument = $arguments[0]; + $reference = $this->parse_mysql_column_reference( $tokens, $argument['start'], $argument['end'] ); + if ( + null === $reference + || $reference['end'] !== $argument['end'] + || 0 !== strcasecmp( $reference['column'], $target_column ) + || null === $this->get_mysql_column_type_for_reference( $reference, $scope ) + ) { + return false; + } + + if ( + null !== $reference['qualifier'] + && ! $this->is_mysql_dml_table_qualifier( $reference['qualifier'], $table_name, null ) + ) { + return false; + } + + $column_key = strtolower( $reference['column'] ); + if ( + ! isset( $table_column_lookup[ $column_key ] ) + || ! $this->is_mysql_auto_increment_column_metadata( $table_column_lookup[ $column_key ] ) + ) { + return false; + } + + return array( + 'column' => (string) ( $table_column_lookup[ $column_key ]['column_name'] ?? $reference['column'] ), + 'sql' => $this->get_postgresql_dml_column_reference_sql( + (string) ( $table_column_lookup[ $column_key ]['column_name'] ?? $reference['column'] ), + $table_name + ), + ); + } + + /** + * Check whether a range contains a LAST_INSERT_ID(...) function call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token. + * @param int $end Final token, exclusive. + * @return bool Whether LAST_INSERT_ID() appears in the range. + */ + private function contains_mysql_last_insert_id_function_call( array $tokens, int $start, int $end ): bool { + for ( $position = $start; $position < $end; $position++ ) { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( null !== $bounds && 'last_insert_id' === $bounds['function'] ) { + return true; + } + } + + return false; + } + + /** + * Parse the target column for an ON DUPLICATE KEY UPDATE assignment. + * + * @param string $table_name Target table name. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Assignment target start. + * @param int $end Final assignment token position, exclusive. + * @return array{column: string, end: int}|null Assignment target, or null when unsupported. + */ + private function parse_mysql_upsert_assignment_target( string $table_name, array $tokens, int $position, int $end ): ?array { + $first_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + if ( $position + 2 >= $end || WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) ) { + return array( + 'column' => $first_identifier, + 'end' => $position + 1, + ); + } + + $second_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null === $second_identifier ) { + return null; + } + + if ( $position + 4 < $end && WP_MySQL_Lexer::DOT_SYMBOL === ( $tokens[ $position + 3 ]->id ?? null ) ) { + $third_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 4 ] ?? null ); + if ( + null === $third_identifier + || 0 !== strcasecmp( $first_identifier, $this->main_db_name ) + || ! $this->is_mysql_dml_table_qualifier( $second_identifier, $table_name, null ) + ) { + return null; + } + + return array( + 'column' => $third_identifier, + 'end' => $position + 5, + ); + } + + if ( ! $this->is_mysql_dml_table_qualifier( $first_identifier, $table_name, null ) ) { + return null; + } + + return array( + 'column' => $second_identifier, + 'end' => $position + 3, + ); + } + + /** + * Check whether an expression is exactly the DEFAULT keyword. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether this is DEFAULT. + */ + private function is_mysql_default_keyword_expression( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::DEFAULT_SYMBOL === $tokens[ $start ]->id; + } + + /** + * Get SQL for a MySQL DEFAULT(column) assignment expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $table_column_lookup Table-column metadata lookup by lowercase name. + * @return string|null PostgreSQL SQL expression, or null when not supported. + */ + private function get_mysql_dml_default_function_assignment_sql( array $tokens, int $start, int $end, array $table_column_lookup ): ?string { + if ( + $start + 4 !== $end + || WP_MySQL_Lexer::DEFAULT_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== ( $tokens[ $start + 3 ]->id ?? null ) + ) { + return null; + } + + $column_name = $this->get_mysql_dml_identifier_token_value( $tokens[ $start + 2 ] ?? null ); + if ( null === $column_name ) { + return null; + } + + $column_metadata = $table_column_lookup[ strtolower( $column_name ) ] ?? null; + if ( null === $column_metadata ) { + return null; + } + + return $this->get_mysql_dml_default_assignment_sql_for_column( $column_metadata ); + } + + /** + * Get SQL for a MySQL DEFAULT assignment expression. + * + * @param array $column_metadata Column metadata row. + * @return string PostgreSQL SQL expression. + */ + private function get_mysql_dml_default_assignment_sql_for_column( array $column_metadata ): string { + $default_sql = $this->get_mysql_dml_default_sql_from_metadata( $column_metadata ); + if ( null !== $default_sql ) { + return $default_sql; + } + + return 'NULL'; + } + + /** + * Get PostgreSQL SQL for a supported scalar subquery upsert assignment. + * + * This supports constant/no-table scalar SELECTs, optional MySQL FROM DUAL, + * and uncorrelated COUNT(*)/COUNT(column) from a single current-database table. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $scope Statement table scope. + * @return string|null PostgreSQL scalar subquery SQL, or null when unsupported. + */ + private function get_mysql_upsert_scalar_subquery_assignment_sql( array $tokens, int $start, int $end, array $scope ): ?string { + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $start, $end ); + if ( + null === $after_subquery + || $after_subquery !== $end + || ! isset( $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return null; + } + + $select_start = $start + 1; + $projection_start = $select_start + 1; + $select_end = $end - 1; + if ( $projection_start >= $select_end ) { + return null; + } + + if ( + $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $select_end, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::SELECT_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ) + ) + ) { + return null; + } + + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $select_end + ); + if ( null !== $from_position ) { + $limit_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::LIMIT_SYMBOL, + $from_position + 1, + $select_end + ); + $order_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::ORDER_SYMBOL, + $from_position + 1, + null === $limit_position ? $select_end : $limit_position + ); + if ( + null !== $limit_position + && null !== $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::ORDER_SYMBOL, + $limit_position + 1, + $select_end + ) + ) { + return null; + } + + $tail_start = $order_position ?? $limit_position ?? $select_end; + $where_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::WHERE_SYMBOL, + $from_position + 1, + $tail_start + ); + $source_end = $where_position ?? $tail_start; + if ( $from_position + 2 === $source_end && WP_MySQL_Lexer::DUAL_SYMBOL === ( $tokens[ $from_position + 1 ]->id ?? null ) ) { + $projection = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $from_position ); + if ( null === $projection || 1 !== count( $projection ) ) { + return null; + } + + if ( ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $projection[0]['start'], $projection[0]['end'] ) ) { + return null; + } + + $expression_sql = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $projection[0]['start'], + $projection[0]['end'], + $scope + ); + + $where_sql = ''; + if ( null !== $where_position ) { + $where_end = $order_position ?? $limit_position ?? $select_end; + if ( + $where_position + 1 >= $where_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $where_end ) + || ! $this->mysql_expression_column_references_resolve_to_scope( $tokens, $where_position + 1, $where_end, $scope ) + ) { + return null; + } + + $translated_where = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + $where_sql = ' WHERE ' . $translated_where['sql']; + } + + $order_sql = ''; + if ( null !== $order_position ) { + $order_end = $limit_position ?? $select_end; + $order_items = $this->split_top_level_mysql_arguments( $tokens, $order_position + 2, $order_end ); + if ( null === $order_items || empty( $order_items ) ) { + return null; + } + foreach ( $order_items as $order_item ) { + $order_item_end = $order_item['end']; + if ( + $order_item['start'] < $order_item_end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === ( $tokens[ $order_item_end - 1 ]->id ?? null ) + || WP_MySQL_Lexer::DESC_SYMBOL === ( $tokens[ $order_item_end - 1 ]->id ?? null ) + ) + ) { + --$order_item_end; + } + if ( + $order_item['start'] >= $order_item_end + || ! $this->mysql_expression_column_references_resolve_to_scope( $tokens, $order_item['start'], $order_item_end, $scope ) + ) { + return null; + } + } + + $order_sql = $this->translate_mysql_joined_dml_order_by_clause_to_postgresql( + $tokens, + $order_position, + $order_end, + $scope + ); + if ( null === $order_sql ) { + return null; + } + } + + $limit_sql = ''; + if ( null !== $limit_position ) { + if ( ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $select_end ) ) { + return null; + } + + $limit_sql = $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $select_end ); + } + + return sprintf( + '(SELECT %s%s%s%s)', + $expression_sql['sql'], + $where_sql, + $order_sql, + $limit_sql + ); + } + + $reference_position = $from_position + 1; + $reference = $this->parse_mysql_main_database_table_reference( $tokens, $reference_position, $source_end ); + if ( null === $reference || $reference_position !== $source_end ) { + return null; + } + + $projection = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $from_position ); + if ( null === $projection || 1 !== count( $projection ) ) { + return null; + } + + $subquery_scope = $this->get_mysql_single_table_scope( $reference['table'], $reference['alias'] ); + $projection_sql = $this->get_mysql_upsert_table_scalar_subquery_projection_sql( + $tokens, + $projection[0]['start'], + $projection[0]['end'], + $reference['table'], + $reference['alias'], + $subquery_scope + ); + if ( null === $projection_sql ) { + return null; + } + + $where_sql = ''; + if ( null !== $where_position ) { + $where_end = $order_position ?? $limit_position ?? $select_end; + if ( + $where_position + 1 >= $where_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $where_end ) + || ! $this->mysql_expression_column_references_resolve_to_scope( $tokens, $where_position + 1, $where_end, $subquery_scope ) + ) { + return null; + } + + $translated_where = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $subquery_scope + ); + $where_sql = ' WHERE ' . $translated_where['sql']; + } + + $order_sql = ''; + if ( null !== $order_position ) { + $order_end = $limit_position ?? $select_end; + $order_items = $this->split_top_level_mysql_arguments( $tokens, $order_position + 2, $order_end ); + if ( null === $order_items || empty( $order_items ) ) { + return null; + } + foreach ( $order_items as $order_item ) { + $order_item_end = $order_item['end']; + if ( + $order_item['start'] < $order_item_end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === ( $tokens[ $order_item_end - 1 ]->id ?? null ) + || WP_MySQL_Lexer::DESC_SYMBOL === ( $tokens[ $order_item_end - 1 ]->id ?? null ) + ) + ) { + --$order_item_end; + } + if ( + $order_item['start'] >= $order_item_end + || ! $this->mysql_expression_column_references_resolve_to_scope( $tokens, $order_item['start'], $order_item_end, $subquery_scope ) + ) { + return null; + } + } + + $order_sql = $this->translate_mysql_joined_dml_order_by_clause_to_postgresql( + $tokens, + $order_position, + $order_end, + $subquery_scope + ); + if ( null === $order_sql ) { + return null; + } + } + + $limit_sql = ''; + if ( null !== $limit_position ) { + if ( ! $this->is_supported_simple_select_limit_clause( $tokens, $limit_position, $select_end ) ) { + return null; + } + + $limit_sql = $this->translate_simple_select_limit_clause_to_postgresql( $tokens, $limit_position, $select_end ); + } + + return sprintf( + '(SELECT %s FROM %s%s%s%s)', + $projection_sql, + $this->get_postgresql_dml_table_reference_sql( $reference['table'], $reference['alias'] ), + $where_sql, + $order_sql, + $limit_sql + ); + } + if ( null === $from_position ) { + $projection_end = $select_end; + } + + $projection = $this->split_top_level_mysql_arguments( $tokens, $projection_start, $projection_end ); + if ( null === $projection || 1 !== count( $projection ) ) { + return null; + } + + if ( ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $projection[0]['start'], $projection[0]['end'] ) ) { + return null; + } + + $expression_sql = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $projection[0]['start'], + $projection[0]['end'], + $scope + ); + + return sprintf( + '(SELECT %s)', + $expression_sql['sql'] + ); + } + + /** + * Get PostgreSQL SQL for a supported table-backed scalar subquery projection. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token. + * @param int $end Final projection token, exclusive. + * @param string $table_name Subquery source table name. + * @param string|null $alias Optional subquery source alias. + * @param array $scope Subquery source table scope. + * @return string|null PostgreSQL projection SQL, or null when unsupported. + */ + private function get_mysql_upsert_table_scalar_subquery_projection_sql( array $tokens, int $start, int $end, string $table_name, ?string $alias, array $scope ): ?string { + $count_sql = $this->get_mysql_upsert_count_subquery_projection_sql( $tokens, $start, $end, $table_name, $alias ); + if ( null !== $count_sql ) { + return $count_sql; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $start, $end ); + if ( + null === $reference + || $reference['end'] !== $end + || null === $this->get_mysql_column_type_for_reference( $reference, $scope ) + ) { + return null; + } + + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ); + } + + /** + * Get PostgreSQL SQL for a supported table-backed COUNT() scalar subquery projection. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token. + * @param int $end Final projection token, exclusive. + * @param string $table_name Subquery source table name. + * @param string|null $alias Optional subquery source alias. + * @return string|null PostgreSQL COUNT() SQL, or null when unsupported. + */ + private function get_mysql_upsert_count_subquery_projection_sql( array $tokens, int $start, int $end, string $table_name, ?string $alias ): ?string { + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::COUNT_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return null; + } + + $after_count = $this->get_mysql_parenthesized_sequence_end( $tokens, $start + 1, $end ); + if ( null === $after_count || $after_count !== $end ) { + return null; + } + + $argument_start = $start + 2; + $argument_end = $after_count - 1; + if ( + $argument_start + 1 === $argument_end + && WP_MySQL_Lexer::MULT_OPERATOR === ( $tokens[ $argument_start ]->id ?? null ) + ) { + return 'COUNT(*)'; + } + + if ( $this->is_supported_mysql_upsert_literal_select_expression( $tokens, $argument_start, $argument_end ) ) { + $expression_sql = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $argument_start, + $argument_end, + array() + ); + + return sprintf( 'COUNT(%s)', $expression_sql['sql'] ); + } + + $reference = $this->parse_mysql_column_reference( $tokens, $argument_start, $argument_end ); + if ( null === $reference || $reference['end'] !== $argument_end ) { + return null; + } + + if ( null === $this->get_mysql_table_column_type( 'public', $table_name, $reference['column'] ) ) { + return null; + } + + $qualifier = null; + if ( null !== $reference['qualifier'] ) { + if ( null !== $alias ) { + if ( 0 !== strcasecmp( $reference['qualifier'], $alias ) ) { + return null; + } + + $qualifier = $alias; + } else { + if ( 0 !== strcasecmp( $reference['qualifier'], $table_name ) ) { + return null; + } + + $qualifier = $table_name; + } + } + + return sprintf( + 'COUNT(%s)', + $this->get_postgresql_dml_column_reference_sql( $reference['column'], $qualifier ) + ); + } + + /** + * Get the columns accepted by MySQL VALUES(column) in an upsert assignment. + * + * MySQL permits VALUES(column) for omitted table columns; PostgreSQL's + * excluded row has those defaulted/null values as well. VALUES-row aliases + * remain constrained separately to the explicit INSERT column list. + * + * @param array $column_lookup Insert-column lookup by lowercase name. + * @param array $table_column_lookup Table-column metadata lookup by lowercase name. + * @return array Lookup of lowercase column name to canonical column name. + */ + private function get_mysql_upsert_values_column_lookup( array $column_lookup, array $table_column_lookup ): array { + $values_column_lookup = array(); + foreach ( $column_lookup as $column_key => $column_name ) { + $values_column_lookup[ strtolower( (string) $column_key ) ] = is_string( $column_name ) + ? $column_name + : (string) $column_key; + } + + foreach ( $table_column_lookup as $column_key => $column_metadata ) { + if ( isset( $values_column_lookup[ strtolower( (string) $column_key ) ] ) ) { + continue; + } + + $column_name = (string) ( $column_metadata['column_name'] ?? '' ); + if ( '' === $column_name ) { + continue; + } + + $values_column_lookup[ strtolower( $column_name ) ] = $column_name; + } + + return $values_column_lookup; + } + + /** + * Get the source column from a supported VALUES(column) upsert assignment expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $column_lookup VALUES-column lookup by lowercase name. + * @param array $source_aliases Optional VALUES-row alias lookup. + * @return string|null Source column name, or null when the expression is not VALUES(column). + */ + private function get_mysql_upsert_values_assignment_source_column( array $tokens, int $start, int $end, array $column_lookup, array $source_aliases = array() ): ?string { + if ( + $start + 4 === $end + && isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ] ) + && WP_MySQL_Lexer::VALUES_SYMBOL === $tokens[ $start ]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $start + 3 ]->id + ) { + $source_column = $this->get_mysql_dml_identifier_token_value( $tokens[ $start + 2 ] ); + $source_key = null === $source_column ? null : strtolower( $source_column ); + if ( null === $source_key || ! isset( $column_lookup[ $source_key ] ) ) { + return null; + } + + return is_string( $column_lookup[ $source_key ] ) ? $column_lookup[ $source_key ] : $source_column; + } + + return $this->get_mysql_upsert_alias_assignment_source_column( $tokens, $start, $end, $source_aliases ); + } + + /** + * Get replacements for supported VALUES(column) references in an upsert expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $column_lookup VALUES-column lookup by lowercase name. + * @param array $source_aliases Optional VALUES-row alias lookup. + * @return array[]|null Replacement ranges, or null when VALUES() is malformed/unsupported. + */ + private function get_mysql_upsert_values_expression_replacements( array $tokens, int $start, int $end, array $column_lookup, array $source_aliases = array() ): ?array { + $replacements = array(); + + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::VALUES_SYMBOL === $tokens[ $position ]->id ) { + if ( + $position + 4 > $end + || ! isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position + 3 ]->id + ) { + return null; + } + + $source_column = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ); + $source_key = null === $source_column ? null : strtolower( $source_column ); + if ( null === $source_key || ! isset( $column_lookup[ $source_key ] ) ) { + return null; + } + $source_column = is_string( $column_lookup[ $source_key ] ) ? $column_lookup[ $source_key ] : $source_column; + + $replacements[] = array( + 'start' => $position, + 'end' => $position + 4, + 'sql' => sprintf( + 'excluded.%s', + $this->connection->quote_identifier( $source_column ) + ), + ); + $position += 3; + continue; + } + + $alias_reference = $this->get_mysql_upsert_alias_expression_reference( $tokens, $position, $start, $end, $source_aliases ); + if ( null === $alias_reference ) { + continue; + } + + if ( false === $alias_reference ) { + return null; + } + + $replacements[] = array( + 'start' => $position, + 'end' => $alias_reference['end'], + 'sql' => sprintf( + 'excluded.%s', + $this->connection->quote_identifier( $alias_reference['column'] ) + ), + ); + $position = $alias_reference['end'] - 1; + } + + return $replacements; + } + + /** + * Get replacements for supported DEFAULT(column) references in an upsert expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $table_column_lookup Table-column metadata lookup by lowercase name. + * @return array[]|null Replacement ranges, or null when DEFAULT() is malformed/unsupported. + */ + private function get_mysql_upsert_default_expression_replacements( array $tokens, int $start, int $end, array $table_column_lookup ): ?array { + $replacements = array(); + + for ( $position = $start; $position < $end; $position++ ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL !== $tokens[ $position ]->id ) { + continue; + } + + if ( + $position + 4 > $end + || ! isset( $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $position + 3 ]->id + ) { + return null; + } + + $column_name = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ); + $column_key = null === $column_name ? null : strtolower( $column_name ); + if ( null === $column_key || ! isset( $table_column_lookup[ $column_key ] ) ) { + return null; + } + + $replacements[] = array( + 'start' => $position, + 'end' => $position + 4, + 'sql' => $this->get_mysql_dml_default_assignment_sql_for_column( $table_column_lookup[ $column_key ] ), + ); + $position += 3; + } + + return $replacements; + } + + /** + * Get replacements for supported scalar subqueries in an upsert expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $scope Statement table scope. + * @return array[]|null Replacement ranges, or null when a scalar subquery is unsupported. + */ + private function get_mysql_upsert_scalar_subquery_expression_replacements( array $tokens, int $start, int $end, array $scope ): ?array { + $replacements = array(); + + for ( $position = $start; $position < $end; $position++ ) { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + continue; + } + + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_subquery ) { + return null; + } + + $sql = $this->get_mysql_upsert_scalar_subquery_assignment_sql( $tokens, $position, $after_subquery, $scope ); + if ( null === $sql ) { + return null; + } + + $replacements[] = array( + 'start' => $position, + 'end' => $after_subquery, + 'sql' => $sql, + ); + $position = $after_subquery - 1; + } + + return $replacements; + } + + /** + * Get replacements for target-row column references in an upsert expression. + * + * PostgreSQL exposes both the target row and excluded row inside + * ON CONFLICT DO UPDATE. Qualify validated target-row references so names + * shared by excluded cannot become ambiguous. + * + * @param string $table_name Target table name. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $scope Statement table scope. + * @param array $table_column_lookup Table-column metadata lookup by lowercase name. + * @param array[] $protected_ranges Ranges already handled by larger replacements. + * @return array[]|null Replacement ranges, or null when a column-like reference is unsupported. + */ + private function get_mysql_upsert_target_column_expression_replacements( string $table_name, array $tokens, int $start, int $end, array $scope, array $table_column_lookup, array $protected_ranges ): ?array { + $replacements = array(); + + for ( $position = $start; $position < $end; $position++ ) { + $protected_end = $this->get_covering_mysql_replacement_range_end( $position, $protected_ranges ); + if ( null !== $protected_end ) { + $position = $protected_end - 1; + continue; + } + + if ( $this->is_mysql_qualified_reference_suffix_position( $tokens, $position, $start ) ) { + continue; + } + + if ( null !== $this->translate_mysql_nonparenthesized_timestamp_function_to_postgresql( $tokens, $position, $end ) ) { + continue; + } + + if ( null === $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ) ) { + continue; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + continue; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( null === $reference ) { + continue; + } + + if ( + null !== $reference['qualifier'] + && ! $this->is_mysql_dml_table_qualifier( $reference['qualifier'], $table_name, null ) + ) { + return null; + } + + $column_key = strtolower( $reference['column'] ); + if ( + null === $this->get_mysql_column_type_for_reference( $reference, $scope ) + || ! isset( $table_column_lookup[ $column_key ] ) + ) { + return null; + } + + $column_name = (string) ( $table_column_lookup[ $column_key ]['column_name'] ?? $reference['column'] ); + $replacements[] = array( + 'start' => $reference['start'], + 'end' => $reference['end'], + 'sql' => $this->get_postgresql_dml_column_reference_sql( $column_name, $table_name ), + ); + $position = $reference['end'] - 1; + } + + return $replacements; + } + + /** + * Check whether unreplaced expression segments contain unsupported CONVERT() forms. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array[] $replacements Replacement ranges. + * @return bool Whether an unsupported CONVERT() appears outside replacements. + */ + private function contains_unsupported_mysql_convert_function_outside_replacements( array $tokens, int $start, int $end, array $replacements ): bool { + $segment_start = $start; + foreach ( $replacements as $replacement ) { + if ( + $segment_start < $replacement['start'] + && $this->contains_unsupported_mysql_convert_function( $tokens, $segment_start, $replacement['start'] ) + ) { + return true; + } + + $segment_start = max( $segment_start, $replacement['end'] ); + } + + return $segment_start < $end + && $this->contains_unsupported_mysql_convert_function( $tokens, $segment_start, $end ); + } + + /** + * Check whether unreplaced expression segments contain unsupported common functions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array[] $replacements Replacement ranges. + * @return bool Whether an unsupported common function appears outside replacements. + */ + private function contains_unsupported_mysql_common_function_outside_replacements( array $tokens, int $start, int $end, array $replacements ): bool { + $segment_start = $start; + foreach ( $replacements as $replacement ) { + if ( + $segment_start < $replacement['start'] + && $this->contains_unsupported_mysql_common_function( $tokens, $segment_start, $replacement['start'] ) + ) { + return true; + } + + $segment_start = max( $segment_start, $replacement['end'] ); + } + + return $segment_start < $end + && $this->contains_unsupported_mysql_common_function( $tokens, $segment_start, $end ); + } + + /** + * Translate an upsert assignment expression while replacing VALUES()/DEFAULT() references. + * + * Runtime functions that wrap replacement expressions need to see the translated + * replacement inside their arguments so they keep MySQL semantics. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array[] $replacements Replacement ranges. + * @return string|null PostgreSQL SQL, or null when unsupported. + */ + private function translate_mysql_upsert_expression_token_sequence_with_replacements_to_postgresql( array $tokens, int $start, int $end, array $replacements ): ?string { + if ( empty( $replacements ) ) { + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ); + } + + usort( + $replacements, + static function ( array $a, array $b ): int { + return ( $a['start'] ?? 0 ) <=> ( $b['start'] ?? 0 ); + } + ); + + $chunks = array(); + $position = $start; + while ( $position < $end ) { + $replacement = $this->get_mysql_token_sequence_replacement_at_position( $replacements, $position ); + if ( null !== $replacement ) { + $chunks[] = $replacement['sql']; + $position = $replacement['end']; + continue; + } + + $function = $this->translate_mysql_common_function_with_replacements_to_postgresql( $tokens, $position, $end, $replacements ); + if ( false === $function ) { + return null; + } + if ( is_array( $function ) ) { + $chunks[] = $function['sql']; + $position = $function['position'] + 1; + continue; + } + + $segment_end = $end; + foreach ( $replacements as $candidate ) { + if ( $candidate['start'] > $position ) { + $segment_end = min( $segment_end, $candidate['start'] ); + break; + } + } + + for ( $scan = $position; $scan < $segment_end; $scan++ ) { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $scan, $end ); + if ( + null !== $bounds + && $this->mysql_token_sequence_replacements_intersect_range( $replacements, $scan, $bounds['close'] + 1 ) + ) { + $segment_end = $scan; + break; + } + } + + if ( $segment_end <= $position ) { + return null; + } + + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $position, $segment_end ); + $position = $segment_end; + } + + return implode( ' ', array_filter( $chunks, 'strlen' ) ); + } + + /** + * Translate a supported MySQL runtime function containing replacement ranges. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @param array[] $replacements Replacement ranges. + * @return array{sql:string,position:int}|false|null Translation data, false when unsupported, or null when not a split function. + */ + private function translate_mysql_common_function_with_replacements_to_postgresql( array $tokens, int $position, int $end, array $replacements ) { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $function_end = $bounds['close'] + 1; + if ( ! $this->mysql_token_sequence_replacements_intersect_range( $replacements, $position, $function_end ) ) { + return null; + } + + if ( in_array( $bounds['function'], array( 'last_insert_id', 'timestampadd', 'timestampdiff' ), true ) ) { + return false; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments ) { + return false; + } + + if ( 'substring' === $bounds['function'] ) { + $arguments = $this->get_mysql_substring_function_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments ) { + return false; + } + } + + $argument_sql = array(); + foreach ( $arguments as $argument ) { + $sql = $this->translate_mysql_upsert_expression_token_sequence_with_replacements_to_postgresql( + $tokens, + $argument['start'], + $argument['end'], + $this->get_mysql_token_sequence_replacements_for_range( $replacements, $argument['start'], $argument['end'] ) + ); + if ( null === $sql ) { + return false; + } + + $argument_sql[] = $sql; + } + + if ( 'if' === $bounds['function'] && 3 === count( $argument_sql ) ) { + $condition_sql = $this->is_mysql_boolean_condition_expression( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ) + ? '(' . $argument_sql[0] . ')' + : $this->get_postgresql_mysql_truthy_expression_sql( $argument_sql[0] ); + $sql = sprintf( 'CASE WHEN %s THEN %s ELSE %s END', $condition_sql, $argument_sql[1], $argument_sql[2] ); + } else { + $sql = $this->get_postgresql_mysql_common_function_sql( $bounds['function'], $argument_sql ); + if ( null === $sql ) { + return false; + } + } + + return array( + 'sql' => $sql, + 'position' => $bounds['close'], + ); + } + + /** + * Get a replacement that starts at the given token position. + * + * @param array[] $replacements Replacement ranges. + * @param int $position Token position. + * @return array|null Replacement range, or null when none starts here. + */ + private function get_mysql_token_sequence_replacement_at_position( array $replacements, int $position ): ?array { + foreach ( $replacements as $replacement ) { + if ( $replacement['start'] === $position ) { + return $replacement; + } + } + + return null; + } + + /** + * Get replacement ranges fully contained by a token range. + * + * @param array[] $replacements Replacement ranges. + * @param int $start Range start. + * @param int $end Range end. + * @return array[] Replacement ranges contained by the range. + */ + private function get_mysql_token_sequence_replacements_for_range( array $replacements, int $start, int $end ): array { + $ranged_replacements = array(); + foreach ( $replacements as $replacement ) { + if ( $replacement['start'] >= $start && $replacement['end'] <= $end ) { + $ranged_replacements[] = $replacement; + } + } + + return $ranged_replacements; + } + + /** + * Check whether replacement ranges overlap a token range. + * + * @param array[] $replacements Replacement ranges. + * @param int $start Range start. + * @param int $end Range end. + * @return bool Whether any replacement overlaps the range. + */ + private function mysql_token_sequence_replacements_intersect_range( array $replacements, int $start, int $end ): bool { + foreach ( $replacements as $replacement ) { + if ( $replacement['start'] < $end && $replacement['end'] > $start ) { + return true; + } + } + + return false; + } + + /** + * Get an exact alias source column from an upsert assignment expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $source_aliases Optional VALUES-row alias lookup. + * @return string|null Source column name, or null when the expression is not an alias reference. + */ + private function get_mysql_upsert_alias_assignment_source_column( array $tokens, int $start, int $end, array $source_aliases ): ?string { + $reference = $this->get_mysql_upsert_alias_expression_reference( $tokens, $start, $start, $end, $source_aliases ); + if ( ! is_array( $reference ) || $reference['end'] !== $end ) { + return null; + } + + return $reference['column']; + } + + /** + * Resolve a VALUES-row alias reference inside an upsert expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate reference position. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $source_aliases Optional VALUES-row alias lookup. + * @return array{column: string, end: int}|false|null Reference data, false for malformed alias use, or null when not an alias reference. + */ + private function get_mysql_upsert_alias_expression_reference( array $tokens, int $position, int $start, int $end, array $source_aliases ) { + if ( empty( $source_aliases ) || ! isset( $tokens[ $position ] ) ) { + return null; + } + + $identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ); + if ( null === $identifier ) { + return null; + } + + $identifier_key = strtolower( $identifier ); + if ( ( $source_aliases['row'] ?? null ) === $identifier_key ) { + if ( + $position + 2 >= $end + || WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) + ) { + return false; + } + + $source_column = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null === $source_column ) { + return false; + } + + $source_key = strtolower( $source_column ); + if ( ! isset( $source_aliases['qualified'][ $source_key ] ) ) { + return false; + } + + return array( + 'column' => $source_aliases['qualified'][ $source_key ], + 'end' => $position + 3, + ); + } + + if ( + isset( $source_aliases['unqualified'][ $identifier_key ] ) + && ( $position <= $start || WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position - 1 ]->id ?? null ) ) + && ( $position + 1 >= $end || WP_MySQL_Lexer::DOT_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) ) + && ( $position + 1 >= $end || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== ( $tokens[ $position + 1 ]->id ?? null ) ) + ) { + return array( + 'column' => $source_aliases['unqualified'][ $identifier_key ], + 'end' => $position + 1, + ); + } + + return null; + } + + /** + * Check whether an upsert expression is simple after removing replacement ranges. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array[] $replacements Replacement ranges. + * @return bool Whether the expression is supported. + */ + private function is_supported_simple_mysql_upsert_expression_fragment( array $tokens, int $start, int $end, array $replacements ): bool { + if ( ! empty( $replacements ) ) { + usort( + $replacements, + static function ( array $a, array $b ): int { + return ( $a['start'] ?? 0 ) <=> ( $b['start'] ?? 0 ); + } + ); + + for ( $position = $start; $position < $end; ) { + $replacement = $this->get_mysql_token_sequence_replacement_at_position( $replacements, $position ); + if ( null !== $replacement ) { + $position = $replacement['end']; + continue; + } + + $function = $this->translate_mysql_common_function_with_replacements_to_postgresql( $tokens, $position, $end, $replacements ); + if ( false === $function ) { + return false; + } + if ( is_array( $function ) ) { + $position = $function['position'] + 1; + continue; + } + + $segment_end = $end; + foreach ( $replacements as $candidate ) { + if ( $candidate['start'] > $position ) { + $segment_end = min( $segment_end, $candidate['start'] ); + break; + } + } + + if ( $segment_end <= $position || ! $this->is_supported_simple_mysql_upsert_expression_segment( $tokens, $position, $segment_end ) ) { + return false; + } + + $position = $segment_end; + } + + return true; + } + + $segment_start = $start; + foreach ( $replacements as $replacement ) { + if ( + $segment_start < $replacement['start'] + && ! $this->is_supported_simple_mysql_upsert_expression_segment( $tokens, $segment_start, $replacement['start'] ) + ) { + return false; + } + + $segment_start = $replacement['end']; + } + + return $segment_start >= $end + || $this->is_supported_simple_mysql_upsert_expression_segment( $tokens, $segment_start, $end ); + } + + /** + * Check upsert expression column references after removing replacement ranges. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array[] $replacements Replacement ranges. + * @param array $scope Statement table scope. + * @return bool Whether all column-like references resolve to the supplied scope. + */ + private function mysql_upsert_expression_column_references_resolve_to_scope( array $tokens, int $start, int $end, array $replacements, array $scope ): bool { + $segment_start = $start; + foreach ( $replacements as $replacement ) { + if ( + $segment_start < $replacement['start'] + && ! $this->mysql_expression_column_references_resolve_to_scope( $tokens, $segment_start, $replacement['start'], $scope ) + ) { + return false; + } + + $segment_start = $replacement['end']; + } + + return $segment_start >= $end + || $this->mysql_expression_column_references_resolve_to_scope( $tokens, $segment_start, $end, $scope ); + } + + /** + * Check whether an upsert expression segment contains supported tokens. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First segment token. + * @param int $end Final segment token, exclusive. + * @return bool Whether the segment is supported. + */ + private function is_supported_simple_mysql_upsert_expression_segment( array $tokens, int $start, int $end ): bool { + for ( $position = $start; $position < $end; $position++ ) { + $common_function = $this->translate_mysql_common_function_to_postgresql( $tokens, $position, $end ); + if ( null !== $common_function ) { + $position = $common_function['position']; + continue; + } + + if ( WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $tokens[ $position ]->id ) { + continue; + } + + if ( ! $this->is_supported_simple_mysql_expression_token( $tokens[ $position ] ) ) { + return false; + } + } + + return true; + } + + /** + * Validate the narrow SET clause supported by the simple UPDATE translator. + * + * @param string $table_name Table name. + * @param string|null $alias Optional table alias. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First SET-clause token position. + * @param int $end Final SET-clause token position, exclusive. + * @return bool Whether the SET clause is supported. + */ + private function is_supported_simple_update_set_clause( string $table_name, ?string $alias, array $tokens, int $start, int $end ): bool { + for ( $position = $start; $position < $end; ) { + $target = $this->parse_simple_mysql_update_assignment_target( $table_name, $alias, $tokens, $position, $end ); + if ( null === $target || ! isset( $tokens[ $target['end'] ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $target['end'] ]->id ) { + return false; + } + + $value_start = $target['end'] + 1; + $assignment_end = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $value_start, + $end + ) ?? $end; + + if ( + $value_start >= $assignment_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $value_start, $assignment_end ) + ) { + return false; + } + + $position = $assignment_end; + if ( $position === $end ) { + return true; + } + + ++$position; + } + + return false; + } + + /** + * Validate a simple SELECT projection list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @return bool Whether the projection is supported. + */ + private function is_supported_simple_select_projection( array $tokens, int $start, int $end ): bool { + if ( $start + 1 === $end && WP_MySQL_Lexer::MULT_OPERATOR === $tokens[ $start ]->id ) { + return true; + } + + if ( $this->is_supported_simple_select_count_projection( $tokens, $start, $end ) ) { + return true; + } + + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges || array() === $ranges ) { + return false; + } + + foreach ( $ranges as $range ) { + $reference = $this->parse_mysql_column_reference( $tokens, $range['start'], $range['end'] ); + if ( null === $reference || $reference['end'] !== $range['end'] ) { + return false; + } + } + + return true; + } + + /** + * Validate the supported COUNT(identifier) projection shape. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @return bool Whether the aggregate projection is supported. + */ + private function is_supported_simple_select_count_projection( array $tokens, int $start, int $end ): bool { + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ] ) + || WP_MySQL_Lexer::COUNT_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $start + 1 ]->id + || null === $this->get_mysql_identifier_token_value( $tokens[ $start + 2 ] ) + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $start + 3 ]->id + ) { + return false; + } + + if ( $start + 4 === $end ) { + return true; + } + + return $start + 6 === $end + && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $start + 4 ]->id + && null !== $this->get_mysql_identifier_token_value( $tokens[ $start + 5 ] ); + } + + /** + * Translate a supported simple SELECT projection to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end Final projection token position, exclusive. + * @return string PostgreSQL projection SQL. + */ + private function translate_simple_select_projection_to_postgresql( array $tokens, int $start, int $end ): string { + if ( $this->is_supported_simple_select_count_projection( $tokens, $start, $end ) ) { + $sql = sprintf( + 'COUNT(%s)', + $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $start + 2 ] ) + ); + + if ( $start + 6 === $end ) { + $sql .= ' ' . $tokens[ $start + 4 ]->get_bytes() . ' ' . $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $start + 5 ] ); + } + + return $sql; + } + + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ); + } + + /** + * Translate a SELECT while applying metadata-backed expression coercions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First token after SELECT modifiers to render. + * @param int $statement_end Final statement token position, exclusive. + * @param bool $require_contextual_change Whether unchanged statements should fall through. + * @return string|null PostgreSQL SELECT SQL, or null when no safe contextual translation applies. + */ + private function translate_mysql_select_statement_with_integer_string_coercion( + array $tokens, + int $projection_start, + int $statement_end, + bool $require_contextual_change + ): ?string { + $replacements = $this->get_mysql_select_statement_contextual_replacements( + $tokens, + $projection_start, + $statement_end + ); + if ( null === $replacements ) { + return null; + } + + if ( $require_contextual_change && empty( $replacements ) ) { + return null; + } + + return 'SELECT ' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + $projection_start, + $statement_end, + $replacements + ); + } + + /** + * Get metadata-backed replacements for SELECT WHERE and ORDER BY clauses. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $projection_start First token after SELECT modifiers to render. + * @param int $statement_end Final statement token position, exclusive. + * @return array[]|null Replacement ranges, or null when contextual rewriting is unavailable. + */ + private function get_mysql_select_statement_contextual_replacements( + array $tokens, + int $projection_start, + int $statement_end + ): ?array { + $where_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::WHERE_SYMBOL, + $projection_start, + $statement_end + ); + $order_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::ORDER_SYMBOL, + $projection_start, + $statement_end + ); + if ( null === $where_position && null === $order_position ) { + return null; + } + + $first_clause_position = min( array_filter( array( $where_position, $order_position ), 'is_int' ) ); + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $first_clause_position + ); + if ( null === $from_position ) { + return null; + } + + $from_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + $from_position + 1, + $statement_end + ) ?? $statement_end; + + $scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $from_end ); + if ( null === $scope ) { + return null; + } + + $replacements = $this->get_mysql_select_projection_contextual_replacements( + $tokens, + $projection_start, + $from_position, + $scope + ); + if ( null === $replacements ) { + return null; + } + + if ( null !== $where_position ) { + $where_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ), + $where_position + 1, + $statement_end + ) ?? $statement_end; + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + if ( $where_sql['changed'] ) { + $replacements[] = array( + 'start' => $where_position + 1, + 'end' => $where_end, + 'sql' => $where_sql['sql'], + ); + } + } + + if ( + null !== $order_position + && isset( $tokens[ $order_position + 1 ] ) + && WP_MySQL_Lexer::BY_SYMBOL === $tokens[ $order_position + 1 ]->id + ) { + $order_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ), + $order_position + 2, + $statement_end + ) ?? $statement_end; + + $order_sql = $this->translate_mysql_order_by_token_sequence_to_postgresql( + $tokens, + $order_position + 2, + $order_end, + $scope, + ! $this->contains_top_level_mysql_token( + $tokens, + $projection_start, + $statement_end, + array( + WP_MySQL_Lexer::DISTINCT_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + ) + ) + ); + if ( $order_sql['changed'] ) { + $replacements[] = array( + 'start' => $order_position + 2, + 'end' => $order_end, + 'sql' => $order_sql['sql'], + ); + } + } + + return $replacements; + } + + /** + * Get metadata-backed replacements for SELECT projection expressions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection token position. + * @param int $end FROM token position. + * @param array $scope Statement table scope. + * @return array[]|null Replacement ranges, or null when projection parsing fails. + */ + private function get_mysql_select_projection_contextual_replacements( array $tokens, int $start, int $end, array $scope ): ?array { + $ranges = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $ranges ) { + return null; + } + + $replacements = array(); + foreach ( $ranges as $range ) { + $expression_start = $range['start']; + $expression_end = $range['end']; + $projection_item = $this->parse_mysql_select_projection_item( $tokens, $range['start'], $range['end'] ); + if ( null !== $projection_item ) { + $expression_start = $projection_item['expression_start']; + $expression_end = $projection_item['expression_end']; + } + + $replacement_sql = $this->translate_mysql_sum_text_column_aggregate_to_postgresql( + $tokens, + $expression_start, + $expression_end, + $scope + ); + if ( null === $replacement_sql ) { + continue; + } + + $replacements[] = array( + 'start' => $expression_start, + 'end' => $expression_end, + 'sql' => $replacement_sql, + ); + } + + return $replacements; + } + + /** + * Translate SUM(text_column) with MySQL numeric text coercion. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First projection expression token. + * @param int $end Final projection expression token, exclusive. + * @param array $scope Statement table scope. + * @return string|null PostgreSQL aggregate SQL, or null when unsupported. + */ + private function translate_mysql_sum_text_column_aggregate_to_postgresql( array $tokens, int $start, int $end, array $scope ): ?string { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + if ( + $start + 4 > $end + || ! isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $end - 1 ] ) + || WP_MySQL_Lexer::SUM_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $start + 1 ]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $end - 1 ]->id + ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $start + 2, $end - 1 ); + if ( + null === $reference + || $reference['end'] !== $end - 1 + || ! $this->is_mysql_text_family_column_reference( $reference, $scope ) + ) { + return null; + } + + return sprintf( + 'SUM(%s)', + $this->get_postgresql_mysql_numeric_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ) + ); + } + + /** + * Translate tokens while replacing known bounded token ranges. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @param array[] $replacements Replacement ranges with translated SQL. + * @return string PostgreSQL SQL fragment. + */ + private function translate_mysql_token_sequence_with_replacements_to_postgresql( + array $tokens, + int $start, + int $end, + array $replacements + ): string { + $chunks = array(); + $position = $start; + + foreach ( $replacements as $replacement ) { + if ( $position < $replacement['start'] ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $position, $replacement['start'] ); + } + + $chunks[] = $replacement['sql']; + $position = $replacement['end']; + } + + if ( $position < $end ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $position, $end ); + } + + return implode( ' ', array_filter( $chunks, 'strlen' ) ); + } + + /** + * Translate tokens while applying replacement ranges bounded to the requested range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @param array[] $replacements Replacement ranges with translated SQL. + * @return string PostgreSQL SQL fragment. + */ + private function translate_mysql_token_sequence_with_optional_replacements_to_postgresql( + array $tokens, + int $start, + int $end, + array $replacements + ): string { + $range_replacements = $this->get_mysql_replacements_for_token_range( $replacements, $start, $end ); + if ( empty( $range_replacements ) ) { + return $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ); + } + + return $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, $start, $end, $range_replacements ); + } + + /** + * Get replacement ranges fully contained in a token range. + * + * @param array[] $replacements Replacement ranges with translated SQL. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return array[] Replacement ranges in the requested range. + */ + private function get_mysql_replacements_for_token_range( array $replacements, int $start, int $end ): array { + $range_replacements = array(); + foreach ( $replacements as $replacement ) { + if ( $replacement['start'] >= $start && $replacement['end'] <= $end ) { + $range_replacements[] = $replacement; + } + } + + return $range_replacements; + } + + /** + * Get the original SQL text for a token range. + * + * @param string $query Original MySQL query. + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return string|null Query slice, or null when the range is invalid. + */ + private function get_mysql_token_range_sql( string $query, array $tokens, int $start, int $end ): ?string { + if ( $start >= $end || ! isset( $tokens[ $start ], $tokens[ $end - 1 ] ) ) { + return null; + } + + $range_start = $tokens[ $start ]->start; + $last_token = $tokens[ $end - 1 ]; + $range_end = $last_token->start + $last_token->length; + + return substr( $query, $range_start, $range_end - $range_start ); + } + + /** + * Translate ORDER BY items with metadata-backed expression coercions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First ORDER BY item token position. + * @param int $end Final ORDER BY token position, exclusive. + * @param array $scope Statement table scope. + * @param bool $allow_wordpress_posts_id_tiebreaker Whether to add WordPress posts ID tie-breakers. + * @return array{sql: string, changed: bool} Translated ORDER BY SQL and change flag. + */ + private function translate_mysql_order_by_token_sequence_to_postgresql( + array $tokens, + int $start, + int $end, + array $scope, + bool $allow_wordpress_posts_id_tiebreaker + ): array { + $order_items = $this->parse_mysql_select_order_by_items( $tokens, $start, $end, array(), $scope ); + if ( null === $order_items ) { + return array( + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ), + 'changed' => false, + ); + } + + $changed = false; + $order_sql = array(); + foreach ( $order_items as $order_item ) { + $changed = $changed || $order_item['changed']; + + $item_sql = $order_item['sql']; + if ( $order_item['direction_explicit'] ) { + $item_sql .= ' ' . $order_item['direction']; + } + + $order_sql[] = $item_sql; + } + + $tiebreaker_sql = null; + if ( $allow_wordpress_posts_id_tiebreaker ) { + $tiebreaker_sql = $this->get_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( $tokens, $order_items, $scope ); + if ( null === $tiebreaker_sql ) { + $tiebreaker_sql = $this->get_wordpress_posts_menu_order_title_order_id_tiebreaker_sql( $tokens, $order_items, $scope ); + } + } + if ( null !== $tiebreaker_sql ) { + $order_sql[] = $tiebreaker_sql; + $changed = true; + } + + return array( + 'sql' => $changed + ? implode( ', ', $order_sql ) + : $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ), + 'changed' => $changed, + ); + } + + /** + * Get the MySQL-compatible posts date tie-breaker for a simple SELECT. + * + * WordPress's posts table has the MySQL type_status_date index ending in ID. + * MySQL scans that index backward for default post_date DESC queries, so rows + * with equal post_date values are returned by descending ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param string $table_name Selected table name. + * @param int $order_position ORDER token position. + * @param int $end Final ORDER BY token position, exclusive. + * @return string|null PostgreSQL ORDER BY item SQL, or null when not applicable. + */ + private function get_simple_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( + array $tokens, + string $table_name, + int $order_position, + int $end + ): ?string { + if ( + ! $this->is_mysql_wordpress_table_name( $table_name, 'posts' ) + || ! isset( $tokens[ $end - 1 ] ) + || WP_MySQL_Lexer::DESC_SYMBOL !== $tokens[ $end - 1 ]->id + ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $order_position + 2, $end - 1 ); + if ( + null === $reference + || $reference['end'] !== $end - 1 + || 'post_date' !== strtolower( $reference['column'] ) + ) { + return null; + } + + if ( null !== $reference['qualifier'] ) { + return sprintf( + '%s.%s DESC', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['start'] + 1 ), + $this->connection->quote_identifier( 'ID' ) + ); + } + + return $this->connection->quote_identifier( 'ID' ) . ' DESC'; + } + + /** + * Get the MySQL-compatible approved-comments date tie-breaker. + * + * get_approved_comments() orders by comment_date_gmt only. MySQL returns + * equal-date rows in comment_ID order for WordPress's comments table shape, + * so make that ordering explicit for PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param string $table_name Selected table name. + * @param int|null $where_position WHERE token position, or null. + * @param int|null $where_end Final WHERE token position, exclusive. + * @param int $order_position ORDER token position. + * @param int $end Final ORDER BY token position, exclusive. + * @return string|null PostgreSQL ORDER BY item SQL, or null when not applicable. + */ + private function get_simple_wordpress_approved_comments_order_tiebreaker_sql( + array $tokens, + string $table_name, + ?int $where_position, + ?int $where_end, + int $order_position, + int $end + ): ?string { + if ( + ! $this->is_mysql_wordpress_table_name( $table_name, 'comments' ) + || null === $where_position + || null === $where_end + || ! $this->is_simple_wordpress_approved_comments_where_clause( $tokens, $where_position + 1, $where_end ) + ) { + return null; + } + + $order_items = $this->split_top_level_mysql_arguments( $tokens, $order_position + 2, $end ); + if ( null === $order_items || 1 !== count( $order_items ) ) { + return null; + } + + $order_item = $order_items[0]; + $direction = 'ASC'; + $item_end = $order_item['end']; + if ( isset( $tokens[ $item_end - 1 ] ) ) { + if ( WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $item_end - 1 ]->id ) { + return null; + } + if ( WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $item_end - 1 ]->id ) { + $item_end = $item_end - 1; + $direction = 'ASC'; + } + } + + if ( + 'ASC' !== $direction + || ! $this->is_mysql_column_reference_expression( + $tokens, + $order_item['start'], + $item_end, + 'comment_date_gmt', + 'comments', + true + ) + ) { + return null; + } + + return $this->connection->quote_identifier( 'comment_ID' ) . ' ASC'; + } + + /** + * Check for get_approved_comments()'s single-post approved comments filter. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First WHERE predicate token. + * @param int $end Final WHERE predicate token, exclusive. + * @return bool Whether the WHERE clause matches the approved-comments shape. + */ + private function is_simple_wordpress_approved_comments_where_clause( array $tokens, int $start, int $end ): bool { + $conjuncts = $this->split_mysql_top_level_boolean_conjuncts( $tokens, $start, $end ); + if ( null === $conjuncts ) { + return false; + } + + $has_post_id = false; + $has_approved = false; + foreach ( $conjuncts as $conjunct ) { + $match = $this->get_simple_wordpress_comments_literal_equality( + $tokens, + $conjunct['start'], + $conjunct['end'] + ); + if ( null === $match ) { + continue; + } + + if ( 'comment_post_id' === $match['column'] ) { + $has_post_id = true; + continue; + } + + if ( 'comment_approved' === $match['column'] && '1' === $match['value'] ) { + $has_approved = true; + } + } + + return $has_post_id && $has_approved; + } + + /** + * Parse a comments-table column = literal predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token. + * @param int $end Final predicate token, exclusive. + * @return array{column: string, value: string}|null Parsed column and literal value. + */ + private function get_simple_wordpress_comments_literal_equality( array $tokens, int $start, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + $equal_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::EQUAL_OPERATOR, $start, $end ); + if ( + null === $equal_position + || null !== $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::EQUAL_OPERATOR, $equal_position + 1, $end ) + ) { + return null; + } + + $match = $this->get_simple_wordpress_comments_literal_equality_side( + $tokens, + $start, + $equal_position, + $equal_position + 1, + $end + ); + if ( null !== $match ) { + return $match; + } + + return $this->get_simple_wordpress_comments_literal_equality_side( + $tokens, + $equal_position + 1, + $end, + $start, + $equal_position + ); + } + + /** + * Parse one column/literal side of a comments-table equality predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $column_start First column token. + * @param int $column_end Final column token, exclusive. + * @param int $literal_start First literal token. + * @param int $literal_end Final literal token, exclusive. + * @return array{column: string, value: string}|null Parsed column and literal value. + */ + private function get_simple_wordpress_comments_literal_equality_side( + array $tokens, + int $column_start, + int $column_end, + int $literal_start, + int $literal_end + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $column_start, $column_end ); + if ( + null === $reference + || $reference['end'] !== $column_end + || ( + null !== $reference['qualifier'] + && ! $this->is_mysql_wordpress_table_name( $reference['qualifier'], 'comments' ) + ) + ) { + return null; + } + + $literal = $this->get_simple_wordpress_comments_literal_value( $tokens, $literal_start, $literal_end ); + if ( null === $literal ) { + return null; + } + + return array( + 'column' => strtolower( $reference['column'] ), + 'value' => $literal, + ); + } + + /** + * Get a supported literal value for the approved-comments WHERE predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @param int $end Final literal token, exclusive. + * @return string|null Literal value, "literal" for unconstrained post IDs, or null. + */ + private function get_simple_wordpress_comments_literal_value( array $tokens, int $start, int $end ): ?string { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + $start = $bounds['start']; + $end = $bounds['end']; + + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + return $tokens[ $start ]->get_value(); + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + if ( null !== $literal && $literal['end'] === $end ) { + return 'literal'; + } + + return null; + } + + /** + * Get the MySQL-compatible posts date tie-breaker for a parsed ORDER BY. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_items Parsed ORDER BY items. + * @param array $scope Statement table scope. + * @return string|null PostgreSQL ORDER BY item SQL, or null when not applicable. + */ + private function get_wordpress_posts_post_date_desc_order_id_tiebreaker_sql( array $tokens, array $order_items, array $scope ): ?string { + if ( + 1 !== count( $order_items ) + || ! empty( $scope['unknown'] ) + || 1 !== count( $scope['tables'] ) + || 'DESC' !== $order_items[0]['direction'] + ) { + return null; + } + + $order_item = $order_items[0]; + $reference = $this->parse_mysql_column_reference( + $tokens, + $order_item['expression_start'], + $order_item['expression_end'] + ); + if ( + null === $reference + || $reference['end'] !== $order_item['expression_end'] + || 'post_date' !== strtolower( $reference['column'] ) + ) { + return null; + } + + $table = $this->get_mysql_single_scope_table_for_column_reference( $reference, $scope ); + if ( null === $table || ! $this->is_mysql_wordpress_table_name( $table['table'], 'posts' ) ) { + return null; + } + + if ( null !== $reference['qualifier'] ) { + return sprintf( + '%s.%s DESC', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['start'] + 1 ), + $this->connection->quote_identifier( 'ID' ) + ); + } + + return $this->connection->quote_identifier( 'ID' ) . ' DESC'; + } + + /** + * Get the MySQL-compatible posts title tie-breaker for admin page searches. + * + * MySQL returns tied page rows for WordPress's menu_order/title ordering in + * primary-key order. PostgreSQL may return those ties in physical order, + * which changes the parent group selected by WP_Posts_List_Table paging. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $order_items Parsed ORDER BY items. + * @param array $scope Statement table scope. + * @return string|null PostgreSQL ORDER BY item SQL, or null when not applicable. + */ + private function get_wordpress_posts_menu_order_title_order_id_tiebreaker_sql( array $tokens, array $order_items, array $scope ): ?string { + if ( + 2 !== count( $order_items ) + || ! empty( $scope['unknown'] ) + || 1 !== count( $scope['tables'] ) + ) { + return null; + } + + $expected_columns = array( 'menu_order', 'post_title' ); + $references = array(); + $matched_table = null; + foreach ( $expected_columns as $index => $expected_column ) { + if ( 'ASC' !== $order_items[ $index ]['direction'] ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( + $tokens, + $order_items[ $index ]['expression_start'], + $order_items[ $index ]['expression_end'] + ); + if ( + null === $reference + || $reference['end'] !== $order_items[ $index ]['expression_end'] + || strtolower( $reference['column'] ) !== $expected_column + ) { + return null; + } + + $table = $this->get_mysql_single_scope_table_for_column_reference( $reference, $scope ); + if ( null === $table || ! $this->is_mysql_wordpress_table_name( $table['table'], 'posts' ) ) { + return null; + } + + if ( null !== $matched_table && $matched_table !== $table ) { + return null; + } + + $matched_table = $table; + $references[] = $reference; + } + + $qualifier_reference = null !== $references[0]['qualifier'] ? $references[0] : $references[1]; + if ( null !== $qualifier_reference['qualifier'] ) { + return sprintf( + '%s.%s ASC', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $qualifier_reference['start'], $qualifier_reference['start'] + 1 ), + $this->connection->quote_identifier( 'ID' ) + ); + } + + return $this->connection->quote_identifier( 'ID' ) . ' ASC'; + } + + /** + * Translate expression tokens with metadata-backed numeric text coercions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, changed: bool} Translated expression SQL and change flag. + */ + private function translate_mysql_expression_token_sequence_to_postgresql( + array $tokens, + int $start, + int $end, + array $scope + ): array { + $chunks = array(); + $segment_start = $start; + $changed = false; + + for ( $position = $start; $position < $end; $position++ ) { + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null !== $after_subquery ) { + $position = $after_subquery - 1; + continue; + } + } + + $translated_expression = $this->translate_mysql_text_column_numeric_arithmetic_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null === $translated_expression ) { + $translated_expression = $this->translate_mysql_wordpress_text_order_expression_to_postgresql( + $tokens, + $position, + $start, + $end, + $scope + ); + } + if ( null === $translated_expression ) { + $translated_expression = $this->translate_mysql_wordpress_text_expression_predicate_to_postgresql( + $tokens, + $position, + $start, + $end, + $scope + ); + } + if ( null === $translated_expression ) { + continue; + } + + if ( $segment_start < $position ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $position ); + } + + $chunks[] = $translated_expression['sql']; + $segment_start = $translated_expression['position'] + 1; + $position = $translated_expression['position']; + $changed = true; + } + + if ( ! $changed ) { + return array( + 'sql' => $this->translate_mysql_token_sequence_to_postgresql( $tokens, $start, $end ), + 'changed' => false, + ); + } + + if ( $segment_start < $end ) { + $chunks[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $segment_start, $end ); + } + + return array( + 'sql' => implode( ' ', array_filter( $chunks, 'strlen' ) ), + 'changed' => true, + ); + } + + /** + * Translate WordPress text ORDER BY expressions with MySQL collation semantics. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate expression start position. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_order_expression_to_postgresql( + array $tokens, + int $position, + int $start, + int $end, + array $scope + ): ?array { + if ( $position !== $start ) { + return null; + } + + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $start, $end ); + if ( $bounds['start'] !== $start || $bounds['end'] !== $end ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $bounds['start'], $bounds['end'] ); + if ( + null === $reference + || $reference['end'] !== $bounds['end'] + || ! $this->is_mysql_case_insensitive_wordpress_text_column_reference( $reference, $scope ) + ) { + return null; + } + + return array( + 'sql' => sprintf( + 'LOWER(%s)', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ), + 'position' => $bounds['end'] - 1, + ); + } + + /** + * Translate WordPress text predicates embedded in expressions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $start First expression token position. + * @param int $end Final expression token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_expression_predicate_to_postgresql( + array $tokens, + int $position, + int $start, + int $end, + array $scope + ): ?array { + if ( ! $this->is_mysql_expression_predicate_start_context( $tokens, $position, $start ) ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( + null === $reference + || ! $this->is_mysql_case_insensitive_wordpress_text_column_reference( $reference, $scope ) + ) { + return null; + } + + return $this->translate_mysql_wordpress_text_like_predicate_to_postgresql( + $tokens, + $reference, + $reference['end'], + $end + ); + } + + /** + * Check whether an expression position starts a boolean predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $start First expression token position. + * @return bool Whether the candidate follows a boolean expression boundary. + */ + private function is_mysql_expression_predicate_start_context( array $tokens, int $position, int $start ): bool { + if ( $position <= $start ) { + return false; + } + + $previous_token_id = $tokens[ $position - 1 ]->id ?? null; + if ( $this->is_mysql_expression_predicate_left_boundary_token_id( $previous_token_id ) ) { + return true; + } + + return WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous_token_id + && $position - 1 > $start + && $this->is_mysql_expression_predicate_left_boundary_token_id( $tokens[ $position - 2 ]->id ?? null ); + } + + /** + * Check whether a token can precede a predicate inside an expression. + * + * @param int|null $token_id MySQL token ID. + * @return bool Whether the token is a predicate boundary. + */ + private function is_mysql_expression_predicate_left_boundary_token_id( ?int $token_id ): bool { + return in_array( + $token_id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::WHEN_SYMBOL, + WP_MySQL_Lexer::XOR_SYMBOL, + ), + true + ); + } + + /** + * Translate predicate tokens with metadata-backed integer string coercion. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First predicate token position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @param array[] $replacements Replacement ranges with translated SQL. + * @return array{sql: string, changed: bool} Translated predicate SQL and change flag. + */ + private function translate_mysql_predicate_token_sequence_to_postgresql( + array $tokens, + int $start, + int $end, + array $scope, + array $replacements = array() + ): array { + $chunks = array(); + $segment_start = $start; + $changed = false; + + for ( $position = $start; $position < $end; $position++ ) { + if ( $this->is_mysql_qualified_reference_suffix_position( $tokens, $position, $start ) ) { + continue; + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $after_subquery = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null !== $after_subquery ) { + $translated_subquery = $this->translate_mysql_parenthesized_select_predicate_to_postgresql( + $tokens, + $position, + $after_subquery, + $scope + ); + if ( null !== $translated_subquery ) { + if ( $segment_start < $position ) { + $chunks[] = $this->translate_mysql_token_sequence_with_optional_replacements_to_postgresql( $tokens, $segment_start, $position, $replacements ); + } + + $chunks[] = $translated_subquery['sql']; + $segment_start = $translated_subquery['position'] + 1; + $position = $translated_subquery['position']; + $changed = true; + continue; + } + + $position = $after_subquery - 1; + continue; + } + } + + $translated_predicate = $this->translate_mysql_integer_column_string_predicate_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null === $translated_predicate ) { + continue; + } + + if ( $segment_start < $position ) { + $chunks[] = $this->translate_mysql_token_sequence_with_optional_replacements_to_postgresql( $tokens, $segment_start, $position, $replacements ); + } + + $chunks[] = $translated_predicate['sql']; + $segment_start = $translated_predicate['position'] + 1; + $position = $translated_predicate['position']; + $changed = true; + } + + if ( ! $changed ) { + return array( + 'sql' => $this->translate_mysql_token_sequence_with_optional_replacements_to_postgresql( $tokens, $start, $end, $replacements ), + 'changed' => ! empty( $replacements ), + ); + } + + if ( $segment_start < $end ) { + $chunks[] = $this->translate_mysql_token_sequence_with_optional_replacements_to_postgresql( $tokens, $segment_start, $end, $replacements ); + } + + return array( + 'sql' => implode( ' ', array_filter( $chunks, 'strlen' ) ), + 'changed' => true, + ); + } + + /** + * Translate one integer-column predicate against string literals. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_integer_column_string_predicate_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $truthiness = $this->translate_mysql_numeric_literal_truthiness_predicate_to_postgresql( + $tokens, + $position, + $end + ); + if ( null !== $truthiness ) { + return $truthiness; + } + + $decimal_like = $this->translate_mysql_decimal_cast_like_predicate_to_postgresql( + $tokens, + $position, + $end + ); + if ( null !== $decimal_like ) { + return $decimal_like; + } + + $temporal_comparison = $this->translate_mysql_temporal_expression_column_comparison_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null !== $temporal_comparison ) { + return $temporal_comparison; + } + + $wordpress_text_predicate = $this->translate_mysql_wordpress_text_predicate_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null !== $wordpress_text_predicate ) { + return $wordpress_text_predicate; + } + + $in_predicate = $this->translate_mysql_integer_column_string_in_predicate_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null !== $in_predicate ) { + return $in_predicate; + } + + $comparison = $this->translate_mysql_integer_column_string_comparison_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null !== $comparison ) { + return $comparison; + } + + $numeric_comparison = $this->translate_mysql_text_column_numeric_comparison_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + if ( null !== $numeric_comparison ) { + return $numeric_comparison; + } + + return $this->translate_mysql_metadata_column_reference_to_postgresql( + $tokens, + $position, + $end, + $scope + ); + } + + /** + * Translate temporal expression comparisons against text-backed temporal columns. + * + * PostgreSQL stores MySQL date/datetime/timestamp columns as text so invalid + * MySQL dates remain readable. Compare temporal expressions as fixed ISO text + * in this metadata-backed lane to avoid timestamp/text operator errors without + * casting zero or partial-zero column values. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_temporal_expression_column_comparison_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $temporal_expression = $this->parse_mysql_temporal_comparison_expression( $tokens, $position, $end ); + if ( + null !== $temporal_expression + && isset( $tokens[ $temporal_expression['end'] ] ) + && $this->is_mysql_comparison_operator_token( $tokens[ $temporal_expression['end'] ] ) + ) { + $reference = $this->parse_mysql_column_reference( $tokens, $temporal_expression['end'] + 1, $end ); + $column_type = null === $reference ? null : $this->get_mysql_temporal_column_type_for_reference( $reference, $scope ); + if ( + null !== $reference + && null !== $column_type + && $this->is_mysql_temporal_comparison_predicate_boundary( $tokens, $reference['end'], $end ) + ) { + return array( + 'sql' => sprintf( + '%s %s %s', + $this->get_postgresql_mysql_temporal_expression_comparison_text_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $temporal_expression['start'], $temporal_expression['end'] ), + $temporal_expression['returns_timestamp'] + ), + $tokens[ $temporal_expression['end'] ]->get_bytes(), + $this->get_postgresql_mysql_temporal_column_comparison_text_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $column_type + ) + ), + 'position' => $reference['end'] - 1, + ); + } + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( + null === $reference + || ! isset( $tokens[ $reference['end'] ] ) + || ! $this->is_mysql_comparison_operator_token( $tokens[ $reference['end'] ] ) + ) { + return null; + } + + $column_type = $this->get_mysql_temporal_column_type_for_reference( $reference, $scope ); + if ( null === $column_type ) { + return null; + } + + $temporal_expression = $this->parse_mysql_temporal_comparison_expression( $tokens, $reference['end'] + 1, $end ); + if ( + null === $temporal_expression + || ! $this->is_mysql_temporal_comparison_predicate_boundary( $tokens, $temporal_expression['end'], $end ) + ) { + return null; + } + + return array( + 'sql' => sprintf( + '%s %s %s', + $this->get_postgresql_mysql_temporal_column_comparison_text_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $column_type + ), + $tokens[ $reference['end'] ]->get_bytes(), + $this->get_postgresql_mysql_temporal_expression_comparison_text_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $temporal_expression['start'], $temporal_expression['end'] ), + $temporal_expression['returns_timestamp'] + ) + ), + 'position' => $temporal_expression['end'] - 1, + ); + } + + /** + * Parse a MySQL temporal expression usable in metadata-backed comparisons. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Expression start position. + * @param int $end Final token position, exclusive. + * @return array{start: int, end: int, returns_timestamp: bool}|null Expression bounds, or null when unsupported. + */ + private function parse_mysql_temporal_comparison_expression( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return null; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close || $after_close > $end ) { + return null; + } + + $inner = $this->parse_mysql_temporal_comparison_expression( $tokens, $position + 1, $after_close - 1 ); + if ( null === $inner || $inner['start'] !== $position + 1 || $inner['end'] !== $after_close - 1 ) { + return null; + } + + return array( + 'start' => $position, + 'end' => $after_close, + 'returns_timestamp' => $inner['returns_timestamp'], + ); + } + + $date_arithmetic = $this->get_mysql_date_arithmetic_function_bounds( $tokens, $position, $end ); + if ( null !== $date_arithmetic ) { + return array( + 'start' => $position, + 'end' => $date_arithmetic['close'] + 1, + 'returns_timestamp' => true, + ); + } + + $common_function = $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( + null !== $common_function + && $this->is_mysql_temporal_comparison_common_function_name( $common_function['function'] ) + && null !== $this->translate_mysql_common_function_to_postgresql( $tokens, $position, $end ) + ) { + return array( + 'start' => $position, + 'end' => $common_function['close'] + 1, + 'returns_timestamp' => 'timestampadd' === $common_function['function'], + ); + } + + $nonparenthesized_function = $this->translate_mysql_nonparenthesized_timestamp_function_to_postgresql( $tokens, $position, $end ); + if ( + null !== $nonparenthesized_function + && $this->is_mysql_temporal_comparison_nonparenthesized_function_token( $tokens[ $position ] ) + ) { + return array( + 'start' => $position, + 'end' => $position + 1, + 'returns_timestamp' => false, + ); + } + + return null; + } + + /** + * Check whether a temporal comparison operand ends at a predicate boundary. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Token position after the operand. + * @param int $end Final predicate token position, exclusive. + * @return bool Whether the operand is complete. + */ + private function is_mysql_temporal_comparison_predicate_boundary( array $tokens, int $position, int $end ): bool { + if ( $position >= $end || ! isset( $tokens[ $position ] ) ) { + return true; + } + + return $this->is_mysql_boolean_predicate_left_boundary_token_id( $tokens[ $position ]->id ) + || in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LOGICAL_AND_OPERATOR, + WP_MySQL_Lexer::LOGICAL_OR_OPERATOR, + WP_MySQL_Lexer::ON_SYMBOL, + ), + true + ); + } + + /** + * Check whether a common function returns a temporal value suitable for text comparison. + * + * @param string $function_name Normalized common MySQL function name. + * @return bool Whether the function is date/datetime-like. + */ + private function is_mysql_temporal_comparison_common_function_name( string $function_name ): bool { + return in_array( + $function_name, + array( + 'curdate', + 'date', + 'from_unixtime', + 'localtime', + 'localtimestamp', + 'now', + 'timestampadd', + 'utc_date', + 'utc_timestamp', + ), + true + ); + } + + /** + * Check whether a non-parenthesized temporal function token is date/datetime-like. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token returns a date/datetime value. + */ + private function is_mysql_temporal_comparison_nonparenthesized_function_token( WP_MySQL_Token $token ): bool { + return in_array( + strtolower( $token->get_value() ), + array( + 'current_date', + 'current_timestamp', + 'localtime', + 'localtimestamp', + ), + true + ); + } + + /** + * Resolve a column reference to date/datetime/timestamp MySQL metadata. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return string|null MySQL column type, or null when not temporal. + */ + private function get_mysql_temporal_column_type_for_reference( array $reference, array $scope ): ?string { + $column_type = $this->get_mysql_column_type_for_reference( $reference, $scope ); + if ( null === $column_type ) { + return null; + } + + return in_array( + $this->get_base_mysql_dml_column_type( $column_type ), + array( + 'date', + 'datetime', + 'timestamp', + ), + true + ) ? $column_type : null; + } + + /** + * Get PostgreSQL text SQL for a temporal expression comparison operand. + * + * @param string $expression_sql PostgreSQL temporal expression SQL. + * @param bool $returns_timestamp Whether the expression SQL is already a timestamp. + * @return string PostgreSQL text expression SQL. + */ + private function get_postgresql_mysql_temporal_expression_comparison_text_sql( string $expression_sql, bool $returns_timestamp ): string { + if ( $returns_timestamp ) { + return sprintf( + 'TO_CHAR(%s, %s)', + $expression_sql, + $this->connection->quote( 'YYYY-MM-DD HH24:MI:SS' ) + ); + } + + return $this->get_postgresql_mysql_temporal_text_comparison_sql( $expression_sql ); + } + + /** + * Get PostgreSQL text SQL for a text-backed MySQL temporal column operand. + * + * @param string $column_sql PostgreSQL column reference SQL. + * @param string $column_type MySQL column type metadata. + * @return string PostgreSQL text expression SQL. + */ + private function get_postgresql_mysql_temporal_column_comparison_text_sql( string $column_sql, string $column_type ): string { + if ( 'date' !== $this->get_base_mysql_dml_column_type( $column_type ) ) { + return sprintf( 'CAST(%s AS text)', $column_sql ); + } + + return $this->get_postgresql_mysql_temporal_text_comparison_sql( $column_sql ); + } + + /** + * Get normalized PostgreSQL text SQL for a text-returning temporal operand. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL text expression SQL. + */ + private function get_postgresql_mysql_temporal_text_comparison_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + "CASE WHEN %1\$s IS NULL THEN NULL WHEN %1\$s = '' THEN '' WHEN %1\$s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN %1\$s || ' 00:00:00' ELSE %1\$s END", + $expression_text_sql + ); + } + + /** + * Check whether a scanner position is inside a qualified reference suffix. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $start First predicate token position. + * @return bool Whether the position follows a dot in the same predicate. + */ + private function is_mysql_qualified_reference_suffix_position( array $tokens, int $position, int $start ): bool { + return $position > $start + && isset( $tokens[ $position - 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position - 1 ]->id; + } + + /** + * Translate WordPress text predicates with MySQL case-insensitive collation semantics. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_predicate_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( + null !== $reference + && $this->is_mysql_case_insensitive_wordpress_text_column_reference( $reference, $scope ) + ) { + $like = $this->translate_mysql_wordpress_text_like_predicate_to_postgresql( + $tokens, + $reference, + $reference['end'], + $end + ); + if ( null !== $like ) { + return $like; + } + + $in = $this->translate_mysql_wordpress_text_in_predicate_to_postgresql( + $tokens, + $reference, + $reference['end'], + $end + ); + if ( null !== $in ) { + return $in; + } + + $comparison = $this->translate_mysql_wordpress_text_comparison_to_postgresql( + $tokens, + $reference, + $reference['end'], + $end + ); + if ( null !== $comparison ) { + return $comparison; + } + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || ! $this->is_mysql_string_literal_token( $tokens[ $position ] ) + || ! $this->is_mysql_case_insensitive_equality_operator_token( $tokens[ $position + 1 ] ) + ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position + 2, $end ); + if ( + null === $reference + || ! $this->is_mysql_case_insensitive_wordpress_text_column_reference( $reference, $scope ) + ) { + return null; + } + + return array( + 'sql' => sprintf( + 'LOWER(%s) %s LOWER(%s)', + $this->translate_mysql_token_to_postgresql( $tokens[ $position ] ), + $tokens[ $position + 1 ]->get_bytes(), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ), + 'position' => $reference['end'] - 1, + ); + } + + /** + * Translate a WordPress text LIKE predicate with case-insensitive semantics. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $reference Parsed column reference. + * @param int $operator_position Candidate LIKE or NOT position. + * @param int $end Final predicate token position, exclusive. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_like_predicate_to_postgresql( + array $tokens, + array $reference, + int $operator_position, + int $end + ): ?array { + $not_sql = ''; + if ( + isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $operator_position ]->id + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $operator_position + 1 ]->id + ) { + $not_sql = ' NOT'; + ++$operator_position; + } + + if ( + ! isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + || WP_MySQL_Lexer::LIKE_SYMBOL !== $tokens[ $operator_position ]->id + ) { + return null; + } + + $pattern = $this->get_mysql_string_like_pattern_sql( $tokens, $operator_position + 1, $end ); + if ( null === $pattern ) { + return null; + } + + return array( + 'sql' => sprintf( + 'LOWER(%s)%s LIKE LOWER(%s)%s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $not_sql, + $pattern['pattern_sql'], + $pattern['escape_sql'] + ), + 'position' => $pattern['end'] - 1, + ); + } + + /** + * Translate a WordPress text equality predicate with case-insensitive semantics. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $reference Parsed column reference. + * @param int $operator_position Candidate comparison operator position. + * @param int $end Final predicate token position, exclusive. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_comparison_to_postgresql( + array $tokens, + array $reference, + int $operator_position, + int $end + ): ?array { + if ( + ! isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + || $operator_position + 1 >= $end + || ! $this->is_mysql_case_insensitive_equality_operator_token( $tokens[ $operator_position ] ) + || ! $this->is_mysql_string_literal_token( $tokens[ $operator_position + 1 ] ) + ) { + return null; + } + + return array( + 'sql' => sprintf( + 'LOWER(%s) %s LOWER(%s)', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $tokens[ $operator_position ]->get_bytes(), + $this->translate_mysql_token_to_postgresql( $tokens[ $operator_position + 1 ] ) + ), + 'position' => $operator_position + 1, + ); + } + + /** + * Translate a WordPress text IN predicate with case-insensitive semantics. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $reference Parsed column reference. + * @param int $operator_position Candidate IN or NOT position. + * @param int $end Final predicate token position, exclusive. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_wordpress_text_in_predicate_to_postgresql( + array $tokens, + array $reference, + int $operator_position, + int $end + ): ?array { + $not_sql = ''; + if ( + isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $operator_position ]->id + && WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $operator_position + 1 ]->id + ) { + $not_sql = ' NOT'; + ++$operator_position; + } + + if ( + ! isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + || WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $operator_position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $operator_position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $operator_position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $operator_position + 2, $after_close - 1 ); + if ( empty( $items ) ) { + return null; + } + + $item_sql = array(); + foreach ( $items as $item ) { + if ( ! $this->is_mysql_string_literal_range( $tokens, $item['start'], $item['end'] ) ) { + return null; + } + + $item_sql[] = 'LOWER(' . $this->translate_mysql_token_to_postgresql( $tokens[ $item['start'] ] ) . ')'; + } + + return array( + 'sql' => sprintf( + 'LOWER(%s)%s IN (%s)', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $not_sql, + implode( ', ', $item_sql ) + ), + 'position' => $after_close - 1, + ); + } + + /** + * Get a simple string LIKE pattern SQL fragment. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Pattern token position. + * @param int $end Final predicate token position, exclusive. + * @return array{pattern_sql: string, escape_sql: string, end: int}|null Pattern SQL, or null when unsupported. + */ + private function get_mysql_string_like_pattern_sql( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ] ) + || $position >= $end + || ! $this->is_mysql_string_literal_token( $tokens[ $position ] ) + ) { + return null; + } + + $pattern_sql = $this->translate_mysql_token_to_postgresql( $tokens[ $position ] ); + $escape_sql = ''; + $pattern_end = $position + 1; + + if ( isset( $tokens[ $pattern_end ] ) && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern_end ]->id ) { + if ( + ! isset( $tokens[ $pattern_end + 1 ] ) + || $pattern_end + 1 >= $end + || ! $this->is_mysql_string_literal_token( $tokens[ $pattern_end + 1 ] ) + ) { + return null; + } + + $escape_sql = ' ESCAPE ' . $this->translate_mysql_token_to_postgresql( $tokens[ $pattern_end + 1 ] ); + $pattern_end += 2; + } else { + $escape_sql = $this->get_mysql_no_backslash_like_escape_sql(); + } + + return array( + 'pattern_sql' => $pattern_sql, + 'escape_sql' => $escape_sql, + 'end' => $pattern_end, + ); + } + + /** + * Get the PostgreSQL LIKE escape clause needed for NO_BACKSLASH_ESCAPES. + * + * PostgreSQL treats backslash as the default LIKE escape character. MySQL's + * NO_BACKSLASH_ESCAPES mode removes that default unless an explicit ESCAPE + * clause is present. + * + * @return string PostgreSQL ESCAPE clause, or an empty string. + */ + private function get_mysql_no_backslash_like_escape_sql(): string { + return $this->is_mysql_sql_mode_active( 'NO_BACKSLASH_ESCAPES' ) ? " ESCAPE ''" : ''; + } + + /** + * Check whether a column is a case-insensitive WordPress text lookup column. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return bool Whether the reference should use MySQL case-insensitive text predicates. + */ + private function is_mysql_case_insensitive_wordpress_text_column_reference( array $reference, array $scope ): bool { + $table = $this->get_mysql_table_for_column_reference( $reference, $scope ); + if ( null === $table || ! $this->is_mysql_wordpress_case_insensitive_text_column( $table['table'], $reference['column'] ) ) { + return false; + } + + if ( + $this->is_mysql_wordpress_table_name( $table['table'], 'postmeta' ) + && null === $reference['qualifier'] + ) { + return false; + } + + $column_type = $this->get_mysql_column_type_for_reference( $reference, $scope ); + if ( null === $column_type || ! $this->is_mysql_text_family_column_type( $column_type ) ) { + return false; + } + + $collation = $this->get_mysql_column_collation_for_reference( $reference, $scope ); + return null !== $collation && $this->is_mysql_case_insensitive_collation( $collation ); + } + + /** + * Resolve a column reference to one table in the statement scope. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return array|null Table metadata, or null when missing/ambiguous. + */ + private function get_mysql_table_for_column_reference( array $reference, array $scope ): ?array { + if ( null !== $reference['qualifier'] ) { + $alias = strtolower( $reference['qualifier'] ); + $table = $scope['aliases'][ $alias ] ?? null; + if ( null === $table || ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return null; + } + + return $table; + } + + if ( ! empty( $scope['unknown'] ) ) { + return null; + } + + $matched_table = null; + foreach ( $scope['tables'] as $table ) { + if ( + count( $scope['tables'] ) > 1 + && ! $this->mysql_table_has_column_metadata( $table['schema'], $table['table'] ) + ) { + return null; + } + + if ( null === $this->get_mysql_table_column_type( $table['schema'], $table['table'], $reference['column'] ) ) { + continue; + } + + if ( null !== $matched_table ) { + return null; + } + + $matched_table = $table; + } + + return $matched_table; + } + + /** + * Resolve a column reference when a statement scope has exactly one table. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return array|null Table metadata, or null when missing/ambiguous. + */ + private function get_mysql_single_scope_table_for_column_reference( array $reference, array $scope ): ?array { + if ( null !== $reference['qualifier'] ) { + $alias = strtolower( $reference['qualifier'] ); + $table = $scope['aliases'][ $alias ] ?? null; + if ( null === $table || ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return null; + } + + return $table; + } + + if ( ! empty( $scope['unknown'] ) || 1 !== count( $scope['tables'] ) ) { + return null; + } + + return $scope['tables'][0]; + } + + /** + * Check whether a table/column pair is in a WordPress text lookup surface. + * + * @param string $table_name Table name. + * @param string $column_name Column name. + * @return bool Whether this is a supported text lookup column. + */ + private function is_mysql_wordpress_case_insensitive_text_column( string $table_name, string $column_name ): bool { + $column_name = strtolower( $column_name ); + + if ( $this->is_mysql_wordpress_table_name( $table_name, 'posts' ) ) { + return in_array( $column_name, array( 'post_content', 'post_excerpt', 'post_title' ), true ); + } + + if ( $this->is_mysql_wordpress_table_name( $table_name, 'terms' ) ) { + return in_array( $column_name, array( 'name', 'slug' ), true ); + } + + if ( $this->is_mysql_wordpress_table_name( $table_name, 'term_taxonomy' ) ) { + return in_array( $column_name, array( 'description', 'taxonomy' ), true ); + } + + if ( $this->is_mysql_wordpress_table_name( $table_name, 'postmeta' ) ) { + return 'meta_value' === $column_name; + } + + if ( $this->is_mysql_wordpress_table_name( $table_name, 'users' ) ) { + return in_array( + $column_name, + array( + 'display_name', + 'user_email', + 'user_login', + 'user_nicename', + 'user_url', + ), + true + ); + } + + return false; + } + + /** + * Check whether a MySQL collation is explicitly case-insensitive. + * + * @param string $collation MySQL collation name. + * @return bool Whether the collation is case-insensitive. + */ + private function is_mysql_case_insensitive_collation( string $collation ): bool { + $collation = strtolower( trim( $collation ) ); + return 1 === preg_match( '/(^|_)ci($|_)/', $collation ); + } + + /** + * Check whether a token is a case-insensitive equality operator candidate. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is an equality or inequality operator. + */ + private function is_mysql_case_insensitive_equality_operator_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + ), + true + ); + } + + /** + * Translate a parenthesized SELECT predicate with inner and outer metadata scope. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Opening parenthesis position. + * @param int $after_subquery Position after the closing parenthesis. + * @param array $outer_scope Outer statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unchanged/unsupported. + */ + private function translate_mysql_parenthesized_select_predicate_to_postgresql( + array $tokens, + int $position, + int $after_subquery, + array $outer_scope + ): ?array { + $select_position = $position + 1; + $statement_end = $after_subquery - 1; + if ( + ! isset( $tokens[ $select_position ] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[ $select_position ]->id + ) { + return null; + } + + $projection_start = $select_position + 1; + $from_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + $projection_start, + $statement_end + ); + if ( null === $from_position ) { + return null; + } + + $from_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + $from_position + 1, + $statement_end + ) ?? $statement_end; + + $inner_scope = $this->get_mysql_select_scope( $tokens, $from_position + 1, $from_end ); + if ( null === $inner_scope ) { + return null; + } + + $scope = $this->merge_mysql_inner_and_outer_scopes( $inner_scope, $outer_scope ); + $replacements = array(); + $where_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::WHERE_SYMBOL, + $projection_start, + $statement_end + ); + if ( null !== $where_position ) { + $where_end = $this->find_first_top_level_mysql_token( + $tokens, + array( + WP_MySQL_Lexer::FOR_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::LOCK_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::PROCEDURE_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + ), + $where_position + 1, + $statement_end + ) ?? $statement_end; + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $where_end, + $scope + ); + if ( $where_sql['changed'] ) { + $replacements[] = array( + 'start' => $where_position + 1, + 'end' => $where_end, + 'sql' => $where_sql['sql'], + ); + } + } + + if ( empty( $replacements ) ) { + return null; + } + + return array( + 'sql' => '(' . $this->translate_mysql_token_sequence_with_replacements_to_postgresql( $tokens, $select_position, $statement_end, $replacements ) . ')', + 'position' => $after_subquery - 1, + ); + } + + /** + * Merge SELECT scopes so inner aliases shadow correlated outer aliases. + * + * @param array $inner_scope Inner SELECT table scope. + * @param array $outer_scope Outer SELECT table scope. + * @return array Combined scope. + */ + private function merge_mysql_inner_and_outer_scopes( array $inner_scope, array $outer_scope ): array { + $scope = $inner_scope; + foreach ( $outer_scope['aliases'] as $alias => $table ) { + if ( ! isset( $scope['aliases'][ $alias ] ) ) { + $scope['aliases'][ $alias ] = $table; + } + } + + if ( ! empty( $outer_scope['unknown'] ) ) { + $scope['unknown'] = true; + } + + return $scope; + } + + /** + * Translate a numeric literal used as a standalone boolean predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate position. + * @param int $end Final predicate token position, exclusive. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_numeric_literal_truthiness_predicate_to_postgresql( + array $tokens, + int $position, + int $end + ): ?array { + $literal = $this->parse_mysql_numeric_literal( $tokens, $position, $end ); + if ( null === $literal || ! $this->is_mysql_boolean_predicate_literal_context( $tokens, $literal['start'], $literal['end'], $end ) ) { + return null; + } + + return array( + 'sql' => sprintf( + '(%s <> 0)', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $literal['start'], $literal['end'] ) + ), + 'position' => $literal['end'] - 1, + ); + } + + /** + * Check whether a numeric literal is a standalone boolean predicate operand. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @param int $end Final literal token, exclusive. + * @param int $limit Final predicate token position, exclusive. + * @return bool Whether the literal is in predicate truthiness context. + */ + private function is_mysql_boolean_predicate_literal_context( array $tokens, int $start, int $end, int $limit ): bool { + if ( $this->is_mysql_between_bound_literal_context( $tokens, $start ) ) { + return false; + } + + $previous_token_id = $tokens[ $start - 1 ]->id ?? null; + $next_token_id = $tokens[ $end ]->id ?? null; + + $left_boundary = 0 === $start + || $this->is_mysql_boolean_predicate_left_boundary_token_id( $previous_token_id ) + || ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous_token_id + && $this->is_mysql_boolean_predicate_left_boundary_token_id( $tokens[ $start - 2 ]->id ?? null ) + ); + if ( ! $left_boundary ) { + return false; + } + + return $end >= $limit || in_array( + $next_token_id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::XOR_SYMBOL, + ), + true + ); + } + + /** + * Check whether a numeric literal belongs to a BETWEEN range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @return bool Whether the literal is a BETWEEN bound. + */ + private function is_mysql_between_bound_literal_context( array $tokens, int $start ): bool { + $previous_token_id = $tokens[ $start - 1 ]->id ?? null; + + if ( WP_MySQL_Lexer::BETWEEN_SYMBOL === $previous_token_id ) { + return true; + } + + if ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous_token_id + && WP_MySQL_Lexer::BETWEEN_SYMBOL === ( $tokens[ $start - 2 ]->id ?? null ) + ) { + return true; + } + + $and_position = null; + if ( WP_MySQL_Lexer::AND_SYMBOL === $previous_token_id ) { + $and_position = $start - 1; + } elseif ( + WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $previous_token_id + && WP_MySQL_Lexer::AND_SYMBOL === ( $tokens[ $start - 2 ]->id ?? null ) + ) { + $and_position = $start - 2; + } + + return null !== $and_position && $this->is_mysql_between_upper_bound_separator( $tokens, $and_position ); + } + + /** + * Check whether an AND token separates the lower and upper BETWEEN bounds. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $and_position Candidate AND token position. + * @return bool Whether the AND token belongs to BETWEEN. + */ + private function is_mysql_between_upper_bound_separator( array $tokens, int $and_position ): bool { + if ( ! isset( $tokens[ $and_position ] ) || WP_MySQL_Lexer::AND_SYMBOL !== $tokens[ $and_position ]->id ) { + return false; + } + + $depth = 0; + for ( $i = $and_position - 1; $i >= 0; $i-- ) { + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return false; + } + continue; + } + + if ( 0 !== $depth ) { + continue; + } + + if ( WP_MySQL_Lexer::BETWEEN_SYMBOL === $tokens[ $i ]->id ) { + return true; + } + + if ( + $this->is_mysql_boolean_predicate_left_boundary_token_id( $tokens[ $i ]->id ) + || WP_MySQL_Lexer::HAVING_SYMBOL === $tokens[ $i ]->id + || WP_MySQL_Lexer::ON_SYMBOL === $tokens[ $i ]->id + || WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $i ]->id + ) { + return false; + } + } + + return false; + } + + /** + * Check whether a token can precede a standalone boolean predicate operand. + * + * @param int|null $token_id MySQL token ID. + * @return bool Whether the token is a boolean left boundary. + */ + private function is_mysql_boolean_predicate_left_boundary_token_id( ?int $token_id ): bool { + return in_array( + $token_id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::NOT_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + WP_MySQL_Lexer::XOR_SYMBOL, + ), + true + ); + } + + /** + * Translate DECIMAL casts used with string-pattern operators. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate position. + * @param int $end Final predicate token position, exclusive. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_decimal_cast_like_predicate_to_postgresql( + array $tokens, + int $position, + int $end + ): ?array { + $cast_bounds = $this->get_mysql_decimal_cast_bounds( $tokens, $position, $end ); + if ( null === $cast_bounds ) { + return null; + } + + $operator_position = $cast_bounds['close'] + 1; + $not_sql = ''; + if ( + isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $operator_position ]->id + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $operator_position + 1 ]->id + ) { + $not_sql = ' NOT'; + $operator_position += 1; + } + + if ( + ! isset( $tokens[ $operator_position ], $tokens[ $operator_position + 1 ] ) + || WP_MySQL_Lexer::LIKE_SYMBOL !== $tokens[ $operator_position ]->id + ) { + return null; + } + + $pattern_end = $this->get_mysql_like_pattern_end( $tokens, $operator_position + 1, $end ); + if ( null === $pattern_end ) { + return null; + } + + $has_explicit_escape = isset( $tokens[ $operator_position + 2 ] ) + && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $operator_position + 2 ]->id; + + return array( + 'sql' => sprintf( + 'CAST(%s AS text)%s LIKE %s%s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $position, $cast_bounds['close'] + 1 ), + $not_sql, + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $operator_position + 1, $pattern_end ), + $has_explicit_escape ? '' : $this->get_mysql_no_backslash_like_escape_sql() + ), + 'position' => $pattern_end - 1, + ); + } + + /** + * Get the end of a simple LIKE pattern expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Pattern token position. + * @param int $end Final predicate token position, exclusive. + * @return int|null Pattern end position, exclusive. + */ + private function get_mysql_like_pattern_end( array $tokens, int $position, int $end ): ?int { + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return null; + } + + $pattern_end = $position + 1; + if ( + isset( $tokens[ $pattern_end ], $tokens[ $pattern_end + 1 ] ) + && WP_MySQL_Lexer::ESCAPE_SYMBOL === $tokens[ $pattern_end ]->id + && $pattern_end + 1 < $end + ) { + $pattern_end += 2; + } + + return $pattern_end; + } + + /** + * Translate metadata-backed qualified column casing when MySQL casing differs. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unchanged/unsupported. + */ + private function translate_mysql_metadata_column_reference_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( null === $reference || null === $reference['qualifier'] ) { + return null; + } + + $resolved_column = $this->get_mysql_column_name_for_reference( $reference, $scope ); + if ( null === $resolved_column || $resolved_column === $reference['column'] ) { + return null; + } + + return array( + 'sql' => sprintf( + '%s.%s', + $this->translate_mysql_token_to_postgresql( $tokens[ $reference['start'] ], $tokens[ $reference['start'] + 1 ] ?? null ), + $this->translate_mysql_identifier_value_to_postgresql( $resolved_column ) + ), + 'position' => $reference['end'] - 1, + ); + } + + /** + * Resolve the stored MySQL column name for a scoped column reference. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return string|null Stored column name, or null when missing/ambiguous. + */ + private function get_mysql_column_name_for_reference( array $reference, array $scope ): ?string { + if ( null === $reference['qualifier'] ) { + return null; + } + + $alias = strtolower( $reference['qualifier'] ); + if ( ! isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $table = $scope['aliases'][ $alias ]; + if ( ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return null; + } + + return $this->get_mysql_table_column_name( $table['schema'], $table['table'], $reference['column'] ); + } + + /** + * Get the metadata-backed stored column name for a MySQL column reference. + * + * @param string $table_schema Metadata schema. + * @param string $table_name Table name. + * @param string $column_name Referenced column name. + * @return string|null Stored column name, or null when no safe casing rewrite exists. + */ + private function get_mysql_table_column_name( + string $table_schema, + string $table_name, + string $column_name + ): ?string { + $this->ensure_mysql_schema_metadata_tables(); + + $table_cache_key = $this->get_mysql_metadata_cache_key( $table_schema, $table_name ); + $column_cache_key = $column_name; + if ( + isset( $this->mysql_table_column_name_cache[ $table_cache_key ] ) + && array_key_exists( $column_cache_key, $this->mysql_table_column_name_cache[ $table_cache_key ] ) + ) { + return $this->mysql_table_column_name_cache[ $table_cache_key ][ $column_cache_key ]; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT column_name FROM %s + WHERE table_schema = ? + AND table_name = ? + AND column_name = ? + LIMIT 1', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $stored_column_name = $stmt->fetchColumn(); + if ( false !== $stored_column_name ) { + $this->mysql_table_column_name_cache[ $table_cache_key ][ $column_cache_key ] = (string) $stored_column_name; + return $this->mysql_table_column_name_cache[ $table_cache_key ][ $column_cache_key ]; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT column_name FROM %s + WHERE table_schema = ? + AND table_name = ? + AND LOWER(column_name) = LOWER(?) + ORDER BY ordinal_position + LIMIT 2', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $table_schema, $table_name, $column_name ) + ); + + $stored_column_names = $stmt->fetchAll( PDO::FETCH_COLUMN ); + $this->mysql_table_column_name_cache[ $table_cache_key ][ $column_cache_key ] = 1 === count( $stored_column_names ) + ? (string) $stored_column_names[0] + : null; + return $this->mysql_table_column_name_cache[ $table_cache_key ][ $column_cache_key ]; + } + + /** + * Translate a stored identifier value for PostgreSQL. + * + * @param string $identifier Identifier value. + * @return string PostgreSQL identifier SQL. + */ + private function translate_mysql_identifier_value_to_postgresql( string $identifier ): string { + return $this->should_quote_bare_mysql_identifier( $identifier ) + ? $this->connection->quote_identifier( $identifier ) + : $identifier; + } + + /** + * Get token bounds for a DECIMAL/NUMERIC CAST expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_decimal_cast_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $as_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::AS_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $as_position + || $as_position <= $position + 2 + || ! $this->is_mysql_decimal_cast_type( $tokens, $as_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $as_position, + 'close' => $close_position, + ); + } + + /** + * Check whether a CAST type is MySQL DECIMAL/NUMERIC. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First cast type token. + * @param int $end Final cast type token, exclusive. + * @return bool Whether the type is supported. + */ + private function is_mysql_decimal_cast_type( array $tokens, int $start, int $end ): bool { + if ( + ! isset( $tokens[ $start ] ) + || ! in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::DECIMAL_SYMBOL, + WP_MySQL_Lexer::NUMERIC_SYMBOL, + ), + true + ) + ) { + return false; + } + + if ( $start + 1 === $end ) { + return true; + } + + return isset( $tokens[ $start + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $start + 1 ]->id + && $this->get_mysql_parenthesized_sequence_end( $tokens, $start + 1, $end ) === $end; + } + + /** + * Translate an integer-column IN list containing string literals. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_integer_column_string_in_predicate_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( null === $reference ) { + return null; + } + + $in_position = $reference['end']; + $not_sql = ''; + if ( + isset( $tokens[ $in_position ], $tokens[ $in_position + 1 ] ) + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $in_position ]->id + && WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $in_position + 1 ]->id + ) { + $not_sql = ' NOT'; + $in_position += 1; + } + + if ( + ! isset( $tokens[ $in_position ], $tokens[ $in_position + 1 ] ) + || WP_MySQL_Lexer::IN_SYMBOL !== $tokens[ $in_position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $in_position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $in_position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $in_position + 2, $after_close - 1 ); + if ( null === $items ) { + return null; + } + + $changed = false; + $item_sql = array(); + foreach ( $items as $item ) { + if ( $this->is_mysql_string_literal_range( $tokens, $item['start'], $item['end'] ) ) { + $item_sql[] = $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_to_postgresql( $tokens[ $item['start'] ] ) + ); + $changed = true; + continue; + } + + $item_sql[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $item['start'], $item['end'] ); + } + + if ( ! $changed ) { + return null; + } + + if ( ! $this->is_mysql_integer_column_reference( $reference, $scope ) ) { + return null; + } + + return array( + 'sql' => sprintf( + '%s%s IN (%s)', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $not_sql, + implode( ', ', $item_sql ) + ), + 'position' => $after_close - 1, + ); + } + + /** + * Translate an integer-column comparison against a string literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_integer_column_string_comparison_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( + null !== $reference + && isset( $tokens[ $reference['end'] ], $tokens[ $reference['end'] + 1 ] ) + && $reference['end'] + 1 < $end + && $this->is_mysql_comparison_operator_token( $tokens[ $reference['end'] ] ) + && $this->is_mysql_string_literal_token( $tokens[ $reference['end'] + 1 ] ) + && $this->is_mysql_integer_column_reference( $reference, $scope ) + ) { + return array( + 'sql' => sprintf( + '%s %s %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ), + $tokens[ $reference['end'] ]->get_bytes(), + $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_to_postgresql( $tokens[ $reference['end'] + 1 ] ) + ) + ), + 'position' => $reference['end'] + 1, + ); + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || ! $this->is_mysql_string_literal_token( $tokens[ $position ] ) + || ! $this->is_mysql_comparison_operator_token( $tokens[ $position + 1 ] ) + ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position + 2, $end ); + if ( null === $reference || ! $this->is_mysql_integer_column_reference( $reference, $scope ) ) { + return null; + } + + return array( + 'sql' => sprintf( + '%s %s %s', + $this->get_postgresql_mysql_integer_cast_sql( + $this->translate_mysql_token_to_postgresql( $tokens[ $position ] ) + ), + $tokens[ $position + 1 ]->get_bytes(), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ), + 'position' => $reference['end'] - 1, + ); + } + + /** + * Translate a text-column comparison against a numeric literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate predicate start position. + * @param int $end Final predicate token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_text_column_numeric_comparison_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( + null !== $reference + && isset( $tokens[ $reference['end'] ] ) + && $this->is_mysql_comparison_operator_token( $tokens[ $reference['end'] ] ) + && $this->is_mysql_text_family_column_reference( $reference, $scope ) + ) { + $literal = $this->parse_mysql_numeric_literal( $tokens, $reference['end'] + 1, $end ); + if ( null !== $literal ) { + return array( + 'sql' => sprintf( + '%s %s %s', + $this->get_postgresql_mysql_numeric_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ), + $tokens[ $reference['end'] ]->get_bytes(), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $literal['start'], $literal['end'] ) + ), + 'position' => $literal['end'] - 1, + ); + } + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $position, $end ); + if ( + null === $literal + || ! isset( $tokens[ $literal['end'] ] ) + || ! $this->is_mysql_comparison_operator_token( $tokens[ $literal['end'] ] ) + ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $literal['end'] + 1, $end ); + if ( null === $reference || ! $this->is_mysql_text_family_column_reference( $reference, $scope ) ) { + return null; + } + + return array( + 'sql' => sprintf( + '%s %s %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $literal['start'], $literal['end'] ), + $tokens[ $literal['end'] ]->get_bytes(), + $this->get_postgresql_mysql_numeric_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ) + ), + 'position' => $reference['end'] - 1, + ); + } + + /** + * Translate a text-column numeric arithmetic expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Candidate expression position. + * @param int $end Final expression token position, exclusive. + * @param array $scope Statement table scope. + * @return array{sql: string, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_text_column_numeric_arithmetic_to_postgresql( + array $tokens, + int $position, + int $end, + array $scope + ): ?array { + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( + null !== $reference + && isset( $tokens[ $reference['end'] ] ) + && in_array( + $tokens[ $reference['end'] ]->id, + array( + WP_MySQL_Lexer::MINUS_OPERATOR, + WP_MySQL_Lexer::PLUS_OPERATOR, + ), + true + ) + && $this->is_mysql_text_family_column_reference( $reference, $scope ) + ) { + $literal = $this->parse_mysql_numeric_literal( $tokens, $reference['end'] + 1, $end ); + if ( null !== $literal ) { + $reference_sql = $this->get_postgresql_mysql_numeric_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ); + if ( $this->is_mysql_zero_numeric_literal_range( $tokens, $literal['start'], $literal['end'] ) ) { + return array( + 'sql' => $reference_sql, + 'position' => $literal['end'] - 1, + ); + } + + return array( + 'sql' => sprintf( + '%s %s %s', + $reference_sql, + $tokens[ $reference['end'] ]->get_bytes(), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $literal['start'], $literal['end'] ) + ), + 'position' => $literal['end'] - 1, + ); + } + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $position, $end ); + if ( + null === $literal + || ! isset( $tokens[ $literal['end'] ] ) + || ! in_array( + $tokens[ $literal['end'] ]->id, + array( + WP_MySQL_Lexer::MINUS_OPERATOR, + WP_MySQL_Lexer::PLUS_OPERATOR, + ), + true + ) + ) { + return null; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $literal['end'] + 1, $end ); + if ( null === $reference || ! $this->is_mysql_text_family_column_reference( $reference, $scope ) ) { + return null; + } + + $reference_sql = $this->get_postgresql_mysql_numeric_cast_sql( + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $reference['start'], $reference['end'] ) + ); + if ( + WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $literal['end'] ]->id + && $this->is_mysql_zero_numeric_literal_range( $tokens, $literal['start'], $literal['end'] ) + ) { + return array( + 'sql' => $reference_sql, + 'position' => $reference['end'] - 1, + ); + } + + return array( + 'sql' => sprintf( + '%s %s %s', + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $literal['start'], $literal['end'] ), + $tokens[ $literal['end'] ]->get_bytes(), + $reference_sql + ), + 'position' => $reference['end'] - 1, + ); + } + + /** + * Build a single-table statement scope. + * + * @param string $table_name Table name. + * @param string|null $alias Optional table alias. + * @param string $schema Metadata schema. + * @return array Statement scope. + */ + private function get_mysql_single_table_scope( + string $table_name, + ?string $alias = null, + string $schema = 'public' + ): array { + $table = array( + 'schema' => $this->resolve_mysql_table_schema_for_introspection( $schema, $table_name ), + 'table' => $table_name, + ); + + return array( + 'tables' => array( $table ), + 'aliases' => array( + strtolower( null === $alias ? $table_name : $alias ) => $table, + ), + ); + } + + /** + * Parse top-level SELECT table references into a metadata lookup scope. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First FROM-clause token after FROM. + * @param int $end Final FROM-clause token, exclusive. + * @return array|null Statement scope, or null when ambiguous/unsupported. + */ + private function get_mysql_select_scope( array $tokens, int $start, int $end ): ?array { + $scope = array( + 'tables' => array(), + 'aliases' => array(), + 'unknown' => false, + ); + $position = $start; + $expect_next = true; + + while ( $position < $end ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position ]->id ) { + $after_parentheses = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_parentheses ) { + return null; + } + + $position = $after_parentheses; + if ( $expect_next ) { + $scope['unknown'] = true; + $position = $this->skip_mysql_table_alias( $tokens, $position, $end ); + $expect_next = false; + } + continue; + } + + if ( $expect_next ) { + $reference = $this->parse_mysql_table_reference( $tokens, $position, $end ); + if ( null === $reference ) { + return null; + } + + $table = array( + 'schema' => $this->resolve_mysql_table_schema_for_introspection( $reference['schema'], $reference['table'] ), + 'table' => $reference['table'], + ); + $alias = strtolower( null === $reference['alias'] ? $reference['table'] : $reference['alias'] ); + if ( isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $scope['tables'][] = $table; + $scope['aliases'][ $alias ] = $table; + $position = $reference['position']; + $expect_next = false; + continue; + } + + if ( + WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id + || $this->is_mysql_join_token( $tokens[ $position ] ) + ) { + $expect_next = true; + } + + ++$position; + } + + return empty( $scope['tables'] ) || $expect_next ? null : $scope; + } + + /** + * Parse an unqualified or main database-qualified MySQL table target. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Table-name start position, updated on success. + * @return string|null Table name, or null when unsupported. + */ + private function parse_mysql_main_database_table_name( array $tokens, int &$position ): ?string { + $first_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::DOT_SYMBOL !== $tokens[ $position ]->id ) { + return $first_identifier; + } + + $table_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $table_name || 0 !== strcasecmp( $first_identifier, $this->main_db_name ) ) { + return null; + } + + $position += 2; + return $table_name; + } + + /** + * Parse an unqualified or main database-qualified MySQL table reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Table-reference start position, updated on success. + * @param int $end Final token position, exclusive. + * @return array{table: string, alias: string|null}|null Parsed table reference, or null when unsupported. + */ + private function parse_mysql_main_database_table_reference( array $tokens, int &$position, int $end ): ?array { + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + + $alias = null; + if ( $position + 1 < $end && WP_MySQL_Lexer::AS_SYMBOL === ( $tokens[ $position ]->id ?? null ) ) { + $alias = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $alias ) { + return null; + } + + $position += 2; + } else { + $implicit_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $implicit_alias ) { + $alias = $implicit_alias; + ++$position; + } + } + + return array( + 'table' => $table_name, + 'alias' => $alias, + ); + } + + /** + * Render a PostgreSQL DML table reference with optional alias. + * + * @param string $table_name Table name. + * @param string|null $alias Optional table alias. + * @return string PostgreSQL table reference SQL. + */ + private function get_postgresql_dml_table_reference_sql( string $table_name, ?string $alias ): string { + $sql = $this->connection->quote_identifier( $table_name ); + if ( null !== $alias ) { + $sql .= ' AS ' . $this->connection->quote_identifier( $alias ); + } + + return $sql; + } + + /** + * Render a PostgreSQL target ctid reference for bounded UPDATE/DELETE rewrites. + * + * @param string|null $alias Optional table alias. + * @return string PostgreSQL ctid reference SQL. + */ + private function get_postgresql_dml_ctid_reference_sql( ?string $alias ): string { + if ( null === $alias ) { + return 'ctid'; + } + + return $this->connection->quote_identifier( $alias ) . '.ctid'; + } + + /** + * Render a PostgreSQL target-column reference for DML predicates. + * + * @param string $column Column name. + * @param string|null $alias Optional table alias. + * @return string PostgreSQL column reference SQL. + */ + private function get_postgresql_dml_column_reference_sql( string $column, ?string $alias ): string { + $column_sql = $this->connection->quote_identifier( $column ); + if ( null === $alias ) { + return $column_sql; + } + + return $this->connection->quote_identifier( $alias ) . '.' . $column_sql; + } + + /** + * Check whether a MySQL table qualifier names the current DML target. + * + * @param string $qualifier MySQL qualifier. + * @param string $table_name Table name. + * @param string|null $alias Optional table alias. + * @return bool Whether the qualifier is supported. + */ + private function is_mysql_dml_table_qualifier( string $qualifier, string $table_name, ?string $alias ): bool { + return 0 === strcasecmp( $qualifier, $table_name ) + || ( null !== $alias && 0 === strcasecmp( $qualifier, $alias ) ); + } + + /** + * Get PostgreSQL SQL for an unqualified or main database-qualified table target. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First table-reference token position. + * @param int $end Final table-reference token position, exclusive. + * @return string PostgreSQL table reference SQL. + */ + private function get_mysql_main_database_table_reference_sql( array $tokens, int $start, int $end ): string { + if ( + $start + 3 === $end + && isset( $tokens[ $start + 1 ], $tokens[ $start + 2 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $start + 1 ]->id + ) { + return $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $start + 2 ] ); + } + + return $this->translate_mysql_identifier_token_to_postgresql( $tokens[ $start ] ?? null ); + } + + /** + * Parse a simple table reference and optional alias. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Table reference start position. + * @param int $end Final FROM-clause token, exclusive. + * @return array{schema: string, table: string, alias: string|null, position: int}|null Parsed table reference. + */ + private function parse_mysql_table_reference( array $tokens, int $position, int $end ): ?array { + $first_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + $schema = 'public'; + $table = $first_identifier; + ++$position; + + if ( $position + 1 < $end && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id ) { + $second_identifier = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $second_identifier ) { + return null; + } + + $schema = $first_identifier; + $table = $second_identifier; + $position += 2; + } + + $alias = null; + if ( $position + 1 < $end && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + $alias = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $alias ) { + return null; + } + + $position += 2; + } else { + $implicit_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $implicit_alias ) { + $alias = $implicit_alias; + ++$position; + } + } + + return array( + 'schema' => $schema, + 'table' => $table, + 'alias' => $alias, + 'position' => $position, + ); + } + + /** + * Skip a derived-table alias when one is present. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @param int $end Final FROM-clause token, exclusive. + * @return int Position after the alias. + */ + private function skip_mysql_table_alias( array $tokens, int $position, int $end ): int { + if ( $position + 1 < $end && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + return null === $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ) + ? $position + : $position + 2; + } + + return null === $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ) + ? $position + : $position + 1; + } + + /** + * Check whether a token starts a JOIN table operand. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a JOIN separator. + */ + private function is_mysql_join_token( WP_MySQL_Token $token ): bool { + return WP_MySQL_Lexer::JOIN_SYMBOL === $token->id || WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL === $token->id; + } + + /** + * Parse a simple column reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Column reference start position. + * @param int $end Final token position, exclusive. + * @return array{start: int, end: int, qualifier: string|null, column: string}|null Parsed reference. + */ + private function parse_mysql_column_reference( array $tokens, int $position, int $end ): ?array { + $first_identifier = $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $first_identifier ) { + return null; + } + + if ( $position + 2 < $end && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id ) { + $column = $this->get_mysql_dml_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + if ( null === $column ) { + return null; + } + + return array( + 'start' => $position, + 'end' => $position + 3, + 'qualifier' => $first_identifier, + 'column' => $column, + ); + } + + return array( + 'start' => $position, + 'end' => $position + 1, + 'qualifier' => null, + 'column' => $first_identifier, + ); + } + + /** + * Check whether a column reference resolves to one integer-family MySQL column. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return bool Whether the reference is a known integer column. + */ + private function is_mysql_integer_column_reference( array $reference, array $scope ): bool { + $column_type = $this->get_mysql_column_type_for_reference( $reference, $scope ); + return null !== $column_type && $this->is_mysql_integer_family_column_type( $column_type ); + } + + /** + * Check whether a column reference resolves to one text-family MySQL column. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return bool Whether the reference is a known text column. + */ + private function is_mysql_text_family_column_reference( array $reference, array $scope ): bool { + $column_type = $this->get_mysql_column_type_for_reference( $reference, $scope ); + return null !== $column_type && $this->is_mysql_text_family_column_type( $column_type ); + } + + /** + * Check whether a MySQL column type belongs to the text family. + * + * @param string $column_type MySQL column type metadata. + * @return bool Whether the type stores textual data. + */ + private function is_mysql_text_family_column_type( string $column_type ): bool { + return in_array( + $this->get_base_mysql_dml_column_type( $column_type ), + array( + 'char', + 'longtext', + 'mediumtext', + 'text', + 'tinytext', + 'varchar', + ), + true + ); + } + + /** + * Resolve a column reference to stored MySQL column type metadata. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return string|null MySQL column type, or null when missing/ambiguous. + */ + private function get_mysql_column_type_for_reference( array $reference, array $scope ): ?string { + if ( null !== $reference['qualifier'] ) { + $alias = strtolower( $reference['qualifier'] ); + if ( ! isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $table = $scope['aliases'][ $alias ]; + if ( ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return null; + } + + return $this->get_mysql_table_column_type( $table['schema'], $table['table'], $reference['column'] ); + } + + if ( ! empty( $scope['unknown'] ) ) { + return null; + } + + $matched_type = null; + foreach ( $scope['tables'] as $table ) { + if ( ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return null; + } + + if ( + count( $scope['tables'] ) > 1 + && ! $this->mysql_table_has_column_metadata( $table['schema'], $table['table'] ) + ) { + return null; + } + + $column_type = $this->get_mysql_table_column_type( $table['schema'], $table['table'], $reference['column'] ); + if ( null === $column_type ) { + continue; + } + + if ( null !== $matched_type ) { + return null; + } + + $matched_type = $column_type; + } + + return $matched_type; + } + + /** + * Resolve a column reference to stored MySQL collation metadata. + * + * @param array $reference Parsed column reference. + * @param array $scope Statement table scope. + * @return string|null MySQL collation, or null when missing/ambiguous. + */ + private function get_mysql_column_collation_for_reference( array $reference, array $scope ): ?string { + if ( null !== $reference['qualifier'] ) { + $alias = strtolower( $reference['qualifier'] ); + if ( ! isset( $scope['aliases'][ $alias ] ) ) { + return null; + } + + $table = $scope['aliases'][ $alias ]; + if ( ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return null; + } + + return $this->get_mysql_table_column_collation( $table['schema'], $table['table'], $reference['column'] ); + } + + if ( ! empty( $scope['unknown'] ) ) { + return null; + } + + $matched_collation = null; + foreach ( $scope['tables'] as $table ) { + if ( ! empty( $table['derived'] ) || null === ( $table['table'] ?? null ) ) { + return null; + } + + if ( + count( $scope['tables'] ) > 1 + && ! $this->mysql_table_has_column_metadata( $table['schema'], $table['table'] ) + ) { + return null; + } + + $collation = $this->get_mysql_table_column_collation( $table['schema'], $table['table'], $reference['column'] ); + if ( null === $collation ) { + continue; + } + + if ( null !== $matched_collation ) { + return null; + } + + $matched_collation = $collation; + } + + return $matched_collation; + } + + /** + * Check whether a token range is exactly one string literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether the range is one string literal. + */ + private function is_mysql_string_literal_range( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end && isset( $tokens[ $start ] ) && $this->is_mysql_string_literal_token( $tokens[ $start ] ); + } + + /** + * Check whether a token range is a literal zero value. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @param int $end Final literal token, exclusive. + * @return bool Whether the literal is zero. + */ + private function is_mysql_zero_literal_range( array $tokens, int $start, int $end ): bool { + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + return 1 === preg_match( '/^[[:space:]]*[+]?0+[[:space:]]*$/', $tokens[ $start ]->get_value() ); + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + return null !== $literal + && $literal['start'] === $start + && $literal['end'] === $end + && $this->is_mysql_zero_numeric_literal_range( $tokens, $start, $end ); + } + + /** + * Parse a numeric literal, including an optional unary sign. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Literal start position. + * @param int $end Final token position, exclusive. + * @return array{start: int, end: int}|null Numeric literal bounds. + */ + private function parse_mysql_numeric_literal( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position ] ) || $position >= $end ) { + return null; + } + + if ( + ( + WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $position ]->id + || WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[ $position ]->id + ) + && isset( $tokens[ $position + 1 ] ) + && $position + 1 < $end + && $this->is_mysql_numeric_literal_token( $tokens[ $position + 1 ] ) + ) { + return array( + 'start' => $position, + 'end' => $position + 2, + ); + } + + if ( $this->is_mysql_numeric_literal_token( $tokens[ $position ] ) ) { + return array( + 'start' => $position, + 'end' => $position + 1, + ); + } + + return null; + } + + /** + * Check whether a numeric literal range represents zero. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @param int $end Final literal token, exclusive. + * @return bool Whether the literal is numeric zero. + */ + private function is_mysql_zero_numeric_literal_range( array $tokens, int $start, int $end ): bool { + if ( ! isset( $tokens[ $start ] ) ) { + return false; + } + + if ( + $start + 2 === $end + && ( + WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $start ]->id + || WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[ $start ]->id + ) + ) { + ++$start; + } + + return $start + 1 === $end + && $this->is_mysql_numeric_literal_token( $tokens[ $start ] ) + && 0.0 === (float) $tokens[ $start ]->get_value(); + } + + /** + * Check whether a numeric literal range is an integer token. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First literal token. + * @param int $end Final literal token, exclusive. + * @return bool Whether the literal is a signed or unsigned integer token. + */ + private function is_mysql_integer_numeric_literal_range( array $tokens, int $start, int $end ): bool { + if ( ! isset( $tokens[ $start ] ) ) { + return false; + } + + if ( + $start + 2 === $end + && ( + WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $start ]->id + || WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[ $start ]->id + ) + ) { + ++$start; + } + + return $start + 1 === $end + && in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ); + } + + /** + * Check whether a token is a numeric literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a numeric literal. + */ + private function is_mysql_numeric_literal_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::DECIMAL_NUMBER, + WP_MySQL_Lexer::FLOAT_NUMBER, + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ); + } + + /** + * Check whether a token is a string literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a string literal. + */ + private function is_mysql_string_literal_token( WP_MySQL_Token $token ): bool { + return WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id; + } + + /** + * Check whether a token is a simple comparison operator. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a comparison operator. + */ + private function is_mysql_comparison_operator_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_THAN_OPERATOR, + WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + ), + true + ); + } + + /** + * Validate the simple expression fragments used by translated DML/SELECT. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First fragment token position. + * @param int $end Final fragment token position, exclusive. + * @return bool Whether the expression fragment is supported. + */ + private function is_supported_simple_mysql_expression_fragment( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( ! $this->is_supported_simple_mysql_expression_token( $tokens[ $i ] ) ) { + return false; + } + } + + return true; + } + + /** + * Validate a simple expression fragment while skipping handled replacement ranges. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First fragment token position. + * @param int $end Final fragment token position, exclusive. + * @param array[] $replacements Replacement ranges with translated SQL. + * @return bool Whether the expression fragment is supported. + */ + private function is_supported_simple_mysql_expression_fragment_with_replacements( array $tokens, int $start, int $end, array $replacements ): bool { + if ( empty( $replacements ) ) { + return $this->is_supported_simple_mysql_expression_fragment( $tokens, $start, $end ); + } + + for ( $i = $start; $i < $end; $i++ ) { + $replacement_end = $this->get_covering_mysql_replacement_range_end( $i, $replacements ); + if ( null !== $replacement_end ) { + $i = $replacement_end - 1; + continue; + } + + if ( ! $this->is_supported_simple_mysql_expression_token( $tokens[ $i ] ) ) { + return false; + } + } + + return true; + } + + /** + * Check that identifier references in a simple expression resolve to scope columns. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $scope Statement table scope. + * @return bool Whether all column-like references resolve to the supplied scope. + */ + private function mysql_expression_column_references_resolve_to_scope( array $tokens, int $start, int $end, array $scope ): bool { + for ( $position = $start; $position < $end; $position++ ) { + if ( $this->is_mysql_qualified_reference_suffix_position( $tokens, $position, $start ) ) { + continue; + } + + if ( null !== $this->translate_mysql_nonparenthesized_timestamp_function_to_postgresql( $tokens, $position, $end ) ) { + continue; + } + + if ( null === $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ) ) { + continue; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + continue; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( null === $reference ) { + continue; + } + + if ( null === $this->get_mysql_column_type_for_reference( $reference, $scope ) ) { + return false; + } + + $position = $reference['end'] - 1; + } + + return true; + } + + /** + * Check that qualified references in a simple expression use statement aliases. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @param array $scope Statement table scope. + * @return bool Whether all qualified references use the supplied scope. + */ + private function mysql_expression_qualified_references_resolve_to_scope( array $tokens, int $start, int $end, array $scope ): bool { + for ( $position = $start; $position < $end; $position++ ) { + if ( $this->is_mysql_qualified_reference_suffix_position( $tokens, $position, $start ) ) { + continue; + } + + if ( null !== $this->translate_mysql_nonparenthesized_timestamp_function_to_postgresql( $tokens, $position, $end ) ) { + continue; + } + + if ( null === $this->get_mysql_dml_identifier_token_value( $tokens[ $position ] ?? null ) ) { + continue; + } + + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === ( $tokens[ $position + 1 ]->id ?? null ) ) { + continue; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( null === $reference ) { + continue; + } + + if ( + null !== $reference['qualifier'] + && ! isset( $scope['aliases'][ strtolower( $reference['qualifier'] ) ] ) + ) { + return false; + } + + $position = $reference['end'] - 1; + } + + return true; + } + + /** + * Validate a token for a simple expression fragment. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is supported. + */ + private function is_supported_simple_mysql_expression_token( WP_MySQL_Token $token ): bool { + if ( null !== $this->get_mysql_dml_identifier_token_value( $token ) ) { + return true; + } + + return in_array( + $token->id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::CASE_SYMBOL, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COALESCE_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::DECIMAL_NUMBER, + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::ELSE_SYMBOL, + WP_MySQL_Lexer::END_SYMBOL, + WP_MySQL_Lexer::FALSE_SYMBOL, + WP_MySQL_Lexer::FLOAT_NUMBER, + WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_THAN_OPERATOR, + WP_MySQL_Lexer::HEX_NUMBER, + WP_MySQL_Lexer::IN_SYMBOL, + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::IS_SYMBOL, + WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::LIKE_SYMBOL, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::MINUS_OPERATOR, + WP_MySQL_Lexer::MULT_OPERATOR, + WP_MySQL_Lexer::NOT_SYMBOL, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + WP_MySQL_Lexer::NULL_SYMBOL, + WP_MySQL_Lexer::NOW_SYMBOL, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::OR_SYMBOL, + WP_MySQL_Lexer::PLUS_OPERATOR, + WP_MySQL_Lexer::REGEXP_SYMBOL, + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL, + WP_MySQL_Lexer::SUBSTR_SYMBOL, + WP_MySQL_Lexer::SUBSTRING_SYMBOL, + WP_MySQL_Lexer::THEN_SYMBOL, + WP_MySQL_Lexer::TRUE_SYMBOL, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + WP_MySQL_Lexer::WHEN_SYMBOL, + WP_MySQL_Lexer::XOR_SYMBOL, + ), + true + ); + } + + /** + * Validate a simple ORDER BY clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start ORDER token position. + * @param int $end Final clause token position, exclusive. + * @return bool Whether the ORDER BY clause is supported. + */ + private function is_supported_simple_select_order_by_clause( array $tokens, int $start, int $end ): bool { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== $tokens[ $start ]->id + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $start + 1 ]->id + ) { + return false; + } + + $reference_end = $end; + if ( + WP_MySQL_Lexer::ASC_SYMBOL === ( $tokens[ $end - 1 ]->id ?? null ) + || WP_MySQL_Lexer::DESC_SYMBOL === ( $tokens[ $end - 1 ]->id ?? null ) + ) { + --$reference_end; + } + + $reference = $this->parse_mysql_column_reference( $tokens, $start + 2, $reference_end ); + return null !== $reference && $reference['end'] === $reference_end; + } + + /** + * Validate a safe trailing SELECT LIMIT clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start LIMIT token position. + * @param int $end Final clause token position, exclusive. + * @return bool Whether the LIMIT clause is supported. + */ + private function is_supported_simple_select_limit_clause( array $tokens, int $start, int $end ): bool { + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $start ]->id + ) { + return false; + } + + if ( $start + 2 === $end ) { + return $this->is_supported_simple_select_limit_number( $tokens[ $start + 1 ] ); + } + + return $start + 4 === $end + && isset( $tokens[ $start + 2 ], $tokens[ $start + 3 ] ) + && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $start + 2 ]->id + && $this->is_supported_simple_select_limit_number( $tokens[ $start + 1 ] ) + && $this->is_supported_simple_select_limit_number( $tokens[ $start + 3 ] ); + } + + /** + * Validate a LIMIT number token. + * + * @param WP_MySQL_Token $token MySQL lexer token. + * @return bool Whether the token is a supported non-negative integer. + */ + private function is_supported_simple_select_limit_number( WP_MySQL_Token $token ): bool { + $is_parameter_marker = WP_MySQL_Lexer::PARAM_MARKER === $token->id; + return in_array( + $token->id, + array( + WP_MySQL_Lexer::INT_NUMBER, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::PARAM_MARKER, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + true + ) && ( $is_parameter_marker || ctype_digit( $token->get_value() ) ); + } + + /** + * Check whether a DML ORDER BY clause has a non-empty item list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start ORDER token position. + * @param int $end Final clause token position, exclusive. + * @return bool Whether this is a non-empty ORDER BY clause. + */ + private function is_nonempty_mysql_order_by_clause( array $tokens, int $start, int $end ): bool { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || WP_MySQL_Lexer::BY_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + ) { + return false; + } + + $items = $this->split_top_level_mysql_arguments( $tokens, $start + 2, $end ); + return null !== $items && ! empty( $items ); + } + + /** + * Translate a safe DML ORDER BY clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start ORDER token position. + * @param int $end Final clause token position, exclusive. + * @param string $table_name Table name. + * @param string|null $alias Optional table alias. + * @return string|null PostgreSQL ORDER BY clause SQL, or null when unsupported. + */ + private function translate_simple_dml_order_by_clause_to_postgresql( array $tokens, int $start, int $end, string $table_name, ?string $alias ): ?string { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || WP_MySQL_Lexer::BY_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + ) { + return null; + } + + $items = array(); + $position = $start + 2; + while ( $position < $end ) { + $item_start = $position; + $reference = $this->parse_mysql_column_reference( $tokens, $position, $end ); + if ( null === $reference ) { + return null; + } + + if ( + null !== $reference['qualifier'] + && ! $this->is_mysql_dml_table_qualifier( $reference['qualifier'], $table_name, $alias ) + ) { + return null; + } + + $position = $reference['end']; + if ( + $position < $end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === ( $tokens[ $position ]->id ?? null ) + || WP_MySQL_Lexer::DESC_SYMBOL === ( $tokens[ $position ]->id ?? null ) + ) + ) { + ++$position; + } + + $items[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $item_start, $position ); + if ( $position === $end ) { + break; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== ( $tokens[ $position ]->id ?? null ) ) { + return null; + } + ++$position; + } + + return empty( $items ) ? null : ' ORDER BY ' . implode( ', ', $items ); + } + + /** + * Translate a joined DML ORDER BY clause against the statement table scope. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start ORDER token position. + * @param int $end Final clause token position, exclusive. + * @param array $scope Statement table scope. + * @return string|null PostgreSQL ORDER BY clause SQL, or null when unsupported. + */ + private function translate_mysql_joined_dml_order_by_clause_to_postgresql( array $tokens, int $start, int $end, array $scope ): ?string { + if ( + $start + 2 >= $end + || WP_MySQL_Lexer::ORDER_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || WP_MySQL_Lexer::BY_SYMBOL !== ( $tokens[ $start + 1 ]->id ?? null ) + ) { + return null; + } + + $item_ranges = $this->split_top_level_mysql_arguments( $tokens, $start + 2, $end ); + if ( null === $item_ranges || empty( $item_ranges ) ) { + return null; + } + + $items = array(); + foreach ( $item_ranges as $item_range ) { + $item_start = $item_range['start']; + $item_end = $item_range['end']; + $direction = ''; + if ( + $item_start < $item_end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + || WP_MySQL_Lexer::DESC_SYMBOL === ( $tokens[ $item_end - 1 ]->id ?? null ) + ) + ) { + $direction = ' ' . strtoupper( $tokens[ $item_end - 1 ]->get_bytes() ); + --$item_end; + } + + if ( + $item_start >= $item_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $item_start, $item_end ) + || ! $this->mysql_expression_qualified_references_resolve_to_scope( $tokens, $item_start, $item_end, $scope ) + ) { + return null; + } + + $expression = $this->translate_mysql_expression_token_sequence_to_postgresql( + $tokens, + $item_start, + $item_end, + $scope + ); + $items[] = $expression['sql'] . $direction; + } + + return ' ORDER BY ' . implode( ', ', $items ); + } + + /** + * Translate a safe DML LIMIT row-count clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start LIMIT token position. + * @param int $end Final clause token position, exclusive. + * @param bool $allow_offset_count Whether LIMIT offset,count is supported for this DML statement. + * @return string|null PostgreSQL LIMIT clause SQL, or null when unsupported. + */ + private function translate_simple_dml_limit_clause_to_postgresql( array $tokens, int $start, int $end, bool $allow_offset_count = false ): ?string { + if ( + WP_MySQL_Lexer::LIMIT_SYMBOL !== ( $tokens[ $start ]->id ?? null ) + || ! isset( $tokens[ $start + 1 ] ) + ) { + return null; + } + + if ( $start + 2 === $end && $this->is_supported_simple_select_limit_number( $tokens[ $start + 1 ] ) ) { + return ' LIMIT ' . $tokens[ $start + 1 ]->get_bytes(); + } + + if ( + $allow_offset_count + && $start + 4 === $end + && isset( $tokens[ $start + 2 ], $tokens[ $start + 3 ] ) + && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $start + 2 ]->id + && $this->is_supported_simple_select_limit_number( $tokens[ $start + 1 ] ) + && $this->is_supported_simple_select_limit_number( $tokens[ $start + 3 ] ) + ) { + return ' LIMIT ' . $tokens[ $start + 3 ]->get_bytes() . ' OFFSET ' . $tokens[ $start + 1 ]->get_bytes(); + } + + if ( + $allow_offset_count + && $start + 4 === $end + && isset( $tokens[ $start + 2 ], $tokens[ $start + 3 ] ) + && WP_MySQL_Lexer::OFFSET_SYMBOL === $tokens[ $start + 2 ]->id + && $this->is_supported_simple_select_limit_number( $tokens[ $start + 1 ] ) + && $this->is_supported_simple_select_limit_number( $tokens[ $start + 3 ] ) + ) { + return ' LIMIT ' . $tokens[ $start + 1 ]->get_bytes() . ' OFFSET ' . $tokens[ $start + 3 ]->get_bytes(); + } + + return null; + } + + /** + * Translate a supported trailing SELECT LIMIT clause to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start LIMIT token position. + * @param int $end Final clause token position, exclusive. + * @return string PostgreSQL LIMIT clause. + */ + private function translate_simple_select_limit_clause_to_postgresql( array $tokens, int $start, int $end ): string { + if ( $start + 4 === $end ) { + return ' LIMIT ' . $tokens[ $start + 3 ]->get_bytes() . ' OFFSET ' . $tokens[ $start + 1 ]->get_bytes(); + } + + return ' LIMIT ' . $tokens[ $start + 1 ]->get_bytes(); + } + + /** + * Strip MySQL SELECT row-locking clauses. + * + * SQLite strips these clauses because file-level locking already protects the + * database. The PostgreSQL adapter uses the same compatibility behavior so + * MySQL plugin queries keep running even when the test backend is SQLite. + * + * @param string $query MySQL SELECT query. + * @return string|null PostgreSQL SQL without the locking clause, or null when absent. + */ + private function translate_mysql_select_row_locking_query( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return null; + } + + $locking_start = $this->find_mysql_select_row_locking_clause_start( $tokens, 1, $statement_end ); + if ( null === $locking_start ) { + return null; + } + + $stripped_query = $this->translate_mysql_token_sequence_to_postgresql( $tokens, 0, $locking_start ); + return $this->translate_mysql_select_query_for_postgresql( $stripped_query )['sql']; + } + + /** + * Find a supported trailing SELECT row-locking clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token to scan. + * @param int $end Final statement token position, exclusive. + * @return int|null Locking clause start position, or null when absent. + */ + private function find_mysql_select_row_locking_clause_start( array $tokens, int $start, int $end ): ?int { + $depth = 0; + for ( $i = $start; $i < $end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 !== $depth ) { + continue; + } + + if ( + WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $i ]->id + && isset( $tokens[ $i + 1 ] ) + && in_array( $tokens[ $i + 1 ]->id, array( WP_MySQL_Lexer::SHARE_SYMBOL, WP_MySQL_Lexer::UPDATE_SYMBOL ), true ) + ) { + if ( $end === $this->parse_supported_mysql_select_row_locking_clause( $tokens, $i, $end ) ) { + return $i; + } + + throw new InvalidArgumentException( 'Unsupported SELECT locking clause.' ); + } + + if ( + WP_MySQL_Lexer::LOCK_SYMBOL === $tokens[ $i ]->id + && isset( $tokens[ $i + 1 ] ) + && WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $i + 1 ]->id + ) { + if ( $end === $this->parse_supported_mysql_select_row_locking_clause( $tokens, $i, $end ) ) { + return $i; + } + + throw new InvalidArgumentException( 'Unsupported SELECT locking clause.' ); + } + } + + return null; + } + + /** + * Parse a supported MySQL SELECT row-locking clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First locking token. + * @param int $end Final statement token position, exclusive. + * @return int|null Position after the clause, or null when unsupported. + */ + private function parse_supported_mysql_select_row_locking_clause( array $tokens, int $start, int $end ): ?int { + if ( + isset( $tokens[ $start ], $tokens[ $start + 1 ], $tokens[ $start + 2 ], $tokens[ $start + 3 ] ) + && WP_MySQL_Lexer::LOCK_SYMBOL === $tokens[ $start ]->id + && WP_MySQL_Lexer::IN_SYMBOL === $tokens[ $start + 1 ]->id + && WP_MySQL_Lexer::SHARE_SYMBOL === $tokens[ $start + 2 ]->id + && WP_MySQL_Lexer::MODE_SYMBOL === $tokens[ $start + 3 ]->id + ) { + return $start + 4; + } + + if ( + ! isset( $tokens[ $start ], $tokens[ $start + 1 ] ) + || WP_MySQL_Lexer::FOR_SYMBOL !== $tokens[ $start ]->id + || ! in_array( $tokens[ $start + 1 ]->id, array( WP_MySQL_Lexer::SHARE_SYMBOL, WP_MySQL_Lexer::UPDATE_SYMBOL ), true ) + ) { + return null; + } + + $position = $start + 2; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::OF_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->parse_mysql_select_locking_table_reference_list( $tokens, $position + 1, $end ); + if ( null === $position ) { + return null; + } + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::NOWAIT_SYMBOL === $tokens[ $position ]->id ) { + return $position + 1; + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::SKIP_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::LOCKED_SYMBOL === $tokens[ $position + 1 ]->id + ) { + return $position + 2; + } + + return $position; + } + + /** + * Parse an OF table-reference list in a SELECT locking clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First table-reference token. + * @param int $end Final statement token position, exclusive. + * @return int|null Position after the list, or null when unsupported. + */ + private function parse_mysql_select_locking_table_reference_list( array $tokens, int $start, int $end ): ?int { + $position = $this->parse_mysql_select_locking_table_reference( $tokens, $start, $end ); + if ( null === $position ) { + return null; + } + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->parse_mysql_select_locking_table_reference( $tokens, $position + 1, $end ); + if ( null === $position ) { + return null; + } + } + + return $position; + } + + /** + * Parse one table reference in a SELECT locking clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First table-reference token. + * @param int $end Final statement token position, exclusive. + * @return int|null Position after the table reference, or null when unsupported. + */ + private function parse_mysql_select_locking_table_reference( array $tokens, int $start, int $end ): ?int { + if ( $start >= $end || null === $this->get_mysql_identifier_token_value( $tokens[ $start ] ?? null ) ) { + return null; + } + + $position = $start + 1; + while ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id + && null !== $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ) + ) { + $position += 2; + } + + return $position; + } + + /** + * Find the token position ending a single MySQL statement. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Token position where scanning starts. + * @return int|null EOF or semicolon token position, or null for multi-statements. + */ + private function get_mysql_statement_end_position( array $tokens, int $position ): ?int { + for ( $i = $position; isset( $tokens[ $i ] ); $i++ ) { + if ( WP_MySQL_Lexer::EOF === $tokens[ $i ]->id ) { + return $i; + } + + if ( WP_MySQL_Lexer::SEMICOLON_SYMBOL === $tokens[ $i ]->id ) { + return $this->is_at_mysql_query_end( $tokens, $i ) ? $i : null; + } + } + + return null; + } + + /** + * Find the position after a matching parenthesized token sequence. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Opening parenthesis position. + * @param int $limit Final token position, exclusive. + * @return int|null Position after the matching close parenthesis, or null. + */ + private function get_mysql_parenthesized_sequence_end( array $tokens, int $position, int $limit ): ?int { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $depth = 0; + for ( $i = $position; $i < $limit; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[ $i ]->id ) { + continue; + } + + --$depth; + if ( 0 === $depth ) { + return $i + 1; + } + + if ( $depth < 0 ) { + return null; + } + } + + return null; + } + + /** + * Find a top-level MySQL token in a bounded token range. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $token_id Token ID to find. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @return int|null Token position, or null when not found. + */ + private function find_top_level_mysql_token( array $tokens, int $token_id, int $start, int $end ): ?int { + $depth = 0; + + for ( $i = $start; $i < $end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 === $depth && $token_id === $tokens[ $i ]->id ) { + return $i; + } + } + + return null; + } + + /** + * Find the first top-level token matching any supplied token ID. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int[] $token_ids Token IDs to find. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @return int|null Token position, or null when not found. + */ + private function find_first_top_level_mysql_token( array $tokens, array $token_ids, int $start, int $end ): ?int { + $lookup = array(); + foreach ( $token_ids as $token_id ) { + $lookup[ $token_id ] = true; + } + + $depth = 0; + for ( $i = $start; $i < $end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 === $depth && isset( $lookup[ $tokens[ $i ]->id ] ) ) { + return $i; + } + } + + return null; + } + + /** + * Check whether a bounded token range contains any top-level token IDs. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @param int[] $token_ids Token IDs to detect. + * @return bool Whether any token ID was found. + */ + private function contains_top_level_mysql_token( array $tokens, int $start, int $end, array $token_ids ): bool { + foreach ( $token_ids as $token_id ) { + if ( null !== $this->find_top_level_mysql_token( $tokens, $token_id, $start, $end ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether the token position is at the end of a single query. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @return bool Whether only an optional semicolon and EOF remain. + */ + private function is_at_mysql_query_end( array $tokens, int $position ): bool { + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::SEMICOLON_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + return isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF === $tokens[ $position ]->id; + } + + /** + * Translate a MySQL token sequence to PostgreSQL SQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position, inclusive. + * @param int $end Final token position, exclusive. + * @return string PostgreSQL SQL fragment. + */ + private function translate_mysql_token_sequence_to_postgresql( array $tokens, int $start, int $end ): string { + $sql = ''; + $previous_token_id = null; + + for ( $i = $start; $i < $end; $i++ ) { + $token = $tokens[ $i ]; + $fragment_token_id = $token->id; + $translated_fragment = $this->translate_mysql_dual_table_reference_to_postgresql( $tokens, $i, $end ); + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_index_hint_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_limit_offset_count_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_field_function_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_integer_cast_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_integer_convert_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_character_convert_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_character_cast_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_date_time_cast_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_binary_cast_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_binary_convert_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_decimal_convert_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_date_convert_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_regexp_operator_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_group_concat_function_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_rand_function_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_session_user_function_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_nonparenthesized_timestamp_function_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_common_function_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_date_arithmetic_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_week_function_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_weekday_index_function_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_date_format_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_date_time_extract_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_convert_using_to_postgresql( $tokens, $i, $end ); + } + if ( null === $translated_fragment ) { + $translated_fragment = $this->translate_mysql_variable_reference_to_postgresql( $tokens, $i ); + } + + $append_no_backslash_like_escape = false; + if ( null !== $translated_fragment ) { + $fragment = $translated_fragment['sql']; + $fragment_token_id = $translated_fragment['token_id']; + $i = $translated_fragment['position']; + } else { + $fragment = $this->translate_mysql_token_to_postgresql( + $token, + $tokens[ $i + 1 ] ?? null + ); + $append_no_backslash_like_escape = true; + } + + if ( '' === $fragment ) { + continue; + } + + if ( $append_no_backslash_like_escape && $this->should_append_mysql_no_backslash_like_escape_sql( $tokens, $i, $end ) ) { + $fragment .= $this->get_mysql_no_backslash_like_escape_sql(); + } + + if ( '' === $sql ) { + $sql = $fragment; + } elseif ( $this->should_join_mysql_tokens_without_space( $previous_token_id, $fragment_token_id ) ) { + $sql .= $fragment; + } else { + $sql .= ' ' . $fragment; + } + + $previous_token_id = $fragment_token_id; + } + + return $sql; + } + + /** + * Check whether a translated string literal is a LIKE pattern needing ESCAPE ''. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @param int $end Final token position, exclusive. + * @return bool Whether to append an implicit NO_BACKSLASH_ESCAPES clause. + */ + private function should_append_mysql_no_backslash_like_escape_sql( array $tokens, int $position, int $end ): bool { + return '' !== $this->get_mysql_no_backslash_like_escape_sql() + && isset( $tokens[ $position - 1 ] ) + && WP_MySQL_Lexer::LIKE_SYMBOL === $tokens[ $position - 1 ]->id + && isset( $tokens[ $position ] ) + && $this->is_mysql_string_literal_token( $tokens[ $position ] ) + && ( + ! isset( $tokens[ $position + 1 ] ) + || $position + 1 >= $end + || WP_MySQL_Lexer::ESCAPE_SYMBOL !== $tokens[ $position + 1 ]->id + ); + } + + /** + * Translate MySQL's dummy DUAL table reference. + * + * MySQL accepts SELECT and INSERT ... SELECT statements with FROM DUAL as a + * one-row dummy table. PostgreSQL supports the same projections without a + * FROM clause, so erase only the exact unaliased FROM DUAL reference. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position FROM token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_dual_table_reference_to_postgresql( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::DUAL_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + if ( + isset( $tokens[ $position + 2 ] ) + && $position + 2 < $end + && ! $this->is_mysql_dual_table_reference_boundary_token( $tokens[ $position + 2 ] ) + ) { + return null; + } + + return array( + 'sql' => '', + 'token_id' => WP_MySQL_Lexer::FROM_SYMBOL, + 'position' => $position + 1, + ); + } + + /** + * Check whether a token can follow an erased FROM DUAL reference. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token starts a clause or closes the SELECT. + */ + private function is_mysql_dual_table_reference_boundary_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::EOF, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + true + ); + } + + /** + * Erase supported MySQL optimizer index hints. + * + * PostgreSQL has no equivalent for MySQL's USE/FORCE/IGNORE INDEX hints. + * Keep this bounded to the parsed hint clause so surrounding aliases, joins, + * predicates, grouping, ordering, and limits are still rendered normally. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Hint keyword token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_index_hint_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_index_hint_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + return array( + 'sql' => '', + 'token_id' => $tokens[ $position ]->id, + 'position' => $bounds['end'] - 1, + ); + } + + /** + * Get token bounds for a supported MySQL optimizer index hint. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Hint keyword token position. + * @param int $end Final token position, exclusive. + * @return array{end: int}|null Hint bounds, or null when unsupported. + */ + private function get_mysql_index_hint_bounds( array $tokens, int $position, int $end ): ?array { + if ( ! $this->is_mysql_index_hint_marker( $tokens, $position, $end ) ) { + return null; + } + + $hint_action = $tokens[ $position ]->id; + $position += 2; + + if ( isset( $tokens[ $position ] ) && $position < $end && WP_MySQL_Lexer::FOR_SYMBOL === $tokens[ $position ]->id ) { + $position = $this->get_mysql_index_hint_scope_end( $tokens, $position, $end ); + if ( null === $position ) { + return null; + } + } + + if ( ! isset( $tokens[ $position ] ) || $position >= $end || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position, $end ); + if ( null === $after_close ) { + return null; + } + + $allow_empty_list = WP_MySQL_Lexer::USE_SYMBOL === $hint_action; + if ( ! $this->is_mysql_index_hint_identifier_list( $tokens, $position + 1, $after_close - 1, $allow_empty_list ) ) { + return null; + } + + return array( + 'end' => $after_close, + ); + } + + /** + * Check whether tokens at a position begin a MySQL optimizer index hint. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an index hint marker is present. + */ + private function is_mysql_index_hint_marker( array $tokens, int $position, int $end ): bool { + return $position + 1 < $end + && $this->is_mysql_index_hint_action_token( $tokens[ $position ] ?? null ) + && $this->is_mysql_index_hint_type_token( $tokens[ $position + 1 ] ?? null ); + } + + /** + * Check whether a token starts a MySQL optimizer index hint. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return bool Whether the token is USE, FORCE, or IGNORE. + */ + private function is_mysql_index_hint_action_token( ?WP_MySQL_Token $token ): bool { + if ( null === $token ) { + return false; + } + + return in_array( + $token->id, + array( + WP_MySQL_Lexer::FORCE_SYMBOL, + WP_MySQL_Lexer::IGNORE_SYMBOL, + WP_MySQL_Lexer::USE_SYMBOL, + ), + true + ); + } + + /** + * Check whether a token names the hinted object type. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return bool Whether the token is INDEX or KEY. + */ + private function is_mysql_index_hint_type_token( ?WP_MySQL_Token $token ): bool { + if ( null === $token ) { + return false; + } + + return WP_MySQL_Lexer::INDEX_SYMBOL === $token->id || WP_MySQL_Lexer::KEY_SYMBOL === $token->id; + } + + /** + * Get the position after a supported MySQL optimizer index hint scope. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position FOR token position. + * @param int $end Final token position, exclusive. + * @return int|null Position after scope tokens, or null when unsupported. + */ + private function get_mysql_index_hint_scope_end( array $tokens, int $position, int $end ): ?int { + if ( ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) || $position + 1 >= $end ) { + return null; + } + + if ( WP_MySQL_Lexer::FOR_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + if ( WP_MySQL_Lexer::JOIN_SYMBOL === $tokens[ $position + 1 ]->id ) { + return $position + 2; + } + + if ( + isset( $tokens[ $position + 2 ] ) + && $position + 2 < $end + && WP_MySQL_Lexer::BY_SYMBOL === $tokens[ $position + 2 ]->id + && ( + WP_MySQL_Lexer::GROUP_SYMBOL === $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::ORDER_SYMBOL === $tokens[ $position + 1 ]->id + ) + ) { + return $position + 3; + } + + return null; + } + + /** + * Check whether a token range is a supported MySQL index-name list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First list token position. + * @param int $end Final list token position, exclusive. + * @param bool $allow_empty Whether an empty list is valid. + * @return bool Whether the token range is a supported index-name list. + */ + private function is_mysql_index_hint_identifier_list( array $tokens, int $start, int $end, bool $allow_empty ): bool { + if ( $start === $end ) { + return $allow_empty; + } + + $expect_identifier = true; + for ( $i = $start; $i < $end; $i++ ) { + if ( $expect_identifier ) { + if ( ! $this->is_mysql_index_hint_identifier_token( $tokens[ $i ] ?? null ) ) { + return false; + } + + $expect_identifier = false; + continue; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $i ]->id ) { + return false; + } + + $expect_identifier = true; + } + + return ! $expect_identifier; + } + + /** + * Check whether a token can name an index in a MySQL optimizer hint. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return bool Whether the token is a supported index identifier. + */ + private function is_mysql_index_hint_identifier_token( ?WP_MySQL_Token $token ): bool { + if ( null === $token ) { + return false; + } + + return WP_MySQL_Lexer::PRIMARY_SYMBOL === $token->id + || null !== $this->get_mysql_identifier_token_value( $token ); + } + + /** + * Translate MySQL LIMIT offset,count syntax to PostgreSQL LIMIT count OFFSET offset. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position LIMIT token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_limit_offset_count_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_limit_offset_count_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $sql = 'LIMIT ' . $tokens[ $bounds['count_position'] ]->get_bytes() + . ' OFFSET ' . $tokens[ $bounds['offset_position'] ]->get_bytes(); + + return array( + 'sql' => $sql, + 'token_id' => WP_MySQL_Lexer::LIMIT_SYMBOL, + 'position' => $bounds['count_position'], + ); + } + + /** + * Get token bounds for a MySQL LIMIT offset,count clause. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position LIMIT token position. + * @param int $end Final token position, exclusive. + * @return array{offset_position: int, count_position: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_limit_offset_count_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::LIMIT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position + 2 ]->id + || $position + 4 !== $end + || ! $this->is_supported_simple_select_limit_number( $tokens[ $position + 1 ] ) + || ! $this->is_supported_simple_select_limit_number( $tokens[ $position + 3 ] ) + ) { + return null; + } + + return array( + 'offset_position' => $position + 1, + 'count_position' => $position + 3, + ); + } + + /** + * Translate MySQL GROUP_CONCAT([DISTINCT] expr [, expr ...] [ORDER BY ...] [SEPARATOR ...]). + * + * Multi-expression rows are concatenated before aggregation. DISTINCT stays + * intentionally single-expression so PostgreSQL does not silently change + * MySQL's de-duplication semantics for composite row values. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_group_concat_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_group_concat_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $parsed = $this->parse_mysql_group_concat_arguments( + $tokens, + $bounds['arguments_start'], + $bounds['arguments_end'] + ); + if ( null === $parsed ) { + return null; + } + + $expression_sql = $this->get_mysql_group_concat_expression_sql( + $tokens, + $parsed['expression_ranges'] + ); + if ( null === $expression_sql ) { + return null; + } + + $separator_sql = null === $parsed['separator_start'] + ? $this->connection->quote( ',' ) + : $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $parsed['separator_start'], + $parsed['separator_end'] + ); + + $order_sql = ''; + if ( null !== $parsed['order_start'] ) { + $order_sql = $parsed['distinct'] + ? $this->get_mysql_group_concat_distinct_order_by_sql( + $tokens, + $parsed['expression_ranges'][0], + $expression_sql, + $parsed['order_start'], + $parsed['order_end'] + ) + : $this->get_mysql_group_concat_order_by_sql( + $tokens, + $parsed['order_start'], + $parsed['order_end'] + ); + if ( null === $order_sql ) { + return null; + } + } + + $aggregate_sql = $this->get_postgresql_mysql_group_concat_aggregate_sql( + $expression_sql, + $separator_sql, + $order_sql, + $parsed['distinct'], + null === $parsed['separator_start'] + ); + if ( null === $aggregate_sql ) { + return null; + } + + return array( + 'sql' => $this->get_mysql_group_concat_max_len_truncation_sql( $aggregate_sql ), + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for a GROUP_CONCAT() call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{arguments_start: int, arguments_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_group_concat_function_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::GROUP_CONCAT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + return array( + 'arguments_start' => $position + 2, + 'arguments_end' => $after_close - 1, + 'close' => $after_close - 1, + ); + } + + /** + * Parse the supported GROUP_CONCAT argument shape. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First argument token position. + * @param int $end Final argument token position, exclusive. + * @return array{distinct: bool, expression_ranges: array[], order_start: int|null, order_end: int, separator_start: int|null, separator_end: int}|null Parsed bounds. + */ + private function parse_mysql_group_concat_arguments( array $tokens, int $start, int $end ): ?array { + if ( $start >= $end ) { + return null; + } + + $distinct = false; + $expression_start = $start; + if ( WP_MySQL_Lexer::DISTINCT_SYMBOL === ( $tokens[ $expression_start ]->id ?? null ) ) { + $distinct = true; + ++$expression_start; + } + if ( $expression_start >= $end ) { + return null; + } + + $separator_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::SEPARATOR_SYMBOL, + $expression_start, + $end + ); + if ( + null !== $separator_position + && ( + $separator_position + 1 >= $end + || null !== $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::SEPARATOR_SYMBOL, + $separator_position + 1, + $end + ) + ) + ) { + return null; + } + + $before_separator_end = $separator_position ?? $end; + $order_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::ORDER_SYMBOL, + $expression_start, + $before_separator_end + ); + if ( + null !== $order_position + && ( + ! isset( $tokens[ $order_position + 1 ] ) + || WP_MySQL_Lexer::BY_SYMBOL !== $tokens[ $order_position + 1 ]->id + || $order_position + 2 >= $before_separator_end + ) + ) { + return null; + } + $expression_end = $order_position ?? $before_separator_end; + if ( $expression_start >= $expression_end ) { + return null; + } + + $expression_arguments = $this->split_top_level_mysql_arguments( $tokens, $expression_start, $expression_end ); + if ( + null === $expression_arguments + || empty( $expression_arguments ) + || ( $distinct && 1 !== count( $expression_arguments ) ) + ) { + return null; + } + if ( + $distinct + && null !== $separator_position + && ! $this->is_mysql_string_literal_range( $tokens, $separator_position + 1, $end ) + ) { + return null; + } + + return array( + 'distinct' => $distinct, + 'expression_ranges' => $expression_arguments, + 'order_start' => null === $order_position ? null : $order_position + 2, + 'order_end' => $before_separator_end, + 'separator_start' => null === $separator_position ? null : $separator_position + 1, + 'separator_end' => $end, + ); + } + + /** + * Render a GROUP_CONCAT row expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array[] $ranges Top-level expression ranges. + * @return string|null PostgreSQL SQL, or null when unsupported. + */ + private function get_mysql_group_concat_expression_sql( array $tokens, array $ranges ): ?string { + if ( empty( $ranges ) ) { + return null; + } + + if ( 1 === count( $ranges ) ) { + $range = $ranges[0]; + return $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $range['start'], + $range['end'] + ); + } + + $parts = array(); + foreach ( $ranges as $range ) { + if ( $range['start'] >= $range['end'] ) { + return null; + } + + $parts[] = sprintf( + 'CAST(%s AS text)', + $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $range['start'], + $range['end'] + ) + ); + } + + return implode( ' || ', $parts ); + } + + /** + * Render a supported GROUP_CONCAT aggregate expression. + * + * @param string $expression_sql Translated expression SQL. + * @param string $separator_sql Translated separator SQL. + * @param string $order_sql Aggregate ORDER BY SQL. + * @param bool $distinct Whether DISTINCT is present. + * @param bool $uses_default_separator Whether the separator is the implicit comma. + * @return string|null Aggregate SQL, or null when unsupported by the active backend. + */ + private function get_postgresql_mysql_group_concat_aggregate_sql( string $expression_sql, string $separator_sql, string $order_sql, bool $distinct, bool $uses_default_separator ): ?string { + if ( ! $distinct ) { + return sprintf( + 'STRING_AGG(CAST(%1$s AS text), CAST(%2$s AS text)%3$s)', + $expression_sql, + $separator_sql, + $order_sql + ); + } + + if ( 'sqlite' === $this->connection->get_driver_name() ) { + return $uses_default_separator + ? sprintf( 'GROUP_CONCAT(DISTINCT CAST(%s AS text))', $expression_sql ) + : null; + } + + return sprintf( + 'STRING_AGG(DISTINCT CAST(%1$s AS text), CAST(%2$s AS text)%3$s)', + $expression_sql, + $separator_sql, + $order_sql + ); + } + + /** + * Render ORDER BY items inside a supported GROUP_CONCAT(). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First ORDER BY item token position. + * @param int $end Final ORDER BY item token position, exclusive. + * @return string|null Aggregate ORDER BY SQL, or null when unsupported. + */ + private function get_mysql_group_concat_order_by_sql( array $tokens, int $start, int $end ): ?string { + $items = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $items || empty( $items ) ) { + return null; + } + + $order_sql = array(); + foreach ( $items as $item ) { + $item_start = $item['start']; + $item_end = $item['end']; + $direction = ''; + + if ( isset( $tokens[ $item_end - 1 ] ) ) { + if ( WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $item_end - 1 ]->id ) { + $direction = ' DESC'; + --$item_end; + } elseif ( WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $item_end - 1 ]->id ) { + $direction = ' ASC'; + --$item_end; + } + } + + if ( $item_start >= $item_end ) { + return null; + } + + $order_sql[] = $this->translate_mysql_token_sequence_to_postgresql( $tokens, $item_start, $item_end ) . $direction; + } + + return ' ORDER BY ' . implode( ', ', $order_sql ); + } + + /** + * Render ORDER BY for GROUP_CONCAT(DISTINCT expr ORDER BY expr). + * + * PostgreSQL requires DISTINCT aggregate ORDER BY expressions to match the + * aggregate argument. Keep this to one item that translates to the same SQL as + * the DISTINCT expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $expression_range DISTINCT expression range. + * @param string $expression_sql Translated DISTINCT expression SQL. + * @param int $start First ORDER BY item token position. + * @param int $end Final ORDER BY item token position, exclusive. + * @return string|null Aggregate ORDER BY SQL, or null when unsupported. + */ + private function get_mysql_group_concat_distinct_order_by_sql( array $tokens, array $expression_range, string $expression_sql, int $start, int $end ): ?string { + $items = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null === $items || 1 !== count( $items ) ) { + return null; + } + + $item_start = $items[0]['start']; + $item_end = $items[0]['end']; + $direction = ''; + + if ( isset( $tokens[ $item_end - 1 ] ) ) { + if ( WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $item_end - 1 ]->id ) { + $direction = ' DESC'; + --$item_end; + } elseif ( WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $item_end - 1 ]->id ) { + $direction = ' ASC'; + --$item_end; + } + } + + if ( + $item_start >= $item_end + || $this->translate_mysql_token_sequence_to_postgresql( $tokens, $expression_range['start'], $expression_range['end'] ) + !== $this->translate_mysql_token_sequence_to_postgresql( $tokens, $item_start, $item_end ) + ) { + return null; + } + + return sprintf( ' ORDER BY CAST(%s AS text)%s', $expression_sql, $direction ); + } + + /** + * Apply the current group_concat_max_len session value to GROUP_CONCAT SQL. + * + * PostgreSQL text cannot carry invalid UTF-8 byte prefixes, so PostgreSQL uses + * the longest valid UTF-8 prefix within the byte limit. The SQLite-backed test + * harness can preserve raw byte prefixes and uses byte-exact truncation. + * + * @param string $aggregate_sql Rendered STRING_AGG SQL. + * @return string Truncated aggregate SQL. + */ + private function get_mysql_group_concat_max_len_truncation_sql( string $aggregate_sql ): string { + $limit = $this->get_mysql_group_concat_max_len_sql_limit(); + + if ( 'sqlite' === $this->connection->get_driver_name() ) { + return sprintf( + 'CAST(SUBSTR(CAST(%1$s AS BLOB), 1, %2$d) AS TEXT)', + $aggregate_sql, + $limit + ); + } + + if ( 'pgsql' === $this->connection->get_driver_name() ) { + return $this->get_postgresql_mysql_utf8_safe_byte_prefix_sql( $aggregate_sql, $limit ); + } + + return sprintf( 'SUBSTR(%1$s, 1, %2$d)', $aggregate_sql, $limit ); + } + + /** + * Get the current group_concat_max_len as a SQL substring limit. + * + * @return int SQL-safe substring length. + */ + private function get_mysql_group_concat_max_len_sql_limit(): int { + $value = $this->get_mysql_system_variable_value( 'group_concat_max_len' ) ?? '1024'; + $value = ltrim( $value, '0' ); + if ( '' === $value ) { + return 0; + } + + $max = (string) self::MYSQL_GROUP_CONCAT_MAX_LEN_SQL_LIMIT; + if ( strlen( $value ) > strlen( $max ) || ( strlen( $value ) === strlen( $max ) && strcmp( $value, $max ) > 0 ) ) { + return self::MYSQL_GROUP_CONCAT_MAX_LEN_SQL_LIMIT; + } + + return (int) $value; + } + + /** + * Get PostgreSQL SQL for a valid UTF-8 prefix within a byte limit. + * + * @param string $expression_sql PostgreSQL text expression SQL. + * @param int $limit Maximum byte length. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_utf8_safe_byte_prefix_sql( string $expression_sql, int $limit ): string { + if ( 0 === $limit ) { + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE %2$s END', + $expression_sql, + $this->connection->quote( '' ) + ); + } + + $bytes_sql = sprintf( "CONVERT_TO(CAST(%s AS text), 'UTF8')", $expression_sql ); + $safe_length_sql = (string) $limit; + $next_byte_sql = sprintf( 'GET_BYTE(%s, %d)', $bytes_sql, $limit ); + + $branches = array( + sprintf( + 'WHEN %s THEN %d', + $this->get_postgresql_mysql_not_utf8_continuation_byte_condition_sql( $next_byte_sql ), + $limit + ), + ); + + for ( $offset = 1; $offset <= 3; $offset++ ) { + if ( $limit < $offset ) { + break; + } + + $byte_sql = sprintf( 'GET_BYTE(%s, %d)', $bytes_sql, $limit - $offset ); + $branches[] = sprintf( + 'WHEN %s THEN %d', + $this->get_postgresql_mysql_not_utf8_continuation_byte_condition_sql( $byte_sql ), + $limit - $offset + ); + } + + $safe_length_sql = 'CASE ' . implode( ' ', $branches ) . ' ELSE 0 END'; + + return sprintf( + "CASE WHEN %1\$s IS NULL THEN NULL WHEN OCTET_LENGTH(CAST(%1\$s AS text)) <= %2\$d THEN CAST(%1\$s AS text) ELSE CONVERT_FROM(SUBSTRING(%3\$s FROM 1 FOR %4\$s), 'UTF8') END", + $expression_sql, + $limit, + $bytes_sql, + $safe_length_sql + ); + } + + /** + * Get a PostgreSQL condition for a byte that is not a UTF-8 continuation byte. + * + * @param string $byte_sql SQL expression returning a byte integer. + * @return string PostgreSQL condition SQL. + */ + private function get_postgresql_mysql_not_utf8_continuation_byte_condition_sql( string $byte_sql ): string { + return sprintf( '(%1$s < 128 OR %1$s >= 192)', $byte_sql ); + } + + /** + * Translate MySQL FIELD(expr, value, ...) to a PostgreSQL CASE expression. + * + * PostgreSQL does not coerce unknown text and integer values the same way + * MySQL FIELD() does. Cast both sides of each comparison to text to keep the + * WordPress ordering use-cases executable across mixed ID/name arguments. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_field_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_function_call_bounds( $tokens, $position, $end, 'field' ); + if ( null === $bounds ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || count( $arguments ) < 2 ) { + return null; + } + + $value_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + + $clauses = array( + sprintf( 'WHEN %s IS NULL THEN 0', $value_sql ), + ); + + for ( $i = 1; $i < count( $arguments ); $i++ ) { + $argument_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[ $i ]['start'], + $arguments[ $i ]['end'] + ); + + $clauses[] = sprintf( + 'WHEN CAST(%1$s AS text) = CAST(%2$s AS text) THEN %3$d', + $value_sql, + $argument_sql, + $i + ); + } + + return array( + 'sql' => 'CASE ' . implode( ' ', $clauses ) . ' ELSE 0 END', + 'token_id' => WP_MySQL_Lexer::CASE_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Translate MySQL CAST(expr AS SIGNED/UNSIGNED [INTEGER]) to PostgreSQL. + * + * Both SIGNED and UNSIGNED map to bigint. This preserves WordPress meta + * comparison/query execution but does not emulate MySQL UNSIGNED wraparound. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_integer_cast_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_integer_cast_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_mysql_integer_cast_sql( $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Translate MySQL CONVERT(expr, SIGNED/UNSIGNED [INTEGER]) to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_integer_convert_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_integer_convert_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_mysql_integer_cast_sql( $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Translate MySQL CONVERT(expr, CHAR) to PostgreSQL text. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_character_convert_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_character_convert_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => sprintf( 'CAST(%s AS text)', $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get PostgreSQL SQL for MySQL-compatible integer text coercion. + * + * MySQL accepts text values when casting to SIGNED/UNSIGNED and coerces the + * leading integer prefix, or zero when no prefix exists. PostgreSQL bigint + * casts reject those values, so extract a safe prefix before casting. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_integer_cast_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $integer_pattern = $this->connection->quote( '^[[:space:]]*[+-]?[0-9]+' ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE CAST(COALESCE(SUBSTRING(%1$s, %2$s), \'0\') AS bigint) END', + $expression_text_sql, + $integer_pattern + ); + } + + /** + * Get PostgreSQL SQL for MySQL-compatible decimal text coercion. + * + * MySQL text values in numeric expression contexts use the leading numeric + * prefix, including decimal and exponent forms, or zero when no prefix exists. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_numeric_cast_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $substring_sql = array(); + $numeric_patterns = array( + '^[[:space:]]*[+-]?[0-9]+[.][0-9]*[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[.][0-9]+[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[0-9]+[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[0-9]+[.][0-9]*', + '^[[:space:]]*[+-]?[.][0-9]+', + '^[[:space:]]*[+-]?[0-9]+', + ); + + foreach ( $numeric_patterns as $pattern ) { + $substring_sql[] = sprintf( + 'SUBSTRING(%1$s, %2$s)', + $expression_text_sql, + $this->connection->quote( $pattern ) + ); + } + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE CAST(COALESCE(%2$s, \'0\') AS numeric) END', + $expression_text_sql, + implode( ', ', $substring_sql ) + ); + } + + /** + * Get token bounds for a supported MySQL integer CAST expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_integer_cast_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $as_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::AS_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $as_position + || $as_position <= $position + 2 + || null === $this->get_postgresql_integer_cast_type( $tokens, $as_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $as_position, + 'close' => $close_position, + ); + } + + /** + * Get token bounds for a supported MySQL integer CONVERT expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_integer_convert_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $comma_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $comma_position + || $comma_position <= $position + 2 + || null === $this->get_postgresql_integer_cast_type( $tokens, $comma_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $comma_position, + 'close' => $close_position, + ); + } + + /** + * Get the PostgreSQL type for supported MySQL integer cast type tokens. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First cast type token. + * @param int $end Final cast type token, exclusive. + * @return string|null PostgreSQL type SQL, or null when unsupported. + */ + private function get_postgresql_integer_cast_type( array $tokens, int $start, int $end ): ?string { + if ( + ! isset( $tokens[ $start ] ) + || ! in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::SIGNED_SYMBOL, + WP_MySQL_Lexer::UNSIGNED_SYMBOL, + ), + true + ) + ) { + return null; + } + + if ( $start + 1 === $end ) { + return 'bigint'; + } + + if ( + $start + 2 === $end + && isset( $tokens[ $start + 1 ] ) + && in_array( + $tokens[ $start + 1 ]->id, + array( + WP_MySQL_Lexer::INT_SYMBOL, + WP_MySQL_Lexer::INTEGER_SYMBOL, + ), + true + ) + ) { + return 'bigint'; + } + + return null; + } + + /** + * Translate MySQL CAST(expr AS CHAR) to PostgreSQL text. + * + * PostgreSQL's CHAR without length is character(1), while MySQL CHAR casts + * are used by WordPress as text ordering expressions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_character_cast_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_character_cast_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => sprintf( 'CAST(%s AS text)', $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for a supported MySQL character CAST expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_character_cast_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $position, $end ); + if ( $bounds['start'] !== $position ) { + return null; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $as_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::AS_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $as_position + || $as_position <= $position + 2 + || ! $this->is_mysql_character_cast_type( $tokens, $as_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $as_position, + 'close' => $close_position, + ); + } + + /** + * Check whether a CAST type is MySQL CHAR. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First cast type token. + * @param int $end Final cast type token, exclusive. + * @return bool Whether the type is supported. + */ + private function is_mysql_character_cast_type( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::CHAR_SYMBOL === $tokens[ $start ]->id; + } + + /** + * Get token bounds for a supported MySQL CONVERT(expr, CHAR) expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_character_convert_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $position, $end ); + if ( $bounds['start'] !== $position ) { + return null; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $comma_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $comma_position + || $comma_position <= $position + 2 + || ! $this->is_mysql_character_cast_type( $tokens, $comma_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $comma_position, + 'close' => $close_position, + ); + } + + /** + * Translate MySQL CAST(expr AS DATETIME/TIMESTAMP) to PostgreSQL timestamp. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_date_time_cast_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_date_time_cast_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for a supported MySQL date/time CAST expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_date_time_cast_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $position, $end ); + if ( $bounds['start'] !== $position ) { + return null; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $as_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::AS_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $as_position + || $as_position <= $position + 2 + || ! $this->is_mysql_date_time_cast_type( $tokens, $as_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $as_position, + 'close' => $close_position, + ); + } + + /** + * Check whether a CAST type is MySQL DATETIME/TIMESTAMP. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First cast type token. + * @param int $end Final cast type token, exclusive. + * @return bool Whether the type is supported. + */ + private function is_mysql_date_time_cast_type( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && in_array( + $tokens[ $start ]->id, + array( + WP_MySQL_Lexer::DATETIME_SYMBOL, + WP_MySQL_Lexer::TIMESTAMP_SYMBOL, + ), + true + ); + } + + /** + * Translate MySQL CAST(expr AS BINARY) to PostgreSQL text. + * + * PostgreSQL regex operators work on text, so keep supported binary regex + * predicates executable without broadening this lane to bytea emulation. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_binary_cast_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_binary_cast_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => sprintf( 'CAST(%s AS text)', $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Translate MySQL CONVERT(expr, BINARY) to PostgreSQL text. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_binary_convert_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_binary_convert_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => sprintf( 'CAST(%s AS text)', $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Translate MySQL CONVERT(expr, DECIMAL) to PostgreSQL numeric. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_decimal_convert_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_decimal_convert_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_mysql_numeric_cast_sql( $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Translate MySQL CONVERT(expr, DATE) to PostgreSQL text. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_date_convert_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_date_convert_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_mysql_date_sql( $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for a supported MySQL binary CAST expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CAST token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_binary_cast_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $position, $end ); + if ( $bounds['start'] !== $position ) { + return null; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CAST_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $as_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::AS_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $as_position + || $as_position <= $position + 2 + || ! $this->is_mysql_binary_cast_type( $tokens, $as_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $as_position, + 'close' => $close_position, + ); + } + + /** + * Get token bounds for a supported MySQL CONVERT(expr, BINARY) expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_binary_convert_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $position, $end ); + if ( $bounds['start'] !== $position ) { + return null; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $comma_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $comma_position + || $comma_position <= $position + 2 + || ! $this->is_mysql_binary_cast_type( $tokens, $comma_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $comma_position, + 'close' => $close_position, + ); + } + + /** + * Check whether a CAST type is MySQL BINARY. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First cast type token. + * @param int $end Final cast type token, exclusive. + * @return bool Whether the type is supported. + */ + private function is_mysql_binary_cast_type( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $start ]->id; + } + + /** + * Get token bounds for a supported MySQL CONVERT(expr, DECIMAL) expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_decimal_convert_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $position, $end ); + if ( $bounds['start'] !== $position ) { + return null; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $comma_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $comma_position + || $comma_position <= $position + 2 + || ! $this->is_mysql_decimal_cast_type( $tokens, $comma_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $comma_position, + 'close' => $close_position, + ); + } + + /** + * Get token bounds for a supported MySQL CONVERT(expr, DATE) expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_date_convert_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->normalize_mysql_expression_bounds( $tokens, $position, $end ); + if ( $bounds['start'] !== $position ) { + return null; + } + + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $comma_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $comma_position + || $comma_position <= $position + 2 + || ! $this->is_mysql_date_convert_type( $tokens, $comma_position + 1, $close_position ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $comma_position, + 'close' => $close_position, + ); + } + + /** + * Check whether a CONVERT type is MySQL DATE. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First convert type token. + * @param int $end Final convert type token, exclusive. + * @return bool Whether the type is supported. + */ + private function is_mysql_date_convert_type( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::DATE_SYMBOL === $tokens[ $start ]->id; + } + + /** + * Translate MySQL REGEXP/RLIKE operators to PostgreSQL regex operators. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Operator token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_regexp_operator_to_postgresql( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( + WP_MySQL_Lexer::REGEXP_SYMBOL === $tokens[ $position ]->id + ) { + if ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position + 1 ]->id + ) { + return null; + } + + $is_binary = $this->is_mysql_regexp_binary_predicate( $tokens, $position + 1, $end ); + + return array( + 'sql' => $is_binary ? '~' : '~*', + 'token_id' => WP_MySQL_Lexer::REGEXP_SYMBOL, + 'position' => $is_binary ? $position + 1 : $position, + ); + } + + if ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::REGEXP_SYMBOL === $tokens[ $position + 1 ]->id + ) { + if ( + isset( $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position + 2 ]->id + ) { + return null; + } + + $is_binary = $this->is_mysql_regexp_binary_predicate( $tokens, $position + 2, $end ); + + return array( + 'sql' => $is_binary ? '!~' : '!~*', + 'token_id' => WP_MySQL_Lexer::REGEXP_SYMBOL, + 'position' => $is_binary ? $position + 2 : $position + 1, + ); + } + + return null; + } + + /** + * Check whether a REGEXP predicate starts with the BINARY modifier. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position First right-hand predicate token. + * @param int $end Final token position, exclusive. + * @return bool Whether the predicate uses REGEXP BINARY/RLIKE BINARY. + */ + private function is_mysql_regexp_binary_predicate( array $tokens, int $position, int $end ): bool { + return $position < $end + && isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $position ]->id; + } + + /** + * Translate MySQL RAND() and literal RAND(seed) calls to PostgreSQL. + * + * PostgreSQL setseed() is session-stateful, so literal seeded calls are + * folded to the first value from SQLite's seeded MySQL-compatible LCG. + * Non-literal seeded calls stay on random() instead of leaking seed state + * into later statements. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_rand_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_function_call_bounds( $tokens, $position, $end, 'rand' ); + if ( null === $bounds ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || count( $arguments ) > 1 ) { + return null; + } + + $sql = 'random()'; + if ( 1 === count( $arguments ) ) { + $seed = $this->get_mysql_literal_rand_seed_value( $tokens, $arguments[0]['start'], $arguments[0]['end'] ); + if ( null !== $seed ) { + $sql = $this->get_mysql_seeded_rand_literal_sql( $seed['value'] ); + } + } + + return array( + 'sql' => $sql, + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $bounds['close'], + ); + } + + /** + * Check whether a range contains an unsupported MySQL RAND() form. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an unsupported RAND() call is present. + */ + private function contains_unsupported_mysql_rand_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( null === $this->get_mysql_function_call_bounds( $tokens, $i, $end, 'rand' ) ) { + continue; + } + + if ( null === $this->translate_mysql_rand_function_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a query contains an unsupported MySQL RAND() form. + * + * @param string $query SQL query. + * @return bool Whether an unsupported RAND() call is present. + */ + private function contains_unsupported_mysql_rand_function_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + return $this->contains_unsupported_mysql_rand_function( + $tokens, + 0, + null === $statement_end ? count( $tokens ) : $statement_end + ); + } + + /** + * Get a literal MySQL RAND(seed) value using SQLite UDF seed coercion. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First seed token. + * @param int $end Final seed token, exclusive. + * @return array{value: int}|null Seed value, or null when not a literal seed. + */ + private function get_mysql_literal_rand_seed_value( array $tokens, int $start, int $end ): ?array { + if ( $start + 1 === $end && isset( $tokens[ $start ] ) && WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $start ]->id ) { + return array( 'value' => 0 ); + } + + if ( $this->is_mysql_string_literal_range( $tokens, $start, $end ) ) { + return array( + 'value' => (int) fmod( round( (float) $tokens[ $start ]->get_value(), 0, PHP_ROUND_HALF_EVEN ), 0x100000000 ), + ); + } + + $literal = $this->parse_mysql_numeric_literal( $tokens, $start, $end ); + if ( null === $literal || $literal['start'] !== $start || $literal['end'] !== $end ) { + return null; + } + + return array( + 'value' => (int) fmod( round( (float) $this->get_mysql_token_sequence_bytes( $tokens, $start, $end ), 0, PHP_ROUND_HALF_EVEN ), 0x100000000 ), + ); + } + + /** + * Get the first SQLite/MySQL-compatible LCG value for RAND(seed). + * + * @param int $seed MySQL-coerced seed value. + * @return string PostgreSQL numeric literal SQL. + */ + private function get_mysql_seeded_rand_literal_sql( int $seed ): string { + $max_value = 0x3FFFFFFF; + $seed_u32 = $seed & 0xFFFFFFFF; + $seed1 = ( ( $seed_u32 * 0x10001 + 55555555 ) & 0xFFFFFFFF ) % $max_value; + $seed2 = ( ( $seed_u32 * 0x10000001 ) & 0xFFFFFFFF ) % $max_value; + $seed1 = ( $seed1 * 3 + $seed2 ) % $max_value; + + $literal = rtrim( rtrim( sprintf( '%.17F', (float) $seed1 / (float) $max_value ), '0' ), '.' ); + return sprintf( 'CAST(%s AS double precision)', $literal ); + } + + /** + * Translate MySQL session user runtime functions to a stable emulated user. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_session_user_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $function = $this->get_mysql_session_user_function_name( $tokens[ $position ] ?? null ); + if ( null === $function ) { + return null; + } + + if ( + isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close || $position + 3 !== $after_close ) { + return null; + } + + return array( + 'sql' => $this->connection->quote( self::MYSQL_SESSION_USER ), + 'token_id' => WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + 'position' => $after_close - 1, + ); + } + + if ( 'current_user' !== $function ) { + return null; + } + + return array( + 'sql' => $this->connection->quote( self::MYSQL_SESSION_USER ), + 'token_id' => WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + 'position' => $position, + ); + } + + /** + * Get a normalized MySQL session user function name. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Function name, or null when unsupported. + */ + private function get_mysql_session_user_function_name( ?WP_MySQL_Token $token ): ?string { + if ( null === $token ) { + return null; + } + + if ( WP_MySQL_Lexer::CURRENT_USER_SYMBOL === $token->id ) { + return 'current_user'; + } + + if ( WP_MySQL_Lexer::USER_SYMBOL !== $token->id ) { + return null; + } + + $name = strtolower( $token->get_value() ); + return in_array( $name, array( 'user', 'session_user', 'system_user' ), true ) ? $name : null; + } + + /** + * Translate MySQL temporal functions that allow no parentheses. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_nonparenthesized_timestamp_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( + isset( $tokens[ $position + 1 ] ) + && $position + 1 < $end + && in_array( $tokens[ $position + 1 ]->id, array( WP_MySQL_Lexer::OPEN_PAR_SYMBOL, WP_MySQL_Lexer::DOT_SYMBOL ), true ) + ) { + return null; + } + + $function_name = strtolower( $tokens[ $position ]->get_value() ); + if ( + ! in_array( $function_name, array( 'current_date', 'current_time', 'current_timestamp', 'localtime', 'localtimestamp' ), true ) + || ( + in_array( $function_name, array( 'current_timestamp', 'localtime', 'localtimestamp' ), true ) + && WP_MySQL_Lexer::NOW_SYMBOL !== $tokens[ $position ]->id + ) + || ( + in_array( $function_name, array( 'current_date', 'current_time' ), true ) + && WP_MySQL_Lexer::IDENTIFIER !== $tokens[ $position ]->id + ) + ) { + return null; + } + + if ( 'current_date' === $function_name ) { + $function_name = 'curdate'; + } elseif ( 'current_time' === $function_name ) { + $function_name = 'utc_time'; + } + + $sql = $this->get_postgresql_mysql_common_function_sql( $function_name, array() ); + if ( null === $sql ) { + return null; + } + + return array( + 'sql' => $sql, + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $position, + ); + } + + /** + * Translate common MySQL runtime functions to PostgreSQL expressions. + * + * This mirrors the broad SQLite UDF layer for simple function shapes used by + * WordPress and plugins. More complex or ambiguous forms intentionally remain + * unsupported so they fail visibly instead of changing semantics silently. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_common_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_common_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments ) { + return null; + } + + if ( 'substring' === $bounds['function'] ) { + $arguments = $this->get_mysql_substring_function_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments ) { + return null; + } + } + + if ( 'timestampadd' === $bounds['function'] ) { + return $this->translate_mysql_timestampadd_function_to_postgresql( $tokens, $arguments, $bounds['close'] ); + } + + if ( 'timestampdiff' === $bounds['function'] ) { + return $this->translate_mysql_timestampdiff_function_to_postgresql( $tokens, $arguments, $bounds['close'] ); + } + + if ( + 'last_insert_id' === $bounds['function'] + && 1 === count( $arguments ) + && $this->mysql_last_insert_id_assignment_translation_enabled + ) { + $last_insert_id = $this->get_mysql_last_insert_id_assignment_literal_value( $tokens, $position, $bounds['close'] + 1 ); + if ( null === $last_insert_id ) { + return null; + } + + $this->mysql_last_insert_id_assignment_value = $last_insert_id; + return array( + 'sql' => (string) $last_insert_id, + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $bounds['close'], + ); + } + + if ( + in_array( $bounds['function'], array( 'char_length', 'character_length', 'length' ), true ) + && 1 === count( $arguments ) + ) { + $binary_length_sql = $this->get_postgresql_mysql_binary_argument_byte_length_sql( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + if ( null !== $binary_length_sql ) { + return array( + 'sql' => $binary_length_sql, + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $bounds['close'], + ); + } + } + + $argument_sql = array(); + foreach ( $arguments as $argument ) { + $argument_sql[] = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $argument['start'], + $argument['end'] + ); + } + + if ( 'if' === $bounds['function'] && 3 === count( $argument_sql ) ) { + $condition_sql = $this->is_mysql_boolean_condition_expression( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ) + ? '(' . $argument_sql[0] . ')' + : $this->get_postgresql_mysql_truthy_expression_sql( $argument_sql[0] ); + + return array( + 'sql' => sprintf( 'CASE WHEN %s THEN %s ELSE %s END', $condition_sql, $argument_sql[1], $argument_sql[2] ), + 'token_id' => WP_MySQL_Lexer::CASE_SYMBOL, + 'position' => $bounds['close'], + ); + } + + if ( 'length' === $bounds['function'] && 1 === count( $arguments ) ) { + $binary_length_sql = $this->get_postgresql_mysql_unhex_length_sql( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + if ( null !== $binary_length_sql ) { + return array( + 'sql' => $binary_length_sql, + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $bounds['close'], + ); + } + } + + $sql = $this->get_postgresql_mysql_common_function_sql( $bounds['function'], $argument_sql ); + if ( null === $sql ) { + return null; + } + + return array( + 'sql' => $sql, + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $bounds['close'], + ); + } + + /** + * Get PostgreSQL SQL for MySQL LENGTH(UNHEX(...)). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First argument token. + * @param int $end Final argument token, exclusive. + * @return string|null PostgreSQL byte-length SQL, or null when unsupported. + */ + private function get_postgresql_mysql_unhex_length_sql( array $tokens, int $start, int $end ): ?string { + $bounds = $this->get_mysql_function_call_bounds( $tokens, $start, $end, 'unhex' ); + if ( null === $bounds || $bounds['close'] + 1 !== $end ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 1 !== count( $arguments ) ) { + return null; + } + + $hex_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + + return sprintf( "OCTET_LENGTH(DECODE(CAST(%s AS text), 'hex'))", $hex_sql ); + } + + /** + * Get PostgreSQL SQL for LENGTH/CHAR_LENGTH of a MySQL binary expression. + * + * MySQL CHAR_LENGTH() counts bytes for binary strings. Keep the binary + * marker local to length functions so other expression contexts continue to + * use the existing text-compatible CAST behavior. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First argument token. + * @param int $end Final argument token, exclusive. + * @return string|null PostgreSQL byte-length SQL, or null when not binary. + */ + private function get_postgresql_mysql_binary_argument_byte_length_sql( array $tokens, int $start, int $end ): ?string { + $hex_literal_length_sql = $this->get_postgresql_mysql_hex_literal_byte_length_sql( $tokens, $start, $end ); + if ( null !== $hex_literal_length_sql ) { + return $hex_literal_length_sql; + } + + $unhex_length_sql = $this->get_postgresql_mysql_unhex_length_sql( $tokens, $start, $end ); + if ( null !== $unhex_length_sql ) { + return $unhex_length_sql; + } + + $from_base64_length_sql = $this->get_postgresql_mysql_from_base64_length_sql( $tokens, $start, $end ); + if ( null !== $from_base64_length_sql ) { + return $from_base64_length_sql; + } + + $binary_cast = $this->get_mysql_binary_cast_bounds( $tokens, $start, $end ); + if ( null !== $binary_cast && $binary_cast['close'] + 1 === $end ) { + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $binary_cast['expression_start'], + $binary_cast['expression_end'] + ); + + return $this->get_postgresql_mysql_text_byte_length_sql( $expression_sql ); + } + + $binary_convert = $this->get_mysql_binary_convert_bounds( $tokens, $start, $end ); + if ( null !== $binary_convert && $binary_convert['close'] + 1 === $end ) { + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $binary_convert['expression_start'], + $binary_convert['expression_end'] + ); + + return $this->get_postgresql_mysql_text_byte_length_sql( $expression_sql ); + } + + if ( + $start + 1 < $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::BINARY_SYMBOL === $tokens[ $start ]->id + ) { + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $start + 1, + $end + ); + + return $this->get_postgresql_mysql_text_byte_length_sql( $expression_sql ); + } + + return null; + } + + /** + * Get PostgreSQL SQL for LENGTH/CHAR_LENGTH of a raw MySQL hex literal. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First argument token. + * @param int $end Final argument token, exclusive. + * @return string|null Literal byte count SQL, or null when not a hex literal. + */ + private function get_postgresql_mysql_hex_literal_byte_length_sql( array $tokens, int $start, int $end ): ?string { + $value = $this->get_mysql_text_hex_literal_value( $tokens, $start, $end ); + return null === $value ? null : (string) strlen( $value ); + } + + /** + * Get PostgreSQL SQL for MySQL LENGTH/CHAR_LENGTH(FROM_BASE64(...)). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First argument token. + * @param int $end Final argument token, exclusive. + * @return string|null PostgreSQL byte-length SQL, or null when unsupported. + */ + private function get_postgresql_mysql_from_base64_length_sql( array $tokens, int $start, int $end ): ?string { + $bounds = $this->get_mysql_function_call_bounds( $tokens, $start, $end, 'from_base64' ); + if ( null === $bounds || $bounds['close'] + 1 !== $end ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 1 !== count( $arguments ) ) { + return null; + } + + $base64_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + + return sprintf( + 'CASE WHEN %1$s THEN NULL ELSE OCTET_LENGTH(DECODE(CAST(%2$s AS text), \'base64\')) END', + $this->get_postgresql_mysql_base64_invalid_condition_sql( $base64_sql ), + $base64_sql + ); + } + + /** + * Get token bounds for a supported common MySQL runtime function. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{function: string, arguments_start: int, arguments_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_common_function_bounds( array $tokens, int $position, int $end ): ?array { + $function = $this->get_mysql_common_function_name( $tokens[ $position ] ?? null ); + if ( + null === $function + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + return array( + 'function' => $function, + 'arguments_start' => $position + 2, + 'arguments_end' => $after_close - 1, + 'close' => $after_close - 1, + ); + } + + /** + * Get the normalized name for a supported common MySQL runtime function token. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Function name, or null when unsupported. + */ + private function get_mysql_common_function_name( ?WP_MySQL_Token $token ): ?string { + if ( null === $token ) { + return null; + } + + $keyword_functions = array( + WP_MySQL_Lexer::COALESCE_SYMBOL => 'coalesce', + WP_MySQL_Lexer::CURRENT_USER_SYMBOL => 'current_user', + WP_MySQL_Lexer::CURDATE_SYMBOL => 'curdate', + WP_MySQL_Lexer::CURTIME_SYMBOL => 'utc_time', + WP_MySQL_Lexer::CURRENT_DATE_SYMBOL => 'curdate', + WP_MySQL_Lexer::CURRENT_TIME_SYMBOL => 'utc_time', + WP_MySQL_Lexer::CURRENT_TIMESTAMP_SYMBOL => 'utc_timestamp', + WP_MySQL_Lexer::DATABASE_SYMBOL => 'database', + WP_MySQL_Lexer::DATE_SYMBOL => 'date', + WP_MySQL_Lexer::IF_SYMBOL => 'if', + WP_MySQL_Lexer::LEFT_SYMBOL => 'left', + WP_MySQL_Lexer::MID_SYMBOL => 'substring', + WP_MySQL_Lexer::NOW_SYMBOL => 'now', + WP_MySQL_Lexer::REPLACE_SYMBOL => 'replace', + WP_MySQL_Lexer::REGEXP_SYMBOL => 'regexp', + WP_MySQL_Lexer::ROW_COUNT_SYMBOL => 'row_count', + WP_MySQL_Lexer::SCHEMA_SYMBOL => 'database', + WP_MySQL_Lexer::SUBSTR_SYMBOL => 'substring', + WP_MySQL_Lexer::SUBSTRING_SYMBOL => 'substring', + WP_MySQL_Lexer::UTC_DATE_SYMBOL => 'utc_date', + WP_MySQL_Lexer::UTC_TIME_SYMBOL => 'utc_time', + WP_MySQL_Lexer::UTC_TIMESTAMP_SYMBOL => 'utc_timestamp', + ); + if ( isset( $keyword_functions[ $token->id ] ) ) { + return $keyword_functions[ $token->id ]; + } + + if ( WP_MySQL_Lexer::USER_SYMBOL === $token->id ) { + $name = strtolower( $token->get_value() ); + return in_array( $name, array( 'user', 'session_user', 'system_user' ), true ) ? $name : null; + } + + $name = $this->get_mysql_identifier_token_value( $token ); + if ( null === $name ) { + return null; + } + + $name = strtolower( $name ); + $aliases = array( + 'current_date' => 'curdate', + 'current_time' => 'utc_time', + 'current_timestamp' => 'utc_timestamp', + ); + if ( isset( $aliases[ $name ] ) ) { + return $aliases[ $name ]; + } + + $supported = array( + 'char_length', + 'character_length', + 'concat', + 'concat_ws', + 'connection_id', + 'curdate', + 'current_user', + 'database', + 'date', + 'datediff', + 'from_base64', + 'from_unixtime', + 'get_lock', + 'greatest', + 'hex', + 'if', + 'ifnull', + 'inet_aton', + 'inet_ntoa', + 'isnull', + 'json_valid', + 'lcase', + 'last_insert_id', + 'left', + 'least', + 'length', + 'locate', + 'log', + 'localtime', + 'localtimestamp', + 'md5', + 'monthnum', + 'now', + 'nullif', + 'release_lock', + 'replace', + 'regexp', + 'row_count', + 'schema', + 'session_user', + 'substr', + 'substring', + 'system_user', + 'timestampadd', + 'timestampdiff', + 'to_base64', + 'ucase', + 'unhex', + 'unix_timestamp', + 'utc_date', + 'utc_time', + 'utc_timestamp', + 'user', + 'version', + 'uuid', + ); + + return in_array( $name, $supported, true ) ? $name : null; + } + + /** + * Check whether a range contains an unsupported known MySQL runtime function. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an unsupported known MySQL runtime function is present. + */ + private function contains_unsupported_mysql_common_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( null === $this->get_mysql_common_function_bounds( $tokens, $i, $end ) ) { + continue; + } + + if ( null === $this->translate_mysql_common_function_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a range contains an unsupported MySQL CONVERT() function. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an unsupported CONVERT() form is present. + */ + private function contains_unsupported_mysql_convert_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( + ! isset( $tokens[ $i ], $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[ $i ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + + if ( + null !== $this->get_mysql_convert_using_bounds( $tokens, $i, $end ) + || null !== $this->get_mysql_integer_convert_bounds( $tokens, $i, $end ) + || null !== $this->get_mysql_character_convert_bounds( $tokens, $i, $end ) + || null !== $this->get_mysql_binary_convert_bounds( $tokens, $i, $end ) + || null !== $this->get_mysql_decimal_convert_bounds( $tokens, $i, $end ) + || null !== $this->get_mysql_date_convert_bounds( $tokens, $i, $end ) + ) { + continue; + } + + return true; + } + + return false; + } + + /** + * Check whether a query contains an unsupported MySQL CONVERT() function. + * + * @param string $query SQL query. + * @return bool Whether an unsupported CONVERT() form is present. + */ + private function contains_unsupported_mysql_convert_function_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + return $this->contains_unsupported_mysql_convert_function( + $tokens, + 0, + null === $statement_end ? count( $tokens ) : $statement_end + ); + } + + /** + * Check whether a query contains an unsupported known MySQL runtime function. + * + * @param string $query SQL query. + * @return bool Whether an unsupported known MySQL runtime function is present. + */ + private function contains_unsupported_mysql_common_function_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + return $this->contains_unsupported_mysql_common_function( + $tokens, + 0, + null === $statement_end ? count( $tokens ) : $statement_end + ); + } + + /** + * Check whether a query contains unsupported MySQL FULLTEXT search syntax. + * + * Metadata-only FULLTEXT indexes are supported for dbDelta compatibility, + * but MATCH (...) AGAINST (...) search semantics are not emulated. + * + * @param string $query SQL query. + * @return bool Whether unsupported FULLTEXT search syntax is present. + */ + private function contains_unsupported_mysql_fulltext_search_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + if ( null === $statement_end ) { + return false; + } + + for ( $i = 0; $i < $statement_end; $i++ ) { + if ( + WP_MySQL_Lexer::MATCH_SYMBOL !== $tokens[ $i ]->id + || ! isset( $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + + $after_match = $this->get_mysql_parenthesized_sequence_end( $tokens, $i + 1, $statement_end ); + if ( + null !== $after_match + && isset( $tokens[ $after_match ], $tokens[ $after_match + 1 ] ) + && WP_MySQL_Lexer::AGAINST_SYMBOL === $tokens[ $after_match ]->id + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $after_match + 1 ]->id + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a range contains an unsupported GROUP_CONCAT() form. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an unsupported GROUP_CONCAT() form is present. + */ + private function contains_unsupported_mysql_group_concat_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( null === $this->get_mysql_group_concat_function_bounds( $tokens, $i, $end ) ) { + continue; + } + + if ( null === $this->translate_mysql_group_concat_function_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a query contains an unsupported GROUP_CONCAT() form. + * + * @param string $query SQL query. + * @return bool Whether an unsupported GROUP_CONCAT() form is present. + */ + private function contains_unsupported_mysql_group_concat_function_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + return $this->contains_unsupported_mysql_group_concat_function( + $tokens, + 0, + null === $statement_end ? count( $tokens ) : $statement_end + ); + } + + /** + * Get PostgreSQL SQL for MySQL FROM_BASE64(). + * + * PostgreSQL DECODE(..., 'base64') raises for malformed input. MySQL and + * the SQLite UDF return NULL, so guard before decoding. + * + * @param string $argument_sql PostgreSQL argument SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_from_base64_sql( string $argument_sql ): string { + return sprintf( + 'CASE WHEN %1$s THEN NULL ELSE CONVERT_FROM(DECODE(CAST(%2$s AS text), \'base64\'), \'UTF8\') END', + $this->get_postgresql_mysql_base64_invalid_condition_sql( $argument_sql ), + $argument_sql + ); + } + + /** + * Get PostgreSQL SQL testing whether a MySQL FROM_BASE64() input is invalid. + * + * @param string $argument_sql PostgreSQL argument SQL. + * @return string PostgreSQL condition SQL. + */ + private function get_postgresql_mysql_base64_invalid_condition_sql( string $argument_sql ): string { + $argument_text_sql = sprintf( 'CAST(%s AS text)', $argument_sql ); + return sprintf( + "%1\$s IS NULL OR %1\$s !~ '^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'", + $argument_text_sql + ); + } + + /** + * Render PostgreSQL SQL for a supported common MySQL runtime function. + * + * @param string $function_name Lowercase MySQL function name. + * @param string[] $argument_sql Translated PostgreSQL arguments. + * @return string|null PostgreSQL SQL, or null when unsupported. + */ + private function get_postgresql_mysql_common_function_sql( string $function_name, array $argument_sql ): ?string { + $count = count( $argument_sql ); + + switch ( $function_name ) { + case 'char_length': + case 'character_length': + return 1 === $count ? sprintf( 'CHAR_LENGTH(CAST(%s AS text))', $argument_sql[0] ) : null; + + case 'length': + return 1 === $count ? $this->get_postgresql_mysql_text_byte_length_sql( $argument_sql[0] ) : null; + + case 'concat': + if ( 0 === $count ) { + return null; + } + + return '(' . implode( + ' || ', + array_map( + static function ( string $sql ): string { + return sprintf( 'CAST(%s AS text)', $sql ); + }, + $argument_sql + ) + ) . ')'; + + case 'concat_ws': + return $count >= 2 ? $this->get_postgresql_mysql_concat_ws_sql( $argument_sql ) : null; + + case 'connection_id': + return 0 === $count ? self::MYSQL_CONNECTION_ID : null; + + case 'current_user': + case 'session_user': + case 'system_user': + case 'user': + return 0 === $count ? $this->connection->quote( self::MYSQL_SESSION_USER ) : null; + + case 'curdate': + case 'utc_date': + return 0 === $count ? "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD')" : null; + + case 'utc_time': + $fsp = $this->get_mysql_temporal_function_fractional_seconds_precision( $argument_sql ); + return null === $fsp ? null : $this->get_postgresql_mysql_current_time_sql( $fsp ); + + case 'current_timestamp': + case 'localtime': + case 'localtimestamp': + case 'now': + case 'utc_timestamp': + $fsp = $this->get_mysql_temporal_function_fractional_seconds_precision( $argument_sql ); + return null === $fsp ? null : $this->get_postgresql_mysql_current_timestamp_sql( $fsp ); + + case 'version': + return 0 === $count ? $this->connection->quote( $this->get_mysql_version_string() ) : null; + + case 'database': + case 'schema': + return 0 === $count ? $this->connection->quote( $this->db_name ) : null; + + case 'md5': + return 1 === $count ? sprintf( 'MD5(CAST(%s AS text))', $argument_sql[0] ) : null; + + case 'left': + return 2 === $count ? sprintf( 'LEFT(CAST(%s AS text), CAST(%s AS integer))', $argument_sql[0], $argument_sql[1] ) : null; + + case 'lcase': + return 1 === $count ? sprintf( 'LOWER(CAST(%s AS text))', $argument_sql[0] ) : null; + + case 'ucase': + return 1 === $count ? sprintf( 'UPPER(CAST(%s AS text))', $argument_sql[0] ) : null; + + case 'isnull': + return 1 === $count ? sprintf( 'CASE WHEN %s IS NULL THEN 1 ELSE 0 END', $argument_sql[0] ) : null; + + case 'json_valid': + return 1 === $count ? $this->get_postgresql_mysql_json_valid_sql( $argument_sql[0] ) : null; + + case 'last_insert_id': + return 0 === $count ? $this->get_postgresql_mysql_last_insert_id_sql() : null; + + case 'row_count': + return 0 === $count ? $this->get_postgresql_mysql_row_count_sql() : null; + + case 'coalesce': + return $count > 0 ? sprintf( 'COALESCE(%s)', implode( ', ', $argument_sql ) ) : null; + + case 'ifnull': + return 2 === $count ? sprintf( 'COALESCE(%s, %s)', $argument_sql[0], $argument_sql[1] ) : null; + + case 'if': + return null; + + case 'nullif': + return 2 === $count ? sprintf( 'NULLIF(%s, %s)', $argument_sql[0], $argument_sql[1] ) : null; + + case 'least': + case 'greatest': + if ( $count < 2 ) { + return null; + } + + return sprintf( + 'CASE WHEN %1$s THEN NULL ELSE %2$s(%3$s) END', + implode( + ' OR ', + array_map( + static function ( string $sql ): string { + return sprintf( '%s IS NULL', $sql ); + }, + $argument_sql + ) + ), + strtoupper( $function_name ), + implode( ', ', $argument_sql ) + ); + + case 'log': + return $this->get_postgresql_mysql_log_sql( $argument_sql ); + + case 'hex': + return 1 === $count ? sprintf( "UPPER(ENCODE(CONVERT_TO(CAST(%s AS text), 'UTF8'), 'hex'))", $argument_sql[0] ) : null; + + case 'unhex': + return 1 === $count ? sprintf( "CONVERT_FROM(DECODE(CAST(%s AS text), 'hex'), 'UTF8')", $argument_sql[0] ) : null; + + case 'from_base64': + return 1 === $count ? $this->get_postgresql_mysql_from_base64_sql( $argument_sql[0] ) : null; + + case 'to_base64': + return 1 === $count ? sprintf( "ENCODE(CONVERT_TO(CAST(%s AS text), 'UTF8'), 'base64')", $argument_sql[0] ) : null; + + case 'inet_aton': + return 1 === $count ? $this->get_postgresql_mysql_inet_aton_sql( $argument_sql[0] ) : null; + + case 'inet_ntoa': + return 1 === $count ? $this->get_postgresql_mysql_inet_ntoa_sql( $argument_sql[0] ) : null; + + case 'datediff': + return 2 === $count ? $this->get_postgresql_mysql_datediff_sql( $argument_sql[0], $argument_sql[1] ) : null; + + case 'locate': + if ( 2 === $count ) { + return sprintf( 'STRPOS(CAST(%2$s AS text), CAST(%1$s AS text))', $argument_sql[0], $argument_sql[1] ); + } + if ( 3 === $count ) { + return $this->get_postgresql_mysql_locate_with_position_sql( $argument_sql[0], $argument_sql[1], $argument_sql[2] ); + } + return null; + + case 'date': + return 1 === $count ? $this->get_postgresql_mysql_date_sql( $argument_sql[0] ) : null; + + case 'replace': + return 3 === $count ? sprintf( 'REPLACE(CAST(%s AS text), CAST(%s AS text), CAST(%s AS text))', $argument_sql[0], $argument_sql[1], $argument_sql[2] ) : null; + + case 'regexp': + return 2 === $count ? $this->get_postgresql_mysql_regexp_function_sql( $argument_sql[0], $argument_sql[1] ) : null; + + case 'substring': + case 'substr': + return $this->get_postgresql_mysql_substring_sql( $argument_sql ); + + case 'from_unixtime': + if ( 1 === $count ) { + return $this->get_postgresql_mysql_from_unixtime_sql( $argument_sql[0] ); + } + if ( 2 === $count ) { + $timestamp_sql = $this->get_postgresql_mysql_from_unixtime_timestamp_sql( $argument_sql[0] ); + $format = $this->get_mysql_sql_string_literal_value( $argument_sql[1] ); + if ( null !== $format ) { + return $this->get_postgresql_mysql_date_format_string_sql( + $format, + $timestamp_sql + ); + } + + return $this->get_postgresql_mysql_dynamic_date_format_sql( + $argument_sql[1], + $timestamp_sql + ); + } + return null; + + case 'monthnum': + return 1 === $count ? $this->get_postgresql_zero_date_safe_extract_sql( 'MONTH', $argument_sql[0] ) : null; + + case 'unix_timestamp': + if ( 0 === $count ) { + return 'CAST(FLOOR(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)) AS bigint)'; + } + if ( 1 === $count ) { + return sprintf( 'CAST(FLOOR(EXTRACT(EPOCH FROM %s)) AS bigint)', $this->get_postgresql_zero_date_safe_timestamp_sql( $argument_sql[0] ) ); + } + return null; + + case 'get_lock': + return 2 === $count ? '1' : null; + + case 'release_lock': + return 1 === $count ? '1' : null; + + case 'uuid': + return null; + } + + return null; + } + + /** + * Get PostgreSQL SQL for MySQL JSON_VALID(). + * + * PostgreSQL needs a small PL/pgSQL helper so invalid JSON returns 0 + * instead of raising a cast error. + * + * @param string $argument_sql PostgreSQL argument SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_json_valid_sql( string $argument_sql ): string { + return sprintf( + '%s(CAST(%s AS text))', + $this->get_postgresql_mysql_json_valid_function_name(), + $argument_sql + ); + } + + /** + * Get PostgreSQL SQL for one-argument MySQL FROM_UNIXTIME(). + * + * @param string $unix_timestamp_sql PostgreSQL Unix timestamp expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_from_unixtime_sql( string $unix_timestamp_sql ): string { + $unix_double_sql = sprintf( 'CAST(%s AS double precision)', $unix_timestamp_sql ); + $timestamp_sql = $this->get_postgresql_mysql_from_unixtime_timestamp_sql( $unix_timestamp_sql ); + + return sprintf( + "CASE WHEN %1\$s IS NULL THEN NULL WHEN %1\$s = FLOOR(%1\$s) THEN TO_CHAR(%2\$s, 'YYYY-MM-DD HH24:MI:SS') ELSE TO_CHAR(%2\$s, 'YYYY-MM-DD HH24:MI:SS.US') END", + $unix_double_sql, + $timestamp_sql + ); + } + + /** + * Get PostgreSQL timestamp SQL for MySQL FROM_UNIXTIME() in the session time zone. + * + * @param string $unix_timestamp_sql PostgreSQL Unix timestamp expression SQL. + * @return string PostgreSQL timestamp SQL. + */ + private function get_postgresql_mysql_from_unixtime_timestamp_sql( string $unix_timestamp_sql ): string { + $timestamp_sql = sprintf( "TO_TIMESTAMP(CAST(%s AS double precision)) AT TIME ZONE 'UTC'", $unix_timestamp_sql ); + $time_zone = $this->get_mysql_system_variable_value( 'time_zone' ); + $offset = $this->get_mysql_time_zone_offset_minutes( null === $time_zone ? 'SYSTEM' : $time_zone ); + + if ( null === $offset || 0 === $offset ) { + return $timestamp_sql; + } + + return sprintf( + '(%s + INTERVAL %s)', + $timestamp_sql, + $this->connection->quote( $offset . ' minutes' ) + ); + } + + /** + * Get a numeric minute offset from a MySQL time_zone value. + * + * @param string $time_zone MySQL time_zone value. + * @return int|null Offset minutes, or null for UTC/SYSTEM/unsupported named zones. + */ + private function get_mysql_time_zone_offset_minutes( string $time_zone ): ?int { + $time_zone = trim( $time_zone, "'\"` \t\n\r\0\x0B" ); + if ( '' === $time_zone || 0 === strcasecmp( $time_zone, 'SYSTEM' ) || 0 === strcasecmp( $time_zone, 'UTC' ) ) { + return 0; + } + + if ( 1 !== preg_match( '/\A([+-])([0-9]{2}):([0-9]{2})\z/', $time_zone, $matches ) ) { + return null; + } + + $offset = ( (int) $matches[2] * 60 ) + (int) $matches[3]; + return '-' === $matches[1] ? -$offset : $offset; + } + + /** + * Get the backend helper function name used for MySQL JSON_VALID(). + * + * @return string Function name SQL. + */ + private function get_postgresql_mysql_json_valid_function_name(): string { + return 'pgsql' === $this->connection->get_driver_name() + ? 'pg_temp.' . self::MYSQL_JSON_VALID_FUNCTION + : self::MYSQL_JSON_VALID_FUNCTION; + } + + /** + * Get the backend helper function name used for strict temporal validation. + * + * @return string Function name SQL. + */ + private function get_postgresql_mysql_validate_temporal_function_name(): string { + return 'pgsql' === $this->connection->get_driver_name() + ? 'pg_temp.' . self::MYSQL_VALIDATE_TEMPORAL_FUNCTION + : self::MYSQL_VALIDATE_TEMPORAL_FUNCTION; + } + + /** + * Ensure runtime helper functions referenced by a translated query exist. + * + * @param string $query PostgreSQL query. + */ + private function ensure_postgresql_runtime_helpers_for_query( string $query ): void { + if ( 1 === preg_match( '/(?:pg_temp\.)?' . preg_quote( self::MYSQL_JSON_VALID_FUNCTION, '/' ) . '\s*\(/i', $query ) ) { + $this->ensure_postgresql_mysql_json_valid_function(); + } + if ( 1 === preg_match( '/(?:pg_temp\.)?' . preg_quote( self::MYSQL_VALIDATE_TEMPORAL_FUNCTION, '/' ) . '\s*\(/i', $query ) ) { + $this->ensure_postgresql_mysql_validate_temporal_function(); + } + } + + /** + * Ensure the MySQL JSON_VALID() helper exists for the current backing driver. + */ + private function ensure_postgresql_mysql_json_valid_function(): void { + if ( $this->postgresql_mysql_json_valid_function_ensured ) { + return; + } + + $driver_name = $this->connection->get_driver_name(); + if ( 'pgsql' === $driver_name ) { + $this->connection->query( + 'CREATE OR REPLACE FUNCTION pg_temp.' . self::MYSQL_JSON_VALID_FUNCTION . '(value text) +RETURNS integer +LANGUAGE plpgsql +IMMUTABLE +STRICT +AS $wp_mysql_json_valid$ +BEGIN + PERFORM $1::json; + RETURN 1; +EXCEPTION WHEN others THEN + RETURN 0; +END; +$wp_mysql_json_valid$' + ); + } elseif ( 'sqlite' === $driver_name ) { + $this->register_sqlite_mysql_json_valid_function(); + } + + $this->postgresql_mysql_json_valid_function_ensured = true; + } + + /** + * Register a SQLite test-harness shim for the MySQL JSON_VALID() helper. + */ + private function register_sqlite_mysql_json_valid_function(): void { + $pdo = $this->connection->get_pdo(); + $callback = static function ( $value ): ?int { + return self::get_mysql_json_valid_runtime_result( $value ); + }; + + if ( method_exists( $pdo, 'createFunction' ) ) { + $pdo->createFunction( self::MYSQL_JSON_VALID_FUNCTION, $callback, 1 ); + return; + } + + if ( method_exists( $pdo, 'sqliteCreateFunction' ) ) { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Base PDO SQLite exposes only the deprecated fallback on PHP 8.5. + @$pdo->sqliteCreateFunction( self::MYSQL_JSON_VALID_FUNCTION, $callback, 1 ); + return; + } + + throw new RuntimeException( 'SQLite JSON_VALID() helper registration is unavailable.' ); + } + + /** + * Get the MySQL-compatible JSON_VALID() result for a runtime value. + * + * @param mixed $value Runtime value. + * @return int|null MySQL-compatible JSON_VALID() result. + */ + private static function get_mysql_json_valid_runtime_result( $value ): ?int { + if ( null === $value ) { + return null; + } + + json_decode( (string) $value ); + return JSON_ERROR_NONE === json_last_error() ? 1 : 0; + } + + /** + * Ensure the strict temporal validation helper exists for the current backing driver. + */ + private function ensure_postgresql_mysql_validate_temporal_function(): void { + if ( $this->postgresql_mysql_validate_temporal_function_ensured ) { + return; + } + + $driver_name = $this->connection->get_driver_name(); + if ( 'pgsql' === $driver_name ) { + $this->connection->query( + 'CREATE OR REPLACE FUNCTION pg_temp.' . self::MYSQL_VALIDATE_TEMPORAL_FUNCTION . '(value text, mysql_type text, reject_zero_date integer, reject_zero_in_date integer) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +STRICT +AS $wp_mysql_validate_temporal$ +DECLARE + date_part text; + normalized_value text; + year_text text; + month_text text; + day_text text; + hour_text text; + minute_text text; + second_text text; + year_value integer; + month_value integer; + day_value integer; + hour_value integer; + minute_value integer; + second_value integer; +BEGIN + IF mysql_type = \'date\' THEN + IF value !~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}(?:[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?Z?)?$\' THEN + RAISE EXCEPTION \'Incorrect % value: %\', mysql_type, quote_literal(value) USING ERRCODE = \'22007\'; + END IF; + date_part := substring(value from 1 for 10); + normalized_value := date_part; + ELSE + IF value ~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}$\' THEN + date_part := value; + normalized_value := value || \' 00:00:00\'; + hour_text := \'00\'; + minute_text := \'00\'; + second_text := \'00\'; + ELSIF value ~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?Z?$\' THEN + date_part := substring(value from 1 for 10); + normalized_value := date_part || \' \' || substring(value from 12 for 8); + hour_text := substring(value from 12 for 2); + minute_text := substring(value from 15 for 2); + second_text := substring(value from 18 for 2); + ELSE + RAISE EXCEPTION \'Incorrect % value: %\', mysql_type, quote_literal(value) USING ERRCODE = \'22007\'; + END IF; + + hour_value := hour_text::integer; + minute_value := minute_text::integer; + second_value := second_text::integer; + IF hour_value < 0 OR hour_value > 23 OR minute_value < 0 OR minute_value > 59 OR second_value < 0 OR second_value > 59 THEN + RAISE EXCEPTION \'Incorrect % value: %\', mysql_type, quote_literal(value) USING ERRCODE = \'22007\'; + END IF; + END IF; + + year_text := substring(date_part from 1 for 4); + month_text := substring(date_part from 6 for 2); + day_text := substring(date_part from 9 for 2); + + IF year_text = \'0000\' AND month_text = \'00\' AND day_text = \'00\' THEN + IF reject_zero_date <> 0 THEN + RAISE EXCEPTION \'Incorrect % value: %\', mysql_type, quote_literal(value) USING ERRCODE = \'22007\'; + END IF; + RETURN normalized_value; + END IF; + + IF year_text <> \'0000\' AND ( month_text = \'00\' OR day_text = \'00\' ) THEN + IF reject_zero_in_date <> 0 THEN + RAISE EXCEPTION \'Incorrect % value: %\', mysql_type, quote_literal(value) USING ERRCODE = \'22007\'; + END IF; + RETURN normalized_value; + END IF; + + year_value := year_text::integer; + month_value := month_text::integer; + day_value := day_text::integer; + BEGIN + PERFORM make_date(year_value, month_value, day_value); + EXCEPTION WHEN others THEN + RAISE EXCEPTION \'Incorrect % value: %\', mysql_type, quote_literal(value) USING ERRCODE = \'22007\'; + END; + + RETURN normalized_value; +END; +$wp_mysql_validate_temporal$' + ); + } elseif ( 'sqlite' === $driver_name ) { + $this->register_sqlite_mysql_validate_temporal_function(); + } + + $this->postgresql_mysql_validate_temporal_function_ensured = true; + } + + /** + * Register a SQLite test-harness shim for strict temporal validation. + */ + private function register_sqlite_mysql_validate_temporal_function(): void { + $pdo = $this->connection->get_pdo(); + $callback = static function ( $value, $mysql_type, $reject_zero_date, $reject_zero_in_date ): ?string { + return self::get_mysql_validate_temporal_runtime_result( + $value, + (string) $mysql_type, + 0 !== (int) $reject_zero_date, + 0 !== (int) $reject_zero_in_date + ); + }; + + if ( method_exists( $pdo, 'createFunction' ) ) { + $pdo->createFunction( self::MYSQL_VALIDATE_TEMPORAL_FUNCTION, $callback, 4 ); + return; + } + + if ( method_exists( $pdo, 'sqliteCreateFunction' ) ) { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Base PDO SQLite exposes only the deprecated fallback on PHP 8.5. + @$pdo->sqliteCreateFunction( self::MYSQL_VALIDATE_TEMPORAL_FUNCTION, $callback, 4 ); + return; + } + + throw new RuntimeException( 'SQLite temporal validation helper registration is unavailable.' ); + } + + /** + * Validate a runtime temporal value using strict MySQL rules. + * + * @param mixed $value Runtime value. + * @param string $mysql_type MySQL temporal type. + * @param bool $reject_zero_date Whether NO_ZERO_DATE rejects full zero dates. + * @param bool $reject_zero_in_date Whether NO_ZERO_IN_DATE rejects partial-zero dates. + * @return string|null Normalized runtime value. + */ + private static function get_mysql_validate_temporal_runtime_result( $value, string $mysql_type, bool $reject_zero_date, bool $reject_zero_in_date ): ?string { + if ( null === $value ) { + return null; + } + + $value = (string) $value; + $mysql_type = strtolower( $mysql_type ); + if ( 'date' === $mysql_type ) { + if ( 1 !== preg_match( '/^([0-9]{4}-[0-9]{2}-[0-9]{2})(?:[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?Z?)?$/', $value, $matches ) ) { + throw new InvalidArgumentException( sprintf( "Incorrect %s value: '%s'", $mysql_type, $value ) ); + } + $date_part = $matches[1]; + $normalized_value = $date_part; + } else { + if ( 1 === preg_match( '/^([0-9]{4}-[0-9]{2}-[0-9]{2})$/', $value, $matches ) ) { + $date_part = $matches[1]; + $normalized_value = $date_part . ' 00:00:00'; + $hour = '00'; + $minute = '00'; + $second = '00'; + } elseif ( 1 === preg_match( '/^([0-9]{4}-[0-9]{2}-[0-9]{2})[ T]([0-9]{2}):([0-9]{2}):([0-9]{2})(?:[.][0-9]+)?Z?$/', $value, $matches ) ) { + $date_part = $matches[1]; + $normalized_value = $date_part . ' ' . $matches[2] . ':' . $matches[3] . ':' . $matches[4]; + $hour = $matches[2]; + $minute = $matches[3]; + $second = $matches[4]; + } else { + throw new InvalidArgumentException( sprintf( "Incorrect %s value: '%s'", $mysql_type, $value ) ); + } + + if ( (int) $hour > 23 || (int) $minute > 59 || (int) $second > 59 ) { + throw new InvalidArgumentException( sprintf( "Incorrect %s value: '%s'", $mysql_type, $value ) ); + } + } + + $year = substr( $date_part, 0, 4 ); + $month = substr( $date_part, 5, 2 ); + $day = substr( $date_part, 8, 2 ); + + if ( '0000' === $year && '00' === $month && '00' === $day ) { + if ( $reject_zero_date ) { + throw new InvalidArgumentException( sprintf( "Incorrect %s value: '%s'", $mysql_type, $value ) ); + } + return $normalized_value; + } + + if ( '0000' !== $year && ( '00' === $month || '00' === $day ) ) { + if ( $reject_zero_in_date ) { + throw new InvalidArgumentException( sprintf( "Incorrect %s value: '%s'", $mysql_type, $value ) ); + } + return $normalized_value; + } + + if ( ! checkdate( (int) $month, (int) $day, (int) $year ) ) { + throw new InvalidArgumentException( sprintf( "Incorrect %s value: '%s'", $mysql_type, $value ) ); + } + + return $normalized_value; + } + + /** + * Get a bounded MySQL fractional seconds precision from translated function arguments. + * + * @param string[] $argument_sql Translated PostgreSQL arguments. + * @return int|null Precision, or null when unsupported. + */ + private function get_mysql_temporal_function_fractional_seconds_precision( array $argument_sql ): ?int { + if ( 0 === count( $argument_sql ) ) { + return 0; + } + + if ( 1 !== count( $argument_sql ) ) { + return null; + } + + return $this->get_mysql_fractional_seconds_precision_sql_value( $argument_sql[0] ); + } + + /** + * Get a bounded MySQL fractional seconds precision from a SQL literal. + * + * @param string $sql SQL expression. + * @return int|null Precision, or null when unsupported. + */ + private function get_mysql_fractional_seconds_precision_sql_value( string $sql ): ?int { + $sql = trim( $sql ); + return 1 === preg_match( '/^[0-6]$/', $sql ) ? (int) $sql : null; + } + + /** + * Get a bounded MySQL fractional seconds precision from a token. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return int|null Precision, or null when unsupported. + */ + private function get_mysql_fractional_seconds_precision_token_value( ?WP_MySQL_Token $token ): ?int { + if ( null === $token || WP_MySQL_Lexer::INT_NUMBER !== $token->id ) { + return null; + } + + return $this->get_mysql_fractional_seconds_precision_sql_value( $token->get_value() ); + } + + /** + * Format the emulated MySQL current time value with optional fractional seconds. + * + * @param int $fsp Fractional seconds precision, 0 through 6. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_current_time_sql( int $fsp ): string { + if ( 0 === $fsp ) { + return "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:MI:SS')"; + } + + return sprintf( + "LEFT(TO_CHAR(CURRENT_TIMESTAMP(%1\$d) AT TIME ZONE 'UTC', 'HH24:MI:SS.US'), %2\$d)", + $fsp, + 9 + $fsp + ); + } + + /** + * Format the emulated MySQL current timestamp value with optional fractional seconds. + * + * @param int $fsp Fractional seconds precision, 0 through 6. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_current_timestamp_sql( int $fsp ): string { + if ( 0 === $fsp ) { + return "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')"; + } + + return sprintf( + "LEFT(TO_CHAR(CURRENT_TIMESTAMP(%1\$d) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US'), %2\$d)", + $fsp, + 20 + $fsp + ); + } + + /** + * Get PostgreSQL SQL for MySQL LAST_INSERT_ID(). + * + * @return string PostgreSQL SQL literal. + */ + private function get_postgresql_mysql_last_insert_id_sql(): string { + $last_insert_id = null !== $this->mysql_last_insert_id_assignment_value + ? $this->mysql_last_insert_id_assignment_value + : $this->get_insert_id(); + return is_numeric( $last_insert_id ) ? (string) (int) $last_insert_id : '0'; + } + + /** + * Get PostgreSQL SQL for MySQL ROW_COUNT(). + * + * @return string PostgreSQL SQL literal. + */ + private function get_postgresql_mysql_row_count_sql(): string { + return (string) $this->last_row_count; + } + + /** + * Get the MySQL ROW_COUNT() value from the previous driver result. + * + * @return int MySQL-compatible ROW_COUNT() value. + */ + private function get_mysql_row_count_from_last_result(): int { + if ( is_array( $this->last_result ) ) { + return -1; + } + + return is_numeric( $this->last_result ) ? (int) $this->last_result : 0; + } + + /** + * Get PostgreSQL SQL for MySQL DATE(expr), preserving zero-date text values. + * + * @param string $expression_sql Translated expression SQL. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_date_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + "CASE WHEN %1\$s THEN NULL WHEN %2\$s THEN SUBSTRING(%3\$s FROM 1 FOR 10) ELSE TO_CHAR(%4\$s, 'YYYY-MM-DD') END", + $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ), + $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql, + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get PostgreSQL SQL for MySQL DATEDIFF(expr1, expr2), guarding zero-date casts. + * + * @param string $start_sql Translated start expression SQL. + * @param string $end_sql Translated end expression SQL. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_datediff_sql( string $start_sql, string $end_sql ): string { + return sprintf( + 'CAST((CAST(%1$s AS date) - CAST(%2$s AS date)) AS integer)', + $this->get_postgresql_zero_date_safe_timestamp_sql( $start_sql ), + $this->get_postgresql_zero_date_safe_timestamp_sql( $end_sql ) + ); + } + + /** + * Get PostgreSQL SQL for SQLite UDF-style MySQL REGEXP(pattern, value). + * + * @param string $pattern_sql Regular expression pattern SQL. + * @param string $value_sql Value SQL. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_regexp_function_sql( string $pattern_sql, string $value_sql ): string { + $pattern = sprintf( 'CAST(%s AS text)', $pattern_sql ); + $value = sprintf( 'CAST(%s AS text)', $value_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s IS NULL THEN NULL WHEN %2$s ~* %1$s THEN 1 ELSE 0 END', + $pattern, + $value + ); + } + + /** + * Get argument bounds for supported MySQL SUBSTRING/SUBSTR/MID forms. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First argument token. + * @param int $end Final argument token, exclusive. + * @return array|null Argument bounds, or null when unsupported. + */ + private function get_mysql_substring_function_arguments( array $tokens, int $start, int $end ): ?array { + $arguments = $this->split_top_level_mysql_arguments( $tokens, $start, $end ); + if ( null !== $arguments && ( 2 === count( $arguments ) || 3 === count( $arguments ) ) ) { + return $arguments; + } + + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $start, $end ); + if ( null === $from_position || $from_position <= $start || $from_position + 1 >= $end ) { + return null; + } + + $for_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FOR_SYMBOL, $from_position + 1, $end ); + if ( null === $for_position ) { + return array( + array( + 'start' => $start, + 'end' => $from_position, + ), + array( + 'start' => $from_position + 1, + 'end' => $end, + ), + ); + } + + if ( $for_position <= $from_position + 1 || $for_position + 1 >= $end ) { + return null; + } + + return array( + array( + 'start' => $start, + 'end' => $from_position, + ), + array( + 'start' => $from_position + 1, + 'end' => $for_position, + ), + array( + 'start' => $for_position + 1, + 'end' => $end, + ), + ); + } + + /** + * Get PostgreSQL SQL for MySQL byte-oriented LENGTH(text). + * + * PostgreSQL-safe text envelopes carry the original MySQL byte length in + * their prefix. Use that length before falling back to UTF-8 byte counting. + * + * @param string $argument_sql Translated argument SQL. + * @return string PostgreSQL byte-length SQL. + */ + private function get_postgresql_mysql_text_byte_length_sql( string $argument_sql ): string { + $text_sql = sprintf( 'CAST(%s AS text)', $argument_sql ); + $prefix_chars = preg_match_all( '/./us', self::MYSQL_TEXT_ENCODING_PREFIX ); + $prefix_length = false === $prefix_chars ? strlen( self::MYSQL_TEXT_ENCODING_PREFIX ) : $prefix_chars; + $prefix_sql = $this->connection->get_pdo()->quote( self::MYSQL_TEXT_ENCODING_PREFIX ); + $payload_sql = sprintf( 'SUBSTR(%s, %d)', $text_sql, $prefix_length + 1 ); + $separator_sql = sprintf( "STRPOS(%s, ':')", $payload_sql ); + $length_sql = sprintf( 'SUBSTR(%s, 1, %s - 1)', $payload_sql, $separator_sql ); + + return sprintf( + "CASE WHEN SUBSTR(%1\$s, 1, %2\$d) = %3\$s AND %4\$s > 1 AND TRANSLATE(%5\$s, '0123456789', '') = '' AND (%5\$s = '0' OR SUBSTR(%5\$s, 1, 1) <> '0') THEN CAST(%5\$s AS bigint) ELSE OCTET_LENGTH(CONVERT_TO(%1\$s, 'UTF8')) END", + $text_sql, + $prefix_length, + $prefix_sql, + $separator_sql, + $length_sql + ); + } + + /** + * Get PostgreSQL SQL for MySQL CONCAT_WS(separator, value, ...). + * + * MySQL skips NULL values after the separator, keeps empty strings, and + * returns NULL only when the separator itself is NULL. + * + * @param string[] $argument_sql Translated PostgreSQL arguments. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_concat_ws_sql( array $argument_sql ): string { + $separator_sql = $argument_sql[0]; + $value_sql = array_slice( $argument_sql, 1 ); + $fragments = array(); + $seen_value_sql = array(); + + foreach ( $value_sql as $index => $sql ) { + if ( $index > 0 ) { + $fragments[] = sprintf( + 'CASE WHEN (%1$s) AND %2$s IS NOT NULL THEN CAST(%3$s AS text) ELSE \'\' END', + implode( ' OR ', $seen_value_sql ), + $sql, + $separator_sql + ); + } + + $fragments[] = sprintf( 'COALESCE(CAST(%s AS text), \'\')', $sql ); + $seen_value_sql[] = sprintf( '%s IS NOT NULL', $sql ); + } + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE (%2$s) END', + $separator_sql, + implode( ' || ', $fragments ) + ); + } + + /** + * Get PostgreSQL SQL for MySQL LOG() forms. + * + * @param string[] $argument_sql Translated PostgreSQL arguments. + * @return string|null PostgreSQL SQL, or null when unsupported. + */ + private function get_postgresql_mysql_log_sql( array $argument_sql ): ?string { + if ( 1 === count( $argument_sql ) ) { + $value = sprintf( 'CAST(%s AS double precision)', $argument_sql[0] ); + + return sprintf( + 'CASE WHEN %1$s IS NULL OR %1$s <= 0 THEN NULL ELSE LN(%1$s) END', + $value + ); + } + + if ( 2 === count( $argument_sql ) ) { + $base = sprintf( 'CAST(%s AS double precision)', $argument_sql[0] ); + $value = sprintf( 'CAST(%s AS double precision)', $argument_sql[1] ); + + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s IS NULL OR %1$s <= 1 OR %2$s <= 0 THEN NULL ELSE LN(%2$s) / LN(%1$s) END', + $base, + $value + ); + } + + return null; + } + + /** + * Get PostgreSQL SQL for MySQL SUBSTRING/SUBSTR/MID() forms. + * + * @param string[] $argument_sql Translated PostgreSQL arguments. + * @return string|null PostgreSQL SQL, or null when unsupported. + */ + private function get_postgresql_mysql_substring_sql( array $argument_sql ): ?string { + if ( 2 !== count( $argument_sql ) && 3 !== count( $argument_sql ) ) { + return null; + } + + $value = sprintf( 'CAST(%s AS text)', $argument_sql[0] ); + $position = sprintf( 'CAST(%s AS integer)', $argument_sql[1] ); + $start = sprintf( + 'CASE WHEN %1$s > 0 THEN %1$s WHEN %1$s < 0 THEN CHAR_LENGTH(%2$s) + %1$s + 1 ELSE 0 END', + $position, + $value + ); + + if ( 2 === count( $argument_sql ) ) { + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s IS NULL THEN NULL WHEN %2$s = 0 OR (%3$s < 1) THEN \'\' ELSE SUBSTRING(%1$s FROM %3$s) END', + $value, + $position, + $start + ); + } + + $length = sprintf( 'CAST(%s AS integer)', $argument_sql[2] ); + + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s IS NULL OR %3$s IS NULL THEN NULL WHEN %2$s = 0 OR %3$s < 1 OR (%4$s < 1) THEN \'\' ELSE SUBSTRING(%1$s FROM %4$s FOR %3$s) END', + $value, + $position, + $length, + $start + ); + } + + /** + * Decode a simple SQL string literal rendered from a MySQL string token. + * + * @param string $sql SQL fragment. + * @return string|null Literal value, or null when the fragment is not a string literal. + */ + private function get_mysql_sql_string_literal_value( string $sql ): ?string { + if ( strlen( $sql ) < 2 || "'" !== $sql[0] || "'" !== substr( $sql, -1 ) ) { + return null; + } + + return str_replace( "''", "'", substr( $sql, 1, -1 ) ); + } + + /** + * Get PostgreSQL SQL for MySQL truthiness in IF(condition, truthy, falsy). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL boolean SQL. + */ + private function get_postgresql_mysql_truthy_expression_sql( string $expression_sql ): string { + return sprintf( 'COALESCE(%s <> 0, false)', $this->get_postgresql_mysql_numeric_cast_sql( $expression_sql ) ); + } + + /** + * Check whether a MySQL expression range is visibly a boolean predicate. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First expression token. + * @param int $end Final expression token, exclusive. + * @return bool Whether the range contains predicate syntax. + */ + private function is_mysql_boolean_condition_expression( array $tokens, int $start, int $end ): bool { + $depth = 0; + for ( $i = $start; $i < $end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + continue; + } + + if ( 0 !== $depth ) { + continue; + } + + if ( + in_array( + $tokens[ $i ]->id, + array( + WP_MySQL_Lexer::BETWEEN_SYMBOL, + WP_MySQL_Lexer::EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::GREATER_THAN_OPERATOR, + WP_MySQL_Lexer::IN_SYMBOL, + WP_MySQL_Lexer::IS_SYMBOL, + WP_MySQL_Lexer::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::LIKE_SYMBOL, + WP_MySQL_Lexer::LOGICAL_AND_OPERATOR, + WP_MySQL_Lexer::LOGICAL_OR_OPERATOR, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + WP_MySQL_Lexer::NULL_SAFE_EQUAL_OPERATOR, + WP_MySQL_Lexer::REGEXP_SYMBOL, + ), + true + ) + ) { + return true; + } + } + + return false; + } + + /** + * Get PostgreSQL SQL for MySQL INET_ATON(expr). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_inet_aton_sql( string $expression_sql ): string { + $ip = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE ((CAST(SPLIT_PART(%1$s, \'.\', 1) AS bigint) << 24) + (CAST(SPLIT_PART(%1$s, \'.\', 2) AS bigint) << 16) + (CAST(SPLIT_PART(%1$s, \'.\', 3) AS bigint) << 8) + CAST(SPLIT_PART(%1$s, \'.\', 4) AS bigint)) END', + $ip + ); + } + + /** + * Get PostgreSQL SQL for MySQL INET_NTOA(expr). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_inet_ntoa_sql( string $expression_sql ): string { + $number = sprintf( 'CAST(%s AS bigint)', $expression_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE (((%1$s >> 24) & 255)::text || \'.\' || ((%1$s >> 16) & 255)::text || \'.\' || ((%1$s >> 8) & 255)::text || \'.\' || (%1$s & 255)::text) END', + $number + ); + } + + /** + * Get PostgreSQL SQL for MySQL LOCATE(substr, str, pos). + * + * @param string $needle_sql Needle SQL. + * @param string $haystack_sql Haystack SQL. + * @param string $position_sql One-based start position SQL. + * @return string PostgreSQL SQL. + */ + private function get_postgresql_mysql_locate_with_position_sql( string $needle_sql, string $haystack_sql, string $position_sql ): string { + $needle = sprintf( 'CAST(%s AS text)', $needle_sql ); + $haystack = sprintf( 'CAST(%s AS text)', $haystack_sql ); + $position = sprintf( 'CAST(%s AS integer)', $position_sql ); + $offset = sprintf( 'STRPOS(SUBSTRING(%s FROM %s), %s)', $haystack, $position, $needle ); + + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s IS NULL OR %3$s IS NULL THEN NULL WHEN %3$s < 1 THEN 0 WHEN %4$s = 0 THEN 0 ELSE %4$s + %3$s - 1 END', + $needle, + $haystack, + $position, + $offset + ); + } + + /** + * Get token bounds for a MySQL identifier function call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @param string $function_name Lowercase function name to match. + * @return array{arguments_start: int, arguments_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_function_call_bounds( array $tokens, int $position, int $end, string $function_name ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::IDENTIFIER !== $tokens[ $position ]->id + || strtolower( $tokens[ $position ]->get_value() ) !== $function_name + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + return array( + 'arguments_start' => $position + 2, + 'arguments_end' => $after_close - 1, + 'close' => $after_close - 1, + ); + } + + /** + * Split a bounded token range into top-level comma-separated arguments. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First argument token position. + * @param int $end Final argument token position, exclusive. + * @return array|null Argument bounds, or null when malformed. + */ + private function split_top_level_mysql_arguments( array $tokens, int $start, int $end ): ?array { + if ( $start === $end ) { + return array(); + } + + $arguments = array(); + $argument_start = $start; + $depth = 0; + + for ( $i = $start; $i < $end; $i++ ) { + if ( WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[ $i ]->id ) { + ++$depth; + continue; + } + + if ( WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[ $i ]->id ) { + --$depth; + if ( $depth < 0 ) { + return null; + } + continue; + } + + if ( 0 === $depth && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $i ]->id ) { + if ( $argument_start === $i ) { + return null; + } + + $arguments[] = array( + 'start' => $argument_start, + 'end' => $i, + ); + $argument_start = $i + 1; + } + } + + if ( 0 !== $depth || $argument_start === $end ) { + return null; + } + + $arguments[] = array( + 'start' => $argument_start, + 'end' => $end, + ); + + return $arguments; + } + + /** + * Translate MySQL DATE_ADD(expr, INTERVAL value unit) and date arithmetic aliases. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_date_arithmetic_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_date_arithmetic_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + $value_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['interval_value_start'], + $bounds['interval_value_end'] + ); + $interval_sql = $bounds['interval_sql'] ?? $this->get_postgresql_mysql_interval_sql( $value_sql, $bounds['interval_unit'] ); + + return array( + 'sql' => sprintf( + '(%1$s %2$s %3$s)', + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $bounds['operator'], + $interval_sql + ), + 'token_id' => $tokens[ $position ]->id, + 'position' => $bounds['close'], + ); + } + + /** + * Check whether a range contains an unsupported MySQL date arithmetic call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an unsupported date arithmetic call is present. + */ + private function contains_unsupported_mysql_date_arithmetic_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( + ! isset( $tokens[ $i ], $tokens[ $i + 1 ] ) + || ! in_array( + $tokens[ $i ]->id, + array( + WP_MySQL_Lexer::ADDDATE_SYMBOL, + WP_MySQL_Lexer::DATE_ADD_SYMBOL, + WP_MySQL_Lexer::DATE_SUB_SYMBOL, + WP_MySQL_Lexer::SUBDATE_SYMBOL, + ), + true + ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $i + 1, $end ); + if ( null === $after_close || null === $this->get_mysql_date_arithmetic_function_bounds( $tokens, $i, $end ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a query contains an unsupported MySQL date arithmetic call. + * + * @param string $query SQL query. + * @return bool Whether an unsupported date arithmetic call is present. + */ + private function contains_unsupported_mysql_date_arithmetic_function_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + $end = null === $statement_end ? count( $tokens ) : $statement_end; + + return $this->contains_unsupported_mysql_date_arithmetic_function( $tokens, 0, $end ) + || $this->contains_unsupported_mysql_timestampadd_function( $tokens, 0, $end ) + || $this->contains_unsupported_mysql_timestampdiff_function( $tokens, 0, $end ); + } + + /** + * Get token bounds for a supported MySQL date arithmetic expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{operator: string, expression_start: int, expression_end: int, interval_value_start: int, interval_value_end: int, interval_unit: string, interval_sql?: string, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_date_arithmetic_function_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || ! in_array( + $tokens[ $position ]->id, + array( + WP_MySQL_Lexer::ADDDATE_SYMBOL, + WP_MySQL_Lexer::DATE_ADD_SYMBOL, + WP_MySQL_Lexer::DATE_SUB_SYMBOL, + WP_MySQL_Lexer::SUBDATE_SYMBOL, + ), + true + ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $position + 2, $after_close - 1 ); + if ( null === $arguments || 2 !== count( $arguments ) ) { + return null; + } + + $interval = $this->get_mysql_interval_argument_bounds( $tokens, $arguments[1]['start'], $arguments[1]['end'] ); + if ( + null === $interval + && in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::ADDDATE_SYMBOL, WP_MySQL_Lexer::SUBDATE_SYMBOL ), true ) + ) { + $interval = array( + 'value_start' => $arguments[1]['start'], + 'value_end' => $arguments[1]['end'], + 'unit' => 'day', + ); + } + if ( null === $interval ) { + return null; + } + + $bounds = array( + 'operator' => in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::DATE_SUB_SYMBOL, WP_MySQL_Lexer::SUBDATE_SYMBOL ), true ) ? '-' : '+', + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'interval_value_start' => $interval['value_start'], + 'interval_value_end' => $interval['value_end'], + 'interval_unit' => $interval['unit'], + 'close' => $after_close - 1, + ); + + if ( isset( $interval['sql'] ) ) { + $bounds['interval_sql'] = $interval['sql']; + } + + return $bounds; + } + + /** + * Get token bounds for a supported MySQL INTERVAL value unit argument. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First interval token position. + * @param int $end Final interval token position, exclusive. + * @return array{value_start: int, value_end: int, unit: string, sql?: string}|null Bounds, or null when unsupported. + */ + private function get_mysql_interval_argument_bounds( array $tokens, int $start, int $end ): ?array { + if ( + $start + 3 > $end + || ! isset( $tokens[ $start ], $tokens[ $end - 1 ] ) + || WP_MySQL_Lexer::INTERVAL_SYMBOL !== $tokens[ $start ]->id + ) { + return null; + } + + $unit_token = $tokens[ $end - 1 ]; + $unit = $this->get_postgresql_simple_interval_unit( $unit_token ); + if ( null !== $unit ) { + return array( + 'value_start' => $start + 1, + 'value_end' => $end - 1, + 'unit' => $unit, + ); + } + + $part_units = $this->get_mysql_composite_interval_part_units( $unit_token ); + if ( null === $part_units ) { + return null; + } + + $sql = $this->get_postgresql_mysql_composite_interval_literal_sql( $tokens, $start + 1, $end - 1, $part_units ); + if ( null === $sql ) { + return null; + } + + return array( + 'value_start' => $start + 1, + 'value_end' => $end - 1, + 'unit' => 'composite', + 'sql' => $sql, + ); + } + + /** + * Get a PostgreSQL interval unit for supported simple MySQL interval units. + * + * @param WP_MySQL_Token $token MySQL interval unit token. + * @return string|null PostgreSQL interval unit, or null when unsupported. + */ + private function get_postgresql_simple_interval_unit( WP_MySQL_Token $token ): ?string { + switch ( $token->id ) { + case WP_MySQL_Lexer::MICROSECOND_SYMBOL: + return 'microsecond'; + + case WP_MySQL_Lexer::SECOND_SYMBOL: + return 'second'; + + case WP_MySQL_Lexer::MINUTE_SYMBOL: + return 'minute'; + + case WP_MySQL_Lexer::HOUR_SYMBOL: + return 'hour'; + + case WP_MySQL_Lexer::DAY_SYMBOL: + return 'day'; + + case WP_MySQL_Lexer::WEEK_SYMBOL: + return 'week'; + + case WP_MySQL_Lexer::MONTH_SYMBOL: + return 'month'; + + case WP_MySQL_Lexer::QUARTER_SYMBOL: + return '3 months'; + + case WP_MySQL_Lexer::YEAR_SYMBOL: + return 'year'; + } + + return null; + } + + /** + * Get component units for supported composite MySQL interval units. + * + * @param WP_MySQL_Token $token MySQL interval unit token. + * @return string[]|null Ordered PostgreSQL component units, or null when unsupported. + */ + private function get_mysql_composite_interval_part_units( WP_MySQL_Token $token ): ?array { + switch ( $token->id ) { + case WP_MySQL_Lexer::SECOND_MICROSECOND_SYMBOL: + return array( 'second', 'microsecond' ); + + case WP_MySQL_Lexer::MINUTE_SECOND_SYMBOL: + return array( 'minute', 'second' ); + + case WP_MySQL_Lexer::MINUTE_MICROSECOND_SYMBOL: + return array( 'minute', 'second', 'microsecond' ); + + case WP_MySQL_Lexer::HOUR_MINUTE_SYMBOL: + return array( 'hour', 'minute' ); + + case WP_MySQL_Lexer::HOUR_SECOND_SYMBOL: + return array( 'hour', 'minute', 'second' ); + + case WP_MySQL_Lexer::HOUR_MICROSECOND_SYMBOL: + return array( 'hour', 'minute', 'second', 'microsecond' ); + + case WP_MySQL_Lexer::DAY_HOUR_SYMBOL: + return array( 'day', 'hour' ); + + case WP_MySQL_Lexer::DAY_MINUTE_SYMBOL: + return array( 'day', 'hour', 'minute' ); + + case WP_MySQL_Lexer::DAY_SECOND_SYMBOL: + return array( 'day', 'hour', 'minute', 'second' ); + + case WP_MySQL_Lexer::DAY_MICROSECOND_SYMBOL: + return array( 'day', 'hour', 'minute', 'second', 'microsecond' ); + + case WP_MySQL_Lexer::YEAR_MONTH_SYMBOL: + return array( 'year', 'month' ); + } + + return null; + } + + /** + * Get PostgreSQL SQL for a safe literal MySQL composite interval. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First interval value token position. + * @param int $end Final interval value token position, exclusive. + * @param string[] $part_units Ordered PostgreSQL component units. + * @return string|null PostgreSQL interval SQL, or null when unsupported. + */ + private function get_postgresql_mysql_composite_interval_literal_sql( array $tokens, int $start, int $end, array $part_units ): ?string { + if ( $start >= $end || ! isset( $tokens[ $start ] ) ) { + return null; + } + + $value = $this->get_mysql_composite_interval_literal_value( $tokens, $start, $end ); + if ( null === $value ) { + return null; + } + + if ( $value['is_null'] ) { + return 'CAST(NULL AS interval)'; + } + + $components = $this->parse_mysql_composite_interval_literal_components( $value['value'], $part_units ); + if ( null === $components ) { + return null; + } + + return $this->get_postgresql_mysql_composite_interval_components_sql( $components ); + } + + /** + * Get a simple literal value for a MySQL composite interval. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First interval value token position. + * @param int $end Final interval value token position, exclusive. + * @return array{value: string, is_null: bool}|null Literal interval value, or null when unsupported. + */ + private function get_mysql_composite_interval_literal_value( array $tokens, int $start, int $end ): ?array { + if ( $start + 1 === $end ) { + if ( WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $start ]->id ) { + return array( + 'value' => '', + 'is_null' => true, + ); + } + + if ( $this->is_mysql_quoted_text_token( $tokens[ $start ] ) ) { + return array( + 'value' => $tokens[ $start ]->get_value(), + 'is_null' => false, + ); + } + + if ( $this->is_mysql_unsigned_numeric_token( $tokens[ $start ] ) ) { + return array( + 'value' => $tokens[ $start ]->get_value(), + 'is_null' => false, + ); + } + } + + if ( + $start + 2 === $end + && isset( $tokens[ $start + 1 ] ) + && ( + WP_MySQL_Lexer::MINUS_OPERATOR === $tokens[ $start ]->id + || WP_MySQL_Lexer::PLUS_OPERATOR === $tokens[ $start ]->id + ) + && $this->is_mysql_unsigned_numeric_token( $tokens[ $start + 1 ] ) + ) { + return array( + 'value' => $tokens[ $start ]->get_bytes() . $tokens[ $start + 1 ]->get_value(), + 'is_null' => false, + ); + } + + return null; + } + + /** + * Check whether a token is a simple unsigned numeric literal. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the token is a simple numeric literal. + */ + private function is_mysql_unsigned_numeric_token( WP_MySQL_Token $token ): bool { + if ( $this->is_mysql_unsigned_integer_token( $token ) ) { + return true; + } + + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::DECIMAL_NUMBER, + ), + true + ) + ) { + return 1 === preg_match( '/^[0-9]+[.][0-9]+$/', $token->get_value() ); + } + + return false; + } + + /** + * Parse a full or right-aligned MySQL composite interval literal. + * + * @param string $value MySQL interval literal value. + * @param string[] $part_units Ordered PostgreSQL component units. + * @return array|null Parsed components, or null when unsupported. + */ + private function parse_mysql_composite_interval_literal_components( string $value, array $part_units ): ?array { + $part_count = count( $part_units ); + $value = trim( $value ); + if ( '' === $value ) { + return null; + } + + $sign = ''; + if ( '-' === $value[0] || '+' === $value[0] ) { + $sign = '-' === $value[0] ? '-' : ''; + $value = trim( substr( $value, 1 ) ); + } + + if ( '' === $value || 1 !== preg_match( '/^[0-9]+(?:[^0-9]+[0-9]+)*$/', $value ) ) { + return null; + } + + $parts = preg_split( '/[^0-9]+/', $value ); + if ( false === $parts || empty( $parts ) || count( $parts ) > $part_count ) { + return null; + } + + $units = array_slice( $part_units, $part_count - count( $parts ) ); + $components = array(); + foreach ( $parts as $index => $part ) { + $unit = $units[ $index ]; + $component_value = 'microsecond' === $unit ? str_pad( $part, 6, '0' ) : $part; + + $components[] = array( + 'value' => $sign . $component_value, + 'unit' => $unit, + ); + } + + return $components; + } + + /** + * Get PostgreSQL SQL for parsed MySQL composite interval components. + * + * @param array $components Parsed interval components. + * @return string PostgreSQL interval SQL. + */ + private function get_postgresql_mysql_composite_interval_components_sql( array $components ): string { + $parts = array(); + foreach ( $components as $component ) { + $parts[] = sprintf( + '(CAST(%1$s AS double precision) * INTERVAL %2$s)', + $this->connection->quote( $component['value'] ), + $this->connection->quote( '1 ' . $component['unit'] ) + ); + } + + return '(' . implode( ' + ', $parts ) . ')'; + } + + /** + * Get PostgreSQL SQL for a MySQL-compatible interval value. + * + * @param string $value_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_interval_value_sql( string $value_sql, string $unit ): string { + $value_cast_sql = 'second' === $unit + ? $this->get_postgresql_mysql_numeric_cast_sql( $value_sql ) + : $this->get_postgresql_mysql_integer_cast_sql( $value_sql ); + + return sprintf( 'CAST(%s AS double precision)', $value_cast_sql ); + } + + /** + * Get PostgreSQL SQL for a MySQL-compatible interval expression. + * + * @param string $value_sql PostgreSQL interval value SQL. + * @param string $unit Normalized interval unit. + * @return string PostgreSQL interval SQL. + */ + private function get_postgresql_mysql_interval_sql( string $value_sql, string $unit ): string { + $interval_unit = '3 months' === $unit ? $unit : '1 ' . $unit; + + return sprintf( + '(%1$s * INTERVAL %2$s)', + $this->get_postgresql_mysql_interval_value_sql( $value_sql, $unit ), + $this->connection->quote( $interval_unit ) + ); + } + + /** + * Translate MySQL TIMESTAMPADD(unit, interval, datetime_expr) to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $arguments Function argument bounds. + * @param int $close Closing parenthesis token position. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_timestampadd_function_to_postgresql( array $tokens, array $arguments, int $close ): ?array { + if ( 3 !== count( $arguments ) ) { + return null; + } + + $interval = $this->get_mysql_timestampadd_interval( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'], + $arguments[1]['start'], + $arguments[1]['end'] + ); + if ( null === $interval ) { + return null; + } + + $interval_sql = $interval['sql'] ?? null; + if ( null === $interval_sql ) { + $value_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[1]['start'], + $arguments[1]['end'] + ); + $interval_sql = $this->get_postgresql_mysql_interval_sql( $value_sql, $interval['unit'] ); + } + + $datetime_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[2]['start'], + $arguments[2]['end'] + ); + + return array( + 'sql' => sprintf( + '(%1$s + %2$s)', + $this->get_postgresql_zero_date_safe_timestamp_sql( $datetime_sql ), + $interval_sql + ), + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $close, + ); + } + + /** + * Check whether a range contains an unsupported MySQL TIMESTAMPADD() call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an unsupported TIMESTAMPADD() call is present. + */ + private function contains_unsupported_mysql_timestampadd_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( + 'timestampadd' !== $this->get_mysql_common_function_name( $tokens[ $i ] ?? null ) + || ! isset( $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + + $bounds = $this->get_mysql_common_function_bounds( $tokens, $i, $end ); + if ( null === $bounds ) { + return true; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( + null === $arguments + || null === $this->translate_mysql_timestampadd_function_to_postgresql( $tokens, $arguments, $bounds['close'] ) + ) { + return true; + } + } + + return false; + } + + /** + * Translate MySQL TIMESTAMPDIFF(unit, datetime_expr1, datetime_expr2) to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param array $arguments Function argument bounds. + * @param int $close Closing parenthesis token position. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_timestampdiff_function_to_postgresql( array $tokens, array $arguments, int $close ): ?array { + if ( 3 !== count( $arguments ) ) { + return null; + } + + $unit = $this->get_mysql_timestampdiff_unit( + $tokens, + $arguments[0]['start'], + $arguments[0]['end'] + ); + if ( null === $unit ) { + return null; + } + + $start_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[1]['start'], + $arguments[1]['end'] + ); + $end_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $arguments[2]['start'], + $arguments[2]['end'] + ); + + return array( + 'sql' => $this->get_postgresql_mysql_timestampdiff_sql( $unit, $start_sql, $end_sql ), + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $close, + ); + } + + /** + * Check whether a range contains an unsupported MySQL TIMESTAMPDIFF() call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an unsupported TIMESTAMPDIFF() call is present. + */ + private function contains_unsupported_mysql_timestampdiff_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( + 'timestampdiff' !== $this->get_mysql_common_function_name( $tokens[ $i ] ?? null ) + || ! isset( $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + + $bounds = $this->get_mysql_common_function_bounds( $tokens, $i, $end ); + if ( null === $bounds ) { + return true; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( + null === $arguments + || null === $this->translate_mysql_timestampdiff_function_to_postgresql( $tokens, $arguments, $bounds['close'] ) + ) { + return true; + } + } + + return false; + } + + /** + * Get supported TIMESTAMPDIFF unit data. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $unit_start First unit token. + * @param int $unit_end Final unit token, exclusive. + * @return string|null Normalized TIMESTAMPDIFF unit, or null when unsupported. + */ + private function get_mysql_timestampdiff_unit( array $tokens, int $unit_start, int $unit_end ): ?string { + if ( $unit_start + 1 !== $unit_end || ! isset( $tokens[ $unit_start ] ) ) { + return null; + } + + switch ( $tokens[ $unit_start ]->id ) { + case WP_MySQL_Lexer::MICROSECOND_SYMBOL: + return 'microsecond'; + + case WP_MySQL_Lexer::SECOND_SYMBOL: + return 'second'; + + case WP_MySQL_Lexer::MINUTE_SYMBOL: + return 'minute'; + + case WP_MySQL_Lexer::HOUR_SYMBOL: + return 'hour'; + + case WP_MySQL_Lexer::DAY_SYMBOL: + return 'day'; + + case WP_MySQL_Lexer::WEEK_SYMBOL: + return 'week'; + + case WP_MySQL_Lexer::MONTH_SYMBOL: + return 'month'; + + case WP_MySQL_Lexer::QUARTER_SYMBOL: + return 'quarter'; + + case WP_MySQL_Lexer::YEAR_SYMBOL: + return 'year'; + } + + return null; + } + + /** + * Get PostgreSQL SQL for a MySQL TIMESTAMPDIFF() expression. + * + * @param string $unit Normalized TIMESTAMPDIFF unit. + * @param string $start_sql Translated start datetime SQL. + * @param string $end_sql Translated end datetime SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_timestampdiff_sql( string $unit, string $start_sql, string $end_sql ): string { + $start_timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $start_sql ); + $end_timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $end_sql ); + + if ( 'microsecond' === $unit ) { + return sprintf( + 'CAST(TRUNC(EXTRACT(EPOCH FROM (%2$s - %1$s)) * 1000000) AS bigint)', + $start_timestamp_sql, + $end_timestamp_sql + ); + } + + $seconds_per_unit = array( + 'second' => 1, + 'minute' => 60, + 'hour' => 3600, + 'day' => 86400, + 'week' => 604800, + ); + if ( isset( $seconds_per_unit[ $unit ] ) ) { + return sprintf( + 'CAST(TRUNC(EXTRACT(EPOCH FROM (%2$s - %1$s)) / %3$d) AS bigint)', + $start_timestamp_sql, + $end_timestamp_sql, + $seconds_per_unit[ $unit ] + ); + } + + $month_sql = $this->get_postgresql_mysql_timestampdiff_month_sql( $start_timestamp_sql, $end_timestamp_sql ); + if ( 'month' === $unit ) { + return $month_sql; + } + + if ( 'quarter' === $unit ) { + return sprintf( 'CAST(TRUNC((%s)::numeric / 3) AS bigint)', $month_sql ); + } + + return sprintf( 'CAST(TRUNC((%s)::numeric / 12) AS bigint)', $month_sql ); + } + + /** + * Get PostgreSQL SQL for MySQL TIMESTAMPDIFF(MONTH, ...). + * + * @param string $start_timestamp_sql Zero-date-safe start timestamp SQL. + * @param string $end_timestamp_sql Zero-date-safe end timestamp SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_timestampdiff_month_sql( string $start_timestamp_sql, string $end_timestamp_sql ): string { + $month_delta_sql = sprintf( + '((CAST(EXTRACT(YEAR FROM %2$s) AS integer) * 12 + CAST(EXTRACT(MONTH FROM %2$s) AS integer)) - (CAST(EXTRACT(YEAR FROM %1$s) AS integer) * 12 + CAST(EXTRACT(MONTH FROM %1$s) AS integer)))', + $start_timestamp_sql, + $end_timestamp_sql + ); + $start_remainder_sql = sprintf( "TO_CHAR(%s, 'DD HH24:MI:SS.US')", $start_timestamp_sql ); + $end_remainder_sql = sprintf( "TO_CHAR(%s, 'DD HH24:MI:SS.US')", $end_timestamp_sql ); + + return sprintf( + 'CAST(CASE WHEN %1$s IS NULL OR %2$s IS NULL THEN NULL WHEN %2$s >= %1$s THEN (%3$s - CASE WHEN %4$s < %5$s THEN 1 ELSE 0 END) ELSE (%3$s + CASE WHEN %4$s > %5$s THEN 1 ELSE 0 END) END AS bigint)', + $start_timestamp_sql, + $end_timestamp_sql, + $month_delta_sql, + $end_remainder_sql, + $start_remainder_sql + ); + } + + /** + * Get supported TIMESTAMPADD interval data from the unit and value arguments. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $unit_start First unit token. + * @param int $unit_end Final unit token, exclusive. + * @param int $value_start First interval value token. + * @param int $value_end Final interval value token, exclusive. + * @return array{unit: string, sql?: string}|null Interval data, or null when unsupported. + */ + private function get_mysql_timestampadd_interval( array $tokens, int $unit_start, int $unit_end, int $value_start, int $value_end ): ?array { + if ( $unit_start + 1 !== $unit_end || ! isset( $tokens[ $unit_start ] ) ) { + return null; + } + + $unit = $this->get_postgresql_simple_interval_unit( $tokens[ $unit_start ] ); + if ( null !== $unit ) { + return array( + 'unit' => $unit, + ); + } + + $part_units = $this->get_mysql_composite_interval_part_units( $tokens[ $unit_start ] ); + if ( null === $part_units ) { + return null; + } + + $sql = $this->get_postgresql_mysql_composite_interval_literal_sql( $tokens, $value_start, $value_end, $part_units ); + if ( null === $sql ) { + return null; + } + + return array( + 'unit' => 'composite', + 'sql' => $sql, + ); + } + + /** + * Translate supported MySQL WEEK() calls to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_week_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_week_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_mysql_week_sql( $expression_sql, $bounds['mode'] ), + 'token_id' => WP_MySQL_Lexer::CASE_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for supported MySQL WEEK() calls. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, mode: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_week_function_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::WEEK_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $position + 2, $after_close - 1 ); + if ( + null === $arguments + || ! in_array( count( $arguments ), array( 1, 2 ), true ) + ) { + return null; + } + + $mode = 0; + if ( 2 === count( $arguments ) ) { + $mode = $this->get_mysql_supported_week_mode_argument( $tokens, $arguments[1]['start'], $arguments[1]['end'] ); + if ( null === $mode ) { + return null; + } + } + + return array( + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'mode' => $mode, + 'close' => $after_close - 1, + ); + } + + /** + * Check whether a range contains an unsupported MySQL WEEK() call. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an unsupported WEEK() call is present. + */ + private function contains_unsupported_mysql_week_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( + ! isset( $tokens[ $i ], $tokens[ $i + 1 ] ) + || WP_MySQL_Lexer::WEEK_SYMBOL !== $tokens[ $i ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id + ) { + continue; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $i + 1, $end ); + if ( null === $after_close || null === $this->get_mysql_week_function_bounds( $tokens, $i, $end ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a query contains an unsupported MySQL WEEK() call. + * + * @param string $query SQL query. + * @return bool Whether an unsupported WEEK() call is present. + */ + private function contains_unsupported_mysql_week_function_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + return $this->contains_unsupported_mysql_week_function( + $tokens, + 0, + null === $statement_end ? count( $tokens ) : $statement_end + ); + } + + /** + * Get a supported MySQL WEEK() mode argument. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First mode token. + * @param int $end Final mode token, exclusive. + * @return int|null Supported mode, or null when unsupported. + */ + private function get_mysql_supported_week_mode_argument( array $tokens, int $start, int $end ): ?int { + if ( + $start + 1 !== $end + || ! isset( $tokens[ $start ] ) + || WP_MySQL_Lexer::INT_NUMBER !== $tokens[ $start ]->id + ) { + return null; + } + + $mode = $tokens[ $start ]->get_value(); + if ( ! in_array( $mode, array( '0', '1', '2', '3', '4', '5', '6', '7' ), true ) ) { + return null; + } + + return (int) $mode; + } + + /** + * Get PostgreSQL SQL for a supported MySQL WEEK() mode. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @param int $mode MySQL WEEK() mode. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_week_sql( string $expression_sql, int $mode ): string { + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + + switch ( $mode ) { + case 0: + return $this->get_postgresql_mysql_sunday_week_mode_zero_sql( $timestamp_sql ); + + case 1: + return $this->get_postgresql_mysql_week_mode_one_timestamp_sql( $timestamp_sql ); + + case 2: + return $this->get_postgresql_mysql_sunday_week_mode_two_sql( $timestamp_sql ); + + case 3: + return $this->get_postgresql_mysql_iso_week_timestamp_sql( $timestamp_sql ); + + case 4: + return $this->get_postgresql_mysql_sunday_week_mode_four_sql( $timestamp_sql ); + + case 5: + return $this->get_postgresql_mysql_monday_week_mode_five_sql( $timestamp_sql ); + + case 6: + return $this->get_postgresql_mysql_sunday_week_mode_six_sql( $timestamp_sql ); + + case 7: + return $this->get_postgresql_mysql_monday_week_mode_seven_sql( $timestamp_sql ); + } + + throw new InvalidArgumentException( 'Unsupported MySQL WEEK() mode.' ); + } + + /** + * Get PostgreSQL SQL for MySQL WEEK(expr, 1). + * + * MySQL mode 1 is Monday-first and returns week numbers in the given year, + * using 0 for dates before that year's first ISO-like week. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_week_mode_one_sql( string $expression_sql ): string { + return $this->get_postgresql_mysql_week_mode_one_timestamp_sql( + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get PostgreSQL SQL for MySQL WEEK(timestamp, 1). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_week_mode_one_timestamp_sql( string $timestamp_sql ): string { + $week_start_sql = sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = sprintf( + "(CASE WHEN EXTRACT(ISODOW FROM %1\$s) <= 4 THEN DATE_TRUNC('week', %1\$s) ELSE DATE_TRUNC('week', %1\$s) + INTERVAL '1 week' END)", + $year_start_sql + ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Translate MySQL DAYOFWEEK(expr) and WEEKDAY(expr) calls to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_weekday_index_function_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_weekday_index_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_mysql_weekday_index_sql( $bounds['function'], $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CAST_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for supported MySQL weekday index functions. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{function: string, expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_weekday_index_function_bounds( array $tokens, int $position, int $end ): ?array { + $function_name = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $function_name ) { + return null; + } + + $function_name = strtolower( $function_name ); + if ( 'dayofweek' !== $function_name && 'weekday' !== $function_name ) { + return null; + } + + $bounds = $this->get_mysql_function_call_bounds( $tokens, $position, $end, $function_name ); + if ( null === $bounds ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 1 !== count( $arguments ) ) { + return null; + } + + return array( + 'function' => $function_name, + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'close' => $bounds['close'], + ); + } + + /** + * Get PostgreSQL SQL for a MySQL weekday index function. + * + * @param string $function_name Lowercase MySQL function name. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_weekday_index_sql( string $function_name, string $expression_sql ): string { + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + + if ( 'dayofweek' === $function_name ) { + return sprintf( 'CAST(EXTRACT(DOW FROM %s) AS integer) + 1', $timestamp_sql ); + } + + return sprintf( 'CAST(EXTRACT(ISODOW FROM %s) AS integer) - 1', $timestamp_sql ); + } + + /** + * Translate supported MySQL DATE_FORMAT(expr, format) calls to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_date_format_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_date_format_call_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + if ( null !== $bounds['format'] ) { + $sql = $this->get_postgresql_mysql_date_format_sql( $bounds['format'], $expression_sql ); + } else { + $format_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['format_start'], + $bounds['format_end'] + ); + $sql = $this->get_postgresql_mysql_dynamic_date_format_sql( $format_sql, $expression_sql ); + } + if ( null === $sql ) { + return null; + } + + return array( + 'sql' => $sql, + 'token_id' => WP_MySQL_Lexer::CASE_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Check whether a range contains an unsupported MySQL DATE_FORMAT() form. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether an unsupported DATE_FORMAT() call is present. + */ + private function contains_unsupported_mysql_date_format_function( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + if ( null === $this->get_mysql_function_call_bounds( $tokens, $i, $end, 'date_format' ) ) { + continue; + } + + if ( null === $this->get_mysql_date_format_call_bounds( $tokens, $i, $end ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a query contains an unsupported MySQL DATE_FORMAT() form. + * + * @param string $query SQL query. + * @return bool Whether an unsupported DATE_FORMAT() call is present. + */ + private function contains_unsupported_mysql_date_format_function_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + $statement_end = $this->get_mysql_statement_end_position( $tokens, 1 ); + return $this->contains_unsupported_mysql_date_format_function( + $tokens, + 0, + null === $statement_end ? count( $tokens ) : $statement_end + ); + } + + /** + * Get token bounds for supported MySQL DATE_FORMAT(expr, format) calls. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{format: string, expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_date_format_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_date_format_call_bounds( $tokens, $position, $end ); + if ( null === $bounds || null === $bounds['format'] ) { + return null; + } + + return $bounds; + } + + /** + * Get token bounds for MySQL DATE_FORMAT(expr, format) calls. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{format: string|null, expression_start: int, expression_end: int, format_start: int, format_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_date_format_call_bounds( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_function_call_bounds( $tokens, $position, $end, 'date_format' ); + if ( null === $bounds ) { + return null; + } + + $arguments = $this->split_top_level_mysql_arguments( $tokens, $bounds['arguments_start'], $bounds['arguments_end'] ); + if ( null === $arguments || 2 !== count( $arguments ) ) { + return null; + } + + $format = $this->is_mysql_string_literal_range( $tokens, $arguments[1]['start'], $arguments[1]['end'] ) + ? $tokens[ $arguments[1]['start'] ]->get_value() + : null; + + return array( + 'format' => $format, + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'format_start' => $arguments[1]['start'], + 'format_end' => $arguments[1]['end'], + 'close' => $bounds['close'], + ); + } + + /** + * Get PostgreSQL SQL for a supported MySQL DATE_FORMAT() format. + * + * @param string $format MySQL DATE_FORMAT format. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string|null PostgreSQL expression SQL, or null when unsupported. + */ + private function get_postgresql_mysql_date_format_sql( string $format, string $expression_sql ): ?string { + switch ( $format ) { + case '%H.%i': + return $this->get_postgresql_mysql_date_format_hour_minute_sql( $expression_sql ); + + case '%H.%i%s': + return $this->get_postgresql_mysql_date_format_hour_minute_second_number_sql( $expression_sql ); + + case '0.%i%s': + return $this->get_postgresql_mysql_date_format_minute_second_fraction_sql( $expression_sql ); + + case '%Y-%m-%d': + return $this->get_postgresql_mysql_date_format_year_month_day_sql( $expression_sql ); + } + + return $this->get_postgresql_mysql_generic_date_format_sql( $format, $expression_sql ); + } + + /** + * Get PostgreSQL SQL for a MySQL formatted date/time string. + * + * DATE_FORMAT() has numeric special cases for WordPress date comparisons. + * FROM_UNIXTIME(expr, format) always returns a formatted string, so it must + * bypass those numeric helpers. + * + * @param string $format MySQL DATE_FORMAT/FROM_UNIXTIME format. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string|null PostgreSQL expression SQL, or null when unsupported. + */ + private function get_postgresql_mysql_date_format_string_sql( string $format, string $expression_sql ): ?string { + return $this->get_postgresql_mysql_generic_date_format_sql( $format, $expression_sql, false ); + } + + /** + * Get PostgreSQL SQL for general MySQL DATE_FORMAT() format strings. + * + * @param string $format MySQL DATE_FORMAT format. + * @param string $expression_sql PostgreSQL expression SQL. + * @param bool $preserve_zero_date_parts Whether to derive numeric/time parts from zero-ish dates. + * @return string|null PostgreSQL expression SQL, or null when unsupported. + */ + private function get_postgresql_mysql_generic_date_format_sql( string $format, string $expression_sql, bool $preserve_zero_date_parts = true ): ?string { + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $empty_date_condition = $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + $zero_date_format_sql = $this->get_postgresql_mysql_zero_date_format_sql( $format, $expression_text_sql ); + $fragments = array(); + $literal = ''; + $length = strlen( $format ); + + for ( $i = 0; $i < $length; $i++ ) { + $character = $format[ $i ]; + if ( '%' !== $character ) { + $literal .= $character; + continue; + } + + if ( $i + 1 >= $length ) { + $literal .= '%'; + continue; + } + + if ( '' !== $literal ) { + $fragments[] = $this->connection->quote( $literal ); + $literal = ''; + } + + ++$i; + $fragment = $this->get_postgresql_mysql_date_format_specifier_sql( $format[ $i ], $timestamp_sql ); + if ( null === $fragment ) { + $literal .= $format[ $i ]; + continue; + } + + $fragments[] = $fragment; + } + + if ( '' !== $literal ) { + $fragments[] = $this->connection->quote( $literal ); + } + + $formatted_sql = empty( $fragments ) ? "''" : implode( ' || ', $fragments ); + if ( ! $preserve_zero_date_parts ) { + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s OR %3$s THEN NULL ELSE %4$s END', + $expression_text_sql, + $empty_date_condition, + $zero_date_condition, + $formatted_sql + ); + } + + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s THEN NULL WHEN %3$s THEN %4$s ELSE %5$s END', + $expression_text_sql, + $empty_date_condition, + $zero_date_condition, + $zero_date_format_sql, + $formatted_sql + ); + } + + /** + * Get PostgreSQL SQL for DATE_FORMAT() against a zero or partial-zero date string. + * + * MySQL can format numeric month/day ranges that include zero, but specifiers + * that need a real calendar date still return NULL for incomplete dates. + * + * @param string $format MySQL DATE_FORMAT format. + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_zero_date_format_sql( string $format, string $expression_text_sql ): string { + $fragments = array(); + $literal = ''; + $length = strlen( $format ); + + for ( $i = 0; $i < $length; $i++ ) { + $character = $format[ $i ]; + if ( '%' !== $character ) { + $literal .= $character; + continue; + } + + if ( $i + 1 >= $length ) { + $literal .= '%'; + continue; + } + + if ( '' !== $literal ) { + $fragments[] = $this->connection->quote( $literal ); + $literal = ''; + } + + ++$i; + $fragment = $this->get_postgresql_mysql_zero_date_format_specifier_sql( $format[ $i ], $expression_text_sql ); + if ( null === $fragment ) { + if ( $this->is_postgresql_mysql_known_date_format_specifier( $format[ $i ] ) ) { + return 'NULL'; + } + + $literal .= $format[ $i ]; + continue; + } + + $fragments[] = $fragment; + } + + if ( '' !== $literal ) { + $fragments[] = $this->connection->quote( $literal ); + } + + return empty( $fragments ) ? "''" : implode( ' || ', $fragments ); + } + + /** + * Get PostgreSQL SQL for one DATE_FORMAT() specifier on a zero-ish date. + * + * @param string $specifier MySQL DATE_FORMAT specifier without the leading percent. + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string|null PostgreSQL SQL fragment, or null when a real calendar date is required. + */ + private function get_postgresql_mysql_zero_date_format_specifier_sql( string $specifier, string $expression_text_sql ): ?string { + switch ( $specifier ) { + case '%': + return $this->connection->quote( '%' ); + + case 'Y': + return sprintf( 'SUBSTRING(%s FROM 1 FOR 4)', $expression_text_sql ); + + case 'y': + return sprintf( 'SUBSTRING(%s FROM 3 FOR 2)', $expression_text_sql ); + + case 'm': + return sprintf( 'SUBSTRING(%s FROM 6 FOR 2)', $expression_text_sql ); + + case 'c': + return sprintf( 'CAST(CAST(SUBSTRING(%s FROM 6 FOR 2) AS integer) AS text)', $expression_text_sql ); + + case 'd': + return sprintf( 'SUBSTRING(%s FROM 9 FOR 2)', $expression_text_sql ); + + case 'e': + return sprintf( 'CAST(CAST(SUBSTRING(%s FROM 9 FOR 2) AS integer) AS text)', $expression_text_sql ); + + case 'D': + return $this->get_postgresql_mysql_day_with_suffix_sql( + sprintf( 'CAST(SUBSTRING(%s FROM 9 FOR 2) AS integer)', $expression_text_sql ) + ); + + case 'H': + return $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 12, 2 ); + + case 'k': + return sprintf( + 'CAST(CAST(%s AS integer) AS text)', + $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 12, 2 ) + ); + + case 'h': + case 'I': + return sprintf( + "LPAD(CAST(%s AS text), 2, '0')", + $this->get_postgresql_mysql_zero_date_hour_12_sql( $expression_text_sql ) + ); + + case 'l': + return sprintf( 'CAST(%s AS text)', $this->get_postgresql_mysql_zero_date_hour_12_sql( $expression_text_sql ) ); + + case 'i': + return $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 15, 2 ); + + case 'S': + case 's': + return $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 18, 2 ); + + case 'T': + return sprintf( + "%1\$s || ':' || %2\$s || ':' || %3\$s", + $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 12, 2 ), + $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 15, 2 ), + $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 18, 2 ) + ); + + case 'r': + return sprintf( + "%1\$s || ':' || %2\$s || ':' || %3\$s || ' ' || %4\$s", + sprintf( + "LPAD(CAST(%s AS text), 2, '0')", + $this->get_postgresql_mysql_zero_date_hour_12_sql( $expression_text_sql ) + ), + $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 15, 2 ), + $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 18, 2 ), + $this->get_postgresql_mysql_zero_date_meridiem_sql( $expression_text_sql ) + ); + + case 'p': + return $this->get_postgresql_mysql_zero_date_meridiem_sql( $expression_text_sql ); + + case 'f': + return $this->get_postgresql_mysql_zero_date_microsecond_sql( $expression_text_sql ); + } + + return null; + } + + /** + * Check whether a DATE_FORMAT() specifier is one MySQL defines. + * + * @param string $specifier MySQL DATE_FORMAT specifier without the leading percent. + * @return bool Whether MySQL defines the specifier. + */ + private function is_postgresql_mysql_known_date_format_specifier( string $specifier ): bool { + return '%' === $specifier + || 'D' === $specifier + || 'w' === $specifier + || null !== $this->get_postgresql_mysql_date_format_week_specifier_sql( $specifier, 'NULL' ) + || isset( $this->get_postgresql_mysql_date_format_to_char_formats()[ $specifier ] ); + } + + /** + * Get a time component from a zero-ish date string, defaulting to 00. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @param int $start One-based substring start. + * @param int $length Substring length. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_zero_date_time_part_sql( string $expression_text_sql, int $start, int $length ): string { + return sprintf( + "CASE WHEN %1\$s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN SUBSTRING(%1\$s FROM %2\$d FOR %3\$d) ELSE '00' END", + $expression_text_sql, + $start, + $length + ); + } + + /** + * Get a 12-hour clock hour from a zero-ish date string. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL integer expression SQL. + */ + private function get_postgresql_mysql_zero_date_hour_12_sql( string $expression_text_sql ): string { + return sprintf( + 'MOD(CAST(%s AS integer) + 11, 12) + 1', + $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 12, 2 ) + ); + } + + /** + * Get an AM/PM marker from a zero-ish date string. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_zero_date_meridiem_sql( string $expression_text_sql ): string { + return sprintf( + "CASE WHEN CAST(%s AS integer) < 12 THEN 'AM' ELSE 'PM' END", + $this->get_postgresql_mysql_zero_date_time_part_sql( $expression_text_sql, 12, 2 ) + ); + } + + /** + * Get the microsecond component from a zero-ish date string. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_zero_date_microsecond_sql( string $expression_text_sql ): string { + return sprintf( + "CASE WHEN %1\$s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}[.][0-9]+' THEN LEFT(RPAD(SUBSTRING(%1\$s FROM '[.]([0-9]+)'), 6, '0'), 6) ELSE '000000' END", + $expression_text_sql + ); + } + + /** + * Get PostgreSQL SQL for a MySQL DATE_FORMAT() call with a runtime format expression. + * + * @param string $format_sql PostgreSQL SQL for the MySQL format expression. + * @param string $expression_sql PostgreSQL SQL for the value to format. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_dynamic_date_format_sql( string $format_sql, string $expression_sql ): string { + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $format_text_sql = sprintf( 'CAST(%s AS text)', $format_sql ); + $empty_date_condition = $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + + $character_sql = sprintf( + 'SUBSTRING(%s FROM "__wp_pg_mysql_date_format"."position" FOR 1)', + $format_text_sql + ); + $specifier_sql = sprintf( + 'SUBSTRING(%s FROM "__wp_pg_mysql_date_format"."position" + 1 FOR 1)', + $format_text_sql + ); + $percent_sql = $this->connection->quote( '%' ); + $next_position_sql = sprintf( + 'CASE WHEN %1$s = %2$s AND "__wp_pg_mysql_date_format"."position" < CHAR_LENGTH(%3$s) THEN "__wp_pg_mysql_date_format"."position" + 2 ELSE "__wp_pg_mysql_date_format"."position" + 1 END', + $character_sql, + $percent_sql, + $format_text_sql + ); + $fragment_sql = sprintf( + 'CASE WHEN %1$s <> %2$s THEN %1$s WHEN "__wp_pg_mysql_date_format"."position" >= CHAR_LENGTH(%3$s) THEN %2$s ELSE %4$s END', + $character_sql, + $percent_sql, + $format_text_sql, + $this->get_postgresql_mysql_dynamic_date_format_specifier_case_sql( $specifier_sql, $timestamp_sql ) + ); + $zero_date_fragment_sql = sprintf( + 'CASE WHEN %1$s <> %2$s THEN %1$s WHEN "__wp_pg_mysql_date_format"."position" >= CHAR_LENGTH(%3$s) THEN %2$s ELSE %4$s END', + $character_sql, + $percent_sql, + $format_text_sql, + $this->get_postgresql_mysql_dynamic_zero_date_format_specifier_case_sql( $specifier_sql, $expression_text_sql ) + ); + $formatter_sql = sprintf( + '(WITH RECURSIVE "__wp_pg_mysql_date_format"("position", "formatted") AS (SELECT 1, CAST(\'\' AS text) UNION ALL SELECT %1$s, "formatted" || %2$s FROM "__wp_pg_mysql_date_format" WHERE "position" <= CHAR_LENGTH(%3$s)) SELECT "formatted" FROM "__wp_pg_mysql_date_format" ORDER BY "position" DESC LIMIT 1)', + $next_position_sql, + $fragment_sql, + $format_text_sql + ); + $zero_date_formatter_sql = sprintf( + '(WITH RECURSIVE "__wp_pg_mysql_date_format"("position", "formatted") AS (SELECT 1, CAST(\'\' AS text) UNION ALL SELECT %1$s, "formatted" || %2$s FROM "__wp_pg_mysql_date_format" WHERE "position" <= CHAR_LENGTH(%3$s)) SELECT "formatted" FROM "__wp_pg_mysql_date_format" ORDER BY "position" DESC LIMIT 1)', + $next_position_sql, + $zero_date_fragment_sql, + $format_text_sql + ); + + return sprintf( + 'CASE WHEN %1$s IS NULL OR %2$s IS NULL OR %3$s THEN NULL WHEN %4$s THEN %5$s ELSE %6$s END', + $expression_text_sql, + $format_text_sql, + $empty_date_condition, + $zero_date_condition, + $zero_date_formatter_sql, + $formatter_sql + ); + } + + /** + * Get PostgreSQL CASE SQL for a runtime MySQL DATE_FORMAT() specifier on a zero-ish date. + * + * @param string $specifier_sql PostgreSQL SQL for the format specifier character. + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL CASE expression SQL. + */ + private function get_postgresql_mysql_dynamic_zero_date_format_specifier_case_sql( string $specifier_sql, string $expression_text_sql ): string { + $cases = array(); + $zero_date_part_specifiers = array( '%', 'Y', 'y', 'm', 'c', 'd', 'e', 'D', 'H', 'k', 'h', 'I', 'l', 'i', 'S', 's', 'T', 'r', 'p', 'f' ); + foreach ( $zero_date_part_specifiers as $specifier ) { + $cases[] = sprintf( + 'WHEN %s THEN %s', + $this->connection->quote( $specifier ), + $this->get_postgresql_mysql_zero_date_format_specifier_sql( $specifier, $expression_text_sql ) + ); + } + + $null_specifiers = array_unique( + array_merge( + array_keys( $this->get_postgresql_mysql_date_format_to_char_formats() ), + array( 'w', 'U', 'u', 'V', 'v', 'X', 'x' ) + ) + ); + foreach ( $null_specifiers as $specifier ) { + if ( null !== $this->get_postgresql_mysql_zero_date_format_specifier_sql( $specifier, $expression_text_sql ) ) { + continue; + } + + $cases[] = sprintf( + 'WHEN %s THEN NULL', + $this->connection->quote( $specifier ) + ); + } + + return sprintf( + 'CASE %1$s %2$s ELSE %1$s END', + $specifier_sql, + implode( ' ', $cases ) + ); + } + + /** + * Get PostgreSQL CASE SQL for a runtime MySQL DATE_FORMAT() specifier. + * + * @param string $specifier_sql PostgreSQL SQL for the format specifier character. + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL CASE expression SQL. + */ + private function get_postgresql_mysql_dynamic_date_format_specifier_case_sql( string $specifier_sql, string $timestamp_sql ): string { + $cases = array(); + foreach ( $this->get_postgresql_mysql_date_format_to_char_formats() as $specifier => $format ) { + $cases[] = sprintf( + 'WHEN %s THEN TO_CHAR(%s, %s)', + $this->connection->quote( $specifier ), + $timestamp_sql, + $this->connection->quote( $format ) + ); + } + + $cases[] = sprintf( + 'WHEN %s THEN %s', + $this->connection->quote( '%' ), + $this->connection->quote( '%' ) + ); + $cases[] = sprintf( + 'WHEN %s THEN %s', + $this->connection->quote( 'D' ), + $this->get_postgresql_mysql_date_format_day_with_suffix_sql( $timestamp_sql ) + ); + $cases[] = sprintf( + 'WHEN %s THEN CAST(CAST(EXTRACT(DOW FROM %s) AS integer) AS text)', + $this->connection->quote( 'w' ), + $timestamp_sql + ); + foreach ( array( 'U', 'u', 'V', 'v', 'X', 'x' ) as $week_specifier ) { + $cases[] = sprintf( + 'WHEN %s THEN %s', + $this->connection->quote( $week_specifier ), + $this->get_postgresql_mysql_date_format_week_specifier_sql( $week_specifier, $timestamp_sql ) + ); + } + + return sprintf( + 'CASE %1$s %2$s ELSE %1$s END', + $specifier_sql, + implode( ' ', $cases ) + ); + } + + /** + * Get PostgreSQL SQL for one MySQL DATE_FORMAT() specifier. + * + * @param string $specifier MySQL DATE_FORMAT specifier without the leading percent. + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string|null PostgreSQL SQL fragment, or null when the specifier is unknown. + */ + private function get_postgresql_mysql_date_format_specifier_sql( string $specifier, string $timestamp_sql ): ?string { + $to_char_formats = $this->get_postgresql_mysql_date_format_to_char_formats(); + + if ( '%' === $specifier ) { + return $this->connection->quote( '%' ); + } + + if ( 'D' === $specifier ) { + return $this->get_postgresql_mysql_date_format_day_with_suffix_sql( $timestamp_sql ); + } + + if ( 'w' === $specifier ) { + return sprintf( 'CAST(CAST(EXTRACT(DOW FROM %s) AS integer) AS text)', $timestamp_sql ); + } + + $week_sql = $this->get_postgresql_mysql_date_format_week_specifier_sql( $specifier, $timestamp_sql ); + if ( null !== $week_sql ) { + return $week_sql; + } + + if ( isset( $to_char_formats[ $specifier ] ) ) { + return sprintf( + 'TO_CHAR(%s, %s)', + $timestamp_sql, + $this->connection->quote( $to_char_formats[ $specifier ] ) + ); + } + + return null; + } + + /** + * Get PostgreSQL TO_CHAR format strings keyed by MySQL DATE_FORMAT() specifier. + * + * @return array PostgreSQL TO_CHAR formats. + */ + private function get_postgresql_mysql_date_format_to_char_formats(): array { + return array( + 'a' => 'Dy', + 'b' => 'Mon', + 'c' => 'FMMM', + 'd' => 'DD', + 'e' => 'FMDD', + 'f' => 'US', + 'H' => 'HH24', + 'h' => 'HH12', + 'I' => 'HH12', + 'i' => 'MI', + 'j' => 'DDD', + 'k' => 'FMHH24', + 'l' => 'FMHH12', + 'M' => 'FMMonth', + 'm' => 'MM', + 'p' => 'AM', + 'r' => 'HH12:MI:SS AM', + 'S' => 'SS', + 's' => 'SS', + 'T' => 'HH24:MI:SS', + 'W' => 'FMDay', + 'Y' => 'YYYY', + 'y' => 'YY', + ); + } + + /** + * Get PostgreSQL SQL for MySQL DATE_FORMAT() week/year specifiers. + * + * @param string $specifier MySQL DATE_FORMAT specifier without the leading percent. + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string|null PostgreSQL SQL fragment, or null when the specifier is not a week specifier. + */ + private function get_postgresql_mysql_date_format_week_specifier_sql( string $specifier, string $timestamp_sql ): ?string { + switch ( $specifier ) { + case 'U': + return $this->get_postgresql_mysql_zero_padded_week_sql( + $this->get_postgresql_mysql_sunday_week_mode_zero_sql( $timestamp_sql ) + ); + + case 'u': + return $this->get_postgresql_mysql_zero_padded_week_sql( + $this->get_postgresql_mysql_week_mode_one_timestamp_sql( $timestamp_sql ) + ); + + case 'V': + return $this->get_postgresql_mysql_zero_padded_week_sql( + $this->get_postgresql_mysql_sunday_week_mode_two_sql( $timestamp_sql ) + ); + + case 'v': + return sprintf( + 'TO_CHAR(%s, %s)', + $timestamp_sql, + $this->connection->quote( 'IW' ) + ); + + case 'X': + return $this->get_postgresql_mysql_sunday_week_mode_two_year_sql( $timestamp_sql ); + + case 'x': + return sprintf( + 'TO_CHAR(%s, %s)', + $timestamp_sql, + $this->connection->quote( 'IYYY' ) + ); + } + + return null; + } + + /** + * Get PostgreSQL SQL for zero-padded MySQL week numbers. + * + * @param string $week_sql PostgreSQL integer week expression. + * @return string PostgreSQL text expression. + */ + private function get_postgresql_mysql_zero_padded_week_sql( string $week_sql ): string { + return sprintf( "LPAD(CAST(%s AS text), 2, '0')", $week_sql ); + } + + /** + * Get PostgreSQL SQL for MySQL WEEK(expr, 0). + * + * Mode 0 is Sunday-first, returns 0-53, and week 1 starts at the first + * Sunday in the calendar year. + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_postgresql_mysql_sunday_week_mode_zero_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_postgresql_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_postgresql_mysql_first_sunday_of_year_sql( $year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get PostgreSQL SQL for MySQL WEEK(expr, 2). + * + * Mode 2 is Sunday-first, returns 1-53, and uses the previous week-year + * for dates before the first Sunday in the calendar year. + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_postgresql_mysql_sunday_week_mode_two_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_postgresql_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_postgresql_mysql_first_sunday_of_year_sql( $year_start_sql ); + $previous_year_start_sql = sprintf( "(%s - INTERVAL '1 year')", $year_start_sql ); + $previous_first_week_sql = $this->get_postgresql_mysql_first_sunday_of_year_sql( $previous_year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %4$s)) / 604800) AS integer) + 1 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $previous_first_week_sql + ); + } + + /** + * Get PostgreSQL SQL for MySQL WEEK(expr, 3). + * + * Mode 3 is ISO week numbering: Monday-first, range 1-53, and week 1 + * has four or more days in the week-year. + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_postgresql_mysql_iso_week_timestamp_sql( string $timestamp_sql ): string { + return sprintf( + 'CAST(TO_CHAR(%s, %s) AS integer)', + $timestamp_sql, + $this->connection->quote( 'IW' ) + ); + } + + /** + * Get PostgreSQL SQL for MySQL WEEK(expr, 4). + * + * Mode 4 is Sunday-first, returns 0-53, and week 1 has four or more + * days in the calendar year. + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_postgresql_mysql_sunday_week_mode_four_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_postgresql_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_postgresql_mysql_first_sunday_four_day_week_of_year_sql( $year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get PostgreSQL SQL for MySQL WEEK(expr, 5). + * + * Mode 5 is Monday-first, returns 0-53, and week 1 starts with the + * first Monday in the calendar year. + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_postgresql_mysql_monday_week_mode_five_sql( string $timestamp_sql ): string { + $week_start_sql = sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_postgresql_mysql_first_monday_of_year_sql( $year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get PostgreSQL SQL for MySQL WEEK(expr, 6). + * + * Mode 6 is Sunday-first, returns 1-53, and week 1 has four or more + * days in the week-year. + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_postgresql_mysql_sunday_week_mode_six_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_postgresql_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_postgresql_mysql_first_sunday_four_day_week_of_year_sql( $year_start_sql ); + $previous_year_start_sql = sprintf( "(%s - INTERVAL '1 year')", $year_start_sql ); + $next_year_start_sql = sprintf( "(%s + INTERVAL '1 year')", $year_start_sql ); + $previous_first_week_sql = $this->get_postgresql_mysql_first_sunday_four_day_week_of_year_sql( $previous_year_start_sql ); + $next_first_week_sql = $this->get_postgresql_mysql_first_sunday_four_day_week_of_year_sql( $next_year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s >= %5$s THEN 1 WHEN %2$s < %3$s THEN CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %4$s)) / 604800) AS integer) + 1 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $previous_first_week_sql, + $next_first_week_sql + ); + } + + /** + * Get PostgreSQL SQL for MySQL WEEK(expr, 7). + * + * Mode 7 is Monday-first, returns 1-53, and week 1 starts with the + * first Monday in the calendar year. + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_postgresql_mysql_monday_week_mode_seven_sql( string $timestamp_sql ): string { + $week_start_sql = sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_postgresql_mysql_first_monday_of_year_sql( $year_start_sql ); + $previous_year_start_sql = sprintf( "(%s - INTERVAL '1 year')", $year_start_sql ); + $previous_first_week_sql = $this->get_postgresql_mysql_first_monday_of_year_sql( $previous_year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %4$s)) / 604800) AS integer) + 1 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $previous_first_week_sql + ); + } + + /** + * Get PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%X'). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL text expression. + */ + private function get_postgresql_mysql_sunday_week_mode_two_year_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_postgresql_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_postgresql_mysql_first_sunday_of_year_sql( $year_start_sql ); + + return sprintf( + "CASE WHEN %1\$s IS NULL THEN NULL WHEN %2\$s < %3\$s THEN TO_CHAR(%4\$s - INTERVAL '1 year', 'YYYY') ELSE TO_CHAR(%4\$s, 'YYYY') END", + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $year_start_sql + ); + } + + /** + * Get PostgreSQL SQL for the Sunday-start week containing a timestamp. + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL timestamp expression. + */ + private function get_postgresql_mysql_sunday_week_start_sql( string $timestamp_sql ): string { + return sprintf( + "(DATE_TRUNC('day', %1\$s) - (CAST(EXTRACT(DOW FROM %1\$s) AS integer) * INTERVAL '1 day'))", + $timestamp_sql + ); + } + + /** + * Get PostgreSQL SQL for the first Sunday in a year. + * + * @param string $year_start_sql PostgreSQL timestamp expression for January 1. + * @return string PostgreSQL timestamp expression. + */ + private function get_postgresql_mysql_first_sunday_of_year_sql( string $year_start_sql ): string { + return sprintf( + "(%1\$s + (MOD(7 - CAST(EXTRACT(DOW FROM %1\$s) AS integer), 7) * INTERVAL '1 day'))", + $year_start_sql + ); + } + + /** + * Get PostgreSQL SQL for the first Sunday-start week with four days in a year. + * + * @param string $year_start_sql PostgreSQL timestamp expression for January 1. + * @return string PostgreSQL timestamp expression. + */ + private function get_postgresql_mysql_first_sunday_four_day_week_of_year_sql( string $year_start_sql ): string { + $week_start_sql = $this->get_postgresql_mysql_sunday_week_start_sql( $year_start_sql ); + + return sprintf( + "(CASE WHEN EXTRACT(DOW FROM %1\$s) <= 3 THEN %2\$s ELSE %2\$s + INTERVAL '1 week' END)", + $year_start_sql, + $week_start_sql + ); + } + + /** + * Get PostgreSQL SQL for the first Monday in a year. + * + * @param string $year_start_sql PostgreSQL timestamp expression for January 1. + * @return string PostgreSQL timestamp expression. + */ + private function get_postgresql_mysql_first_monday_of_year_sql( string $year_start_sql ): string { + return sprintf( + "(%1\$s + (MOD(8 - CAST(EXTRACT(ISODOW FROM %1\$s) AS integer), 7) * INTERVAL '1 day'))", + $year_start_sql + ); + } + + /** + * Get PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%D'). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL SQL fragment. + */ + private function get_postgresql_mysql_date_format_day_with_suffix_sql( string $timestamp_sql ): string { + $day_sql = sprintf( 'CAST(EXTRACT(DAY FROM %s) AS integer)', $timestamp_sql ); + + return $this->get_postgresql_mysql_day_with_suffix_sql( $day_sql ); + } + + /** + * Get PostgreSQL SQL for a MySQL ordinal day value. + * + * @param string $day_sql PostgreSQL integer day expression. + * @return string PostgreSQL SQL fragment. + */ + private function get_postgresql_mysql_day_with_suffix_sql( string $day_sql ): string { + return sprintf( + 'CAST(%1$s AS text) || CASE WHEN %1$s %% 100 BETWEEN 11 AND 13 THEN \'th\' WHEN %1$s %% 10 = 1 THEN \'st\' WHEN %1$s %% 10 = 2 THEN \'nd\' WHEN %1$s %% 10 = 3 THEN \'rd\' ELSE \'th\' END', + $day_sql + ); + } + + /** + * Get PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%H.%i'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_date_format_hour_minute_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + $date_time_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}'"; + $zero_date_format_sql = sprintf( + 'CASE WHEN %1$s ~ %2$s THEN CAST(SUBSTRING(%1$s FROM 12 FOR 2) || \'.\' || SUBSTRING(%1$s FROM 15 FOR 2) AS double precision) ELSE 0 END', + $expression_text_sql, + $date_time_pattern + ); + + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(TO_CHAR(%3$s, %4$s) AS double precision) END', + $zero_date_condition, + $zero_date_format_sql, + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $this->connection->quote( 'HH24.MI' ) + ); + } + + /** + * Get PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%H.%i%s'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_date_format_hour_minute_second_number_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + $date_time_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}'"; + $zero_date_format_sql = sprintf( + 'CASE WHEN %1$s ~ %2$s THEN CAST(SUBSTRING(%1$s FROM 12 FOR 2) || \'.\' || SUBSTRING(%1$s FROM 15 FOR 2) || SUBSTRING(%1$s FROM 18 FOR 2) AS double precision) ELSE 0 END', + $expression_text_sql, + $date_time_pattern + ); + + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(TO_CHAR(%3$s, %4$s) AS double precision) END', + $zero_date_condition, + $zero_date_format_sql, + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $this->connection->quote( 'HH24.MISS' ) + ); + } + + /** + * Get PostgreSQL SQL for MySQL DATE_FORMAT(expr, '0.%i%s'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_date_format_minute_second_fraction_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + $date_time_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}'"; + $zero_date_format_sql = sprintf( + 'CASE WHEN %1$s ~ %2$s THEN CAST(\'0.\' || SUBSTRING(%1$s FROM 15 FOR 2) || SUBSTRING(%1$s FROM 18 FOR 2) AS double precision) ELSE 0 END', + $expression_text_sql, + $date_time_pattern + ); + + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(\'0.\' || TO_CHAR(%3$s, %4$s) AS double precision) END', + $zero_date_condition, + $zero_date_format_sql, + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $this->connection->quote( 'MISS' ) + ); + } + + /** + * Get PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%Y-%m-%d'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_mysql_date_format_year_month_day_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CASE WHEN %1$s THEN NULL WHEN %2$s THEN SUBSTRING(%3$s FROM 1 FOR 10) ELSE TO_CHAR(%4$s, %5$s) END', + $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ), + $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql, + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $this->connection->quote( 'YYYY-MM-DD' ) + ); + } + + /** + * Translate supported MySQL date/time extract functions to PostgreSQL. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_date_time_extract_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_extract_function_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => $this->get_postgresql_zero_date_safe_extract_sql( $bounds['unit'], $expression_sql ), + 'token_id' => $tokens[ $position ]->id, + 'position' => $bounds['close'], + ); + } + + /** + * Get PostgreSQL SQL for a MySQL date/time extract that preserves MySQL zero-date behavior. + * + * PostgreSQL rejects MySQL zero-ish dates such as 0000-00-00 during timestamp + * casts. Detect those text-backed values first and extract the requested + * numeric part directly from the text; keep valid dates on PostgreSQL's + * timestamp EXTRACT path. + * + * @param string $unit PostgreSQL EXTRACT unit. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_zero_date_safe_extract_sql( string $unit, string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $empty_date_condition = $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + + return sprintf( + 'CASE WHEN %1$s THEN NULL WHEN %2$s THEN %3$s ELSE CAST(EXTRACT(%4$s FROM %5$s) AS integer) END', + $empty_date_condition, + $zero_date_condition, + $this->get_postgresql_zero_date_extract_part_sql( $unit, $expression_text_sql ), + $unit, + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get PostgreSQL SQL that casts a MySQL date/time expression without casting zero dates. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_zero_date_safe_timestamp_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CAST(CASE WHEN %1$s OR %2$s THEN NULL ELSE %3$s END AS timestamp)', + $this->get_postgresql_empty_temporal_condition_sql( $expression_text_sql ), + $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql + ); + } + + /** + * Get a condition that detects MySQL empty temporal strings. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL condition SQL. + */ + private function get_postgresql_empty_temporal_condition_sql( string $expression_text_sql ): string { + return sprintf( "%s = ''", $expression_text_sql ); + } + + /** + * Get a condition that detects MySQL zero or partial-zero date strings. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL condition SQL. + */ + private function get_postgresql_zero_date_condition_sql( string $expression_text_sql ): string { + $date_text_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}'"; + + return sprintf( + '%1$s ~ %2$s AND (SUBSTRING(%1$s FROM 1 FOR 4) = \'0000\' OR SUBSTRING(%1$s FROM 6 FOR 2) = \'00\' OR SUBSTRING(%1$s FROM 9 FOR 2) = \'00\')', + $expression_text_sql, + $date_text_pattern + ); + } + + /** + * Get PostgreSQL SQL that extracts one part from a zero-ish MySQL date string. + * + * @param string $unit PostgreSQL EXTRACT unit. + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL expression SQL. + */ + private function get_postgresql_zero_date_extract_part_sql( string $unit, string $expression_text_sql ): string { + $date_time_text_pattern = "'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}'"; + + switch ( $unit ) { + case 'DOY': + return 'NULL'; + + case 'YEAR': + return sprintf( 'CAST(SUBSTRING(%s FROM 1 FOR 4) AS integer)', $expression_text_sql ); + + case 'MONTH': + return sprintf( 'CAST(SUBSTRING(%s FROM 6 FOR 2) AS integer)', $expression_text_sql ); + + case 'QUARTER': + return sprintf( 'CAST(FLOOR((CAST(SUBSTRING(%s FROM 6 FOR 2) AS integer) + 2) / 3.0) AS integer)', $expression_text_sql ); + + case 'DAY': + return sprintf( 'CAST(SUBSTRING(%s FROM 9 FOR 2) AS integer)', $expression_text_sql ); + + case 'HOUR': + $start = 12; + break; + + case 'MINUTE': + $start = 15; + break; + + case 'SECOND': + $start = 18; + break; + + default: + return sprintf( 'CAST(EXTRACT(%s FROM CAST(%s AS timestamp)) AS integer)', $unit, $expression_text_sql ); + } + + return sprintf( + 'CASE WHEN %1$s ~ %2$s THEN CAST(SUBSTRING(%1$s FROM %3$d FOR 2) AS integer) ELSE 0 END', + $expression_text_sql, + $date_time_text_pattern, + $start + ); + } + + /** + * Get token bounds for supported MySQL date/time extract forms. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{unit: string, expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_extract_function_bounds( array $tokens, int $position, int $end ): ?array { + if ( ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::EXTRACT_SYMBOL === $tokens[ $position ]->id ) { + return $this->get_mysql_extract_keyword_bounds( $tokens, $position, $end ); + } + + return $this->get_mysql_date_time_function_bounds( $tokens, $position, $end ); + } + + /** + * Get token bounds for EXTRACT(unit FROM expr). + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position EXTRACT token position. + * @param int $end Final token position, exclusive. + * @return array{unit: string, expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_extract_keyword_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[ $position + 3 ]->id + ) { + return null; + } + + $unit = $this->get_mysql_date_time_extract_unit( $tokens[ $position + 2 ] ); + if ( null === $unit ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close || $position + 4 >= $after_close - 1 ) { + return null; + } + + return array( + 'unit' => $unit, + 'expression_start' => $position + 4, + 'expression_end' => $after_close - 1, + 'close' => $after_close - 1, + ); + } + + /** + * Get token bounds for YEAR(expr), MONTH(expr), DAY(expr), and similar calls. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Function token position. + * @param int $end Final token position, exclusive. + * @return array{unit: string, expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_date_time_function_bounds( array $tokens, int $position, int $end ): ?array { + $unit = $this->get_mysql_date_time_extract_unit( $tokens[ $position ] ); + if ( + null === $unit + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + if ( + $position + 2 >= $close_position + || null !== $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::COMMA_SYMBOL, + $position + 2, + $close_position + ) + ) { + return null; + } + + return array( + 'unit' => $unit, + 'expression_start' => $position + 2, + 'expression_end' => $close_position, + 'close' => $close_position, + ); + } + + /** + * Get the PostgreSQL EXTRACT unit for a MySQL date/time token. + * + * @param WP_MySQL_Token $token MySQL token. + * @return string|null PostgreSQL EXTRACT unit, or null when unsupported. + */ + private function get_mysql_date_time_extract_unit( WP_MySQL_Token $token ): ?string { + switch ( $token->id ) { + case WP_MySQL_Lexer::YEAR_SYMBOL: + return 'YEAR'; + + case WP_MySQL_Lexer::MONTH_SYMBOL: + return 'MONTH'; + + case WP_MySQL_Lexer::QUARTER_SYMBOL: + return 'QUARTER'; + + case WP_MySQL_Lexer::DAY_SYMBOL: + case WP_MySQL_Lexer::DAYOFMONTH_SYMBOL: + return 'DAY'; + + case WP_MySQL_Lexer::HOUR_SYMBOL: + return 'HOUR'; + + case WP_MySQL_Lexer::MINUTE_SYMBOL: + return 'MINUTE'; + + case WP_MySQL_Lexer::SECOND_SYMBOL: + return 'SECOND'; + } + + $name = $this->get_mysql_identifier_token_value( $token ); + if ( null !== $name && 0 === strcasecmp( $name, 'dayofyear' ) ) { + return 'DOY'; + } + + return null; + } + + /** + * Translate a MySQL CONVERT(expr USING charset) expression to PostgreSQL. + * + * PostgreSQL text is already stored in the database encoding, so the MySQL + * character-set conversion is represented by the inner expression. A directly + * attached MySQL COLLATE clause is dropped because PostgreSQL does not have + * MySQL collation names. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{sql: string, token_id: int, position: int}|null Translation data, or null when unsupported. + */ + private function translate_mysql_convert_using_to_postgresql( array $tokens, int $position, int $end ): ?array { + $bounds = $this->get_mysql_convert_using_bounds( $tokens, $position, $end ); + if ( null === $bounds ) { + return null; + } + + $final_position = $bounds['close']; + if ( + isset( $tokens[ $final_position + 1 ], $tokens[ $final_position + 2 ] ) + && $final_position + 2 < $end + && WP_MySQL_Lexer::COLLATE_SYMBOL === $tokens[ $final_position + 1 ]->id + && null !== $this->get_mysql_identifier_token_value( $tokens[ $final_position + 2 ] ) + ) { + $final_position += 2; + } + + $expression_sql = $this->translate_mysql_token_sequence_to_postgresql( + $tokens, + $bounds['expression_start'], + $bounds['expression_end'] + ); + + return array( + 'sql' => '(' . $expression_sql . ')', + 'token_id' => $tokens[ $bounds['expression_start'] ]->id, + 'position' => $final_position, + ); + } + + /** + * Get token bounds for a supported MySQL CONVERT(expr USING charset) expression. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position CONVERT token position. + * @param int $end Final token position, exclusive. + * @return array{expression_start: int, expression_end: int, close: int}|null Bounds, or null when unsupported. + */ + private function get_mysql_convert_using_bounds( array $tokens, int $position, int $end ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::CONVERT_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return null; + } + + $after_close = $this->get_mysql_parenthesized_sequence_end( $tokens, $position + 1, $end ); + if ( null === $after_close ) { + return null; + } + + $close_position = $after_close - 1; + $using_position = $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::USING_SYMBOL, + $position + 2, + $close_position + ); + if ( + null === $using_position + || $using_position <= $position + 2 + || $using_position + 2 !== $close_position + || ! $this->is_mysql_charset_token( $tokens[ $using_position + 1 ] ?? null ) + ) { + return null; + } + + return array( + 'expression_start' => $position + 2, + 'expression_end' => $using_position, + 'close' => $close_position, + ); + } + + /** + * Translate a single MySQL token to a PostgreSQL fragment. + * + * @param WP_MySQL_Token $token MySQL token. + * @param WP_MySQL_Token|null $next_token Next MySQL token, if known. + * @return string PostgreSQL SQL fragment. + */ + private function translate_mysql_token_to_postgresql( WP_MySQL_Token $token, ?WP_MySQL_Token $next_token = null ): string { + if ( + WP_MySQL_Lexer::IDENTIFIER === $token->id + && $this->should_quote_bare_mysql_identifier( $token->get_value() ) + && ( null === $next_token || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $next_token->id ) + ) { + return $this->connection->quote_identifier( $token->get_value() ); + } + + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return $this->connection->quote_identifier( $token->get_value() ); + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $this->connection->quote( $token->get_value() ); + } + + if ( WP_MySQL_Lexer::LOGICAL_OR_OPERATOR === $token->id ) { + return 'OR'; + } + + if ( WP_MySQL_Lexer::LOGICAL_AND_OPERATOR === $token->id ) { + return 'AND'; + } + + return $token->get_bytes(); + } + + /** + * Translate a MySQL identifier token to a PostgreSQL identifier fragment. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string PostgreSQL identifier fragment. + */ + private function translate_mysql_identifier_token_to_postgresql( ?WP_MySQL_Token $token ): string { + if ( null === $token ) { + return ''; + } + + return $this->translate_mysql_token_to_postgresql( $token ); + } + + /** + * Check whether two translated tokens should be joined without a space. + * + * @param int|null $previous_token_id Previous token ID. + * @param int $token_id Current token ID. + * @return bool Whether no separator should be added. + */ + private function should_join_mysql_tokens_without_space( ?int $previous_token_id, int $token_id ): bool { + return in_array( + $token_id, + array( + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::SEMICOLON_SYMBOL, + ), + true + ) || in_array( + $previous_token_id, + array( + WP_MySQL_Lexer::DOT_SYMBOL, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + ), + true + ); + } + + /** + * Get a MySQL identifier token value. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @param bool $allow_double_quoted Whether to accept double-quoted text as an identifier. + * @return string|null Identifier value, or null when the token is unsupported. + */ + private function get_mysql_identifier_token_value( ?WP_MySQL_Token $token, bool $allow_double_quoted = false ): ?string { + if ( null === $token ) { + return null; + } + + if ( WP_MySQL_Lexer::IDENTIFIER === $token->id || WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return $token->get_value(); + } + + if ( $allow_double_quoted && WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return $token->get_value(); + } + + return null; + } + + /** + * Get an identifier token value in DML column contexts. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return string|null Identifier value, or null when unsupported. + */ + private function get_mysql_dml_identifier_token_value( ?WP_MySQL_Token $token ): ?string { + $identifier = $this->get_mysql_identifier_token_value( $token ); + if ( null !== $identifier ) { + return $identifier; + } + + if ( + null !== $token + && in_array( + $token->id, + array( + WP_MySQL_Lexer::COMMENT_SYMBOL, + WP_MySQL_Lexer::STATUS_SYMBOL, + WP_MySQL_Lexer::VALUE_SYMBOL, + ), + true + ) + ) { + return $token->get_value(); + } + + return null; + } + + /** + * Check whether a token is an identifier-like token with the expected value. + * + * Some MySQL information_schema column names, such as TABLE_NAME, are lexed + * as keyword tokens. Treat them like identifiers only in explicit catalog + * translator contexts. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @param string $value Expected identifier value. + * @return bool Whether the token has the expected identifier-like value. + */ + private function is_mysql_identifier_like_token_value( ?WP_MySQL_Token $token, string $value ): bool { + if ( null === $token ) { + return false; + } + + $identifier = $this->get_mysql_identifier_token_value( $token ); + if ( null === $identifier && WP_MySQL_Lexer::TABLE_NAME_SYMBOL === $token->id ) { + $identifier = $token->get_value(); + } + + return null !== $identifier && strtolower( $identifier ) === strtolower( $value ); + } + + /** + * Check whether a token's semantic value matches a keyword or identifier. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @param string $value Expected value. + * @return bool Whether the token value matches. + */ + private function is_mysql_token_value( ?WP_MySQL_Token $token, string $value ): bool { + if ( null === $token ) { + return false; + } + + return strtolower( $token->get_value() ) === strtolower( $value ); + } + + /** + * Check whether a token can represent a MySQL character set name. + * + * @param WP_MySQL_Token|null $token MySQL token. + * @return bool Whether the token is a supported charset token. + */ + private function is_mysql_charset_token( ?WP_MySQL_Token $token ): bool { + if ( null === $token ) { + return false; + } + + return null !== $this->get_mysql_identifier_token_value( $token ) + || in_array( + $token->id, + array( + WP_MySQL_Lexer::ASCII_SYMBOL, + WP_MySQL_Lexer::BINARY_SYMBOL, + WP_MySQL_Lexer::DEFAULT_SYMBOL, + WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + ), + true + ); + } + + /** + * Get a MySQL charset/collation token value. + * + * @param WP_MySQL_Token $token MySQL token. + * @return string Token value. + */ + private function get_mysql_charset_token_value( WP_MySQL_Token $token ): string { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + return 'default'; + } + + return $token->get_value(); + } + + /** + * Check whether a bare MySQL identifier needs PostgreSQL quoting. + * + * @param string $identifier Identifier token value. + * @return bool Whether the bare identifier must be quoted. + */ + private function should_quote_bare_mysql_identifier( string $identifier ): bool { + return strtolower( $identifier ) !== $identifier; + } + + /** + * Check whether a token range needs the compatibility rewrite. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First token position. + * @param int $end Final token position, exclusive. + * @return bool Whether any token needs PostgreSQL compatibility rewriting. + */ + private function needs_mysql_compatible_rewrite( array $tokens, int $start, int $end ): bool { + for ( $i = $start; $i < $end; $i++ ) { + $token = $tokens[ $i ]; + if ( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + return true; + } + + if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $token->id ) { + return true; + } + + if ( WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $token->id ) { + return true; + } + + if ( WP_MySQL_Lexer::LOGICAL_OR_OPERATOR === $token->id || WP_MySQL_Lexer::LOGICAL_AND_OPERATOR === $token->id ) { + return true; + } + + if ( + WP_MySQL_Lexer::AT_TEXT_SUFFIX === $token->id + || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $token->id + ) { + return true; + } + + if ( null !== $this->get_mysql_convert_using_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->translate_mysql_dual_table_reference_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_index_hint_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_function_call_bounds( $tokens, $i, $end, 'field' ) ) { + return true; + } + + if ( null !== $this->get_mysql_integer_cast_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_integer_convert_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_character_convert_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_date_time_cast_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_binary_cast_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_binary_convert_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_decimal_convert_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_date_convert_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->translate_mysql_regexp_operator_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->translate_mysql_group_concat_function_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_function_call_bounds( $tokens, $i, $end, 'rand' ) ) { + return true; + } + + if ( null !== $this->translate_mysql_session_user_function_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->translate_mysql_nonparenthesized_timestamp_function_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->translate_mysql_common_function_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_date_arithmetic_function_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_week_function_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_weekday_index_function_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_date_format_call_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_limit_offset_count_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_extract_function_bounds( $tokens, $i, $end ) ) { + return true; + } + + if ( + WP_MySQL_Lexer::IDENTIFIER === $token->id + && $this->should_quote_bare_mysql_identifier( $token->get_value() ) + && ( ! isset( $tokens[ $i + 1 ] ) || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[ $i + 1 ]->id ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a query still contains raw MySQL optimizer index hint syntax. + * + * @param string $query SQL query. + * @return bool Whether raw MySQL index hint syntax remains. + */ + private function contains_mysql_index_hint_syntax( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + for ( $i = 0; isset( $tokens[ $i ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $i ]->id; $i++ ) { + if ( $this->is_mysql_index_hint_marker( $tokens, $i, count( $tokens ) ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a query is a supported MySQL CREATE TABLE statement. + * + * @param string $query MySQL query. + * @return bool Whether the query should be translated before execution. + */ + private function is_create_table_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return false; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + $table_name_position = $position + 1; + + return isset( $tokens[ $position ] ) + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[ $position ]->id + && ( + $this->has_mysql_create_table_marker( $tokens ) + || $this->is_mysql_create_table_qualified_target( $tokens, $table_name_position ) + ); + } + + /** + * Validate a CREATE TABLE target database qualifier. + * + * @param string $query MySQL query. + */ + private function validate_mysql_create_table_target_database( string $query ): void { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return; + } + + ++$position; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position += 3; + } + + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name || ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id ) ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE statement.' ); + } + } + + /** + * Check whether CREATE TABLE IF NOT EXISTS targets an existing table. + * + * @param string $query MySQL query. + * @return bool Whether the statement should be a MySQL-compatible no-op. + */ + private function mysql_create_table_if_not_exists_target_exists( string $query ): bool { + $target = $this->get_mysql_create_table_if_not_exists_target( $query ); + if ( null === $target ) { + return false; + } + + return $this->mysql_create_table_target_exists( $target['schema'], $target['table'], $target['temporary'] ); + } + + /** + * Parse the target of a CREATE TABLE IF NOT EXISTS statement. + * + * @param string $query MySQL query. + * @return array{schema: string, table: string, temporary: bool}|null Parsed target, or null when this is not IF NOT EXISTS. + */ + private function get_mysql_create_table_if_not_exists_target( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + $is_temporary = false; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + $is_temporary = true; + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + || WP_MySQL_Lexer::IF_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::NOT_SYMBOL !== $tokens[ $position + 1 ]->id + || WP_MySQL_Lexer::EXISTS_SYMBOL !== $tokens[ $position + 2 ]->id + ) { + return null; + } + + $position += 3; + $table_reference = $this->get_mysql_table_administration_table_reference( $tokens, $position, true ); + if ( null === $table_reference || $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + return array( + 'schema' => $this->get_mysql_create_table_select_backend_schema( $table_reference, $is_temporary ), + 'table' => $table_reference['table'], + 'temporary' => $is_temporary, + ); + } + + /** + * Check whether a CREATE TABLE target already exists in the backend. + * + * @param string $schema_name Backend schema name. + * @param string $table_name Table name. + * @param bool $is_temporary Whether the target is temporary. + * @return bool Whether the target exists. + */ + private function mysql_create_table_target_exists( string $schema_name, string $table_name, bool $is_temporary ): bool { + if ( $is_temporary ) { + return null !== $this->get_active_temporary_table_schema( $table_name ); + } + + if ( 'sqlite' === $this->connection->get_driver_name() ) { + return $this->sqlite_table_administration_table_exists( $schema_name, $table_name ); + } + + $stmt = $this->connection->query( + 'SELECT 1 + FROM pg_catalog.pg_class c + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = c.relnamespace + WHERE n.nspname = ? + AND c.relname = ? + AND c.relkind IN (\'r\', \'p\') + LIMIT 1', + array( $schema_name, $table_name ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Check whether a CREATE TABLE statement uses a qualified table target. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Table-name position. + * @return bool Whether the table target is qualified. + */ + private function is_mysql_create_table_qualified_target( array $tokens, int $position ): bool { + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position += 3; + } + + return null !== $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ) + && isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position + 1 ]->id + && null !== $this->get_mysql_identifier_token_value( $tokens[ $position + 2 ] ?? null ); + } + + /** + * Check whether a CREATE TABLE query creates a temporary table. + * + * @param string $query MySQL query. + * @return bool Whether the query is CREATE TEMPORARY TABLE. + */ + private function is_temporary_create_table_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + + return isset( $tokens[0], $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::CREATE_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id; + } + + /** + * Check whether a CREATE TABLE query contains MySQL install-schema syntax. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the query should use the install DDL translator. + */ + private function has_mysql_create_table_marker( array $tokens ): bool { + foreach ( $tokens as $position => $token ) { + if ( $this->is_mysql_create_table_charset_set_marker( $tokens, $position ) ) { + return true; + } + + if ( $this->is_mysql_create_table_secondary_index_marker( $tokens, $position ) ) { + return true; + } + + if ( $this->is_mysql_create_table_foreign_key_marker( $tokens, $position ) ) { + return true; + } + + if ( $this->is_mysql_create_table_primary_key_index_option_marker( $tokens, $position ) ) { + return true; + } + + if ( + WP_MySQL_Lexer::ON_SYMBOL === $token->id + && isset( $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::UPDATE_SYMBOL === $tokens[ $position + 1 ]->id + ) { + return true; + } + + if ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL, + WP_MySQL_Lexer::AUTOEXTEND_SIZE_SYMBOL, + WP_MySQL_Lexer::AVG_ROW_LENGTH_SYMBOL, + WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, + WP_MySQL_Lexer::BIT_SYMBOL, + WP_MySQL_Lexer::BOOLEAN_SYMBOL, + WP_MySQL_Lexer::BOOL_SYMBOL, + WP_MySQL_Lexer::CHARSET_SYMBOL, + WP_MySQL_Lexer::CHECK_SYMBOL, + WP_MySQL_Lexer::CHECKSUM_SYMBOL, + WP_MySQL_Lexer::COLLATE_SYMBOL, + WP_MySQL_Lexer::COMMENT_SYMBOL, + WP_MySQL_Lexer::COMPRESSION_SYMBOL, + WP_MySQL_Lexer::CONNECTION_SYMBOL, + WP_MySQL_Lexer::DATE_SYMBOL, + WP_MySQL_Lexer::DATETIME_SYMBOL, + WP_MySQL_Lexer::DEC_SYMBOL, + WP_MySQL_Lexer::DELAY_KEY_WRITE_SYMBOL, + WP_MySQL_Lexer::DIRECTORY_SYMBOL, + WP_MySQL_Lexer::ENCRYPTION_SYMBOL, + WP_MySQL_Lexer::ENFORCED_SYMBOL, + WP_MySQL_Lexer::ENGINE_SYMBOL, + WP_MySQL_Lexer::ENGINE_ATTRIBUTE_SYMBOL, + WP_MySQL_Lexer::FIXED_SYMBOL, + WP_MySQL_Lexer::FULLTEXT_SYMBOL, + WP_MySQL_Lexer::INSERT_METHOD_SYMBOL, + WP_MySQL_Lexer::JSON_SYMBOL, + WP_MySQL_Lexer::KEY_BLOCK_SIZE_SYMBOL, + WP_MySQL_Lexer::LONG_SYMBOL, + WP_MySQL_Lexer::MAX_ROWS_SYMBOL, + WP_MySQL_Lexer::MIN_ROWS_SYMBOL, + WP_MySQL_Lexer::PACK_KEYS_SYMBOL, + WP_MySQL_Lexer::PASSWORD_SYMBOL, + WP_MySQL_Lexer::REAL_SYMBOL, + WP_MySQL_Lexer::ROW_FORMAT_SYMBOL, + WP_MySQL_Lexer::SECONDARY_ENGINE_SYMBOL, + WP_MySQL_Lexer::SECONDARY_ENGINE_ATTRIBUTE_SYMBOL, + WP_MySQL_Lexer::SPATIAL_SYMBOL, + WP_MySQL_Lexer::STATS_AUTO_RECALC_SYMBOL, + WP_MySQL_Lexer::STATS_PERSISTENT_SYMBOL, + WP_MySQL_Lexer::STATS_SAMPLE_PAGES_SYMBOL, + WP_MySQL_Lexer::TABLESPACE_SYMBOL, + WP_MySQL_Lexer::TIME_SYMBOL, + WP_MySQL_Lexer::TIMESTAMP_SYMBOL, + WP_MySQL_Lexer::UNSIGNED_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::YEAR_SYMBOL, + ), + true + ) + ) { + return true; + } + } + + return false; + } + + /** + * Check whether a CREATE TABLE token introduces a FOREIGN KEY definition. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @return bool Whether the tokens are a FOREIGN KEY marker. + */ + private function is_mysql_create_table_foreign_key_marker( array $tokens, int $position ): bool { + return isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::FOREIGN_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[ $position + 1 ]->id; + } + + /** + * Check whether a CREATE TABLE token introduces a MySQL secondary index definition. + * + * PostgreSQL-compatible PRIMARY KEY clauses can fall through to the backend + * parser, but MySQL KEY/INDEX table elements must use the DDL translator even + * when the statement has no other MySQL-only markers. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @return bool Whether the token is a secondary KEY/INDEX marker. + */ + private function is_mysql_create_table_secondary_index_marker( array $tokens, int $position ): bool { + if ( + ! isset( $tokens[ $position ] ) + || ! in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::KEY_SYMBOL, WP_MySQL_Lexer::INDEX_SYMBOL ), true ) + ) { + return false; + } + + $previous_token = $tokens[ $position - 1 ] ?? null; + return null === $previous_token + || ! in_array( $previous_token->id, array( WP_MySQL_Lexer::PRIMARY_SYMBOL, WP_MySQL_Lexer::FOREIGN_SYMBOL ), true ); + } + + /** + * Check whether a CREATE TABLE PRIMARY KEY clause uses MySQL index options. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @return bool Whether the token is a PRIMARY KEY option marker. + */ + private function is_mysql_create_table_primary_key_index_option_marker( array $tokens, int $position ): bool { + return isset( $tokens[ $position ], $tokens[ $position - 1 ], $tokens[ $position - 2 ] ) + && WP_MySQL_Lexer::USING_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::KEY_SYMBOL === $tokens[ $position - 1 ]->id + && WP_MySQL_Lexer::PRIMARY_SYMBOL === $tokens[ $position - 2 ]->id; + } + + /** + * Check whether tokens at a position form CHAR SET or CHARACTER SET. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position. + * @return bool Whether this is a MySQL charset marker. + */ + private function is_mysql_create_table_charset_set_marker( array $tokens, int $position ): bool { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $position + 1 ]->id + ) { + return false; + } + + if ( WP_MySQL_Lexer::CHARACTER_SYMBOL === $tokens[ $position ]->id ) { + return true; + } + + return WP_MySQL_Lexer::CHAR_SYMBOL === $tokens[ $position ]->id + && in_array( strtolower( $tokens[ $position ]->get_bytes() ), array( 'char', 'character' ), true ); + } + + /** + * Get a simple MySQL variable SELECT query. + * + * @param string $query MySQL query. + * @return array{columns: string[], row: array}|null Parsed variable query, or null when not applicable. + */ + private function get_mysql_variable_select_query( string $query ): ?array { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || ( + WP_MySQL_Lexer::AT_TEXT_SUFFIX !== $tokens[1]->id + && WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[1]->id + ) + ) { + return null; + } + + $position = 1; + $columns = array(); + $row = array(); + + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + $variable = $this->parse_mysql_select_variable_reference( $tokens, $position ); + if ( null === $variable ) { + throw new InvalidArgumentException( 'Unsupported MySQL variable SELECT statement.' ); + } + + $column = $variable['display']; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::AS_SYMBOL === $tokens[ $position ]->id ) { + $alias = $this->get_mysql_projection_alias_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $alias ) { + throw new InvalidArgumentException( 'Unsupported MySQL variable SELECT statement.' ); + } + + $column = $alias; + $position += 2; + } else { + $implicit_alias = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null !== $implicit_alias ) { + $column = $implicit_alias; + ++$position; + } + } + + $columns[] = $column; + $row[ $column ] = $variable['value']; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + if ( + ! isset( $tokens[ $position ] ) + || ( + WP_MySQL_Lexer::AT_TEXT_SUFFIX !== $tokens[ $position ]->id + && WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[ $position ]->id + ) + ) { + if ( isset( $tokens[ $position ] ) && ! $this->is_mysql_variable_select_fallback_boundary_token( $tokens[ $position ] ) ) { + return null; + } + + throw new InvalidArgumentException( 'Unsupported MySQL variable SELECT statement.' ); + } + + continue; + } + + break; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + if ( isset( $tokens[ $position ] ) && $this->is_mysql_variable_select_fallback_boundary_token( $tokens[ $position ] ) ) { + return null; + } + + throw new InvalidArgumentException( 'Unsupported MySQL variable SELECT statement.' ); + } + + return array( + 'columns' => $columns, + 'row' => $row, + ); + } + + /** + * Check whether a token can start a clause handled by the general SELECT translator. + * + * @param WP_MySQL_Token $token MySQL token. + * @return bool Whether the simple variable SELECT path should defer. + */ + private function is_mysql_variable_select_fallback_boundary_token( WP_MySQL_Token $token ): bool { + return in_array( + $token->id, + array( + WP_MySQL_Lexer::FROM_SYMBOL, + WP_MySQL_Lexer::GROUP_SYMBOL, + WP_MySQL_Lexer::HAVING_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::UNION_SYMBOL, + WP_MySQL_Lexer::WHERE_SYMBOL, + ), + true + ); + } + + /** + * Parse a variable reference in a simple SELECT list. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return array{display: string, value: string|null}|null Variable result descriptor. + */ + private function parse_mysql_select_variable_reference( array $tokens, int &$position ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + if ( WP_MySQL_Lexer::AT_TEXT_SUFFIX === $tokens[ $position ]->id ) { + $display = $tokens[ $position ]->get_value(); + ++$position; + + return array( + 'display' => $display, + 'value' => $this->get_mysql_user_variable_value( $this->normalize_mysql_user_variable_name( $display ) ), + ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $display = null; + $scope = null; + $name = $this->parse_mysql_system_variable_reference( $tokens, $position, $display, $scope ); + if ( null === $name || null === $display ) { + return null; + } + + $value = $this->get_mysql_system_variable_value( $name, $scope ); + if ( null === $value ) { + throw new InvalidArgumentException( 'Unsupported MySQL system variable.' ); + } + + return array( + 'display' => $display, + 'value' => $value, + ); + } + + /** + * Get the result column name from a supported SELECT DATABASE() query. + * + * @param string $query MySQL query. + * @return string|null Result column name, or null when unsupported. + */ + private function get_mysql_database_function_select_column( string $query ): ?string { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::DATABASE_SYMBOL !== $tokens[1]->id + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[2]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[3]->id + || ! $this->is_at_mysql_query_end( $tokens, 4 ) + ) { + return null; + } + + return $tokens[1]->get_value() . '()'; + } + + /** + * Check whether a query asks for MySQL FOUND_ROWS(). + * + * @param string $query MySQL query. + * @return bool Whether the query is SELECT FOUND_ROWS(). + */ + private function is_found_rows_query( string $query ): bool { + $tokens = $this->get_mysql_tokens( $query ); + if ( + ! isset( $tokens[0], $tokens[1], $tokens[2], $tokens[3] ) + || WP_MySQL_Lexer::SELECT_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::IDENTIFIER !== $tokens[1]->id + || 'found_rows' !== strtolower( $tokens[1]->get_value() ) + || WP_MySQL_Lexer::OPEN_PAR_SYMBOL !== $tokens[2]->id + || WP_MySQL_Lexer::CLOSE_PAR_SYMBOL !== $tokens[3]->id + ) { + return false; + } + + return $this->is_at_mysql_query_end( $tokens, 4 ); + } + + /** + * Read the backend server version without requiring a PostgreSQL-only query. + * + * @return string + */ + private function read_server_version(): string { + try { + $version = $this->connection->get_pdo()->getAttribute( PDO::ATTR_SERVER_VERSION ); + if ( false !== $version && null !== $version ) { + return (string) $version; + } + } catch ( Throwable $e ) { + return 'PostgreSQL'; + } + + return 'PostgreSQL'; + } + + /** + * Normalize PDO column metadata into the MySQLi-shaped fields wpdb expects. + * + * @param PDOStatement $stmt The statement to inspect. + * @param array $excluded_names Column names hidden from callers. + * @return array + */ + private function normalize_column_meta( PDOStatement $stmt, array $excluded_names = array() ): array { + $meta = array(); + for ( $i = 0; $i < $stmt->columnCount(); $i++ ) { + $column_meta = $stmt->getColumnMeta( $i ); + if ( ! is_array( $column_meta ) ) { + $column_meta = array(); + } + $normalized_column_meta = $this->normalize_single_column_meta( $column_meta ); + if ( isset( $excluded_names[ $normalized_column_meta['name'] ] ) ) { + continue; + } + + $meta[] = $normalized_column_meta; + } + return $meta; + } + + /** + * Normalize metadata for one column. + * + * @param array $column_meta Raw PDO column metadata. + * @return array + */ + private function normalize_single_column_meta( array $column_meta ): array { + $name = isset( $column_meta['name'] ) ? (string) $column_meta['name'] : ''; + $table = isset( $column_meta['table'] ) ? (string) $column_meta['table'] : ''; + $native_type = isset( $column_meta['native_type'] ) ? strtolower( (string) $column_meta['native_type'] ) : ''; + $type = $this->map_native_type( $native_type ); + $length = isset( $column_meta['len'] ) ? (int) $column_meta['len'] : 0; + $precision = isset( $column_meta['precision'] ) ? (int) $column_meta['precision'] : 0; + + return array( + 'native_type' => $type['native_type'], + 'pdo_type' => $type['pdo_type'], + 'flags' => isset( $column_meta['flags'] ) && is_array( $column_meta['flags'] ) ? $column_meta['flags'] : array(), + 'table' => $table, + 'name' => $name, + 'len' => $length, + 'precision' => $precision, + 'mysqli:orgname' => $name, + 'mysqli:orgtable' => $table, + 'mysqli:db' => $this->db_name, + 'mysqli:charsetnr' => $type['charsetnr'], + 'mysqli:flags' => 0, + 'mysqli:type' => $type['mysqli_type'], + ); + } + + /** + * Map PostgreSQL native type names to conservative MySQL/PDO metadata. + * + * @param string $native_type Lowercase PDO native type. + * @return array + */ + private function map_native_type( string $native_type ): array { + $defaults = array( + 'native_type' => 'VAR_STRING', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 253, + 'charsetnr' => 255, + ); + + $map = array( + 'int2' => array( + 'native_type' => 'SHORT', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 2, + 'charsetnr' => 63, + ), + 'smallint' => array( + 'native_type' => 'SHORT', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 2, + 'charsetnr' => 63, + ), + 'int4' => array( + 'native_type' => 'LONG', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 3, + 'charsetnr' => 63, + ), + 'integer' => array( + 'native_type' => 'LONG', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 3, + 'charsetnr' => 63, + ), + 'int8' => array( + 'native_type' => 'LONGLONG', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 8, + 'charsetnr' => 63, + ), + 'bigint' => array( + 'native_type' => 'LONGLONG', + 'pdo_type' => PDO::PARAM_INT, + 'mysqli_type' => 8, + 'charsetnr' => 63, + ), + 'bytea' => array( + 'native_type' => 'BLOB', + 'pdo_type' => PDO::PARAM_LOB, + 'mysqli_type' => 252, + 'charsetnr' => 63, + ), + 'blob' => array( + 'native_type' => 'BLOB', + 'pdo_type' => PDO::PARAM_LOB, + 'mysqli_type' => 252, + 'charsetnr' => 63, + ), + 'bool' => array( + 'native_type' => 'TINY', + 'pdo_type' => PDO::PARAM_BOOL, + 'mysqli_type' => 1, + 'charsetnr' => 63, + ), + 'boolean' => array( + 'native_type' => 'TINY', + 'pdo_type' => PDO::PARAM_BOOL, + 'mysqli_type' => 1, + 'charsetnr' => 63, + ), + 'numeric' => array( + 'native_type' => 'NEWDECIMAL', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 246, + 'charsetnr' => 63, + ), + 'decimal' => array( + 'native_type' => 'NEWDECIMAL', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 246, + 'charsetnr' => 63, + ), + 'float4' => array( + 'native_type' => 'FLOAT', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 4, + 'charsetnr' => 63, + ), + 'float8' => array( + 'native_type' => 'DOUBLE', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 5, + 'charsetnr' => 63, + ), + 'date' => array( + 'native_type' => 'DATE', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 10, + 'charsetnr' => 63, + ), + 'time' => array( + 'native_type' => 'TIME', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 11, + 'charsetnr' => 63, + ), + 'timestamp' => array( + 'native_type' => 'DATETIME', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 12, + 'charsetnr' => 63, + ), + 'timestamptz' => array( + 'native_type' => 'DATETIME', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 12, + 'charsetnr' => 63, + ), + 'datetime' => array( + 'native_type' => 'DATETIME', + 'pdo_type' => PDO::PARAM_STR, + 'mysqli_type' => 12, + 'charsetnr' => 63, + ), + ); + + return isset( $map[ $native_type ] ) ? $map[ $native_type ] : $defaults; + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php new file mode 100644 index 000000000..872e68d2c --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO.php @@ -0,0 +1,25 @@ + new PDO( 'sqlite::memory:' ) ) ); + } + + /** + * Return a stale insert ID to verify driver-level MySQL compatibility. + * + * @param string|null $sequence Optional sequence name. + * @return string + */ + public function get_last_insert_id( ?string $sequence = null ): string { + return '29'; + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php new file mode 100644 index 000000000..27dcecb8d --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO.php @@ -0,0 +1,99 @@ +pdo = new PDO( 'sqlite::memory:' ); + $this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $this->pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); + } + + /** + * Begin a transaction. + * + * @return bool Whether the transaction started. + */ + public function beginTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->pdo->beginTransaction(); + } + + /** + * Roll back the active transaction. + * + * @return bool Whether the transaction was rolled back. + */ + public function rollBack(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->pdo->rollBack(); + } + + /** + * Check whether a transaction is active. + * + * @return bool Whether a transaction is active. + */ + public function inTransaction(): bool { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + return $this->pdo->inTransaction(); + } + + /** + * Prepare a SQL statement. + * + * @param string $sql SQL statement. + * @return PDOStatement Statement object. + */ + public function prepare( string $sql ): PDOStatement { + $this->prepared_sql[] = $sql; + return $this->pdo->prepare( $sql ); + } + + /** + * Execute a SQL statement and record savepoint commands. + * + * @param string $sql SQL statement. + * @return int|false Affected row count, or false on failure. + */ + public function exec( string $sql ) { + $this->exec_sql[] = $sql; + return $this->pdo->exec( $sql ); + } + + /** + * Get PDO attributes. + * + * @param int $attribute Attribute identifier. + * @return mixed Attribute value. + */ + public function getAttribute( int $attribute ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid + if ( PDO::ATTR_DRIVER_NAME === $attribute ) { + return 'pgsql'; + } + + return $this->pdo->getAttribute( $attribute ); + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php new file mode 100644 index 000000000..2fc89ceb2 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -0,0 +1,504 @@ +assertSame( + 'pgsql:host=localhost;port=5432;dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'wp', + ) + ) + ); + } + + /** + * Tests PostgreSQL DSN construction does not include credentials. + */ + public function test_build_dsn_keeps_credentials_out_of_structured_dsn(): void { + $this->assertSame( + 'pgsql:host=localhost;port=5432;dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'wp', + 'user' => 'wp_user', + 'password' => 'secret', + ) + ) + ); + } + + /** + * Tests PostgreSQL DSN construction requires a database name. + * + * @dataProvider data_missing_dbname_options + * + * @param array $options Connection options. + */ + public function test_build_dsn_requires_non_empty_dbname( array $options ): void { + $this->expectException( InvalidArgumentException::class ); + WP_PostgreSQL_Connection::build_dsn( $options ); + } + + /** + * Data provider for missing database-name options. + * + * @return array + */ + public function data_missing_dbname_options(): array { + return array( + 'not set' => array( array() ), + 'empty' => array( array( 'dbname' => '' ) ), + 'null' => array( array( 'dbname' => null ) ), + ); + } + + /** + * Tests empty host and port values are omitted. + */ + public function test_build_dsn_omits_empty_host_and_port(): void { + $this->assertSame( + 'pgsql:dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => '', + 'port' => '', + 'dbname' => 'wp', + ) + ) + ); + } + + /** + * Tests PostgreSQL DSN construction preserves socket-style host paths. + */ + public function test_build_dsn_preserves_socket_style_host_paths(): void { + $this->assertSame( + 'pgsql:host=/var/run/postgresql;dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => '/var/run/postgresql', + 'dbname' => 'wp', + ) + ) + ); + + $this->assertSame( + 'pgsql:host=/tmp/.s.PGSQL.5432;port=5432;dbname=wp', + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => '/tmp/.s.PGSQL.5432', + 'port' => 5432, + 'dbname' => 'wp', + ) + ) + ); + } + + /** + * Tests PostgreSQL DSN separator rejection. + */ + public function test_build_dsn_rejects_structured_option_separators(): void { + $this->expectException( InvalidArgumentException::class ); + WP_PostgreSQL_Connection::build_dsn( + array( + 'host' => 'local;host', + 'dbname' => 'wp', + ) + ); + } + + /** + * Tests PostgreSQL DSN NUL-byte rejection. + * + * @dataProvider data_nul_byte_dsn_options + * + * @param array $options Connection options. + */ + public function test_build_dsn_rejects_nul_bytes_in_structured_options( array $options ): void { + $this->expectException( InvalidArgumentException::class ); + WP_PostgreSQL_Connection::build_dsn( $options ); + } + + /** + * Data provider for NUL-containing DSN options. + * + * @return array + */ + public function data_nul_byte_dsn_options(): array { + return array( + 'host' => array( + array( + 'host' => "local\0host", + 'dbname' => 'wp', + ), + ), + 'port' => array( + array( + 'port' => "5432\0", + 'dbname' => 'wp', + ), + ), + 'dbname' => array( + array( + 'dbname' => "w\0p", + ), + ), + ); + } + + /** + * Tests PostgreSQL identifier quoting. + */ + public function test_quote_identifier_value_uses_postgresql_double_quotes(): void { + $this->assertSame( + '"wp_""posts"', + WP_PostgreSQL_Connection::quote_identifier_value( 'wp_"posts' ) + ); + } + + /** + * Tests PostgreSQL identifier NUL-byte rejection. + */ + public function test_quote_identifier_value_rejects_nul_bytes(): void { + $this->expectException( InvalidArgumentException::class ); + WP_PostgreSQL_Connection::quote_identifier_value( "wp_\0posts" ); + } + + /** + * Tests injected PDO instances are configured and reused. + */ + public function test_constructor_uses_injected_pdo_and_sets_exception_mode(): void { + $pdo = new PDO( 'sqlite::memory:' ); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + + $this->assertSame( $pdo, $connection->get_pdo() ); + $this->assertSame( PDO::ERRMODE_EXCEPTION, $pdo->getAttribute( PDO::ATTR_ERRMODE ) ); + } + + /** + * Tests query execution with parameters and query logging. + */ + public function test_query_executes_parameters_and_logs_query(): void { + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + $log = array(); + $connection->set_query_logger( + function ( string $sql, array $params ) use ( &$log ): void { + $log[] = array( $sql, $params ); + } + ); + + $stmt = $connection->query( 'SELECT ? AS value', array( 'ok' ) ); + + $this->assertSame( array( 'value' => 'ok' ), $stmt->fetch( PDO::FETCH_ASSOC ) ); + $this->assertSame( array( array( 'SELECT ? AS value', array( 'ok' ) ) ), $log ); + } + + /** + * Tests failed statements are isolated from the active PostgreSQL transaction. + */ + public function test_query_rolls_back_failed_postgresql_statement_to_transaction_savepoint(): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + $connection->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)' ); + $connection->query( "INSERT INTO t (id, value) VALUES (1, 'ok')" ); + + try { + $connection->query( 'INSERT INTO missing_table (id) VALUES (1)' ); + $this->fail( 'Expected the invalid statement to throw.' ); + } catch ( PDOException $exception ) { + $this->assertStringContainsString( 'missing_table', $exception->getMessage() ); + } + + $stmt = $connection->query( 'SELECT value FROM t WHERE id = 1' ); + + $this->assertSame( 'ok', $stmt->fetchColumn() ); + $pdo->rollBack(); + $this->assertSame( + array( + 'SAVEPOINT wp_statement_1', + 'RELEASE SAVEPOINT wp_statement_1', + 'SAVEPOINT wp_statement_2', + 'RELEASE SAVEPOINT wp_statement_2', + 'SAVEPOINT wp_statement_3', + 'ROLLBACK TO SAVEPOINT wp_statement_3', + 'RELEASE SAVEPOINT wp_statement_3', + 'SAVEPOINT wp_statement_4', + ), + $pdo->exec_sql + ); + } + + /** + * Tests consecutive plain SELECT statements reuse one generated read savepoint. + */ + public function test_query_reuses_read_savepoint_for_consecutive_plain_select_statements(): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + $first = $connection->query( 'SELECT 1 AS value' ); + $second = $connection->query( 'SELECT 2 AS value' ); + $connection->query( 'CREATE TABLE t (id INTEGER)' ); + + $this->assertSame( '1', $first->fetchColumn() ); + $this->assertSame( '2', $second->fetchColumn() ); + $this->assertSame( + array( 'SELECT 1 AS value', 'SELECT 2 AS value', 'CREATE TABLE t (id INTEGER)' ), + $pdo->prepared_sql + ); + $this->assertSame( + array( + 'SAVEPOINT wp_statement_1', + 'RELEASE SAVEPOINT wp_statement_1', + ), + $pdo->exec_sql + ); + $pdo->rollBack(); + } + + /** + * Tests failed read statements are isolated from the active PostgreSQL transaction. + */ + public function test_query_rolls_back_failed_read_to_shared_savepoint(): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + $connection->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)' ); + + try { + $connection->query( 'SELECT missing_column FROM t' ); + $this->fail( 'Expected the invalid read to throw.' ); + } catch ( PDOException $exception ) { + $this->assertStringContainsString( 'missing_column', $exception->getMessage() ); + } + + $stmt = $connection->query( 'SELECT 1 AS value' ); + + $this->assertSame( '1', $stmt->fetchColumn() ); + $pdo->rollBack(); + $this->assertSame( + array( + 'SAVEPOINT wp_statement_1', + 'RELEASE SAVEPOINT wp_statement_1', + 'SAVEPOINT wp_statement_2', + 'ROLLBACK TO SAVEPOINT wp_statement_2', + 'RELEASE SAVEPOINT wp_statement_2', + 'SAVEPOINT wp_statement_3', + ), + $pdo->exec_sql + ); + } + + /** + * Tests locking SELECT statements use per-statement savepoints. + * + * @dataProvider data_locking_select_statements + * + * @param string $sql Locking SELECT statement. + */ + public function test_query_wraps_locking_select_statement_in_per_statement_savepoint( string $sql ): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + + try { + $connection->query( $sql ); + $this->fail( 'Expected SQLite to reject the PostgreSQL/MySQL locking SELECT shape.' ); + } catch ( PDOException $exception ) { + $this->assertNotSame( '', $exception->getMessage() ); + } + + $pdo->rollBack(); + $this->assertSame( + array( + 'SAVEPOINT wp_statement_1', + 'ROLLBACK TO SAVEPOINT wp_statement_1', + 'RELEASE SAVEPOINT wp_statement_1', + ), + $pdo->exec_sql + ); + } + + /** + * Provides locking SELECT statements. + * + * @return array + */ + public function data_locking_select_statements(): array { + return array( + 'for_update' => array( 'SELECT 1 FOR UPDATE' ), + 'for_share' => array( 'SELECT 1 FOR SHARE' ), + 'lock_in_share_mode' => array( 'SELECT 1 LOCK IN SHARE MODE' ), + ); + } + + /** + * Tests transaction-control statements are not wrapped in generated savepoints. + */ + public function test_query_does_not_wrap_transaction_control_statement_in_savepoint(): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + $connection->query( 'ROLLBACK;' ); + + $this->assertSame( array( 'ROLLBACK;' ), $pdo->prepared_sql ); + $this->assertSame( array(), $pdo->exec_sql ); + } + + /** + * Tests prepare returns a PDO statement and logs without parameters. + */ + public function test_prepare_returns_statement_and_logs_without_params(): void { + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + $log = array(); + $connection->set_query_logger( + function ( string $sql, array $params ) use ( &$log ): void { + $log[] = array( $sql, $params ); + } + ); + + $stmt = $connection->prepare( 'SELECT ? AS value' ); + $stmt->execute( array( 'ok' ) ); + + $this->assertInstanceOf( PDOStatement::class, $stmt ); + $this->assertSame( array( 'value' => 'ok' ), $stmt->fetch( PDO::FETCH_ASSOC ) ); + $this->assertSame( array( array( 'SELECT ? AS value', array() ) ), $log ); + } + + /** + * Tests prepare consumes an active read savepoint before returning a statement. + */ + public function test_prepare_consumes_active_read_savepoint_before_prepared_write(): void { + $pdo = new WP_PostgreSQL_Connection_Statement_Savepoint_Fake_PDO(); + $connection = $this->create_connection_with_pdo_fixture( $pdo ); + + $pdo->beginTransaction(); + $connection->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)' ); + $connection->query( 'SELECT 1' ); + + $stmt = $connection->prepare( 'INSERT INTO t (id, value) VALUES (1, ?)' ); + $stmt->execute( array( 'kept' ) ); + + try { + $connection->query( 'SELECT missing_column FROM t' ); + $this->fail( 'Expected the invalid read to throw.' ); + } catch ( PDOException $exception ) { + $this->assertStringContainsString( 'missing_column', $exception->getMessage() ); + } + + $count = $connection->query( 'SELECT COUNT(*) FROM t' ); + + $this->assertSame( '1', $count->fetchColumn() ); + $pdo->rollBack(); + $this->assertSame( + array( + 'CREATE TABLE t (id INTEGER PRIMARY KEY, value TEXT)', + 'SELECT 1', + 'INSERT INTO t (id, value) VALUES (1, ?)', + 'SELECT missing_column FROM t', + 'SELECT COUNT(*) FROM t', + ), + $pdo->prepared_sql + ); + $this->assertSame( + array( + 'SAVEPOINT wp_statement_1', + 'RELEASE SAVEPOINT wp_statement_1', + 'SAVEPOINT wp_statement_2', + 'RELEASE SAVEPOINT wp_statement_2', + 'SAVEPOINT wp_statement_3', + 'ROLLBACK TO SAVEPOINT wp_statement_3', + 'RELEASE SAVEPOINT wp_statement_3', + 'SAVEPOINT wp_statement_4', + ), + $pdo->exec_sql + ); + } + + /** + * Tests last insert ID delegates to the injected PDO. + */ + public function test_get_last_insert_id_delegates_to_injected_pdo_default_sequence(): void { + $pdo = new PDO( 'sqlite::memory:' ); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + + $pdo->exec( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $pdo->exec( "INSERT INTO t (value) VALUES ('first')" ); + + $this->assertSame( '1', $connection->get_last_insert_id() ); + } + + /** + * Tests value quoting delegates to the injected PDO. + */ + public function test_quote_delegates_to_injected_pdo(): void { + $pdo = new PDO( 'sqlite::memory:' ); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + + $this->assertSame( $pdo->quote( "O'Reilly" ), $connection->quote( "O'Reilly" ) ); + } + + /** + * Tests PostgreSQL string values with backslashes use escape string syntax. + */ + public function test_quote_uses_postgresql_escape_string_syntax_for_backslashes(): void { + $connection = $this->create_connection_with_pdo_fixture( new WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO() ); + + $this->assertSame( + "E'O''Reilly \\\\ path'", + $connection->quote( "O'Reilly \\ path" ) + ); + } + + /** + * Tests PostgreSQL string values with NUL bytes are encoded before quoting. + */ + public function test_quote_encodes_mysql_text_nul_bytes_for_postgresql(): void { + $connection = $this->create_connection_with_pdo_fixture( new WP_PostgreSQL_Connection_Pgsql_Quote_Fake_PDO() ); + + $quoted = $connection->quote( "protected\0property" ); + $this->assertStringNotContainsString( "\0", $quoted ); + $this->assertStringContainsString( 'WP_MYSQL_TEXT_V1:', $quoted ); + $this->assertNotSame( "'protected\0property'", $quoted ); + } + + /** + * Creates a PostgreSQL connection backed by a lightweight PDO fixture. + * + * @param object $pdo_fixture PDO-like fixture. + * @return WP_PostgreSQL_Connection Connection under test. + */ + private function create_connection_with_pdo_fixture( $pdo_fixture ): WP_PostgreSQL_Connection { + $reflection = new ReflectionClass( WP_PostgreSQL_Connection::class ); + $connection = $reflection->newInstanceWithoutConstructor(); + + $property = $reflection->getProperty( 'pdo' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $property->setValue( $connection, $pdo_fixture ); + + return $connection; + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Create_Table_Translator_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Create_Table_Translator_Tests.php new file mode 100644 index 000000000..630e6bac3 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Create_Table_Translator_Tests.php @@ -0,0 +1,876 @@ +assertSame( + array( + "CREATE TABLE \"wp_options\" (\n \"option_id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"option_name\" varchar(191) NOT NULL DEFAULT '',\n \"option_value\" text NOT NULL,\n \"autoload\" varchar(20) NOT NULL DEFAULT 'yes',\n PRIMARY KEY (\"option_id\")\n)", + 'CREATE UNIQUE INDEX "wp_options__option_name" ON "wp_options" ("option_name")', + 'CREATE INDEX "wp_options__autoload" ON "wp_options" ("autoload")', + ), + $this->translate( + "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) + ) DEFAULT CHARACTER SET utf8mb4" + ) + ); + } + + /** + * Tests a compound primary key and secondary index. + */ + public function test_translate_wp_term_relationships_create_table(): void { + $this->assertSame( + array( + "CREATE TABLE \"wp_term_relationships\" (\n \"object_id\" bigint NOT NULL DEFAULT '0',\n \"term_taxonomy_id\" bigint NOT NULL DEFAULT '0',\n \"term_order\" integer NOT NULL DEFAULT '0',\n PRIMARY KEY (\"object_id\", \"term_taxonomy_id\")\n)", + 'CREATE INDEX "wp_term_relationships__term_taxonomy_id" ON "wp_term_relationships" ("term_taxonomy_id")', + ), + $this->translate( + 'CREATE TABLE wp_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) + ) DEFAULT CHARACTER SET utf8mb4' + ) + ); + } + + /** + * Tests WordPress schema identifiers that are tokenized as MySQL keywords. + */ + public function test_translate_wordpress_keyword_identifier_columns(): void { + $this->assertSame( + array( + "CREATE TABLE \"wp_keyword_identifiers\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"name\" varchar(200) NOT NULL DEFAULT '',\n \"description\" text NOT NULL,\n PRIMARY KEY (\"id\")\n)", + 'CREATE INDEX "wp_keyword_identifiers__name" ON "wp_keyword_identifiers" ("name")', + 'CREATE INDEX "wp_keyword_identifiers__description" ON "wp_keyword_identifiers" ("description")', + ), + $this->translate( + "CREATE TABLE wp_keyword_identifiers ( + id bigint(20) unsigned NOT NULL auto_increment, + name varchar(200) NOT NULL default '', + description longtext NOT NULL, + PRIMARY KEY (id), + KEY name (name(191)), + KEY description (description(191)) + ) DEFAULT CHARACTER SET utf8mb4" + ) + ); + } + + /** + * Tests MySQL prefix index lengths are stripped from PostgreSQL index columns. + */ + public function test_translate_strips_prefix_index_lengths(): void { + $this->assertSame( + array( + "CREATE TABLE \"wp_postmeta\" (\n \"meta_id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"meta_key\" varchar(255) DEFAULT NULL,\n PRIMARY KEY (\"meta_id\")\n)", + 'CREATE INDEX "wp_postmeta__meta_key" ON "wp_postmeta" ("meta_key")', + ), + $this->translate( + 'CREATE TABLE wp_postmeta ( + meta_id bigint(20) unsigned NOT NULL auto_increment, + meta_key varchar(255) default NULL, + PRIMARY KEY (meta_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4' + ) + ); + } + + /** + * Tests unique MySQL prefix indexes become PostgreSQL expression indexes. + */ + public function test_translate_unique_prefix_index_lengths_as_expression_indexes(): void { + $sql = 'CREATE TABLE wp_prefix_unique ( + id bigint(20) unsigned NOT NULL auto_increment, + slug varchar(255) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_prefix (slug(10)) + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_prefix_unique\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"slug\" varchar(255) NOT NULL,\n PRIMARY KEY (\"id\")\n)", + 'CREATE UNIQUE INDEX "wp_prefix_unique__slug_prefix" ON "wp_prefix_unique" (SUBSTR(CAST("slug" AS text), 1, 10))', + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( '10', (string) $metadata[0]['indexes'][1]['columns'][0]['sub_part'] ); + } + + /** + * Tests MySQL key part directions are preserved in secondary index DDL and metadata. + */ + public function test_translate_preserves_secondary_index_directions(): void { + $sql = 'CREATE TABLE wp_directional_indexes ( + id bigint(20) unsigned NOT NULL auto_increment, + score int NOT NULL, + name varchar(255) NOT NULL, + created_at datetime NOT NULL, + PRIMARY KEY (id), + KEY score_name (score ASC, name(16) DESC, created_at DESC) + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_directional_indexes\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"score\" integer NOT NULL,\n \"name\" varchar(255) NOT NULL,\n \"created_at\" text NOT NULL,\n PRIMARY KEY (\"id\")\n)", + 'CREATE INDEX "wp_directional_indexes__score_name" ON "wp_directional_indexes" ("score" ASC, "name" DESC, "created_at" DESC)', + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( + array( 'A', 'D', 'D' ), + array_column( $metadata[0]['indexes'][1]['columns'], 'collation' ) + ); + $this->assertSame( + array( null, 16, null ), + array_column( $metadata[0]['indexes'][1]['columns'], 'sub_part' ) + ); + } + + /** + * Tests supported MySQL BTREE index options are ignored for PostgreSQL DDL. + */ + public function test_translate_ignores_supported_btree_index_options(): void { + $sql = 'CREATE TABLE wp_index_options ( + id int NOT NULL, + value varchar(255) NOT NULL, + KEY value_lookup USING BTREE (value) KEY_BLOCK_SIZE=8 INVISIBLE COMMENT "Lookup", + UNIQUE KEY id_lookup (id) VISIBLE + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_index_options\" (\n \"id\" integer NOT NULL,\n \"value\" varchar(255) NOT NULL\n)", + 'CREATE INDEX "wp_index_options__value_lookup" ON "wp_index_options" ("value")', + 'CREATE UNIQUE INDEX "wp_index_options__id_lookup" ON "wp_index_options" ("id")', + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( + array( 'value_lookup', 'id_lookup' ), + array_column( $metadata[0]['indexes'], 'name' ) + ); + } + + /** + * Tests inline column constraints are translated and preserved in metadata. + */ + public function test_translate_inline_column_constraints_and_metadata(): void { + $sql = 'CREATE TABLE wp_inline_constraints ( + id bigint(20) unsigned NOT NULL auto_increment PRIMARY KEY, + slug varchar(100) UNIQUE, + parent_id bigint(20) unsigned REFERENCES wp_inline_parent(id) ON DELETE CASCADE ON UPDATE SET NULL + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_inline_constraints\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY,\n \"slug\" varchar(100) UNIQUE,\n \"parent_id\" bigint CONSTRAINT \"wp_inline_constraints_ibfk_1\" REFERENCES \"wp_inline_parent\" (\"id\") ON DELETE CASCADE ON UPDATE SET NULL\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( + array( 'PRIMARY', 'slug' ), + array_column( $metadata[0]['indexes'], 'name' ) + ); + $this->assertSame( + array( + array( + 'name' => 'wp_inline_constraints_ibfk_1', + 'columns' => array( 'parent_id' ), + 'referenced_schema' => null, + 'referenced_table' => 'wp_inline_parent', + 'referenced_columns' => array( 'id' ), + 'update_rule' => 'SET NULL', + 'delete_rule' => 'CASCADE', + ), + ), + $metadata[0]['foreign_keys'] + ); + $this->assertSame( 'NO', $metadata[0]['columns'][0]['nullable'] ); + } + + /** + * Tests table-level foreign keys are translated and preserved in metadata. + */ + public function test_translate_table_level_foreign_keys_and_metadata(): void { + $sql = 'CREATE TABLE wp_table_foreign_keys ( + id int NOT NULL, + parent_id int NOT NULL, + parent_site_id int NOT NULL, + parent_extra_id int NOT NULL, + CONSTRAINT fk_parent FOREIGN KEY parent_lookup (parent_id, parent_site_id) REFERENCES wp_parent (id, site_id) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT wp_table_foreign_keys_ibfk_1 FOREIGN KEY (parent_extra_id) REFERENCES wp_parent (id), + FOREIGN KEY (parent_id) REFERENCES wp_parent (id) + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_table_foreign_keys\" (\n \"id\" integer NOT NULL,\n \"parent_id\" integer NOT NULL,\n \"parent_site_id\" integer NOT NULL,\n \"parent_extra_id\" integer NOT NULL,\n CONSTRAINT \"fk_parent\" FOREIGN KEY (\"parent_id\", \"parent_site_id\") REFERENCES \"wp_parent\" (\"id\", \"site_id\") ON DELETE CASCADE ON UPDATE RESTRICT,\n CONSTRAINT \"wp_table_foreign_keys_ibfk_1\" FOREIGN KEY (\"parent_extra_id\") REFERENCES \"wp_parent\" (\"id\"),\n CONSTRAINT \"wp_table_foreign_keys_ibfk_2\" FOREIGN KEY (\"parent_id\") REFERENCES \"wp_parent\" (\"id\")\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( + array( + array( + 'name' => 'fk_parent', + 'columns' => array( 'parent_id', 'parent_site_id' ), + 'referenced_schema' => null, + 'referenced_table' => 'wp_parent', + 'referenced_columns' => array( 'id', 'site_id' ), + 'update_rule' => 'RESTRICT', + 'delete_rule' => 'CASCADE', + ), + array( + 'name' => 'wp_table_foreign_keys_ibfk_1', + 'columns' => array( 'parent_extra_id' ), + 'referenced_schema' => null, + 'referenced_table' => 'wp_parent', + 'referenced_columns' => array( 'id' ), + 'update_rule' => 'NO ACTION', + 'delete_rule' => 'NO ACTION', + ), + array( + 'name' => 'wp_table_foreign_keys_ibfk_2', + 'columns' => array( 'parent_id' ), + 'referenced_schema' => null, + 'referenced_table' => 'wp_parent', + 'referenced_columns' => array( 'id' ), + 'update_rule' => 'NO ACTION', + 'delete_rule' => 'NO ACTION', + ), + ), + $metadata[0]['foreign_keys'] + ); + } + + /** + * Tests unsupported MySQL index options fail explicitly. + */ + public function test_translate_rejects_unsupported_index_options(): void { + $queries = array( + 'CREATE TABLE wp_bad_index_option (id int, body text, FULLTEXT KEY body_fulltext (body) WITH PARSER ngram)', + 'CREATE TABLE wp_bad_index_option (id int, shape point, SPATIAL KEY shape_spatial (shape) KEY_BLOCK_SIZE=8)', + 'CREATE TABLE wp_bad_index_option (id int, PRIMARY KEY (id) INVISIBLE)', + ); + + foreach ( $queries as $query ) { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + try { + $translator->translate_schema( $query ); + $this->fail( 'Expected unsupported CREATE TABLE index option to throw.' ); + } catch ( InvalidArgumentException $exception ) { + $this->assertSame( 'Unsupported CREATE TABLE index option.', $exception->getMessage(), $query ); + } + } + } + + /** + * Tests HASH index declarations are normalized to BTREE like the SQLite backend. + */ + public function test_translate_accepts_hash_indexes_as_btree(): void { + $sql = 'CREATE TABLE wp_hash_index ( + id int, + value varchar(255), + slug varchar(191), + KEY value_lookup USING HASH (value), + UNIQUE KEY slug_lookup (slug) USING HASH + )'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_hash_index\" (\n \"id\" integer,\n \"value\" varchar(255),\n \"slug\" varchar(191)\n)", + 'CREATE INDEX "wp_hash_index__value_lookup" ON "wp_hash_index" ("value")', + 'CREATE UNIQUE INDEX "wp_hash_index__slug_lookup" ON "wp_hash_index" ("slug")', + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( + array( 'value_lookup', 'slug_lookup' ), + array_column( $metadata[0]['indexes'], 'name' ) + ); + $this->assertSame( + array( 'BTREE', 'BTREE' ), + array_column( $metadata[0]['indexes'], 'index_type' ) + ); + } + + /** + * Tests unsupported MySQL column attributes fail explicitly. + */ + public function test_translate_rejects_unsupported_column_attributes(): void { + $queries = array( + 'CREATE TABLE wp_bad_column_attribute (base int, generated_value int GENERATED ALWAYS AS (base + 1) STORED)', + 'CREATE TABLE wp_bad_column_attribute (base int, generated_value int GENERATED ALWAYS AS (base + 1) VIRTUAL)', + 'CREATE TABLE wp_bad_column_attribute (id int INVISIBLE)', + 'CREATE TABLE wp_bad_column_attribute (id int VISIBLE)', + 'CREATE TABLE wp_bad_column_attribute (id int COLUMN_FORMAT FIXED)', + 'CREATE TABLE wp_bad_column_attribute (id int STORAGE DISK)', + ); + + foreach ( $queries as $query ) { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + try { + $translator->translate_schema( $query ); + $this->fail( 'Expected unsupported CREATE TABLE column attribute to throw.' ); + } catch ( InvalidArgumentException $exception ) { + $this->assertSame( 'Unsupported CREATE TABLE column attribute.', $exception->getMessage(), $query ); + } + } + } + + /** + * Tests zero date defaults are translated as text while MySQL metadata is preserved. + */ + public function test_translate_zero_date_defaults_as_text_and_metadata_defaults(): void { + $sql = "CREATE TABLE wp_zero_dates ( + id bigint(20) unsigned NOT NULL auto_increment, + created_date date NOT NULL DEFAULT '0000-00-00', + created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + updated_at timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4"; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_zero_dates\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"created_date\" text NOT NULL DEFAULT '0000-00-00',\n \"created_at\" text NOT NULL DEFAULT '0000-00-00 00:00:00',\n \"updated_at\" text NOT NULL DEFAULT '0000-00-00 00:00:00',\n PRIMARY KEY (\"id\")\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( + array( 'date', 'datetime', 'timestamp' ), + array_column( array_slice( $metadata[0]['columns'], 1 ), 'type' ) + ); + $this->assertSame( + array( '0000-00-00', '0000-00-00 00:00:00', '0000-00-00 00:00:00' ), + array_column( array_slice( $metadata[0]['columns'], 1 ), 'default' ) + ); + } + + /** + * Tests FULLTEXT and SPATIAL indexes are metadata-only in PostgreSQL DDL. + */ + public function test_translate_fulltext_and_spatial_indexes_as_metadata_only(): void { + $sql = 'CREATE TABLE wp_search_geo ( + id bigint(20) unsigned NOT NULL, + body longtext NOT NULL, + shape point NOT NULL, + PRIMARY KEY (id), + FULLTEXT KEY body_fulltext (body), + SPATIAL KEY shape_spatial (shape), + KEY id_lookup (id) + ) DEFAULT CHARACTER SET utf8mb4'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + "CREATE TABLE \"wp_search_geo\" (\n \"id\" bigint NOT NULL,\n \"body\" text NOT NULL,\n \"shape\" text NOT NULL,\n PRIMARY KEY (\"id\")\n)", + 'CREATE INDEX "wp_search_geo__id_lookup" ON "wp_search_geo" ("id")', + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + $this->assertSame( + array( 'PRIMARY', 'body_fulltext', 'shape_spatial', 'id_lookup' ), + array_column( $metadata[0]['indexes'], 'name' ) + ); + $this->assertSame( + array( 'BTREE', 'FULLTEXT', 'SPATIAL', 'BTREE' ), + array_column( $metadata[0]['indexes'], 'index_type' ) + ); + $this->assertNull( $metadata[0]['indexes'][1]['columns'][0]['sub_part'] ); + $this->assertSame( 32, $metadata[0]['indexes'][2]['columns'][0]['sub_part'] ); + } + + /** + * Tests temporary IF NOT EXISTS statements. + */ + public function test_translate_temporary_if_not_exists(): void { + $this->assertSame( + array( + "CREATE TEMPORARY TABLE IF NOT EXISTS \"wp_tmp\" (\n \"id\" integer NOT NULL,\n PRIMARY KEY (\"id\")\n)", + 'CREATE INDEX IF NOT EXISTS "wp_tmp__id_idx" ON "wp_tmp" ("id")', + ), + $this->translate( + 'CREATE TEMPORARY TABLE IF NOT EXISTS wp_tmp ( + id int(11) NOT NULL, + PRIMARY KEY (id), + KEY id_idx (id) + ) DEFAULT CHARACTER SET utf8mb4' + ) + ); + } + + /** + * Tests MySQL charset metadata is extracted from CREATE TABLE statements. + */ + public function test_extract_schema_metadata_preserves_mysql_charsets(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + + $this->assertSame( + array( + array( + 'table_name' => 'wp_charset_test', + 'comment' => '', + 'columns' => array( + array( + 'name' => 'a', + 'type' => 'varchar(50)', + 'charset' => 'latin1', + 'collation' => 'latin1_swedish_ci', + 'comment' => '', + 'ordinal' => 1, + ), + array( + 'name' => 'b', + 'type' => 'text', + 'charset' => 'koi8r', + 'collation' => 'koi8r_general_ci', + 'comment' => '', + 'ordinal' => 2, + ), + array( + 'name' => 'c', + 'type' => 'binary(1)', + 'charset' => null, + 'collation' => null, + 'comment' => '', + 'ordinal' => 3, + ), + array( + 'name' => 'd', + 'type' => 'int', + 'charset' => null, + 'collation' => null, + 'comment' => '', + 'ordinal' => 4, + ), + ), + ), + ), + $translator->extract_schema_metadata( + 'CREATE TABLE wp_charset_test ( + a VARCHAR(50) CHARACTER SET latin1, + b TEXT COLLATE koi8r_general_ci, + c BINARY, + d INT + ) DEFAULT CHARSET utf8mb3' + ) + ); + } + + /** + * Tests MySQL table, column, and index comments are extracted from CREATE TABLE statements. + */ + public function test_extract_schema_metadata_preserves_mysql_comments(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $metadata = $translator->extract_schema_metadata( + "CREATE TABLE wp_comment_meta ( + id int NOT NULL COMMENT 'ID comment', + value varchar(50) COMMENT \"Value comment\", + KEY value_lookup (value) COMMENT 'Index comment' + ) COMMENT='Table comment'", + true + ); + + $this->assertSame( 'Table comment', $metadata[0]['comment'] ); + $this->assertSame( array( 'ID comment', 'Value comment' ), array_column( $metadata[0]['columns'], 'comment' ) ); + $this->assertSame( 'Index comment', $metadata[0]['indexes'][0]['comment'] ); + } + + /** + * Tests numeric precision and scale are preserved in DDL and metadata. + */ + public function test_numeric_precision_and_scale_are_preserved(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_numeric_test ( + amount DECIMAL(10,2) NOT NULL, + ratio NUMERIC(12,6), + score FLOAT(10,3), + measure DOUBLE(8,4) + )'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_numeric_test\" (\n \"amount\" numeric(10,2) NOT NULL,\n \"ratio\" numeric(12,6),\n \"score\" numeric(10,3),\n \"measure\" numeric(8,4)\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql ); + + $this->assertSame( 'decimal(10,2)', $metadata[0]['columns'][0]['type'] ); + $this->assertSame( 'numeric(12,6)', $metadata[0]['columns'][1]['type'] ); + $this->assertSame( 'float(10,3)', $metadata[0]['columns'][2]['type'] ); + $this->assertSame( 'double(8,4)', $metadata[0]['columns'][3]['type'] ); + } + + /** + * Tests MySQL data type aliases translate while preserving MySQL metadata. + */ + public function test_mysql_data_type_aliases_are_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_alias_test ( + flags BIT(10), + enabled BOOL NOT NULL DEFAULT 0, + toggled BOOLEAN, + amount DEC(10,2), + fixed_value FIXED(8,3), + real_value REAL + )'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_alias_test\" (\n \"flags\" integer,\n \"enabled\" integer NOT NULL DEFAULT '0',\n \"toggled\" integer,\n \"amount\" numeric(10,2),\n \"fixed_value\" numeric(8,3),\n \"real_value\" double precision\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql ); + + $this->assertSame( 'bit(10)', $metadata[0]['columns'][0]['type'] ); + $this->assertSame( 'bool', $metadata[0]['columns'][1]['type'] ); + $this->assertSame( 'boolean', $metadata[0]['columns'][2]['type'] ); + $this->assertSame( 'dec(10,2)', $metadata[0]['columns'][3]['type'] ); + $this->assertSame( 'fixed(8,3)', $metadata[0]['columns'][4]['type'] ); + $this->assertSame( 'real', $metadata[0]['columns'][5]['type'] ); + } + + /** + * Tests MySQL character type aliases translate while preserving MySQL metadata. + */ + public function test_mysql_character_type_aliases_are_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_character_alias_test ( + c1 CHAR, + c2 CHARACTER(10), + c3 CHAR VARYING(255), + c4 CHARACTER VARYING(255), + c5 NATIONAL CHAR, + c6 NCHAR, + c7 NATIONAL CHAR(10), + c8 NCHAR(10), + c9 NCHAR VARCHAR(255), + c10 NCHAR VARYING(255), + c11 NVARCHAR(255), + c12 NATIONAL VARCHAR(255), + c13 NATIONAL CHAR VARYING(255), + c14 NATIONAL CHARACTER VARYING(255) + ) DEFAULT CHARACTER SET utf8mb4'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_character_alias_test\" (\n \"c1\" char(1),\n \"c2\" char(10),\n \"c3\" varchar(255),\n \"c4\" varchar(255),\n \"c5\" char(1),\n \"c6\" char(1),\n \"c7\" char(10),\n \"c8\" char(10),\n \"c9\" varchar(255),\n \"c10\" varchar(255),\n \"c11\" varchar(255),\n \"c12\" varchar(255),\n \"c13\" varchar(255),\n \"c14\" varchar(255)\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( + array( + 'char(1)', + 'char(10)', + 'varchar(255)', + 'varchar(255)', + 'char(1)', + 'char(1)', + 'char(10)', + 'char(10)', + 'varchar(255)', + 'varchar(255)', + 'varchar(255)', + 'varchar(255)', + 'varchar(255)', + 'varchar(255)', + ), + array_column( $metadata[0]['columns'], 'type' ) + ); + $this->assertSame( + array( 'utf8mb4', 'utf8mb4', 'utf8mb4', 'utf8mb4', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8', 'utf8' ), + array_column( $metadata[0]['columns'], 'charset' ) + ); + $this->assertSame( + array( 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci', 'utf8_general_ci' ), + array_column( $metadata[0]['columns'], 'collation' ) + ); + } + + /** + * Tests MySQL LONG-prefixed aliases translate while preserving MySQL metadata. + */ + public function test_mysql_long_type_aliases_are_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_long_alias_test ( + c1 LONG VARCHAR, + c2 LONG CHAR, + c3 LONG CHAR VARYING, + c4 LONG CHARACTER, + c5 LONG CHARACTER VARYING, + c6 LONG VARBINARY + ) DEFAULT CHARACTER SET utf8mb4'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_long_alias_test\" (\n \"c1\" text,\n \"c2\" text,\n \"c3\" text,\n \"c4\" text,\n \"c5\" text,\n \"c6\" bytea\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( + array( 'mediumtext', 'mediumtext', 'mediumtext', 'mediumtext', 'mediumtext', 'mediumblob' ), + array_column( $metadata[0]['columns'], 'type' ) + ); + $this->assertSame( + array( 'utf8mb4', 'utf8mb4', 'utf8mb4', 'utf8mb4', 'utf8mb4', null ), + array_column( $metadata[0]['columns'], 'charset' ) + ); + $this->assertSame( + array( 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', 'utf8mb4_unicode_ci', null ), + array_column( $metadata[0]['columns'], 'collation' ) + ); + } + + /** + * Tests MySQL SERIAL translates to identity DDL and preserves MySQL metadata. + */ + public function test_serial_type_is_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_serial_test ( + id SERIAL, + label VARCHAR(20) + )'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_serial_test\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL UNIQUE,\n \"label\" varchar(20)\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( 'bigint unsigned', $metadata[0]['columns'][0]['type'] ); + $this->assertSame( 'NO', $metadata[0]['columns'][0]['nullable'] ); + $this->assertSame( 'auto_increment', $metadata[0]['columns'][0]['extra'] ); + $this->assertSame( 'id', $metadata[0]['indexes'][0]['name'] ); + $this->assertSame( '0', $metadata[0]['indexes'][0]['non_unique'] ); + $this->assertSame( 'id', $metadata[0]['indexes'][0]['columns'][0]['column_name'] ); + } + + /** + * Tests MySQL enumerated string types translate while preserving metadata. + */ + public function test_enum_and_set_types_are_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = "CREATE TABLE wp_enum_set_test ( + status ENUM('draft','published') NOT NULL DEFAULT 'draft', + flags SET('featured','archived') DEFAULT 'featured' + ) DEFAULT CHARACTER SET utf8mb4"; + + $this->assertSame( + array( + "CREATE TABLE \"wp_enum_set_test\" (\n \"status\" text NOT NULL DEFAULT 'draft',\n \"flags\" text DEFAULT 'featured'\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( "enum('draft','published')", $metadata[0]['columns'][0]['type'] ); + $this->assertSame( "set('featured','archived')", $metadata[0]['columns'][1]['type'] ); + $this->assertSame( array( 'utf8mb4', 'utf8mb4' ), array_column( $metadata[0]['columns'], 'charset' ) ); + $this->assertSame( array( 'draft', 'featured' ), array_column( $metadata[0]['columns'], 'default' ) ); + } + + /** + * Tests MySQL JSON translates to text storage while preserving MySQL metadata. + */ + public function test_json_type_is_supported(): void { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $sql = 'CREATE TABLE wp_json_test ( + id int NOT NULL, + payload JSON DEFAULT NULL + ) DEFAULT CHARACTER SET utf8mb4'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_json_test\" (\n \"id\" integer NOT NULL,\n \"payload\" text DEFAULT NULL\n)", + ), + $translator->translate_schema( $sql ) + ); + + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( 'json', $metadata[0]['columns'][1]['type'] ); + $this->assertNull( $metadata[0]['columns'][1]['charset'] ); + $this->assertNull( $metadata[0]['columns'][1]['collation'] ); + $this->assertSame( 'YES', $metadata[0]['columns'][1]['nullable'] ); + $this->assertNull( $metadata[0]['columns'][1]['default'] ); + } + + /** + * Tests unsupported CREATE TABLE ... SELECT statements are rejected. + */ + public function test_translate_rejects_create_table_as_select(): void { + $this->expectException( InvalidArgumentException::class ); + $this->translate( 'CREATE TABLE wp_copy AS SELECT 1 AS id' ); + } + + /** + * Tests inline and table CHECK constraints are translated. + */ + public function test_translate_supports_check_constraints(): void { + $sql = 'CREATE TABLE wp_inline_check ( + id int CHECK (id > 0), + `score` int NOT NULL CHECK (`score` < 10), + CONSTRAINT c CHECK (id < 100), + CHECK (score >= 0) + )'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_inline_check\" (\n \"id\" integer CONSTRAINT \"wp_inline_check_chk_1\" CHECK (id > 0),\n \"score\" integer NOT NULL CONSTRAINT \"wp_inline_check_chk_2\" CHECK (\"score\" < 10),\n CONSTRAINT \"c\" CHECK (id < 100),\n CONSTRAINT \"wp_inline_check_chk_3\" CHECK (score >= 0)\n)", + ), + $this->translate( $sql ) + ); + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( array( 'id', 'score' ), array_column( $metadata[0]['columns'], 'name' ) ); + $this->assertSame( array(), $metadata[0]['indexes'] ); + $this->assertSame( array(), $metadata[0]['foreign_keys'] ); + $this->assertSame( + array( + array( + 'name' => 'wp_inline_check_chk_1', + 'check_clause' => 'id > 0', + 'enforced' => 'YES', + ), + array( + 'name' => 'wp_inline_check_chk_2', + 'check_clause' => '"score" < 10', + 'enforced' => 'YES', + ), + array( + 'name' => 'c', + 'check_clause' => 'id < 100', + 'enforced' => 'YES', + ), + array( + 'name' => 'wp_inline_check_chk_3', + 'check_clause' => 'score >= 0', + 'enforced' => 'YES', + ), + ), + $metadata[0]['checks'] + ); + } + + /** + * Tests NOT ENFORCED CHECK constraints are metadata-only. + */ + public function test_translate_not_enforced_check_constraints_as_metadata_only(): void { + $sql = 'CREATE TABLE wp_inline_check ( + id int CHECK (id > 0) NOT ENFORCED, + score int CHECK (score > 0) ENFORCED, + CONSTRAINT c CHECK (id < 100) NOT ENFORCED + )'; + + $this->assertSame( + array( + "CREATE TABLE \"wp_inline_check\" (\n \"id\" integer,\n \"score\" integer CONSTRAINT \"wp_inline_check_chk_2\" CHECK (score > 0)\n)", + ), + $this->translate( $sql ) + ); + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $metadata = $translator->extract_schema_metadata( $sql, true ); + + $this->assertSame( + array( + array( + 'name' => 'wp_inline_check_chk_1', + 'check_clause' => 'id > 0', + 'enforced' => 'NO', + ), + array( + 'name' => 'wp_inline_check_chk_2', + 'check_clause' => 'score > 0', + 'enforced' => 'YES', + ), + array( + 'name' => 'c', + 'check_clause' => 'id < 100', + 'enforced' => 'NO', + ), + ), + $metadata[0]['checks'] + ); + } + + /** + * Translates MySQL CREATE TABLE SQL. + * + * @param string $sql MySQL CREATE TABLE statement. + * @return string[] PostgreSQL DDL statements. + */ + private function translate( string $sql ): array { + $translator = new WP_PostgreSQL_Create_Table_Translator(); + return $translator->translate_schema( $sql ); + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php new file mode 100644 index 000000000..936ec3e2c --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -0,0 +1,5016 @@ +run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb { + public static $next_charset = ''; + + public $charset; + public $parent_args; + + public function __construct( $dbuser, $dbpassword, $dbname, $dbhost ) { + $this->charset = self::$next_charset; + $this->parent_args = array( $dbuser, $dbpassword, $dbname, $dbhost ); + } + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +wpdb::$next_charset = ''; +$default_db = new WP_PostgreSQL_DB( 'pg_user', 'pg_pass', 'pg_db', 'pg_host' ); +$default_is_global = $GLOBALS['wpdb'] === $default_db; + +wpdb::$next_charset = 'latin1'; +$latin_db = new WP_PostgreSQL_DB( 'latin_user', 'latin_pass', 'latin_db', 'latin_host' ); +$latin_is_global = $GLOBALS['wpdb'] === $latin_db; + +wp_postgresql_db_test_respond( + array( + 'default_is_global' => $default_is_global, + 'default_args' => $default_db->parent_args, + 'default_charset' => $default_db->charset, + 'latin_is_global' => $latin_is_global, + 'latin_args' => $latin_db->parent_args, + 'latin_charset' => $latin_db->charset, + ) +); +PHP + ); + + $this->assertSame( + array( + 'default_is_global' => true, + 'default_args' => array( 'pg_user', 'pg_pass', 'pg_db', 'pg_host' ), + 'default_charset' => 'utf8mb4', + 'latin_is_global' => true, + 'latin_args' => array( 'latin_user', 'latin_pass', 'latin_db', 'latin_host' ), + 'latin_charset' => 'latin1', + ), + $result + ); + } + + /** + * Tests WordPress core's expected wpdb capability checks. + */ + public function test_has_cap_matches_wordpress_db_expectations(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' + require_once getcwd() . '/bootstrap.php'; + +class wpdb {} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$capabilities = array(); +foreach ( + array( + 'collation', + 'group_concat', + 'subqueries', + 'identifier_placeholders', + 'utf8mb4', + 'utf8mb4_520', + 'COLLATION', + 'GROUP_CONCAT', + 'SUBQUERIES', + 'IDENTIFIER_PLACEHOLDERS', + 'UTF8MB4', + 'UTF8MB4_520', + 'set_charset', + 'SET_CHARSET', + 'unsupported_postgresql_capability', + ) as $capability +) { + $capabilities[ $capability ] = $db->has_cap( $capability ); +} + +wp_postgresql_db_test_respond( + array( + 'db_version' => $db->db_version(), + 'capabilities' => $capabilities, + ) +); +PHP + ); + + $this->assertSame( '8.0', $result['db_version'] ); + $this->assertSame( + array( + 'collation' => true, + 'group_concat' => true, + 'subqueries' => true, + 'identifier_placeholders' => true, + 'utf8mb4' => true, + 'utf8mb4_520' => true, + 'COLLATION' => true, + 'GROUP_CONCAT' => true, + 'SUBQUERIES' => true, + 'IDENTIFIER_PLACEHOLDERS' => true, + 'UTF8MB4' => true, + 'UTF8MB4_520' => true, + 'set_charset' => true, + 'SET_CHARSET' => true, + 'unsupported_postgresql_capability' => false, + ), + $result['capabilities'] + ); + } + + /** + * Tests the wpdb adapter applies set_charset() to the PostgreSQL driver state. + */ + public function test_set_charset_updates_postgresql_driver_session_state(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $collate = ''; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ), + 'wptests' +); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$db->set_charset( $driver, 'utf8', 'utf8_general_ci' ); + +$collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); +$charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + +wp_postgresql_db_test_respond( + array( + 'charset' => $charset[0]->Value, + 'collation' => $collation[0]->Value, + ) +); +PHP + ); + + $this->assertSame( + array( + 'charset' => 'utf8', + 'collation' => 'utf8_general_ci', + ), + $result + ); + } + + /** + * Tests set_charset() uses wpdb defaults and ignores invalid handles. + */ + public function test_set_charset_uses_defaults_and_ignores_invalid_handles(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $collate = ''; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ), + 'wptests' +); + +function wp_postgresql_db_charset_state( WP_PostgreSQL_Driver $driver ) { + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + + return array( + 'charset' => $charset[0]->Value, + 'collation' => $collation[0]->Value, + ); +} + +$initial = wp_postgresql_db_charset_state( $driver ); + +$db->charset = 'latin1'; +$db->collate = ''; +$db->set_charset( $driver ); +$after_defaults = wp_postgresql_db_charset_state( $driver ); + +$db->set_charset( $driver, '', 'utf8_general_ci' ); +$after_empty_charset = wp_postgresql_db_charset_state( $driver ); + +$db->set_charset( new stdClass(), 'utf8mb4', 'utf8mb4_bin' ); +$after_non_driver = wp_postgresql_db_charset_state( $driver ); + +$db->set_charset( $driver, 'utf8mb4', '' ); +$after_empty_collate = wp_postgresql_db_charset_state( $driver ); + +wp_postgresql_db_test_respond( + array( + 'initial' => $initial, + 'after_defaults' => $after_defaults, + 'after_empty_charset' => $after_empty_charset, + 'after_non_driver' => $after_non_driver, + 'after_empty_collate' => $after_empty_collate, + ) +); +PHP + ); + + $this->assertSame( + array( + 'initial' => array( + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ), + 'after_defaults' => array( + 'charset' => 'latin1', + 'collation' => 'latin1_swedish_ci', + ), + 'after_empty_charset' => array( + 'charset' => 'latin1', + 'collation' => 'latin1_swedish_ci', + ), + 'after_non_driver' => array( + 'charset' => 'latin1', + 'collation' => 'latin1_swedish_ci', + ), + 'after_empty_collate' => array( + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ), + ), + $result + ); + } + + /** + * Tests the wpdb adapter applies WordPress charset upgrade rules. + */ + public function test_determine_charset_applies_wordpress_utf8mb4_upgrade_rules(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb {} +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$results = array( + 'without_dbh' => $db->determine_charset( 'utf8', '' ), +); + +$dbh_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +if ( PHP_VERSION_ID < 80100 ) { + $dbh_property->setAccessible( true ); +} +$dbh_property->setValue( $db, new stdClass() ); + +foreach ( + array( + 'utf8_empty' => array( 'utf8', '' ), + 'utf8_general_ci' => array( 'utf8', 'utf8_general_ci' ), + 'utf8_bin' => array( 'utf8', 'utf8_bin' ), + 'utf8mb4_unicode_ci' => array( 'utf8mb4', 'utf8mb4_unicode_ci' ), + 'latin1_swedish_ci' => array( 'latin1', 'latin1_swedish_ci' ), + ) as $name => $args +) { + $results[ $name ] = $db->determine_charset( $args[0], $args[1] ); +} + +wp_postgresql_db_test_respond( $results ); +PHP + ); + + $this->assertSame( + array( + 'without_dbh' => array( + 'charset' => 'utf8', + 'collate' => '', + ), + 'utf8_empty' => array( + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_unicode_520_ci', + ), + 'utf8_general_ci' => array( + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_unicode_520_ci', + ), + 'utf8_bin' => array( + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_bin', + ), + 'utf8mb4_unicode_ci' => array( + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_unicode_520_ci', + ), + 'latin1_swedish_ci' => array( + 'charset' => 'latin1', + 'collate' => 'latin1_swedish_ci', + ), + ), + $result + ); + } + + /** + * Tests the wpdb adapter filters and forwards SQL mode state to the PostgreSQL driver. + */ + public function test_set_sql_mode_filters_incompatible_modes_and_updates_postgresql_driver(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +$GLOBALS['wp_postgresql_db_test_filter_calls'] = array(); + +function apply_filters( $hook_name, $value ) { + $GLOBALS['wp_postgresql_db_test_filter_calls'][] = array( + 'hook_name' => $hook_name, + 'value' => $value, + ); + + return $value; +} + +class wpdb { + protected $incompatible_modes = array( + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + ); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_Driver( + new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ), + 'wptests' +); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$initial_mode = $driver->get_sql_mode(); +$db->set_sql_mode(); +$mode_after_empty_call = $driver->get_sql_mode(); +$filter_calls_after_empty = $GLOBALS['wp_postgresql_db_test_filter_calls']; + +$zero_date_insert_result = null; +$zero_date_value = null; +try { + $driver->query( + 'CREATE TABLE wptests_zero_dates ( + id bigint(20) NOT NULL, + logged_at datetime NOT NULL, + PRIMARY KEY (id) + )' + ); + $zero_date_insert_result = $driver->query( + "INSERT INTO `wptests_zero_dates` (`id`, `logged_at`) VALUES (1, '0000-00-00 00:00:00')" + ); + $zero_date_rows = $driver->query( 'SELECT logged_at FROM wptests_zero_dates WHERE id = 1' ); + $zero_date_value = $zero_date_rows[0]->logged_at ?? null; +} catch ( Throwable $e ) { + $zero_date_insert_result = get_class( $e ) . ': ' . $e->getMessage(); +} + +$db->set_sql_mode( + array( + 'strict_trans_tables', + 'NO_ZERO_DATE', + 'ansi_quotes', + 'no_engine_substitution', + ) +); +$mode_after_filtered_call = $driver->get_sql_mode(); +$filter_calls_after_modes = $GLOBALS['wp_postgresql_db_test_filter_calls']; + +$driver_property->setValue( $db, null ); +$db->set_sql_mode( array( 'STRICT_ALL_TABLES' ) ); +$mode_after_detached_call = $driver->get_sql_mode(); + +wp_postgresql_db_test_respond( + array( + 'initial_mode' => $initial_mode, + 'mode_after_empty_call' => $mode_after_empty_call, + 'filter_calls_after_empty' => $filter_calls_after_empty, + 'zero_date_insert_result' => $zero_date_insert_result, + 'zero_date_value' => $zero_date_value, + 'mode_after_filtered_call' => $mode_after_filtered_call, + 'filter_calls_after_modes' => $filter_calls_after_modes, + 'mode_after_detached_call' => $mode_after_detached_call, + ) +); +PHP + ); + + $this->assertSame( + 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES', + $result['initial_mode'] + ); + $this->assertSame( + 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_IN_DATE', + $result['mode_after_empty_call'] + ); + $this->assertSame( + array( + array( + 'hook_name' => 'incompatible_sql_modes', + 'value' => array( + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + ), + ), + ), + $result['filter_calls_after_empty'] + ); + $this->assertSame( 1, $result['zero_date_insert_result'] ); + $this->assertSame( '0000-00-00 00:00:00', $result['zero_date_value'] ); + $this->assertSame( 'ANSI_QUOTES,NO_ENGINE_SUBSTITUTION', $result['mode_after_filtered_call'] ); + $this->assertSame( + array( + array( + 'hook_name' => 'incompatible_sql_modes', + 'value' => array( + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + ), + ), + array( + 'hook_name' => 'incompatible_sql_modes', + 'value' => array( + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + ), + ), + ), + $result['filter_calls_after_modes'] + ); + $this->assertSame( 'ANSI_QUOTES,NO_ENGINE_SUBSTITUTION', $result['mode_after_detached_call'] ); + } + + /** + * Tests suppressed print_error() calls record explicit and stored errors. + */ + public function test_print_error_records_explicit_and_stored_errors_when_suppressed(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb { + public $last_error = 'stored backend failure'; + public $last_query = 'SELECT * FROM probe'; + public $suppress_errors = true; + public $show_errors = false; + + public function get_caller() { + return 'sentinel caller'; + } + } +} + +global $EZSQL_ERROR; +$EZSQL_ERROR = array(); + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$explicit_return = $db->print_error( 'explicit PostgreSQL error' ); + +$db->last_query = 'UPDATE probe SET x = 1'; +$stored_return = $db->print_error(); + +wp_postgresql_db_test_respond( + array( + 'explicit_return' => $explicit_return, + 'stored_return' => $stored_return, + 'errors' => $EZSQL_ERROR, + ) +); +PHP + ); + + $this->assertFalse( $result['explicit_return'] ); + $this->assertFalse( $result['stored_return'] ); + $this->assertSame( + array( + array( + 'query' => 'SELECT * FROM probe', + 'error_str' => 'explicit PostgreSQL error', + ), + array( + 'query' => 'UPDATE probe SET x = 1', + 'error_str' => 'stored backend failure', + ), + ), + $result['errors'] + ); + } + + /** + * Tests flush() resets query state while preserving the connection handle. + */ + public function test_flush_resets_query_state_and_preserves_connection_handle(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb { + public $last_result = array( 'row' ); + public $col_info = array( 'column' ); + public $last_query = 'SELECT * FROM probe'; + public $rows_affected = 7; + public $num_rows = 3; + public $last_error = 'stored backend failure'; + public $result = 'driver-result'; + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$sentinel = new stdClass(); + +$dbh_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +if ( PHP_VERSION_ID < 80100 ) { + $dbh_property->setAccessible( true ); +} +$dbh_property->setValue( $db, $sentinel ); + +$db->flush(); + +wp_postgresql_db_test_respond( + array( + 'last_result' => $db->last_result, + 'col_info' => $db->col_info, + 'last_query' => $db->last_query, + 'rows_affected' => $db->rows_affected, + 'num_rows' => $db->num_rows, + 'last_error' => $db->last_error, + 'result' => $db->result, + 'preserved_dbh' => $sentinel === $dbh_property->getValue( $db ), + ) +); +PHP + ); + + $this->assertSame( + array( + 'last_result' => array(), + 'col_info' => null, + 'last_query' => null, + 'rows_affected' => 0, + 'num_rows' => 0, + 'last_error' => '', + 'result' => null, + 'preserved_dbh' => true, + ), + $result + ); + } + + /** + * Tests _real_escape() escapes scalar values and rejects non-scalar values. + */ + public function test_real_escape_escapes_scalars_and_rejects_non_scalars(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb { + public function add_placeholder_escape( $query ) { + return 'placeholder:' . $query; + } + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +wp_postgresql_db_test_respond( + array( + 'apostrophe' => $db->_real_escape( "Bob's" ), + 'backslash' => $db->_real_escape( 'C:\\Temp' ), + 'nul_byte' => $db->_real_escape( "a\0b" ), + 'integer' => $db->_real_escape( 123 ), + 'boolean_true' => $db->_real_escape( true ), + 'null' => $db->_real_escape( null ), + 'array' => $db->_real_escape( array( 'x' ) ), + 'object' => $db->_real_escape( (object) array( 'x' => true ) ), + ) +); +PHP + ); + + $this->assertSame( + array( + 'apostrophe' => 'placeholder:' . addslashes( "Bob's" ), + 'backslash' => 'placeholder:' . addslashes( 'C:\\Temp' ), + 'nul_byte' => 'placeholder:' . addslashes( "a\0b" ), + 'integer' => 'placeholder:123', + 'boolean_true' => 'placeholder:1', + 'null' => '', + 'array' => '', + 'object' => '', + ), + $result + ); + } + + /** + * Tests the PostgreSQL adapter strips legacy charset text without MySQL. + */ + public function test_strip_invalid_text_handles_legacy_charsets_in_php(): void { + if ( ! function_exists( 'mb_convert_encoding' ) ) { + $this->markTestSkipped( 'mbstring is required for legacy charset conversion.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function mbstring_binary_safe_encoding() {} +function reset_mbstring_encoding() {} +function __( $text ) { + return $text; +} + +class WP_Error {} + +class wpdb { + public $charset = 'big5'; + public $collate = ''; + + public function check_ascii( $text ) { + return 1 === preg_match( '/^[\x00-\x7F]*$/', $text ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$method = new ReflectionMethod( WP_PostgreSQL_DB::class, 'strip_invalid_text' ); +$method->setAccessible( true ); + +$utf8 = "a\xe5\x85\xb1b"; +$big5 = mb_convert_encoding( $utf8, 'BIG-5', 'UTF-8' ); + +$big5_result = $method->invoke( + $db, + array( + array( + 'charset' => 'big5', + 'value' => str_repeat( $big5, 10 ), + 'length' => array( + 'type' => 'byte', + 'length' => 10, + ), + ), + ) +); + +$db->charset = 'tis620'; +$tis620_result = $method->invoke( + $db, + array( + array( + 'charset' => 'tis620', + 'value' => str_repeat( "\xcc\xe3", 10 ), + 'length' => array( + 'type' => 'char', + 'length' => 10, + ), + ), + ) +); + +wp_postgresql_db_test_respond( + array( + 'big5' => bin2hex( $big5_result[0]['value'] ), + 'tis620' => bin2hex( $tis620_result[0]['value'] ), + ) +); +PHP + ); + + $big5 = mb_convert_encoding( "a\xe5\x85\xb1b", 'BIG-5', 'UTF-8' ); + + $this->assertSame( + array( + 'big5' => bin2hex( str_repeat( $big5, 2 ) . 'a' ), + 'tis620' => bin2hex( str_repeat( "\xcc\xe3", 5 ) ), + ), + $result + ); + } + + /** + * Tests query validation lets charset-aware stripping handle non-UTF-8 SQL. + */ + public function test_query_uses_strip_invalid_text_for_non_utf8_sql(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function __( $text ) { + return $text; +} +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( $hook_name, $value ) { + return $value; + } +} + +class wpdb { + public $ready = true; + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $queries = array(); + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; + public $check_current_query = true; + public $strip_calls = array(); + + public function check_ascii( $text ) { + return 1 === preg_match( '/^[\x00-\x7F]*$/', $text ); + } + + public function strip_invalid_text_from_query( $query ) { + $this->strip_calls[] = $query; + return $query; + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Invalid_Text_Fake_Driver extends WP_PostgreSQL_Driver { + private $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + return 1; + } + + public function get_last_return_value() { + return 1; + } + + public function get_insert_id() { + return 0; + } + + public function get_last_postgresql_queries(): array { + return array(); + } + + public function get_last_column_meta(): array { + return array(); + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$driver = new WP_PostgreSQL_DB_Invalid_Text_Fake_Driver(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$query = "INSERT INTO binary_probe (payload) VALUES ('\xff')"; +$return = $db->query( $query ); + +$queries = $driver->get_recorded_queries(); +wp_postgresql_db_test_respond( + array( + 'return' => $return, + 'strip_calls' => count( $db->strip_calls ), + 'strip_query_hex' => bin2hex( $db->strip_calls[0] ?? '' ), + 'driver_query_hex' => bin2hex( $queries[0] ?? '' ), + 'last_error' => $db->last_error, + 'check_current_query' => $db->check_current_query, + ) +); +PHP + ); + + $query = "INSERT INTO binary_probe (payload) VALUES ('\xff')"; + $this->assertSame( + array( + 'return' => 1, + 'strip_calls' => 1, + 'strip_query_hex' => bin2hex( $query ), + 'driver_query_hex' => bin2hex( $query ), + 'last_error' => '', + 'check_current_query' => true, + ), + $result + ); + } + + /** + * Tests temp table charset lookups prefer the temp schema over stored permanent metadata. + */ + public function test_get_col_charset_prefers_temporary_table_schema_over_stored_permanent_metadata(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Temp_Charset_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema'; + return $this->statement_from_rows( + array( + array( + 'nspname' => 'pg_temp_42', + ), + ) + ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.columns' ) && array( 'pg_temp_42', 'wptests_shadow_charset' ) === $params ) { + $this->queries[] = 'native_temp_columns'; + return $this->statement_from_rows( + array( + array( + 'column_name' => 'temp_value', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 1, + ), + ) + ); + } + + if ( false !== strpos( $sql, WP_PostgreSQL_DB::MYSQL_CHARSET_METADATA_TABLE ) ) { + $this->queries[] = 'stored_charset_metadata'; + return $this->statement_from_rows( + array( + array( + 'column_name' => 'permanent_value', + 'column_type' => 'text', + 'collation_name' => 'latin1_swedish_ci', + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Temp_Charset_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + + public function __construct( WP_PostgreSQL_DB_Temp_Charset_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } +} + +$connection = new WP_PostgreSQL_DB_Temp_Charset_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Temp_Charset_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +wp_postgresql_db_test_respond( + array( + 'charset' => $db->get_col_charset( 'wptests_shadow_charset', 'temp_value' ), + 'queries' => $connection->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'charset' => 'utf8mb4', + 'queries' => array( + 'temp_schema', + 'native_temp_columns', + ), + ), + $result + ); + } + + /** + * Tests temp table charset lookups use metadata from the temporary CREATE TABLE query. + */ + public function test_get_charset_uses_temporary_create_table_metadata(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Temp_Create_Charset_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema'; + return $this->statement_from_rows( + array( + array( + 'nspname' => 'pg_temp_42', + ), + ) + ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.columns' ) ) { + $this->queries[] = 'native_temp_columns'; + return $this->statement_from_rows( + array( + array( + 'column_name' => 'a', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Temp_Create_Charset_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + + public function __construct( WP_PostgreSQL_DB_Temp_Create_Charset_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } +} + +$connection = new WP_PostgreSQL_DB_Temp_Create_Charset_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Temp_Create_Charset_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$store_metadata = new ReflectionMethod( WP_PostgreSQL_DB::class, 'store_postgresql_create_table_charset_metadata' ); +$store_metadata->setAccessible( true ); +$store_metadata->invoke( + $db, + 'CREATE TEMPORARY TABLE wptests_temp_declared_charset ( a VARCHAR(50) CHARACTER SET big5, b TEXT CHARACTER SET koi8r )' +); + +$get_table_charset = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_table_charset' ); +$get_table_charset->setAccessible( true ); + +wp_postgresql_db_test_respond( + array( + 'table_charset' => $get_table_charset->invoke( $db, 'wptests_temp_declared_charset' ), + 'column_a_charset' => $db->get_col_charset( 'wptests_temp_declared_charset', 'a' ), + 'column_b_charset' => $db->get_col_charset( 'WPTESTS_TEMP_DECLARED_CHARSET', 'B' ), + 'connection_queries' => $connection->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'table_charset' => 'ascii', + 'column_a_charset' => 'big5', + 'column_b_charset' => 'koi8r', + 'connection_queries' => array( + 'temp_schema', + ), + ), + $result + ); + } + + /** + * Tests broad DDL cache invalidation preserves temporary table charset metadata. + */ + public function test_broad_cache_invalidation_preserves_temporary_table_charset_metadata(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $ready = true; + public $charset = 'utf8'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $queries = array(); + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Temp_Charset_Broad_Clear_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( + array( + array( + 'nspname' => 'pg_temp_42', + ), + ) + ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.columns' ) ) { + $this->queries[] = 'native_temp_columns'; + return $this->statement_from_rows( + array( + array( + 'column_name' => 'a', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + array( + 'column_name' => 'b', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Temp_Charset_Broad_Clear_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Temp_Charset_Broad_Clear_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + return true; + } + + public function get_last_postgresql_queries(): array { + return array(); + } + + public function get_queries(): array { + return $this->queries; + } +} + +$connection = new WP_PostgreSQL_DB_Temp_Charset_Broad_Clear_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Temp_Charset_Broad_Clear_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$store_metadata = new ReflectionMethod( WP_PostgreSQL_DB::class, 'store_postgresql_create_table_charset_metadata' ); +$store_metadata->setAccessible( true ); +$store_metadata->invoke( + $db, + 'CREATE TEMPORARY TABLE wptests_temp_declared_charset ( a VARCHAR(50) CHARACTER SET big5, b TEXT CHARACTER SET koi8r )' +); + +$before_a = $db->get_col_charset( 'wptests_temp_declared_charset', 'a' ); +$before_b = $db->get_col_charset( 'wptests_temp_declared_charset', 'b' ); +$altered = $db->query( 'ALTER TABLE wptests_unrelated ADD COLUMN flag INTEGER' ); +$after_a = $db->get_col_charset( 'wptests_temp_declared_charset', 'a' ); +$after_b = $db->get_col_charset( 'wptests_temp_declared_charset', 'b' ); + +wp_postgresql_db_test_respond( + array( + 'before_a' => $before_a, + 'before_b' => $before_b, + 'altered' => $altered, + 'after_a' => $after_a, + 'after_b' => $after_b, + 'connection_queries' => $connection->get_queries(), + 'driver_queries' => $driver->get_queries(), + ) +); +PHP + ); + + $this->assertSame( 'big5', $result['before_a'] ); + $this->assertSame( 'koi8r', $result['before_b'] ); + $this->assertTrue( $result['altered'] ); + $this->assertSame( 'big5', $result['after_a'] ); + $this->assertSame( 'koi8r', $result['after_b'] ); + $this->assertSame( + array( + 'temp_schema:wptests_temp_declared_charset', + 'temp_schema:wptests_temp_declared_charset', + ), + $result['connection_queries'] + ); + $this->assertSame( + array( + 'ALTER TABLE wptests_unrelated ADD COLUMN flag INTEGER', + ), + $result['driver_queries'] + ); + } + + /** + * Tests charset lookups can use MySQL metadata stored by the PostgreSQL driver. + */ + public function test_get_charset_uses_driver_show_columns_metadata_when_adapter_metadata_is_absent(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Show_Columns_Charset_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema'; + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 0, + ), + ) + ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.columns' ) ) { + $this->queries[] = 'native_columns'; + return $this->statement_from_rows( + array( + array( + 'column_name' => 'a', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Show_Columns_Charset_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Show_Columns_Charset_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( 'SHOW FULL COLUMNS FROM `wptests_declared_charset`' !== $query ) { + return array(); + } + + $rows = array( + array( + 'Field' => 'a', + 'Type' => 'varchar(50)', + 'Collation' => 'utf8_unicode_ci', + ), + array( + 'Field' => 'b', + 'Type' => 'text', + 'Collation' => 'big5_chinese_ci', + ), + ); + + if ( PDO::FETCH_ASSOC === $fetch_mode ) { + return $rows; + } + + return array_map( + static function ( array $row ) { + return (object) $row; + }, + $rows + ); + } + + public function get_queries(): array { + return $this->queries; + } +} + +$connection = new WP_PostgreSQL_DB_Show_Columns_Charset_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Show_Columns_Charset_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$get_table_charset = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_table_charset' ); +$get_table_charset->setAccessible( true ); + +wp_postgresql_db_test_respond( + array( + 'table_charset' => $get_table_charset->invoke( $db, 'wptests_declared_charset' ), + 'column_a_charset' => $db->get_col_charset( 'wptests_declared_charset', 'a' ), + 'column_b_charset' => $db->get_col_charset( 'WPTESTS_DECLARED_CHARSET', 'B' ), + 'connection_queries' => $connection->get_queries(), + 'driver_queries' => $driver->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'table_charset' => 'ascii', + 'column_a_charset' => 'utf8', + 'column_b_charset' => 'big5', + 'connection_queries' => array( + 'temp_schema', + 'metadata_exists', + ), + 'driver_queries' => array( + 'SHOW FULL COLUMNS FROM `wptests_declared_charset`', + ), + ), + $result + ); + } + + /** + * Tests length checks prefer MySQL column metadata over widened PostgreSQL storage. + */ + public function test_get_col_length_uses_mysql_declared_metadata_before_postgresql_storage(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Column_Length_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema:' . ( $params[0] ?? '' ); + if ( array( 'wptests_temp_comments' ) === $params ) { + return $this->statement_from_rows( + array( + array( + 'nspname' => 'pg_temp_42', + ), + ) + ); + } + + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 0, + ), + ) + ); + } + + if ( false !== strpos( $sql, 'SELECT column_name, data_type, character_maximum_length' ) ) { + $this->queries[] = 'native_columns:' . ( $params[0] ?? '' ); + + if ( array( 'wptests_native_text' ) === $params ) { + return $this->statement_from_rows( + array( + array( + 'column_name' => 'body', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + if ( array( 'wptests_comments' ) === $params ) { + return $this->statement_from_rows( + array( + array( + 'column_name' => 'comment_author', + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + } + + if ( false !== strpos( $sql, 'SELECT data_type, character_maximum_length' ) ) { + $this->queries[] = 'direct_length:' . implode( ':', $params ); + return $this->statement_from_rows( + array( + array( + 'data_type' => 'text', + 'character_maximum_length' => null, + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Column_Length_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Column_Length_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( 'SHOW FULL COLUMNS FROM `wptests_comments`' !== $query ) { + return array(); + } + + $rows = array( + array( + 'Field' => 'comment_author', + 'Type' => 'varchar(245)', + 'Collation' => 'utf8mb4_unicode_ci', + ), + array( + 'Field' => 'comment_content', + 'Type' => 'longtext', + 'Collation' => 'utf8mb4_unicode_ci', + ), + ); + + if ( PDO::FETCH_ASSOC === $fetch_mode ) { + return $rows; + } + + return array_map( + static function ( array $row ) { + return (object) $row; + }, + $rows + ); + } + + public function get_queries(): array { + return $this->queries; + } +} + +$connection = new WP_PostgreSQL_DB_Column_Length_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Column_Length_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$store_metadata = new ReflectionMethod( WP_PostgreSQL_DB::class, 'store_postgresql_create_table_charset_metadata' ); +$store_metadata->setAccessible( true ); +$store_metadata->invoke( + $db, + "CREATE TEMPORARY TABLE wptests_temp_comments ( + comment_author varchar(245) NOT NULL default '', + comment_content longtext NOT NULL + ) DEFAULT CHARACTER SET utf8mb4" +); + +wp_postgresql_db_test_respond( + array( + 'temporary_comment_author_length' => $db->get_col_length( 'wptests_temp_comments', 'comment_author' ), + 'comment_author_length' => $db->get_col_length( 'wptests_comments', 'comment_author' ), + 'comment_content_length' => $db->get_col_length( 'wptests_comments', 'comment_content' ), + 'native_text_length' => $db->get_col_length( 'wptests_native_text', 'body' ), + 'connection_queries' => $connection->get_queries(), + 'driver_queries' => $driver->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'type' => 'char', + 'length' => 245, + ), + $result['temporary_comment_author_length'], + 'Temporary CREATE TABLE metadata should preserve declared MySQL varchar length.' + ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 245, + ), + $result['comment_author_length'], + 'Declared MySQL varchar length should win over PostgreSQL text storage.' + ); + $this->assertSame( + array( + 'type' => 'byte', + 'length' => 4294967295, + ), + $result['comment_content_length'] + ); + $this->assertSame( + array( + 'type' => 'byte', + 'length' => 65535, + ), + $result['native_text_length'] + ); + $this->assertSame( + array( + 'temp_schema:wptests_temp_comments', + 'temp_schema:wptests_comments', + 'metadata_exists', + 'temp_schema:wptests_native_text', + 'native_columns:wptests_native_text', + ), + $result['connection_queries'] + ); + $this->assertSame( + array( + 'SHOW FULL COLUMNS FROM `wptests_comments`', + 'SHOW FULL COLUMNS FROM `wptests_native_text`', + ), + $result['driver_queries'] + ); + } + + /** + * Tests PostgreSQL column metadata is cached per table and can be invalidated. + */ + public function test_column_charset_metadata_cache_reuses_table_load_until_invalidated(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Cached_Metadata_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + private $metadata_length = 50; + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 1, + ), + ) + ); + } + + if ( false !== strpos( $sql, WP_PostgreSQL_DB::MYSQL_CHARSET_METADATA_TABLE ) ) { + $this->queries[] = 'stored:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( + array( + array( + 'column_name' => 'name', + 'column_type' => 'varchar(' . $this->metadata_length . ')', + 'collation_name' => 'utf8mb4_unicode_ci', + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function set_metadata_length( int $metadata_length ): void { + $this->metadata_length = $metadata_length; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Cached_Metadata_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + + public function __construct( WP_PostgreSQL_DB_Cached_Metadata_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } +} + +$connection = new WP_PostgreSQL_DB_Cached_Metadata_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Cached_Metadata_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$first = $db->get_col_length( 'wptests_cache_probe', 'name' ); + +$connection->set_metadata_length( 75 ); +$second = $db->get_col_length( 'WPTESTS_CACHE_PROBE', 'NAME' ); + +$clear_cache = new ReflectionMethod( WP_PostgreSQL_DB::class, 'clear_postgresql_table_charset_cache' ); +$clear_cache->setAccessible( true ); +$clear_cache->invoke( $db, array( 'wptests_cache_probe' ) ); + +$third = $db->get_col_length( 'wptests_cache_probe', 'name' ); + +wp_postgresql_db_test_respond( + array( + 'first' => $first, + 'second' => $second, + 'third' => $third, + 'queries' => $connection->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'type' => 'char', + 'length' => 50, + ), + $result['first'] + ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 50, + ), + $result['second'] + ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 75, + ), + $result['third'] + ); + $this->assertSame( + array( + 'temp_schema:wptests_cache_probe', + 'metadata_exists', + 'stored:wptests_cache_probe', + 'temp_schema:wptests_cache_probe', + 'stored:wptests_cache_probe', + ), + $result['queries'] + ); + } + + /** + * Tests direct PostgreSQL column length fallback metadata is cached until invalidated. + */ + public function test_column_length_fallback_cache_reuses_lookup_until_invalidated(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Length_Fallback_Cache_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + private $length = 50; + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 0, + ), + ) + ); + } + + if ( false !== strpos( $sql, 'SELECT column_name, data_type, character_maximum_length' ) ) { + $this->queries[] = 'native_columns:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'SELECT data_type, character_maximum_length' ) ) { + $this->queries[] = 'direct_length:' . implode( ':', $params ); + return $this->statement_from_rows( + array( + array( + 'data_type' => 'character varying', + 'character_maximum_length' => $this->length, + ), + ) + ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function set_length( int $length ): void { + $this->length = $length; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Length_Fallback_Cache_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Length_Fallback_Cache_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + return array(); + } + + public function get_queries(): array { + return $this->queries; + } +} + +$connection = new WP_PostgreSQL_DB_Length_Fallback_Cache_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Length_Fallback_Cache_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$first = $db->get_col_length( 'wptests_length_fallback_cache', 'name' ); + +$connection->set_length( 75 ); +$second = $db->get_col_length( 'WPTESTS_LENGTH_FALLBACK_CACHE', 'NAME' ); + +$clear_cache = new ReflectionMethod( WP_PostgreSQL_DB::class, 'clear_postgresql_table_charset_cache' ); +$clear_cache->setAccessible( true ); +$clear_cache->invoke( $db, array( 'wptests_length_fallback_cache' ) ); + +$third = $db->get_col_length( 'wptests_length_fallback_cache', 'name' ); + +wp_postgresql_db_test_respond( + array( + 'first' => $first, + 'second' => $second, + 'third' => $third, + 'connection_queries' => $connection->get_queries(), + 'driver_queries' => $driver->get_queries(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'type' => 'char', + 'length' => 50, + ), + $result['first'] + ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 50, + ), + $result['second'] + ); + $this->assertSame( + array( + 'type' => 'char', + 'length' => 75, + ), + $result['third'] + ); + $this->assertSame( + array( + 'temp_schema:wptests_length_fallback_cache', + 'metadata_exists', + 'native_columns:wptests_length_fallback_cache', + 'direct_length:wptests_length_fallback_cache:name', + 'temp_schema:wptests_length_fallback_cache', + 'native_columns:wptests_length_fallback_cache', + 'direct_length:wptests_length_fallback_cache:name', + ), + $result['connection_queries'] + ); + $this->assertSame( + array( + 'SHOW FULL COLUMNS FROM `wptests_length_fallback_cache`', + 'SHOW FULL COLUMNS FROM `wptests_length_fallback_cache`', + ), + $result['driver_queries'] + ); + } + + /** + * Tests plain permanent CREATE TABLE invalidates cached missing metadata. + */ + public function test_plain_create_table_invalidates_cached_missing_column_charset_metadata(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function __( $text ) { + return $text; +} + +class WP_Error { + public $code; + public $message; + + public function __construct( $code = '', $message = '' ) { + $this->code = $code; + $this->message = $message; + } +} + +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} + +class wpdb { + public $ready = true; + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $queries = array(); + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Plain_Create_Cache_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + private $plain_table_exists = false; + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $this->queries[] = 'temp_schema:' . ( $params[0] ?? '' ); + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 0, + ), + ) + ); + } + + if ( false !== strpos( $sql, 'SELECT column_name, data_type, character_maximum_length' ) ) { + $table = $params[0] ?? ''; + $this->queries[] = 'native_columns:' . $table; + + if ( $this->plain_table_exists && 'wptests_plain_metadata_cache' === $table ) { + return $this->statement_from_rows( + array( + array( + 'column_name' => 'name', + 'data_type' => 'character varying', + 'character_maximum_length' => 191, + ), + ) + ); + } + + return $this->statement_from_rows( array() ); + } + + $this->queries[] = 'unexpected'; + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function set_plain_table_exists(): void { + $this->plain_table_exists = true; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Plain_Create_Cache_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Plain_Create_Cache_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( 0 === stripos( $query, 'CREATE TABLE' ) ) { + $this->fake_connection->set_plain_table_exists(); + return true; + } + + return array(); + } + + public function get_last_return_value() { + return 0; + } + + public function get_insert_id() { + return 0; + } + + public function get_last_postgresql_queries(): array { + return array(); + } + + public function get_last_column_meta(): array { + return array(); + } + + public function get_queries(): array { + return $this->queries; + } +} + +$connection = new WP_PostgreSQL_DB_Plain_Create_Cache_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Plain_Create_Cache_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$missing = $db->get_col_charset( 'wptests_plain_metadata_cache', 'name' ); +$created = $db->query( + 'CREATE TABLE wptests_plain_metadata_cache ( + id INTEGER NOT NULL, + name VARCHAR(191) NOT NULL + )' +); +$reloaded = $db->get_col_charset( 'wptests_plain_metadata_cache', 'name' ); + +wp_postgresql_db_test_respond( + array( + 'missing_is_error' => $missing instanceof WP_Error, + 'created' => $created, + 'reloaded' => $reloaded, + 'connection_queries' => $connection->get_queries(), + 'driver_queries' => $driver->get_queries(), + ) +); +PHP + ); + + $this->assertTrue( $result['missing_is_error'] ); + $this->assertTrue( $result['created'] ); + $this->assertSame( 'utf8mb4', $result['reloaded'] ); + $this->assertSame( + array( + 'temp_schema:wptests_plain_metadata_cache', + 'metadata_exists', + 'native_columns:wptests_plain_metadata_cache', + 'temp_schema:wptests_plain_metadata_cache', + 'native_columns:wptests_plain_metadata_cache', + ), + $result['connection_queries'] + ); + $this->assertSame( + array( + 'SHOW FULL COLUMNS FROM `wptests_plain_metadata_cache`', + "CREATE TABLE wptests_plain_metadata_cache (\n\t\tid INTEGER NOT NULL,\n\t\tname VARCHAR(191) NOT NULL\n\t)", + 'SHOW FULL COLUMNS FROM `wptests_plain_metadata_cache`', + ), + $result['driver_queries'] + ); + } + + /** + * Tests DROP TABLE clears PostgreSQL charset metadata and cached wpdb metadata. + */ + public function test_drop_table_clears_postgresql_charset_metadata_and_cache(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function __( $text ) { + return $text; +} + +class WP_Error { + public $code; + public $message; + + public function __construct( $code = '', $message = '' ) { + $this->code = $code; + $this->message = $message; + } +} + +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} + +class wpdb { + public $ready = true; + public $charset = 'utf8mb4'; + public $is_mysql = true; + public $table_charset = array(); + public $col_meta = array(); + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $queries = array(); + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Drop_Metadata_Fake_Connection extends WP_PostgreSQL_Connection { + private $pdo; + private $queries = array(); + private $permanent_metadata_deleted = false; + private $temporary_table_exists = true; + + public function __construct() { + $this->pdo = new PDO( 'sqlite::memory:' ); + } + + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) && false !== strpos( $sql, 'pg_my_temp_schema()' ) ) { + $table = $params[0] ?? ''; + $this->queries[] = 'temp_schema:' . $table; + + if ( $this->temporary_table_exists && 'wptests_temp_drop_metadata_cache' === $table ) { + return $this->statement_from_rows( + array( + array( + 'nspname' => 'pg_temp_42', + ), + ) + ); + } + + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'FROM information_schema.tables' ) ) { + $this->queries[] = 'metadata_exists'; + return $this->statement_from_rows( + array( + array( + 'exists' => 1, + ), + ) + ); + } + + if ( false !== strpos( $sql, 'SELECT column_name, column_type, collation_name' ) ) { + $table = $params[0] ?? ''; + $this->queries[] = 'stored_columns:' . $table; + + if ( ! $this->permanent_metadata_deleted && 'wptests_drop_metadata_cache' === $table ) { + return $this->statement_from_rows( + array( + array( + 'column_name' => 'name', + 'column_type' => 'varchar(191)', + 'collation_name' => 'utf8mb4_unicode_ci', + ), + ) + ); + } + + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'DELETE FROM "__wp_postgresql_mysql_charset_metadata"' ) ) { + $table = $params[0] ?? ''; + $this->queries[] = 'delete_metadata:' . $table; + + if ( 'wptests_drop_metadata_cache' === $table ) { + $this->permanent_metadata_deleted = true; + } + + return $this->statement_from_rows( array() ); + } + + if ( false !== strpos( $sql, 'SELECT column_name, data_type, character_maximum_length' ) ) { + $table = end( $params ); + $this->queries[] = 'native_columns:' . $table; + return $this->statement_from_rows( array() ); + } + + $this->queries[] = 'unexpected:' . preg_replace( '/\s+/', ' ', trim( $sql ) ); + return $this->statement_from_rows( array() ); + } + + public function get_pdo(): PDO { + return $this->pdo; + } + + public function mark_temporary_table_dropped(): void { + $this->temporary_table_exists = false; + } + + public function get_queries(): array { + return $this->queries; + } + + private function statement_from_rows( array $rows ): PDOStatement { + if ( empty( $rows ) ) { + return $this->pdo->query( 'SELECT 1 WHERE 0 = 1' ); + } + + $columns = array_keys( $rows[0] ); + $selects = array(); + $params = array(); + foreach ( $rows as $row ) { + $fields = array(); + foreach ( $columns as $column ) { + $fields[] = '? AS ' . WP_PostgreSQL_Connection::quote_identifier_value( $column ); + $params[] = $row[ $column ]; + } + $selects[] = 'SELECT ' . implode( ', ', $fields ); + } + + $stmt = $this->pdo->prepare( implode( ' UNION ALL ', $selects ) ); + $stmt->execute( $params ); + return $stmt; + } +} + +class WP_PostgreSQL_DB_Drop_Metadata_Fake_Driver extends WP_PostgreSQL_Driver { + private $fake_connection; + private $queries = array(); + + public function __construct( WP_PostgreSQL_DB_Drop_Metadata_Fake_Connection $connection ) { + $this->fake_connection = $connection; + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->fake_connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( 0 === stripos( $query, 'DROP TEMPORARY TABLE' ) ) { + $this->fake_connection->mark_temporary_table_dropped(); + return true; + } + + if ( 0 === stripos( $query, 'DROP TABLE' ) ) { + return true; + } + + if ( 0 === stripos( $query, 'SHOW FULL COLUMNS' ) ) { + return array(); + } + + return true; + } + + public function get_last_return_value() { + return 0; + } + + public function get_insert_id() { + return 0; + } + + public function get_last_postgresql_queries(): array { + return array(); + } + + public function get_last_column_meta(): array { + return array(); + } + + public function get_queries(): array { + return $this->queries; + } +} + +$connection = new WP_PostgreSQL_DB_Drop_Metadata_Fake_Connection(); +$driver = new WP_PostgreSQL_DB_Drop_Metadata_Fake_Driver( $connection ); +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$store_metadata = new ReflectionMethod( WP_PostgreSQL_DB::class, 'store_postgresql_create_table_charset_metadata' ); +$store_metadata->setAccessible( true ); +$store_metadata->invoke( + $db, + 'CREATE TEMPORARY TABLE wptests_temp_drop_metadata_cache ( name VARCHAR(50) CHARACTER SET big5 )' +); + +$permanent_before = $db->get_col_charset( 'wptests_drop_metadata_cache', 'name' ); +$temporary_before = $db->get_col_charset( 'wptests_temp_drop_metadata_cache', 'name' ); + +$permanent_dropped = $db->query( 'DROP TABLE IF EXISTS wptests_drop_metadata_cache' ); +$permanent_after = $db->get_col_charset( 'wptests_drop_metadata_cache', 'name' ); + +$temporary_dropped = $db->query( 'DROP TEMPORARY TABLE wptests_temp_drop_metadata_cache' ); +$temporary_after = $db->get_col_charset( 'wptests_temp_drop_metadata_cache', 'name' ); + +wp_postgresql_db_test_respond( + array( + 'permanent_before' => $permanent_before, + 'temporary_before' => $temporary_before, + 'permanent_dropped' => $permanent_dropped, + 'permanent_after_error' => $permanent_after instanceof WP_Error, + 'temporary_dropped' => $temporary_dropped, + 'temporary_after_error' => $temporary_after instanceof WP_Error, + 'connection_queries' => $connection->get_queries(), + 'driver_queries' => $driver->get_queries(), + ) +); +PHP + ); + + $this->assertSame( 'utf8mb4', $result['permanent_before'] ); + $this->assertSame( 'big5', $result['temporary_before'] ); + $this->assertTrue( $result['permanent_dropped'] ); + $this->assertTrue( $result['permanent_after_error'] ); + $this->assertTrue( $result['temporary_dropped'] ); + $this->assertTrue( $result['temporary_after_error'] ); + $this->assertSame( + array( + 'temp_schema:wptests_drop_metadata_cache', + 'metadata_exists', + 'stored_columns:wptests_drop_metadata_cache', + 'temp_schema:wptests_temp_drop_metadata_cache', + 'delete_metadata:wptests_drop_metadata_cache', + 'temp_schema:wptests_drop_metadata_cache', + 'stored_columns:wptests_drop_metadata_cache', + 'native_columns:wptests_drop_metadata_cache', + 'temp_schema:wptests_temp_drop_metadata_cache', + 'stored_columns:wptests_temp_drop_metadata_cache', + 'native_columns:wptests_temp_drop_metadata_cache', + ), + $result['connection_queries'] + ); + $this->assertSame( + array( + 'DROP TABLE IF EXISTS wptests_drop_metadata_cache', + 'SHOW FULL COLUMNS FROM `wptests_drop_metadata_cache`', + 'DROP TEMPORARY TABLE wptests_temp_drop_metadata_cache', + 'SHOW FULL COLUMNS FROM `wptests_temp_drop_metadata_cache`', + ), + $result['driver_queries'] + ); + } + + /** + * Tests metadata helpers return final table identifiers for qualified DDL. + */ + public function test_postgresql_metadata_helpers_parse_schema_qualified_table_names(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb {} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$create_table_name = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_postgresql_create_table_name' ); +$create_table_name->setAccessible( true ); + +$drop_table_names = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_postgresql_drop_table_names' ); +$drop_table_names->setAccessible( true ); + +wp_postgresql_db_test_respond( + array( + 'create_names' => array( + 'plain' => $create_table_name->invoke( $db, 'CREATE TABLE wptests_plain (id bigint)' ), + 'qualified' => $create_table_name->invoke( $db, 'CREATE TABLE app_schema.wptests_qualified (id bigint)' ), + 'quoted_qualified' => $create_table_name->invoke( $db, 'CREATE TABLE `app_schema`.`wptests_quoted` (id bigint)' ), + 'temporary_if_exists' => $create_table_name->invoke( $db, 'CREATE TEMPORARY TABLE IF NOT EXISTS `app_schema`.`wptests_temp` (id bigint)' ), + ), + 'drop_names' => array( + 'plain' => $drop_table_names->invoke( $db, 'DROP TABLE wptests_plain' ), + 'qualified_list' => $drop_table_names->invoke( $db, 'DROP TABLE IF EXISTS app_schema.wptests_one, `app_schema`.`wptests_two`, wptests_three CASCADE' ), + 'temporary_qualified' => $drop_table_names->invoke( $db, 'DROP TEMPORARY TABLE IF EXISTS `app_schema`.`wptests_temp`, scratch.wptests_other RESTRICT' ), + ), + ) +); +PHP + ); + + $this->assertSame( + array( + 'plain' => 'wptests_plain', + 'qualified' => 'wptests_qualified', + 'quoted_qualified' => 'wptests_quoted', + 'temporary_if_exists' => 'wptests_temp', + ), + $result['create_names'] + ); + $this->assertSame( + array( + 'plain' => array( 'wptests_plain' ), + 'qualified_list' => array( 'wptests_one', 'wptests_two', 'wptests_three' ), + 'temporary_qualified' => array( 'wptests_temp', 'wptests_other' ), + ), + $result['drop_names'] + ); + } + + /** + * Tests real wpdb identifier placeholders use PostgreSQL identifier quotes. + */ + public function test_real_wpdb_prepare_identifier_placeholders_use_postgresql_quotes(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' + require_once getcwd() . '/bootstrap.php'; + + function wp_load_translations_early() {} + function __( $text ) { + return $text; + } + function _doing_it_wrong() {} + function has_filter() { + return false; + } + function add_filter() { + return true; + } + + require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + + class WP_PostgreSQL_DB_Prepare_Fake_Connection extends WP_PostgreSQL_Connection { + public function __construct() {} + + public function quote( $value, int $type = PDO::PARAM_STR ): string { + return "'" . str_replace( "'", "''", (string) $value ) . "'"; + } + } + + class WP_PostgreSQL_DB_Prepare_Fake_Driver extends WP_PostgreSQL_Driver { + private $connection; + + public function __construct() { + $this->connection = new WP_PostgreSQL_DB_Prepare_Fake_Connection(); + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } + } + + $db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + + $driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); + $driver_property->setAccessible( true ); + $driver_property->setValue( $db, new WP_PostgreSQL_DB_Prepare_Fake_Driver() ); + + $db->charset = 'utf8mb4'; + + $marker_like_value = '__wp_pg_identifier_' . spl_object_hash( $db ) . '_1_0__'; + $prepared_marker_collision = $db->prepare( + 'SELECT %s AS value FROM %i', + $marker_like_value, + 'my_table' + ); + + $identifier_collision_value = '__wp_pg_identifier_' . spl_object_hash( $db ) . '_4_1__'; + $prepared_identifier_collision = $db->prepare( + 'SELECT %i, %i', + $identifier_collision_value, + 'second' + ); + + $quote_identifier_nul_exception = null; + $quote_identifier_nul_message = null; + try { + $db->quote_identifier( "wp_\0posts" ); + } catch ( Throwable $e ) { + $quote_identifier_nul_exception = get_class( $e ); + $quote_identifier_nul_message = $e->getMessage(); + } + + wp_postgresql_db_test_respond( + array( + 'has_identifier_cap' => $db->has_cap( 'identifier_placeholders' ), + 'quoted_table' => $db->quote_identifier( 'wptests_options' ), + 'quoted_weird' => $db->quote_identifier( 'weird"name' ), + 'quote_identifier_nul_exception' => $quote_identifier_nul_exception, + 'quote_identifier_nul_message' => $quote_identifier_nul_message, + 'marker_like_value' => $marker_like_value, + 'prepared_marker_collision' => $prepared_marker_collision, + 'identifier_collision_value' => $identifier_collision_value, + 'prepared_identifier_collision' => $prepared_identifier_collision, + 'prepared_identifier' => $db->prepare( + 'SELECT * FROM %i WHERE %i = %s', + 'wptests_options', + 'option_name', + "Bob's" + ), + 'prepared_identifier_array' => $db->prepare( + 'SELECT %i FROM %i WHERE %i = %s', + array( 'option_value', 'wptests_options', 'option_name', "Bob's" ) + ), + 'prepared_formatted_identifier' => $db->prepare( + 'SELECT * FROM %05i WHERE %i = %s', + 'wptests_options', + 'option_name', + "Bob's" + ), + 'prepared_string' => $db->prepare( 'SELECT %s', "Bob's" ), + ) + ); +PHP + ); + + $this->assertTrue( $result['has_identifier_cap'] ); + $this->assertSame( '"wptests_options"', $result['quoted_table'] ); + $this->assertSame( '"weird""name"', $result['quoted_weird'] ); + $this->assertSame( 'InvalidArgumentException', $result['quote_identifier_nul_exception'] ); + $this->assertSame( + 'PostgreSQL identifiers cannot contain NUL bytes.', + $result['quote_identifier_nul_message'] + ); + $this->assertSame( + 'SELECT * FROM "wptests_options" WHERE "option_name" = \'Bob\\\'s\'', + $result['prepared_identifier'] + ); + $this->assertSame( + 'SELECT \'' . $result['marker_like_value'] . '\' AS value FROM "my_table"', + $result['prepared_marker_collision'] + ); + $this->assertSame( + 'SELECT "' . $result['identifier_collision_value'] . '", "second"', + $result['prepared_identifier_collision'] + ); + $this->assertNotSame( + 'SELECT ""second"", "second"', + $result['prepared_identifier_collision'] + ); + $this->assertSame( + 'SELECT "option_value" FROM "wptests_options" WHERE "option_name" = \'Bob\\\'s\'', + $result['prepared_identifier_array'] + ); + $this->assertSame( + 'SELECT * FROM `wptests_options` WHERE `option_name` = \'Bob\\\'s\'', + $result['prepared_formatted_identifier'] + ); + $this->assertSame( "SELECT 'Bob\\'s'", $result['prepared_string'] ); + } + + /** + * Tests db_connect() short-circuits when a PostgreSQL driver already exists. + */ + public function test_db_connect_short_circuits_when_postgresql_driver_already_exists(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $ready = false; + public $is_mysql = false; + public $last_error = 'previous error'; + public $charset = 'latin1'; + public $init_charset_calls = 0; + public $bail_calls = array(); + + public function init_charset() { + ++$this->init_charset_calls; + $this->charset = 'utf8mb4'; + } + + public function bail( $message, $error_code = '500' ) { + $this->bail_calls[] = array( $message, $error_code ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Existing_Driver_Fake_Driver extends WP_PostgreSQL_Driver { + public function __construct() {} +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$driver = new WP_PostgreSQL_DB_Existing_Driver_Fake_Driver(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$connect_result = $db->db_connect( false ); +$driver_after_connect = $driver_property->getValue( $db ); + +wp_postgresql_db_test_respond( + array( + 'connect_result' => $connect_result, + 'ready' => $db->ready, + 'is_mysql' => $db->is_mysql, + 'driver_same' => $driver_after_connect === $driver, + 'init_charset_calls' => $db->init_charset_calls, + 'bail_calls' => $db->bail_calls, + 'last_error' => $db->last_error, + 'charset' => $db->charset, + ) +); +PHP + ); + + $this->assertSame( + array( + 'connect_result' => true, + 'ready' => true, + 'is_mysql' => true, + 'driver_same' => true, + 'init_charset_calls' => 0, + 'bail_calls' => array(), + 'last_error' => 'previous error', + 'charset' => 'latin1', + ), + $result + ); + } + + /** + * Tests db_connect() with a reusable PostgreSQL PDO and connection lifecycle methods. + */ + public function test_db_connect_reuses_global_postgresql_pdo_and_exposes_connection_lifecycle(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $dbuser = ''; + public $dbpassword = ''; + public $dbname = ''; + public $dbhost = ''; + public $ready = false; + public $is_mysql = true; + public $last_error = 'previous error'; + public $charset = ''; + public $bail_calls = array(); + + public function init_charset() { + $this->charset = 'utf8mb4'; + } + + public function parse_db_host( $host ) { + return false; + } + + public function bail( $message, $error_code = '500' ) { + $this->bail_calls[] = array( $message, $error_code ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Connect_Fake_PDO extends PDO { + public $attributes = array(); + + public function __construct() {} + + public function setAttribute( $attribute, $value ): bool { + $this->attributes[ $attribute ] = $value; + return true; + } + + /** + * Get a fake PDO attribute. + * + * @param int $attribute PDO attribute. + * @return mixed Attribute value. + */ + #[\ReturnTypeWillChange] + public function getAttribute( $attribute ) { + if ( PDO::ATTR_DRIVER_NAME === $attribute ) { + return 'pgsql'; + } + + if ( PDO::ATTR_SERVER_VERSION === $attribute ) { + return 'PostgreSQL 16 test'; + } + + return $this->attributes[ $attribute ] ?? null; + } +} + +$pdo = new WP_PostgreSQL_DB_Connect_Fake_PDO(); +$GLOBALS['@pdo'] = $pdo; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$db->dbuser = 'wptests_user'; +$db->dbpassword = 'wptests_password'; +$db->dbname = 'wptests'; +$db->dbhost = 'localhost'; + +$connect_result = $db->db_connect( false ); +$ready_after_connect = $db->ready; + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver = $driver_property->getValue( $db ); +$driver_uses_global_pdo = $driver->get_connection()->get_pdo() === $pdo; + +$select_other_result = $db->select( 'other', $driver ); +$ready_after_other = $db->ready; +$select_current_result = $db->select( 'wptests', $driver ); +$ready_after_current = $db->ready; +$server_info = $db->db_server_info(); +$close_result = $db->close(); +$ready_after_close = $db->ready; +$driver_after_close = $driver_property->getValue( $db ); +$second_close_result = $db->close(); + +wp_postgresql_db_test_respond( + array( + 'connect_result' => $connect_result, + 'ready_after_connect' => $ready_after_connect, + 'is_mysql' => $db->is_mysql, + 'last_error' => $db->last_error, + 'charset' => $db->charset, + 'bail_calls' => $db->bail_calls, + 'driver_uses_global_pdo' => $driver_uses_global_pdo, + 'server_info' => $server_info, + 'select_other_result' => $select_other_result, + 'ready_after_other' => $ready_after_other, + 'select_current_result' => $select_current_result, + 'ready_after_current' => $ready_after_current, + 'close_result' => $close_result, + 'ready_after_close' => $ready_after_close, + 'driver_after_close' => null === $driver_after_close, + 'second_close_result' => $second_close_result, + ) +); +PHP + ); + + $this->assertSame( + array( + 'connect_result' => true, + 'ready_after_connect' => true, + 'is_mysql' => true, + 'last_error' => '', + 'charset' => 'utf8mb4', + 'bail_calls' => array(), + 'driver_uses_global_pdo' => true, + 'server_info' => 'PostgreSQL 16 test', + 'select_other_result' => false, + 'ready_after_other' => false, + 'select_current_result' => true, + 'ready_after_current' => true, + 'close_result' => true, + 'ready_after_close' => false, + 'driver_after_close' => true, + 'second_close_result' => false, + ), + $result, + 'The PostgreSQL wpdb adapter keeps is_mysql=true so WordPress runs charset and length validation paths.' + ); + } + + /** + * Tests close() clears stale ready state even without a driver handle. + */ + public function test_close_clears_stale_ready_state_without_driver(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $ready = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, null ); + +$db->ready = true; + +$close_result = $db->close(); +$ready_after_close = $db->ready; +$dbh_after_close = $driver_property->getValue( $db ); + +wp_postgresql_db_test_respond( + array( + 'close_result' => $close_result, + 'ready_after_close' => $ready_after_close, + 'dbh_after_close' => $dbh_after_close, + ) +); +PHP + ); + + $this->assertSame( + array( + 'close_result' => false, + 'ready_after_close' => false, + 'dbh_after_close' => null, + ), + $result + ); + } + + /** + * Tests PostgreSQL connection options normalize socket-style DB_HOST values. + */ + public function test_get_connection_options_normalizes_postgresql_socket_hosts(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $dbuser = ''; + public $dbpassword = ''; + public $dbname = ''; + public $dbhost = ''; + + public function parse_db_host( $host ) { + $socket = null; + $is_ipv6 = false; + + $socket_pos = strpos( $host, ':/' ); + if ( false !== $socket_pos ) { + $socket = substr( $host, $socket_pos + 1 ); + $host = substr( $host, 0, $socket_pos ); + } + + if ( substr_count( $host, ':' ) > 1 ) { + $pattern = '#^(?:\[)?(?P[0-9a-fA-F:]+)(?:\]:(?P[\d]+))?#'; + $is_ipv6 = true; + } else { + $pattern = '#^(?P[^:/]*)(?::(?P[\d]+))?#'; + } + + $matches = array(); + $result = preg_match( $pattern, $host, $matches ); + if ( 1 !== $result ) { + return false; + } + + $host = ! empty( $matches['host'] ) ? $matches['host'] : ''; + $port = ! empty( $matches['port'] ) ? abs( (int) $matches['port'] ) : null; + + return array( $host, $port, $socket, $is_ipv6 ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Unparsed_Host extends WP_PostgreSQL_DB { + public function __construct() {} + + public function parse_db_host( $host ) { + return false; + } +} + +function wp_postgresql_db_get_connection_options( WP_PostgreSQL_DB $db ) { + $method = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_connection_options' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method->invoke( $db ); +} + +function wp_postgresql_db_options_for_host( $case, $dbhost ) { + $db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + $db->dbuser = 'user_' . $case; + $db->dbpassword = 'password_' . $case; + $db->dbname = 'name_' . $case; + $db->dbhost = $dbhost; + + return wp_postgresql_db_get_connection_options( $db ); +} + +$options = array( + 'host_only' => wp_postgresql_db_options_for_host( 'host_only', 'postgres' ), + 'host_port' => wp_postgresql_db_options_for_host( 'host_port', 'postgres:6543' ), + 'socket_file' => wp_postgresql_db_options_for_host( 'socket_file', 'localhost:/tmp/.s.PGSQL.6544' ), + 'explicit_port_socket_file' => wp_postgresql_db_options_for_host( 'explicit_port_socket_file', 'localhost:6545:/tmp/.s.PGSQL.6544' ), + 'socket_directory' => wp_postgresql_db_options_for_host( 'socket_directory', 'localhost:/var/run/postgresql' ), +); + +$unparsed_db = new WP_PostgreSQL_DB_Unparsed_Host(); +$unparsed_db->dbuser = 'user_unparsed_fallback'; +$unparsed_db->dbpassword = 'password_unparsed_fallback'; +$unparsed_db->dbname = 'name_unparsed_fallback'; +$unparsed_db->dbhost = 'fallback-host'; + +$options['unparsed_fallback'] = wp_postgresql_db_get_connection_options( $unparsed_db ); + +wp_postgresql_db_test_respond( $options ); +PHP + ); + + $this->assertSame( + array( + 'host_only' => array( + 'host' => 'postgres', + 'port' => null, + 'dbname' => 'name_host_only', + 'user' => 'user_host_only', + 'password' => 'password_host_only', + ), + 'host_port' => array( + 'host' => 'postgres', + 'port' => 6543, + 'dbname' => 'name_host_port', + 'user' => 'user_host_port', + 'password' => 'password_host_port', + ), + 'socket_file' => array( + 'host' => '/tmp', + 'port' => 6544, + 'dbname' => 'name_socket_file', + 'user' => 'user_socket_file', + 'password' => 'password_socket_file', + ), + 'explicit_port_socket_file' => array( + 'host' => '/tmp', + 'port' => 6545, + 'dbname' => 'name_explicit_port_socket_file', + 'user' => 'user_explicit_port_socket_file', + 'password' => 'password_explicit_port_socket_file', + ), + 'socket_directory' => array( + 'host' => '/var/run/postgresql', + 'port' => null, + 'dbname' => 'name_socket_directory', + 'user' => 'user_socket_directory', + 'password' => 'password_socket_directory', + ), + 'unparsed_fallback' => array( + 'host' => 'fallback-host', + 'port' => null, + 'dbname' => 'name_unparsed_fallback', + 'user' => 'user_unparsed_fallback', + 'password' => 'password_unparsed_fallback', + ), + ), + $result + ); + } + + /** + * Tests PostgreSQL connection options only reuse PostgreSQL-backed global PDO objects. + */ + public function test_get_connection_options_reuses_only_global_postgresql_pdo(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $dbuser = 'pg_user'; + public $dbpassword = 'pg_password'; + public $dbname = 'wptests'; + public $dbhost = 'postgres:5432'; + + public function parse_db_host( $host ) { + return array( 'postgres', 5432, null, false ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_PDO_Filter_Fake_PDO extends PDO { + private $driver_name; + private $throw_on_driver_lookup; + + public function __construct( $driver_name, $throw_on_driver_lookup = false ) { + $this->driver_name = $driver_name; + $this->throw_on_driver_lookup = $throw_on_driver_lookup; + } + + /** + * Get a fake PDO attribute. + * + * @param int $attribute PDO attribute. + * @return mixed Attribute value. + */ + #[\ReturnTypeWillChange] + public function getAttribute( $attribute ) { + if ( PDO::ATTR_DRIVER_NAME === $attribute ) { + if ( $this->throw_on_driver_lookup ) { + throw new RuntimeException( 'driver lookup failed' ); + } + + return $this->driver_name; + } + + return null; + } +} + +function wp_postgresql_db_get_connection_options_for_global_pdo( $global_value, $set_global ) { + if ( $set_global ) { + $GLOBALS['@pdo'] = $global_value; + } else { + unset( $GLOBALS['@pdo'] ); + } + + $db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + + $method = new ReflectionMethod( WP_PostgreSQL_DB::class, 'get_connection_options' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + $options = $method->invoke( $db ); + + return array( + 'has_pdo' => array_key_exists( 'pdo', $options ), + 'pdo_same' => array_key_exists( 'pdo', $options ) ? $options['pdo'] === $global_value : null, + 'keys' => array_keys( $options ), + ); +} + +$pgsql_pdo = new WP_PostgreSQL_DB_PDO_Filter_Fake_PDO( 'pgsql' ); +$mysql_pdo = new WP_PostgreSQL_DB_PDO_Filter_Fake_PDO( 'mysql' ); +$throwing_pdo = new WP_PostgreSQL_DB_PDO_Filter_Fake_PDO( 'pgsql', true ); + +wp_postgresql_db_test_respond( + array( + 'no_global' => wp_postgresql_db_get_connection_options_for_global_pdo( null, false ), + 'pgsql_pdo' => wp_postgresql_db_get_connection_options_for_global_pdo( $pgsql_pdo, true ), + 'mysql_pdo' => wp_postgresql_db_get_connection_options_for_global_pdo( $mysql_pdo, true ), + 'throwing_pdo' => wp_postgresql_db_get_connection_options_for_global_pdo( $throwing_pdo, true ), + 'non_pdo_value' => wp_postgresql_db_get_connection_options_for_global_pdo( (object) array( 'driver' => 'pgsql' ), true ), + ) +); +PHP + ); + + $base_keys = array( 'host', 'port', 'dbname', 'user', 'password' ); + + $this->assertSame( + array( + 'no_global' => array( + 'has_pdo' => false, + 'pdo_same' => null, + 'keys' => $base_keys, + ), + 'pgsql_pdo' => array( + 'has_pdo' => true, + 'pdo_same' => true, + 'keys' => array_merge( $base_keys, array( 'pdo' ) ), + ), + 'mysql_pdo' => array( + 'has_pdo' => false, + 'pdo_same' => null, + 'keys' => $base_keys, + ), + 'throwing_pdo' => array( + 'has_pdo' => false, + 'pdo_same' => null, + 'keys' => $base_keys, + ), + 'non_pdo_value' => array( + 'has_pdo' => false, + 'pdo_same' => null, + 'keys' => $base_keys, + ), + ), + $result + ); + } + + /** + * Tests select() uses the current PostgreSQL driver when no handle is passed. + */ + public function test_select_uses_current_postgresql_driver_when_handle_is_omitted(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $dbname = ''; + public $ready = false; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Select_Fake_Driver extends WP_PostgreSQL_Driver { + public function __construct() {} +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$db->dbname = 'wptests'; + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, new WP_PostgreSQL_DB_Select_Fake_Driver() ); + +$select_other_default_result = $db->select( 'other' ); +$ready_after_other_default = $db->ready; + +$select_current_default_result = $db->select( 'wptests' ); +$ready_after_current_default = $db->ready; + +$driver_property->setValue( $db, null ); +$db->ready = true; +$select_missing_driver_result = $db->select( 'wptests' ); +$ready_after_missing_driver = $db->ready; + +wp_postgresql_db_test_respond( + array( + 'select_other_default_result' => $select_other_default_result, + 'ready_after_other_default' => $ready_after_other_default, + 'select_current_default_result' => $select_current_default_result, + 'ready_after_current_default' => $ready_after_current_default, + 'select_missing_driver_result' => $select_missing_driver_result, + 'ready_after_missing_driver' => $ready_after_missing_driver, + ) +); +PHP + ); + + $this->assertSame( + array( + 'select_other_default_result' => false, + 'ready_after_other_default' => false, + 'select_current_default_result' => true, + 'ready_after_current_default' => true, + 'select_missing_driver_result' => false, + 'ready_after_missing_driver' => false, + ), + $result + ); + } + + /** + * Tests db_connect() rejects missing PostgreSQL database names before connecting. + */ + public function test_db_connect_rejects_missing_database_name_before_connecting(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $dbname = null; + public $ready = true; + public $is_mysql = false; + public $last_error = 'previous error'; + public $charset = ''; + public $bail_calls = array(); + + public function init_charset() { + $this->charset = 'utf8mb4'; + } + + public function bail( $message, $error_code = '500' ) { + $this->bail_calls[] = array( $message, $error_code ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$null_db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$null_result = $null_db->db_connect( false ); +$null_db_bail_calls = $null_db->bail_calls; +$null_db_last_error = $null_db->last_error; +$null_db_ready = $null_db->ready; +$null_db_is_mysql = $null_db->is_mysql; +$null_db_charset = $null_db->charset; + +$empty_db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$empty_db->dbname = ''; +$empty_result = $empty_db->db_connect( true ); +$empty_db_bail_calls = $empty_db->bail_calls; +$empty_db_last_error = $empty_db->last_error; +$empty_db_ready = $empty_db->ready; +$empty_db_is_mysql = $empty_db->is_mysql; +$empty_db_charset = $empty_db->charset; + +wp_postgresql_db_test_respond( + array( + 'null_result' => $null_result, + 'null_bail_calls' => $null_db_bail_calls, + 'null_last_error' => $null_db_last_error, + 'null_ready' => $null_db_ready, + 'null_is_mysql' => $null_db_is_mysql, + 'null_charset' => $null_db_charset, + 'empty_result' => $empty_result, + 'empty_bail_calls' => $empty_db_bail_calls, + 'empty_last_error' => $empty_db_last_error, + 'empty_ready' => $empty_db_ready, + 'empty_is_mysql' => $empty_db_is_mysql, + 'empty_charset' => $empty_db_charset, + ) +); +PHP + ); + + $expected_error = 'The database name was not set. The PostgreSQL backend requires DB_NAME.'; + + $this->assertSame( + array( + 'null_result' => false, + 'null_bail_calls' => array(), + 'null_last_error' => $expected_error, + 'null_ready' => false, + 'null_is_mysql' => true, + 'null_charset' => 'utf8mb4', + 'empty_result' => false, + 'empty_bail_calls' => array( + array( $expected_error, 'db_connect_fail' ), + ), + 'empty_last_error' => $expected_error, + 'empty_ready' => false, + 'empty_is_mysql' => true, + 'empty_charset' => 'utf8mb4', + ), + $result + ); + } + + /** + * Tests db_connect() maps connection option failures to wpdb state. + */ + public function test_db_connect_maps_connection_option_failures_to_wpdb_state(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $dbuser = 'pg_user'; + public $dbpassword = 'pg_password'; + public $dbname = 'wptests'; + public $dbhost = 'bad;host'; + public $ready = true; + public $is_mysql = false; + public $last_error = 'previous error'; + public $charset = ''; + public $bail_calls = array(); + + public function init_charset() { + $this->charset = 'utf8mb4'; + } + + public function parse_db_host( $host ) { + return false; + } + + public function bail( $message, $error_code = '500' ) { + $this->bail_calls[] = array( $message, $error_code ); + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +unset( $GLOBALS['@pdo'] ); + +$dbh_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$dbh_property->setAccessible( true ); + +$non_bailing_db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$non_bailing_result = $non_bailing_db->db_connect( false ); +$non_bailing_dbh_after = $dbh_property->getValue( $non_bailing_db ); +$non_bailing_bail_calls = $non_bailing_db->bail_calls; +$non_bailing_last_error = $non_bailing_db->last_error; +$non_bailing_ready = $non_bailing_db->ready; +$non_bailing_is_mysql = $non_bailing_db->is_mysql; +$non_bailing_charset = $non_bailing_db->charset; + +$bailing_db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$bailing_result = $bailing_db->db_connect( true ); +$bailing_dbh_after = $dbh_property->getValue( $bailing_db ); +$bailing_bail_calls = $bailing_db->bail_calls; +$bailing_last_error = $bailing_db->last_error; +$bailing_ready = $bailing_db->ready; +$bailing_is_mysql = $bailing_db->is_mysql; +$bailing_charset = $bailing_db->charset; + +wp_postgresql_db_test_respond( + array( + 'non_bailing_result' => $non_bailing_result, + 'non_bailing_dbh_null' => null === $non_bailing_dbh_after, + 'non_bailing_bail_calls' => $non_bailing_bail_calls, + 'non_bailing_last_error' => $non_bailing_last_error, + 'non_bailing_ready' => $non_bailing_ready, + 'non_bailing_is_mysql' => $non_bailing_is_mysql, + 'non_bailing_charset' => $non_bailing_charset, + 'bailing_result' => $bailing_result, + 'bailing_dbh_null' => null === $bailing_dbh_after, + 'bailing_bail_calls' => $bailing_bail_calls, + 'bailing_last_error' => $bailing_last_error, + 'bailing_ready' => $bailing_ready, + 'bailing_is_mysql' => $bailing_is_mysql, + 'bailing_charset' => $bailing_charset, + ) +); +PHP + ); + + $expected_error = 'PostgreSQL DSN parts cannot contain NUL bytes or semicolons.'; + + $this->assertSame( + array( + 'non_bailing_result' => false, + 'non_bailing_dbh_null' => true, + 'non_bailing_bail_calls' => array(), + 'non_bailing_last_error' => $expected_error, + 'non_bailing_ready' => false, + 'non_bailing_is_mysql' => true, + 'non_bailing_charset' => 'utf8mb4', + 'bailing_result' => false, + 'bailing_dbh_null' => true, + 'bailing_bail_calls' => array( + array( $expected_error, 'db_connect_fail' ), + ), + 'bailing_last_error' => $expected_error, + 'bailing_ready' => false, + 'bailing_is_mysql' => true, + 'bailing_charset' => 'utf8mb4', + ), + $result + ); + } + + /** + * Tests check_connection() probes an existing driver and reconnects after failure. + */ + public function test_check_connection_probes_existing_driver_and_reconnects_after_failure(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +if ( ! class_exists( 'wpdb', false ) ) { + class wpdb { + public $ready = true; + public $last_error = ''; + public $dbname = ''; + public $bail_calls = array(); + + public function bail( $message, $error_code = '500' ) { + $this->bail_calls[] = array( $message, $error_code ); + } + } +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Check_Fake_Connection extends WP_PostgreSQL_Connection { + public $queries = array(); + public $should_throw = false; + + public function __construct() {} + + public function query( string $sql, array $params = array() ): PDOStatement { + $this->queries[] = array( $sql, $params ); + + if ( $this->should_throw ) { + throw new RuntimeException( 'health probe failed' ); + } + + return ( new ReflectionClass( PDOStatement::class ) )->newInstanceWithoutConstructor(); + } +} + +class WP_PostgreSQL_DB_Check_Fake_Driver extends WP_PostgreSQL_Driver { + public $connection; + + public function __construct() {} + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } +} + +class WP_PostgreSQL_DB_Check_Testable extends WP_PostgreSQL_DB { + public $db_connect_calls = array(); + + public function __construct() {} + + public function db_connect( $allow_bail = true ) { + $this->db_connect_calls[] = $allow_bail; + return false; + } +} + +function wp_postgresql_db_check_set_driver( WP_PostgreSQL_DB $db, $driver ) { + $driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); + if ( PHP_VERSION_ID < 80100 ) { + $driver_property->setAccessible( true ); + } + $driver_property->setValue( $db, $driver ); +} + +function wp_postgresql_db_check_get_driver( WP_PostgreSQL_DB $db ) { + $driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); + if ( PHP_VERSION_ID < 80100 ) { + $driver_property->setAccessible( true ); + } + return $driver_property->getValue( $db ); +} + +$success_connection = new WP_PostgreSQL_DB_Check_Fake_Connection(); +$success_driver = new WP_PostgreSQL_DB_Check_Fake_Driver(); +$success_driver->connection = $success_connection; +$success_db = new WP_PostgreSQL_DB_Check_Testable(); +$success_db->ready = true; +$success_db->last_error = 'previous'; +$success_db->dbname = strtolower( 'WordPress' ); +wp_postgresql_db_check_set_driver( $success_db, $success_driver ); +$success_result = $success_db->check_connection( false ); +$success_driver_after_probe = wp_postgresql_db_check_get_driver( $success_db ); + +$failure_connection = new WP_PostgreSQL_DB_Check_Fake_Connection(); +$failure_connection->should_throw = true; +$failure_driver = new WP_PostgreSQL_DB_Check_Fake_Driver(); +$failure_driver->connection = $failure_connection; +$failure_db = new WP_PostgreSQL_DB_Check_Testable(); +$failure_db->ready = true; +$failure_db->last_error = 'previous'; +$failure_db->dbname = strtolower( 'WordPress' ); +wp_postgresql_db_check_set_driver( $failure_db, $failure_driver ); +$failure_result = $failure_db->check_connection( false ); +$failure_driver_after_probe = wp_postgresql_db_check_get_driver( $failure_db ); + +wp_postgresql_db_test_respond( + array( + 'success_result' => $success_result, + 'success_queries' => $success_connection->queries, + 'success_ready' => $success_db->ready, + 'success_last_error' => $success_db->last_error, + 'success_db_connect_calls' => $success_db->db_connect_calls, + 'success_driver_after_probe' => $success_driver_after_probe instanceof WP_PostgreSQL_Driver, + 'failure_queries' => $failure_connection->queries, + 'failure_result' => $failure_result, + 'failure_ready' => $failure_db->ready, + 'failure_last_error' => $failure_db->last_error, + 'failure_driver_after_probe' => null === $failure_driver_after_probe, + 'failure_db_connect_calls' => $failure_db->db_connect_calls, + 'failure_bail_calls' => $failure_db->bail_calls, + ) +); +PHP + ); + + $this->assertSame( + array( + 'success_result' => true, + 'success_queries' => array( + array( 'SELECT 1', array() ), + ), + 'success_ready' => true, + 'success_last_error' => 'previous', + 'success_db_connect_calls' => array(), + 'success_driver_after_probe' => true, + 'failure_queries' => array( + array( 'SELECT 1', array() ), + ), + 'failure_result' => false, + 'failure_ready' => false, + 'failure_last_error' => 'health probe failed', + 'failure_driver_after_probe' => true, + 'failure_db_connect_calls' => array( false ), + 'failure_bail_calls' => array(), + ), + $result + ); + } + + /** + * Tests db_server_info() reports a pending PostgreSQL connection without a driver. + */ + public function test_db_server_info_reports_pending_connection_without_driver(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb {} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, null ); + +wp_postgresql_db_test_respond( + array( + 'server_info' => $db->db_server_info(), + ) +); +PHP + ); + + $this->assertSame( + array( + 'server_info' => 'PostgreSQL backend pending connection', + ), + $result + ); + } + + /** + * Tests query() returns before the driver for not-ready and empty queries. + */ + public function test_query_returns_false_before_driver_for_not_ready_and_empty_queries(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +$GLOBALS['wp_postgresql_db_query_filter_inputs'] = array(); + +function apply_filters( $hook_name, $value ) { + if ( 'query' !== $hook_name ) { + return $value; + } + + $GLOBALS['wp_postgresql_db_query_filter_inputs'][] = $value; + if ( 'FILTER_TO_EMPTY' === $value ) { + return ''; + } + + return $value; +} + +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $ready = true; + public $insert_id = 0; + public $last_query = null; + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $last_error = ''; + public $result = null; +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Query_Early_Return_Fake_Driver extends WP_PostgreSQL_Driver { + public $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + return array(); + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); +$driver = new WP_PostgreSQL_DB_Query_Early_Return_Fake_Driver(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +if ( PHP_VERSION_ID < 80100 ) { + $driver_property->setAccessible( true ); +} +$driver_property->setValue( $db, $driver ); + +$db->ready = false; +$db->insert_id = 123; +$not_ready = array( + 'return' => $db->query( 'SELECT 1' ), + 'insert_id' => $db->insert_id, + 'num_queries' => $db->num_queries, + 'last_query' => $db->last_query, + 'driver_query_count' => count( $driver->queries ), + 'filter_input_count' => count( $GLOBALS['wp_postgresql_db_query_filter_inputs'] ), +); + +$db->ready = true; +$db->insert_id = 456; +$empty_query = array( + 'return' => $db->query( '' ), + 'insert_id' => $db->insert_id, + 'num_queries' => $db->num_queries, + 'last_query' => $db->last_query, + 'driver_query_count' => count( $driver->queries ), + 'filter_inputs' => $GLOBALS['wp_postgresql_db_query_filter_inputs'], +); + +$db->insert_id = 789; +$filter_cancelled = array( + 'return' => $db->query( 'FILTER_TO_EMPTY' ), + 'insert_id' => $db->insert_id, + 'num_queries' => $db->num_queries, + 'last_query' => $db->last_query, + 'driver_query_count' => count( $driver->queries ), + 'filter_inputs' => $GLOBALS['wp_postgresql_db_query_filter_inputs'], +); + +wp_postgresql_db_test_respond( + array( + 'not_ready' => $not_ready, + 'empty_query' => $empty_query, + 'filter_cancelled' => $filter_cancelled, + ) +); +PHP + ); + + $this->assertSame( + array( + 'return' => false, + 'insert_id' => 123, + 'num_queries' => 0, + 'last_query' => null, + 'driver_query_count' => 0, + 'filter_input_count' => 0, + ), + $result['not_ready'] + ); + + $this->assertSame( + array( + 'return' => false, + 'insert_id' => 0, + 'num_queries' => 0, + 'last_query' => null, + 'driver_query_count' => 0, + 'filter_inputs' => array( '' ), + ), + $result['empty_query'] + ); + + $this->assertSame( + array( + 'return' => false, + 'insert_id' => 0, + 'num_queries' => 0, + 'last_query' => null, + 'driver_query_count' => 0, + 'filter_inputs' => array( '', 'FILTER_TO_EMPTY' ), + ), + $result['filter_cancelled'] + ); + } + + /** + * Tests query state, metadata, and SAVEQUERIES mapping. + */ + public function test_query_maps_backend_state_to_wpdb_fields(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +define( 'SAVEQUERIES', true ); + +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function is_multisite() { + return false; +} +function __( $text ) { + return $text; +} + +class wpdb { + public $ready = true; + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $queries = array(); + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; + public $time_start = 0; + + public function timer_start() { + $this->time_start = microtime( true ); + } + + public function timer_stop() { + return microtime( true ) - $this->time_start; + } + + public function get_caller() { + return 'wpdb-test'; + } + + public function log_query( $query, $elapsed, $caller, $start, $data ) { + $this->queries[] = array( + 'query' => $query, + 'elapsed' => $elapsed, + 'caller' => $caller, + 'start' => $start, + 'data' => $data, + ); + } + + public function add_placeholder_escape( $query ) { + return $query; + } + + public function get_col_info( $info_type = 'name', $col_offset = -1 ) { + $this->load_col_info(); + + if ( -1 === $col_offset ) { + return array_map( + static function ( $column ) use ( $info_type ) { + return $column->{$info_type}; + }, + $this->col_info + ); + } + + return $this->col_info[ $col_offset ]->{$info_type} ?? null; + } + + protected function load_col_info() {} +} + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Fake_Driver extends WP_PostgreSQL_Driver { + private $last_return_value = 0; + private $insert_id = 0; + private $last_postgresql_queries = array(); + private $last_column_meta = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->last_postgresql_queries = array( + array( + 'sql' => $query, + 'params' => array(), + ), + ); + + if ( false !== strpos( $query, 'broken' ) ) { + throw new RuntimeException( 'synthetic backend failure' ); + } + + if ( 0 === stripos( $query, 'insert' ) ) { + $this->last_return_value = 1; + $this->insert_id = 7; + $this->last_column_meta = array(); + return 1; + } + + $this->last_return_value = 0; + $this->insert_id = 0; + $this->last_column_meta = array( + array( + 'name' => 'id', + 'mysqli:orgname' => 'id', + 'table' => 'probe', + 'mysqli:orgtable' => 'probe', + 'mysqli:db' => 'wptests', + 'len' => 11, + 'mysqli:charsetnr' => 63, + 'mysqli:flags' => 1, + 'mysqli:type' => 3, + 'precision' => 0, + ), + array( + 'name' => 'label', + 'mysqli:orgname' => 'label', + 'table' => 'probe', + 'mysqli:orgtable' => 'probe', + 'mysqli:db' => 'wptests', + 'len' => 255, + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'precision' => 0, + ), + ); + + return array( + (object) array( + 'id' => '1', + 'label' => 'ok', + ), + ); + } + + public function get_last_return_value() { + return $this->last_return_value; + } + + public function get_insert_id() { + return $this->insert_id; + } + + public function get_last_postgresql_queries(): array { + return $this->last_postgresql_queries; + } + + public function get_last_column_meta(): array { + return $this->last_column_meta; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, new WP_PostgreSQL_DB_Fake_Driver() ); +$db->ready = true; + +$select_return = $db->query( 'SELECT id, label FROM probe' ); +$select = array( + 'return' => $select_return, + 'num_rows' => $db->num_rows, + 'last_result_label' => $db->last_result[0]->label ?? null, + 'col_names' => $db->get_col_info( 'name' ), + 'first_col_type' => $db->get_col_info( 'type', 0 ), + 'savequeries_pg_sql' => $db->queries[0]['postgresql_queries'][0]['sql'] ?? null, +); + +$insert_return = $db->query( "INSERT INTO probe (label) VALUES ('ok')" ); +$insert = array( + 'return' => $insert_return, + 'rows_affected' => $db->rows_affected, + 'insert_id' => $db->insert_id, +); + +$failed_return = $db->query( "INSERT INTO broken (label) VALUES ('bad')" ); +$failed_insert = array( + 'return' => $failed_return, + 'last_error' => $db->last_error, + 'insert_id' => $db->insert_id, +); + +wp_postgresql_db_test_respond( + array( + 'select' => $select, + 'insert' => $insert, + 'failed_insert' => $failed_insert, + ) +); +PHP + ); + + $this->assertSame( + array( + 'return' => 1, + 'num_rows' => 1, + 'last_result_label' => 'ok', + 'col_names' => array( 'id', 'label' ), + 'first_col_type' => 3, + 'savequeries_pg_sql' => 'SELECT id, label FROM probe', + ), + $result['select'] + ); + + $this->assertSame( + array( + 'return' => 1, + 'rows_affected' => 1, + 'insert_id' => 7, + ), + $result['insert'] + ); + + $this->assertSame( + array( + 'return' => false, + 'last_error' => 'synthetic backend failure', + 'insert_id' => 0, + ), + $result['failed_insert'] + ); + } + + /** + * Tests query() detects write statements after leading SQL comments. + */ + public function test_query_detects_statement_keyword_after_leading_sql_comments(): void { + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +class wpdb { + public $ready = true; + public $insert_id = 0; + public $last_query = null; + public $func_call = null; + public $last_error = ''; + public $num_queries = 0; + public $last_result = array(); + public $col_info = null; + public $rows_affected = 0; + public $num_rows = 0; + public $result = null; + public $suppress_errors = true; + public $show_errors = false; + + public function get_caller() { + return 'wpdb-leading-comments-test'; + } + + public function add_placeholder_escape( $query ) { + return $query; + } +} + +global $EZSQL_ERROR; +$EZSQL_ERROR = array(); + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Leading_Comments_Fake_Driver extends WP_PostgreSQL_Driver { + private $insert_id = 0; + private $last_return_value = 0; + private $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( false !== strpos( $query, 'broken' ) ) { + throw new RuntimeException( 'synthetic comment-prefixed failure' ); + } + + $this->insert_id = 42; + $this->last_return_value = 3; + return 3; + } + + public function get_last_return_value() { + return $this->last_return_value; + } + + public function get_insert_id() { + return $this->insert_id; + } + + public function get_last_postgresql_queries(): array { + return array( + array( + 'sql' => end( $this->queries ), + 'params' => array(), + ), + ); + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_DB_Leading_Comments_Fake_Driver(); +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); +$db->ready = true; + +$commented_insert = "/* plugin preamble */\n-- runtime marker\nINSERT INTO probe (label) VALUES ('ok')"; +$insert_return = $db->query( $commented_insert ); +$insert = array( + 'return' => $insert_return, + 'rows_affected' => $db->rows_affected, + 'insert_id' => $db->insert_id, + 'num_rows' => $db->num_rows, + 'last_error' => $db->last_error, + 'queries' => $driver->get_recorded_queries(), +); + +$db->insert_id = 99; + +$commented_failed_insert = "/* plugin preamble */\n-- runtime marker\nINSERT INTO broken (label) VALUES ('bad')"; +$failed_return = $db->query( $commented_failed_insert ); +$failed_insert = array( + 'return' => $failed_return, + 'last_error' => $db->last_error, + 'insert_id' => $db->insert_id, + 'rows_affected' => $db->rows_affected, + 'num_rows' => $db->num_rows, + 'queries' => $driver->get_recorded_queries(), + 'errors' => $EZSQL_ERROR, +); + +wp_postgresql_db_test_respond( + array( + 'commented_insert' => $commented_insert, + 'commented_failed_insert' => $commented_failed_insert, + 'insert' => $insert, + 'failed_insert' => $failed_insert, + ) +); +PHP + ); + + $commented_insert = "/* plugin preamble */\n-- runtime marker\nINSERT INTO probe (label) VALUES ('ok')"; + $commented_failed_insert = "/* plugin preamble */\n-- runtime marker\nINSERT INTO broken (label) VALUES ('bad')"; + + $this->assertSame( $commented_insert, $result['commented_insert'] ); + $this->assertSame( + array( + 'return' => 3, + 'rows_affected' => 3, + 'insert_id' => 42, + 'num_rows' => 0, + 'last_error' => '', + 'queries' => array( + $commented_insert, + ), + ), + $result['insert'] + ); + + $this->assertSame( $commented_failed_insert, $result['commented_failed_insert'] ); + $this->assertSame( + array( + 'return' => false, + 'last_error' => 'synthetic comment-prefixed failure', + 'insert_id' => 0, + 'rows_affected' => 0, + 'num_rows' => 0, + 'queries' => array( + $commented_insert, + $commented_failed_insert, + ), + 'errors' => array( + array( + 'query' => $commented_failed_insert, + 'error_str' => 'synthetic comment-prefixed failure', + ), + ), + ), + $result['failed_insert'] + ); + } + + /** + * Tests the SQL generated by real wpdb helper methods before the driver sees it. + */ + public function test_real_wpdb_update_and_delete_helpers_pass_backticked_sql_to_driver(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function is_multisite() { + return false; +} +function __( $text ) { + return $text; +} +function _doing_it_wrong() {} +function has_filter() { + return false; +} +function add_filter() { + return true; +} +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} +function mbstring_binary_safe_encoding() {} +function reset_mbstring_encoding() {} + +if ( ! class_exists( 'WP_Error', false ) ) { + class WP_Error {} +} + +require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Helper_SQL_Fake_Connection extends WP_PostgreSQL_Connection { + public function __construct() {} + + public function quote( $value, int $type = PDO::PARAM_STR ): string { + return "'" . str_replace( "'", "''", (string) $value ) . "'"; + } +} + +class WP_PostgreSQL_DB_Helper_SQL_Fake_Driver extends WP_PostgreSQL_Driver { + private $connection; + private $queries = array(); + private $last_return_value = 0; + + public function __construct() { + $this->connection = new WP_PostgreSQL_DB_Helper_SQL_Fake_Connection(); + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + $this->last_return_value = 1; + + return 1; + } + + public function get_last_return_value() { + return $this->last_return_value; + } + + public function get_insert_id() { + return 0; + } + + public function get_last_postgresql_queries(): array { + return array( + array( + 'sql' => end( $this->queries ), + 'params' => array(), + ), + ); + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_DB_Helper_SQL_Fake_Driver(); +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$db->ready = true; +$db->is_mysql = false; +$db->dbname = 'wptests'; +$db->charset = 'utf8mb4'; +$db->suppress_errors = true; + +$update_return = $db->update( + 'wptests_options', + array( + 'option_value' => 'Site Name', + ), + array( + 'option_name' => 'blogname', + ) +); +$delete_return = $db->delete( + 'wptests_options', + array( + 'option_name' => 'temporary', + ) +); + +wp_postgresql_db_test_respond( + array( + 'loaded_wpdb' => class_exists( 'wpdb', false ), + 'update_return' => $update_return, + 'delete_return' => $delete_return, + 'queries' => $driver->get_recorded_queries(), + ) +); +PHP + ); + + $this->assertTrue( $result['loaded_wpdb'] ); + $this->assertSame( 1, $result['update_return'] ); + $this->assertSame( 1, $result['delete_return'] ); + $this->assertSame( + array( + "UPDATE `wptests_options` SET `option_value` = 'Site Name' WHERE `option_name` = 'blogname'", + "DELETE FROM `wptests_options` WHERE `option_name` = 'temporary'", + ), + $result['queries'] + ); + } + + /** + * Tests real wpdb query rejects empty-WHERE UPDATE statements before driver execution. + */ + public function test_real_wpdb_query_rejects_empty_where_update_before_driver(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function is_multisite() { + return false; +} +function __( $text ) { + return $text; +} +function _doing_it_wrong() {} +function has_filter() { + return false; +} +function add_filter() { + return true; +} +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} +function mbstring_binary_safe_encoding() {} +function reset_mbstring_encoding() {} + +if ( ! class_exists( 'WP_Error', false ) ) { + class WP_Error {} +} + +require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Empty_Where_Fake_Driver extends WP_PostgreSQL_Driver { + private $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + return 1; + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +function wp_postgresql_db_test_empty_where_result( string $query ): array { + $db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + + $driver = new WP_PostgreSQL_DB_Empty_Where_Fake_Driver(); + $driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); + if ( PHP_VERSION_ID < 80100 ) { + $driver_property->setAccessible( true ); + } + $driver_property->setValue( $db, $driver ); + + $db->ready = true; + $db->is_mysql = false; + $db->dbname = 'wptests'; + $db->charset = 'utf8mb4'; + $db->suppress_errors = true; + + $return = $db->query( $query ); + + return array( + 'return' => $return, + 'last_error' => $db->last_error, + 'queries' => $driver->get_recorded_queries(), + 'rows_affected' => $db->rows_affected, + 'num_queries' => $db->num_queries, + ); +} + +$queries = array( + 'plain' => "UPDATE `wptests_options` SET `option_value` = 'x' WHERE", + 'block' => "/* plugin preamble */\nUPDATE `wptests_options` SET `option_value` = 'x' WHERE", + 'dash' => "-- plugin preamble\nUPDATE `wptests_options` SET `option_value` = 'x' WHERE", + 'hash' => "# plugin preamble\nUPDATE `wptests_options` SET `option_value` = 'x' WHERE", +); + +$results = array(); +foreach ( $queries as $name => $query ) { + $results[ $name ] = wp_postgresql_db_test_empty_where_result( $query ); +} + +wp_postgresql_db_test_respond( + array( + 'results' => $results, + ) +); +PHP + ); + + foreach ( $result['results'] as $case => $case_result ) { + $this->assertFalse( $case_result['return'], $case ); + $this->assertSame( + 'PostgreSQL query rejected because UPDATE requires a non-empty WHERE condition.', + $case_result['last_error'], + $case + ); + $this->assertSame( array(), $case_result['queries'], $case ); + $this->assertSame( 0, $case_result['rows_affected'], $case ); + $this->assertSame( 0, $case_result['num_queries'], $case ); + } + } + + /** + * Tests the SQL generated by real wpdb insert helpers before the driver sees it. + */ + public function test_real_wpdb_insert_and_replace_helpers_pass_backticked_sql_to_driver(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function is_multisite() { + return false; +} +function __( $text ) { + return $text; +} +function _doing_it_wrong() {} +function has_filter() { + return false; +} +function add_filter() { + return true; +} +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} +function mbstring_binary_safe_encoding() {} +function reset_mbstring_encoding() {} + +if ( ! class_exists( 'WP_Error', false ) ) { + class WP_Error {} +} + +require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Insert_SQL_Fake_Connection extends WP_PostgreSQL_Connection { + public function __construct() {} + + public function quote( $value, int $type = PDO::PARAM_STR ): string { + return "'" . str_replace( "'", "''", (string) $value ) . "'"; + } +} + +class WP_PostgreSQL_DB_Insert_SQL_Fake_Driver extends WP_PostgreSQL_Driver { + private $connection; + private $insert_id = 0; + private $last_return_value = 0; + private $queries = array(); + + public function __construct() { + $this->connection = new WP_PostgreSQL_DB_Insert_SQL_Fake_Connection(); + } + + public function get_connection(): WP_PostgreSQL_Connection { + return $this->connection; + } + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( 0 === stripos( $query, 'replace' ) ) { + $this->insert_id = 22; + $this->last_return_value = 2; + return 2; + } + + $this->insert_id = 11; + $this->last_return_value = 1; + return 1; + } + + public function get_last_return_value() { + return $this->last_return_value; + } + + public function get_insert_id() { + return $this->insert_id; + } + + public function get_last_postgresql_queries(): array { + return array( + array( + 'sql' => end( $this->queries ), + 'params' => array(), + ), + ); + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_DB_Insert_SQL_Fake_Driver(); +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$db->ready = true; +$db->is_mysql = false; +$db->dbname = 'wptests'; +$db->charset = 'utf8mb4'; +$db->suppress_errors = true; + +$insert_return = $db->insert( + 'wptests_options', + array( + 'option_name' => 'blogdescription', + 'option_value' => 'Just another site', + ) +); +$insert_id_after_insert = $db->insert_id; + +$replace_return = $db->replace( + 'wptests_options', + array( + 'option_name' => 'siteurl', + 'option_value' => 'http://example.org', + ) +); +$insert_id_after_replace = $db->insert_id; + +wp_postgresql_db_test_respond( + array( + 'loaded_wpdb' => class_exists( 'wpdb', false ), + 'insert_return' => $insert_return, + 'insert_id_after_insert' => $insert_id_after_insert, + 'replace_return' => $replace_return, + 'insert_id_after_replace' => $insert_id_after_replace, + 'queries' => $driver->get_recorded_queries(), + ) +); +PHP + ); + + $this->assertTrue( $result['loaded_wpdb'] ); + $this->assertSame( 1, $result['insert_return'] ); + $this->assertSame( 11, $result['insert_id_after_insert'] ); + $this->assertSame( 2, $result['replace_return'] ); + $this->assertSame( 22, $result['insert_id_after_replace'] ); + $this->assertSame( + array( + "INSERT INTO `wptests_options` (`option_name`, `option_value`) VALUES ('blogdescription', 'Just another site')", + "REPLACE INTO `wptests_options` (`option_name`, `option_value`) VALUES ('siteurl', 'http://example.org')", + ), + $result['queries'] + ); + } + + /** + * Tests the SQL sent by real wpdb read helpers before the driver sees it. + */ + public function test_real_wpdb_read_helpers_pass_identifier_select_sql_to_driver(): void { + $wpdb_file = __DIR__ . '/../../../wordpress/src/wp-includes/class-wpdb.php'; + if ( ! is_readable( $wpdb_file ) ) { + $this->markTestSkipped( 'Real WordPress wpdb class is not available.' ); + } + + $result = $this->run_isolated_wpdb_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +function wp_load_translations_early() {} +function is_multisite() { + return false; +} +function __( $text ) { + return $text; +} +function _doing_it_wrong() {} +function has_filter() { + return false; +} +function add_filter() { + return true; +} +function is_wp_error( $thing ) { + return $thing instanceof WP_Error; +} +function mbstring_binary_safe_encoding() {} +function reset_mbstring_encoding() {} + +if ( ! class_exists( 'WP_Error', false ) ) { + class WP_Error {} +} + +require_once getcwd() . '/../../../wordpress/src/wp-includes/class-wpdb.php'; +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php'; + +class WP_PostgreSQL_DB_Read_SQL_Fake_Driver extends WP_PostgreSQL_Driver { + private $queries = array(); + + public function __construct() {} + + public function query( string $query, $fetch_mode = PDO::FETCH_OBJ, ...$fetch_mode_args ) { + $this->queries[] = $query; + + if ( false !== strpos( $query, 'wptests_users' ) ) { + return array( + (object) array( + 'ID' => '1', + 'user_login' => 'admin', + ), + ); + } + + if ( 0 === strpos( $query, 'SELECT `option_value`' ) ) { + return array( + (object) array( + 'option_value' => 'http://example.org', + ), + ); + } + + if ( 0 === strpos( $query, 'SELECT `option_name` FROM' ) ) { + return array( + (object) array( + 'option_name' => 'siteurl', + ), + ); + } + + return array( + (object) array( + 'option_name' => 'siteurl', + 'option_value' => 'http://example.org', + ), + ); + } + + public function get_last_return_value() { + return 0; + } + + public function get_insert_id() { + return 0; + } + + public function get_last_postgresql_queries(): array { + return array( + array( + 'sql' => end( $this->queries ), + 'params' => array(), + ), + ); + } + + public function get_last_column_meta(): array { + return array(); + } + + public function get_recorded_queries(): array { + return $this->queries; + } +} + +$db = ( new ReflectionClass( WP_PostgreSQL_DB::class ) )->newInstanceWithoutConstructor(); + +$driver = new WP_PostgreSQL_DB_Read_SQL_Fake_Driver(); +$driver_property = new ReflectionProperty( WP_PostgreSQL_DB::class, 'dbh' ); +$driver_property->setAccessible( true ); +$driver_property->setValue( $db, $driver ); + +$check_current_query_property = new ReflectionProperty( 'wpdb', 'check_current_query' ); +$check_current_query_property->setAccessible( true ); +$check_current_query_property->setValue( $db, false ); + +$db->ready = true; +$db->is_mysql = false; +$db->dbname = 'wptests'; +$db->charset = 'utf8mb4'; +$db->suppress_errors = true; + +$option_value = $db->get_var( "SELECT `option_value` FROM `wptests_options` WHERE `option_name` = 'siteurl'" ); +$option_row = $db->get_row( "SELECT `option_name`, `option_value` FROM `wptests_options` WHERE `option_name` = 'siteurl'", ARRAY_A ); +$option_rows = $db->get_results( 'SELECT `option_name` FROM `wptests_options` ORDER BY `option_name`', ARRAY_A ); +$user_row = $db->get_row( 'SELECT ID, user_login FROM wptests_users WHERE ID = 1', ARRAY_A ); + +wp_postgresql_db_test_respond( + array( + 'option_value' => $option_value, + 'option_row' => $option_row, + 'option_rows' => $option_rows, + 'user_row' => $user_row, + 'queries' => $driver->get_recorded_queries(), + ) +); +PHP + ); + + $this->assertSame( 'http://example.org', $result['option_value'] ); + $this->assertSame( + array( + 'option_name' => 'siteurl', + 'option_value' => 'http://example.org', + ), + $result['option_row'] + ); + $this->assertSame( + array( + array( + 'option_name' => 'siteurl', + ), + ), + $result['option_rows'] + ); + $this->assertSame( + array( + 'ID' => '1', + 'user_login' => 'admin', + ), + $result['user_row'] + ); + $this->assertSame( + array( + "SELECT `option_value` FROM `wptests_options` WHERE `option_name` = 'siteurl'", + "SELECT `option_name`, `option_value` FROM `wptests_options` WHERE `option_name` = 'siteurl'", + 'SELECT `option_name` FROM `wptests_options` ORDER BY `option_name`', + 'SELECT ID, user_login FROM wptests_users WHERE ID = 1', + ), + $result['queries'] + ); + } + + /** + * Runs a PostgreSQL wpdb script in a separate PHP process. + * + * @param string $script Script body without the opening PHP tag. + * @return array Decoded JSON response from the script. + */ + private function run_isolated_wpdb_script( string $script ): array { + $script_file = tempnam( sys_get_temp_dir(), 'wp_pg_db_' ); + if ( false === $script_file ) { + $this->fail( 'Could not create temporary PostgreSQL wpdb test script.' ); + } + + $script_written = file_put_contents( + $script_file, + "get_isolated_script_prelude() . "\n" . $script + ); + if ( false === $script_written ) { + unlink( $script_file ); + $this->fail( 'Could not write temporary PostgreSQL wpdb test script.' ); + } + + $descriptor_spec = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + $process = proc_open( + escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( $script_file ), + $descriptor_spec, + $pipes, + __DIR__ + ); + + if ( ! is_resource( $process ) ) { + unlink( $script_file ); + $this->fail( 'Could not start isolated PostgreSQL wpdb test process.' ); + } + + fclose( $pipes[0] ); + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + $exitcode = proc_close( $process ); + unlink( $script_file ); + + $this->assertSame( + 0, + $exitcode, + "Isolated PostgreSQL wpdb script failed.\nSTDOUT:\n" . $stdout . "\nSTDERR:\n" . $stderr + ); + + $decoded = json_decode( $stdout, true ); + $this->assertIsArray( + $decoded, + "Isolated PostgreSQL wpdb script did not return JSON.\nSTDOUT:\n" . $stdout . "\nSTDERR:\n" . $stderr + ); + + return $decoded; + } + + /** + * Gets helper code prepended to every isolated script. + * + * @return string PHP script body. + */ + private function get_isolated_script_prelude(): string { + return <<<'PHP' +function wp_postgresql_db_test_respond( array $payload ) { + echo json_encode( $payload ); +} +PHP; + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection.php new file mode 100644 index 000000000..bc33236d9 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection.php @@ -0,0 +1,152 @@ + new PDO( 'sqlite::memory:' ) ) ); + + if ( ! empty( $identity_metadata_rows ) ) { + $this->install_information_schema_marker(); + $this->install_identity_metadata_fixture( $identity_metadata_rows ); + $this->has_identity_metadata_fixture = true; + } + } + + /** + * Execute a query against PostgreSQL test fixtures when needed. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( $this->has_identity_metadata_fixture && false !== strpos( $sql, 'pg_catalog.pg_get_serial_sequence' ) ) { + return parent::query( + 'SELECT + column_name, + data_type, + is_identity, + column_default, + mysql_column_type, + mysql_extra, + sequence_schema, + sequence_name + FROM dml_identity_metadata_fixture + WHERE table_schema = ? + AND table_name = ? + ORDER BY ordinal_position', + array( $params[0] ?? '', $params[1] ?? '' ) + ); + } + + if ( $this->has_identity_metadata_fixture && false !== strpos( $sql, 'pg_catalog.setval' ) ) { + ++$this->sequence_sync_query_count; + return parent::query( 'SELECT 1' ); + } + + if ( 0 === strpos( $sql, 'ALTER TABLE ' ) ) { + return parent::query( 'SELECT 1 WHERE 0 = 1' ); + } + + if ( 0 === strpos( $sql, 'ALTER INDEX ' ) ) { + return parent::query( 'SELECT 1 WHERE 0 = 1' ); + } + + if ( 0 === strpos( $sql, 'CREATE INDEX ' ) || 0 === strpos( $sql, 'CREATE UNIQUE INDEX ' ) ) { + return parent::query( 'SELECT 1 WHERE 0 = 1' ); + } + + if ( 0 === strpos( $sql, 'CREATE OR REPLACE VIEW ' ) ) { + return parent::query( 'SELECT 1 WHERE 0 = 1' ); + } + + if ( 0 === strpos( $sql, 'DROP INDEX ' ) ) { + return parent::query( 'SELECT 1 WHERE 0 = 1' ); + } + + return parent::query( $sql, $params ); + } + + /** + * Get the number of sequence repair queries executed. + * + * @return int Sequence repair query count. + */ + public function get_sequence_sync_query_count(): int { + return $this->sequence_sync_query_count; + } + + /** + * Install the information_schema marker used by the SQLite test shim. + */ + private function install_information_schema_marker(): void { + $pdo = $this->get_pdo(); + $pdo->exec( "ATTACH DATABASE ':memory:' AS information_schema" ); + $pdo->exec( 'CREATE TABLE information_schema.columns (table_schema TEXT)' ); + } + + /** + * Install identity metadata rows. + * + * @param array[] $identity_metadata_rows Fixture identity metadata rows. + */ + private function install_identity_metadata_fixture( array $identity_metadata_rows ): void { + parent::query( + 'CREATE TABLE dml_identity_metadata_fixture ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + ordinal_position INTEGER NOT NULL, + data_type TEXT NOT NULL, + is_identity TEXT NOT NULL, + column_default TEXT, + mysql_column_type TEXT, + mysql_extra TEXT NOT NULL, + sequence_schema TEXT, + sequence_name TEXT + )' + ); + + foreach ( $identity_metadata_rows as $row ) { + parent::query( + 'INSERT INTO dml_identity_metadata_fixture + (table_schema, table_name, column_name, ordinal_position, data_type, is_identity, column_default, mysql_column_type, mysql_extra, sequence_schema, sequence_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + array( + $row['table_schema'] ?? 'public', + $row['table_name'], + $row['column_name'], + $row['ordinal_position'] ?? 1, + $row['data_type'] ?? 'bigint', + $row['is_identity'] ?? 'YES', + $row['column_default'] ?? null, + $row['mysql_column_type'] ?? 'bigint(20)', + $row['mysql_extra'] ?? 'auto_increment', + $row['sequence_schema'] ?? 'public', + $row['sequence_name'], + ) + ); + } + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php new file mode 100644 index 000000000..52fbcdf68 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_RegExp_Tests.php @@ -0,0 +1,263 @@ + new PDO( 'sqlite::memory:' ) ) ); + $connection->set_query_logger( + static function ( string $sql, array $params ) use ( &$logged_queries ): void { + $logged_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + } + ); + + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $query = "DELETE FROM `wptests_options` WHERE `option_name` REGEXP '^_transient_feed_'"; + + try { + $driver->query( $query ); + $this->fail( 'SQLite unexpectedly accepted the PostgreSQL regular expression operator.' ); + } catch ( PDOException $exception ) { + $this->assertStringContainsString( 'near "~"', $exception->getMessage() ); + } + + $this->assertSame( $query, $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( + array( + 'sql' => 'DELETE FROM "wptests_options" WHERE "option_name" ~* \'^_transient_feed_\'', + 'params' => array(), + ), + end( $logged_queries ) + ); + } + + /** + * Tests REGEXP, RLIKE, and NOT REGEXP predicates use case-insensitive PostgreSQL regex operators. + */ + public function test_regexp_predicates_are_translated_to_postgresql_case_insensitive_regex_operators(): void { + $driver = $this->create_driver(); + + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE meta_key ~* '^foo'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_postmeta WHERE meta_key REGEXP '^foo'" + ) + ); + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE meta_key !~* '^foo'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_postmeta WHERE meta_key NOT REGEXP '^foo'" + ) + ); + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE meta_key ~* '^foo'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_postmeta WHERE meta_key RLIKE '^foo'" + ) + ); + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE meta_key !~* '^foo'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_postmeta WHERE meta_key NOT RLIKE '^foo'" + ) + ); + } + + /** + * Tests default REGEXP collation behavior is represented by case-insensitive operators. + */ + public function test_regexp_predicates_match_mysql_case_insensitive_collation_shape(): void { + $driver = $this->create_driver(); + + $this->assertSame( + "SELECT 'rss_123' ~* '^RSS_.+$' AS is_match", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT 'rss_123' REGEXP '^RSS_.+$' AS is_match" + ) + ); + $this->assertSame( + "SELECT 'rss_123' !~* '^RSS_.+$' AS is_not_match", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT 'rss_123' NOT REGEXP '^RSS_.+$' AS is_not_match" + ) + ); + $this->assertSame( + "SELECT 'rss_123' ~* '^RSS_.+$' AS is_match", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT 'rss_123' RLIKE '^RSS_.+$' AS is_match" + ) + ); + } + + /** + * Tests lower-case RLIKE predicates and qualified identifiers are translated. + */ + public function test_lowercase_rlike_predicate_with_qualified_identifier_is_translated(): void { + $driver = $this->create_driver(); + + $this->assertSame( + "SELECT * FROM wptests_posts WHERE wptests_posts.\"ID\" ~* '^[0-9]+$'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_posts WHERE wptests_posts.ID rlike '^[0-9]+$'" + ) + ); + } + + /** + * Tests REGEXP-like text inside string literals is not rewritten. + */ + public function test_regexp_rewrite_does_not_replace_string_literals(): void { + $driver = $this->create_driver(); + + $this->assertSame( + "SELECT 'REGEXP', 'RLIKE', 'NOT REGEXP' AS literal_value", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT 'REGEXP', 'RLIKE', 'NOT REGEXP' AS literal_value" + ) + ); + } + + /** + * Tests REGEXP BINARY and RLIKE BINARY predicates use case-sensitive PostgreSQL regex operators. + */ + public function test_binary_regexp_predicates_are_translated_to_postgresql_case_sensitive_regex_operators(): void { + $driver = $this->create_driver(); + + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE meta_key ~ '^foo'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_postmeta WHERE meta_key REGEXP BINARY '^foo'" + ) + ); + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE meta_key ~ '^foo'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_postmeta WHERE meta_key RLIKE BINARY '^foo'" + ) + ); + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE meta_key !~ '^foo'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_postmeta WHERE meta_key NOT REGEXP BINARY '^foo'" + ) + ); + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE meta_key !~ '^foo'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_postmeta WHERE meta_key NOT RLIKE BINARY '^foo'" + ) + ); + } + + /** + * Tests CAST(... AS BINARY) regex predicates render as text for PostgreSQL regex execution. + */ + public function test_binary_cast_regexp_predicates_are_rendered_as_text_regex_predicates(): void { + $driver = $this->create_driver(); + + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE CAST(meta_key AS text) ~ '^foo'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_postmeta WHERE CAST(meta_key AS BINARY) REGEXP BINARY '^foo'" + ) + ); + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE CAST(wptests_postmeta.meta_key AS text) ~ '^foo'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_postmeta WHERE CAST(wptests_postmeta.meta_key AS BINARY) RLIKE BINARY '^foo'" + ) + ); + } + + /** + * Tests nested binary REGEXP predicates do not leak raw MySQL regex syntax. + */ + public function test_nested_binary_regexp_predicate_is_fully_translated(): void { + $driver = $this->create_driver(); + $query = "SELECT * FROM wptests_postmeta WHERE NOT EXISTS (SELECT 1 FROM wptests_postmeta mt1 WHERE mt1.post_ID = wptests_postmeta.post_ID AND CAST(mt1.meta_key AS BINARY) REGEXP BINARY '^foo' LIMIT 1)"; + + $translated_query = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ); + + $this->assertSame( + "SELECT * FROM wptests_postmeta WHERE NOT EXISTS (SELECT 1 FROM wptests_postmeta mt1 WHERE mt1.\"post_ID\" = wptests_postmeta.\"post_ID\" AND CAST(mt1.meta_key AS text) ~ '^foo' LIMIT 1)", + $translated_query + ); + $this->assertStringNotContainsString( 'REGEXP BINARY', $translated_query ); + $this->assertStringNotContainsString( 'CAST(mt1.meta_key AS BINARY)', $translated_query ); + } + + /** + * Creates a PostgreSQL driver backed by an injected in-memory PDO. + * + * @return WP_PostgreSQL_Driver + */ + private function create_driver(): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + + /** + * Translate a query by calling a private driver translator. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $method_name Private driver method name. + * @param string $query MySQL query. + * @return string|null PostgreSQL SQL, or null when unsupported. + */ + private function translate_driver_query_with_private_method( WP_PostgreSQL_Driver $driver, string $method_name, string $query ): ?string { + $translator = Closure::bind( + function ( string $bound_method_name, string $bound_query ): ?string { + return $this->$bound_method_name( $bound_query ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + return $translator( $method_name, $query ); + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php new file mode 100644 index 000000000..dc9e34391 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php @@ -0,0 +1,148 @@ + new PDO( 'sqlite::memory:' ) ) ); + + $this->install_fixture(); + } + + /** + * Execute a query against the fixture when the PostgreSQL catalog query is used. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( false === strpos( $sql, 'pg_catalog.pg_index' ) ) { + return parent::query( $sql, $params ); + } + + $fixture_sql = 'SELECT + table_name AS "Table", + non_unique AS "Non_unique", + key_name AS "Key_name", + seq_in_index AS "Seq_in_index", + column_name AS "Column_name", + collation AS "Collation", + cardinality AS "Cardinality", + sub_part AS "Sub_part", + packed AS "Packed", + nullable AS "Null", + index_type AS "Index_type", + comment AS "Comment", + index_comment AS "Index_comment", + visible AS "Visible", + expression AS "Expression" + FROM show_index_fixture + WHERE table_schema = ? + AND table_name = ?'; + $fixture_params = array( $params[0] ?? '', $params[1] ?? '' ); + + if ( isset( $params[2] ) ) { + $filter = $this->get_show_index_fixture_filter( $sql ); + if ( null !== $filter ) { + $fixture_sql .= sprintf( + ' + AND %s %s ?', + $filter['column'], + $filter['operator'] + ); + } else { + $fixture_sql .= ' + AND key_name = ?'; + } + + $fixture_params[] = $params[2]; + } + + $fixture_sql .= ' + ORDER BY sort_position, CAST(seq_in_index AS INTEGER)'; + + return parent::query( $fixture_sql, $fixture_params ); + } + + /** + * Get the fixture column/operator backing the SHOW INDEX filter in the driver query. + * + * @param string $sql Driver SQL query. + * @return array{column: string, operator: string}|null Fixture filter, or null for the legacy key_name filter. + */ + private function get_show_index_fixture_filter( string $sql ): ?array { + if ( ! preg_match( '/WHERE\s+"([^"]+)"\s+(=|LIKE)\s+\?/i', $sql, $matches ) ) { + return null; + } + + $columns = array( + 'Table' => 'table_name', + 'Non_unique' => 'non_unique', + 'Key_name' => 'key_name', + 'Seq_in_index' => 'seq_in_index', + 'Column_name' => 'column_name', + 'Collation' => 'collation', + 'Cardinality' => 'cardinality', + 'Sub_part' => 'sub_part', + 'Packed' => 'packed', + 'Null' => 'nullable', + 'Index_type' => 'index_type', + 'Comment' => 'comment', + 'Index_comment' => 'index_comment', + 'Visible' => 'visible', + 'Expression' => 'expression', + ); + + if ( ! isset( $columns[ $matches[1] ] ) ) { + return null; + } + + return array( + 'column' => $columns[ $matches[1] ], + 'operator' => strtoupper( $matches[2] ), + ); + } + + /** + * Install SHOW INDEX fixture rows into the injected PDO. + */ + private function install_fixture(): void { + $pdo = $this->get_pdo(); + + $pdo->exec( + 'CREATE TABLE show_index_fixture ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + sort_position INTEGER NOT NULL, + non_unique TEXT NOT NULL, + key_name TEXT NOT NULL, + seq_in_index TEXT NOT NULL, + column_name TEXT, + collation TEXT, + cardinality TEXT, + sub_part TEXT, + packed TEXT, + nullable TEXT NOT NULL, + index_type TEXT NOT NULL, + comment TEXT NOT NULL, + index_comment TEXT NOT NULL, + visible TEXT NOT NULL, + expression TEXT + )' + ); + $pdo->exec( + "INSERT INTO show_index_fixture + (table_schema, table_name, sort_position, non_unique, key_name, seq_in_index, column_name, collation, cardinality, sub_part, packed, nullable, index_type, comment, index_comment, visible, expression) + VALUES + ('public', 'wptests_options', 1, '0', 'PRIMARY', '1', 'option_id', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), + ('public', 'wptests_options', 2, '0', 'option_name', '1', 'option_name', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), + ('public', 'wptests_options', 3, '1', 'autoload', '1', 'autoload', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL), + ('public', 'wptests_posts', 4, '0', 'PRIMARY', '1', 'ID', 'A', '0', NULL, NULL, '', 'BTREE', '', '', 'YES', NULL)" + ); + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php new file mode 100644 index 000000000..75a4a3b15 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -0,0 +1,31990 @@ +create_driver(); + + $rows = $driver->query( "SELECT 1 AS id, 'ok' AS value" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'ok', $rows[0]->value ); + $this->assertSame( 'SELECT 1 AS id, \'ok\' AS value', $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => "SELECT 1 AS id, 'ok' AS value", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $column_meta = $driver->get_last_column_meta(); + $this->assertCount( 2, $column_meta ); + $this->assertSame( 'id', $column_meta[0]['name'] ); + $this->assertSame( 'wptests', $column_meta[0]['mysqli:db'] ); + $this->assertArrayHasKey( 'mysqli:type', $column_meta[0] ); + $this->assertArrayHasKey( 'mysqli:charsetnr', $column_meta[0] ); + } + + /** + * Tests MySQL optimizer index hints are removed before PostgreSQL execution. + */ + public function test_select_index_hints_are_removed_before_postgresql_execution(): void { + $driver = $this->create_driver_with_index_hint_tables(); + + $queries = array( + 'SELECT * FROM t USE INDEX (i)' => 'SELECT * FROM t', + 'SELECT * FROM t USE KEY (k)' => 'SELECT * FROM t', + 'SELECT * FROM t FORCE INDEX (i)' => 'SELECT * FROM t', + 'SELECT * FROM t FORCE KEY (k)' => 'SELECT * FROM t', + 'SELECT * FROM t IGNORE INDEX (i)' => 'SELECT * FROM t', + 'SELECT * FROM t IGNORE KEY (k)' => 'SELECT * FROM t', + 'SELECT * FROM t USE INDEX FOR JOIN (i) JOIN j ON t.id = j.t_id' => 'SELECT * FROM t JOIN j ON t.id = j.t_id', + 'SELECT * FROM t USE INDEX FOR ORDER BY (i) ORDER BY id DESC' => 'SELECT * FROM t ORDER BY id DESC', + 'SELECT * FROM t USE INDEX FOR GROUP BY (i) GROUP BY id HAVING id = 1' => 'SELECT * FROM t GROUP BY id HAVING id = 1', + 'SELECT * FROM `t` USE INDEX (i) USE INDEX FOR JOIN (j) USE KEY FOR ORDER BY (o) IGNORE INDEX FOR GROUP BY (g) JOIN j ON t.id = j.t_id WHERE id = 1 GROUP BY id HAVING id = 1 ORDER BY id DESC' => 'SELECT * FROM "t" JOIN j ON t.id = j.t_id WHERE id = 1 GROUP BY id HAVING id = 1 ORDER BY id DESC', + ); + + foreach ( $queries as $mysql_query => $postgresql_sql ) { + $rows = $driver->query( $mysql_query ); + + $this->assertSame( array(), $rows, $mysql_query ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertSame( $postgresql_sql, $sql, $mysql_query ); + $this->assert_postgresql_sql_omits_mysql_index_hints( $sql ); + } + } + + /** + * Tests quoted table and index identifiers keep aliases while index hints are removed. + */ + public function test_select_index_hints_preserve_quoted_table_and_alias(): void { + $driver = $this->create_driver_with_index_hint_tables(); + + $driver->query( "INSERT INTO t (id, value) VALUES (1, 'first')" ); + + $rows = $driver->query( 'SELECT tt.id FROM `t` AS tt USE INDEX (`ix_t_id`) WHERE tt.id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertSame( 'SELECT tt.id FROM "t" AS tt WHERE tt.id = 1', $sql ); + $this->assert_postgresql_sql_omits_mysql_index_hints( $sql ); + } + + /** + * Tests malformed MySQL index hints are not sent raw to PostgreSQL. + */ + public function test_malformed_select_index_hint_is_rejected_before_postgresql_execution(): void { + $driver = $this->create_driver_with_index_hint_tables(); + + try { + $driver->query( 'SELECT * FROM t USE INDEX' ); + $this->fail( 'Malformed MySQL index hint was not rejected.' ); + } catch ( InvalidArgumentException $exception ) { + $this->assertSame( 'Unsupported MySQL index hint syntax.', $exception->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests result column metadata is normalized only when requested. + */ + public function test_query_defers_column_metadata_until_requested(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SELECT 1 AS id, 'ok' AS value" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 2, $driver->get_last_column_count() ); + $this->assertSame( array(), $this->get_driver_private_property( $driver, 'last_column_meta' ) ); + $this->assertInstanceOf( PDOStatement::class, $this->get_driver_private_property( $driver, 'last_column_meta_statement' ) ); + + $column_meta = $driver->get_last_column_meta(); + $this->assertCount( 2, $column_meta ); + $this->assertSame( 'id', $column_meta[0]['name'] ); + $this->assertNull( $this->get_driver_private_property( $driver, 'last_column_meta_statement' ) ); + } + + /** + * Tests fetched PostgreSQL-safe text decodes to MySQL NUL bytes. + */ + public function test_query_decodes_postgresql_text_sentinel_to_mysql_nul_byte(): void { + $driver = $this->create_driver_with_postgresql_quote_translation(); + $connection = $driver->get_connection(); + + $driver->query( 'CREATE TABLE t (value TEXT NOT NULL)' ); + $connection->query( 'INSERT INTO t (value) VALUES (' . $connection->quote( "protected\0property" ) . ')' ); + + $stored_rows = $connection->query( 'SELECT value FROM t' )->fetchAll( PDO::FETCH_OBJ ); + $this->assertCount( 1, $stored_rows ); + $this->assertStringNotContainsString( "\0", $stored_rows[0]->value ); + $this->assertStringContainsString( 'WP_MYSQL_TEXT_V1:', $stored_rows[0]->value ); + + $rows = $driver->query( 'SELECT value FROM t' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( "protected\0property", $rows[0]->value ); + } + + /** + * Tests external sentinel-shaped PostgreSQL text is preserved on fetch. + */ + public function test_query_preserves_external_postgresql_text_sentinel_collision_shape(): void { + $driver = $this->create_driver_with_postgresql_quote_translation(); + $connection = $driver->get_connection(); + + $driver->query( 'CREATE TABLE t (value TEXT NOT NULL)' ); + + $external_value = 'pre' . "\xEE\x80\x80" . '0post'; + $connection->query( 'INSERT INTO t (value) VALUES (?)', array( $external_value ) ); + + $rows = $driver->query( 'SELECT value FROM t' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( $external_value, $rows[0]->value ); + } + + /** + * Tests write queries return PDO row counts. + */ + public function test_write_query_returns_row_count(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $result = $driver->query( "INSERT INTO t (value) VALUES ('first')" ); + + $this->assertSame( 1, $result ); + $this->assertSame( 1, $driver->get_last_return_value() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + + /** + * Tests simple WordPress INSERT statements are translated to PostgreSQL. + */ + public function test_simple_wordpress_insert_with_backticks_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users (user_login TEXT NOT NULL)' ); + + $insert = "INSERT INTO `wptests_users` (`user_login`) VALUES ('admin')"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( $insert, $driver->get_last_mysql_query() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( 'INSERT INTO "wptests_users" ("user_login") VALUES (\'admin\')', $queries[0]['sql'] ); + $this->assertSame( array(), $queries[0]['params'] ); + + $rows = $driver->query( "SELECT user_login FROM wptests_users WHERE user_login = 'admin'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'admin', $rows[0]->user_login ); + } + + /** + * Tests simple MySQL INSERT forms without INTO and with multi-row VALUES are translated. + */ + public function test_simple_insert_without_into_and_multi_row_values_translate_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_bulk_insert (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + + $this->assertSame( + 2, + $driver->query( "INSERT wptests_bulk_insert (`id`, `value`) VALUES (1, 'one'), (2, 'two')" ) + ); + $this->assertSame( + 'INSERT INTO "wptests_bulk_insert" ("id", "value") VALUES (1, \'one\'), (2, \'two\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( + 1, + $driver->query( "INSERT IGNORE wptests_bulk_insert (`id`, `value`) VALUES (2, 'duplicate'), (3, 'three')" ) + ); + $this->assertSame( + 'INSERT INTO "wptests_bulk_insert" ("id", "value") VALUES (2, \'duplicate\'), (3, \'three\') ON CONFLICT DO NOTHING', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_bulk_insert ORDER BY id' ); + $this->assertSame( + array( + array( '1', 'one' ), + array( '2', 'two' ), + array( '3', 'three' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests singular VALUE row-list INSERT syntax is translated like VALUES. + */ + public function test_value_keyword_insert_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_value_keyword_insert (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + + $insert = "INSERT INTO wptests_value_keyword_insert (`id`, `value`) VALUE (1, 'one')"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_value_keyword_insert" ("id", "value") VALUES (1, \'one\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_value_keyword_insert' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'one', $rows[0]->value ); + } + + /** + * Tests columnless INSERT VALUES statements infer target columns from MySQL metadata. + */ + public function test_columnless_insert_values_uses_mysql_metadata_columns(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_columnless_insert (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_columnless_insert ( + id bigint(20) unsigned NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + + $insert = "INSERT INTO `wptests_columnless_insert` VALUES (1, 'one'), (2, 'two')"; + + $this->assertSame( 2, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_columnless_insert" ("id", "value") VALUES (1, \'one\'), (2, \'two\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_columnless_insert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'one', + ), + (object) array( + 'id' => '2', + 'value' => 'two', + ), + ), + $rows + ); + } + + /** + * Tests INSERT IGNORE ... SELECT without INTO keeps SQLite plugin-corpus parity. + */ + public function test_insert_ignore_select_without_into_translates_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_insert_ignore_select (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_insert_ignore_select_source (id INTEGER NOT NULL, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_insert_ignore_select (id, value) VALUES (1, 'existing')" ); + $driver->query( "INSERT INTO wptests_insert_ignore_select_source (id, value) VALUES (1, 'duplicate'), (2, 'new')" ); + + $insert = 'INSERT IGNORE wptests_insert_ignore_select (`id`, `value`) + SELECT id, value FROM wptests_insert_ignore_select_source ORDER BY id'; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO wptests_insert_ignore_select ("id", "value") SELECT id, value FROM wptests_insert_ignore_select_source ORDER BY id ON CONFLICT DO NOTHING', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_insert_ignore_select ORDER BY id' ); + $this->assertSame( + array( + array( '1', 'existing' ), + array( '2', 'new' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests simple MySQL INSERT ... SET assignments are translated to PostgreSQL. + */ + public function test_simple_insert_set_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_insert_set (id INTEGER PRIMARY KEY, value TEXT NOT NULL, attempts INTEGER NOT NULL)' ); + + $insert = "INSERT INTO wptests_insert_set SET id = 1, value = 'one', attempts = 2"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_set" ("id", "value", "attempts") VALUES (1, \'one\', 2)', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value, attempts FROM wptests_insert_set' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'one', $rows[0]->value ); + $this->assertSame( '2', $rows[0]->attempts ); + + $insert_ignore = "INSERT IGNORE wptests_insert_set SET id = 1, value = 'ignored', attempts = 3"; + + $this->assertSame( 0, $driver->query( $insert_ignore ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_set" ("id", "value", "attempts") VALUES (1, \'ignored\', 3) ON CONFLICT DO NOTHING', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT value, attempts FROM wptests_insert_set WHERE id = 1' ); + $this->assertSame( 'one', $rows[0]->value ); + $this->assertSame( '2', $rows[0]->attempts ); + + $qualified_insert = "INSERT INTO wptests_insert_set SET wptests_insert_set.id = 2, wptests_insert_set.value = 'two', wptests.wptests_insert_set.attempts = 4"; + + $this->assertSame( 1, $driver->query( $qualified_insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_set" ("id", "value", "attempts") VALUES (2, \'two\', 4)', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT value, attempts FROM wptests_insert_set WHERE id = 2' ); + $this->assertSame( 'two', $rows[0]->value ); + $this->assertSame( '4', $rows[0]->attempts ); + } + + /** + * Tests INSERT ... SET accepts unquoted keyword column names. + */ + public function test_insert_set_accepts_unquoted_name_column_target(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_insert_set_name (id INTEGER PRIMARY KEY, name TEXT NOT NULL, attempts INTEGER NOT NULL)' ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO wptests_insert_set_name SET id = 1, name = UPPER('alpha'), attempts = 1 + 2" + ) + ); + + $rows = $driver->query( 'SELECT id, name, attempts FROM wptests_insert_set_name' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'ALPHA', $rows[0]->name ); + $this->assertSame( '3', $rows[0]->attempts ); + } + + /** + * Tests MySQL INSERT priority modifiers are accepted as compatibility no-ops. + */ + public function test_insert_priority_modifiers_are_accepted_as_noops(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_insert_priority (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_insert_priority_source (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_insert_priority_source (id, value) VALUES (2, 'delayed-select')" ); + + $this->assertSame( 1, $driver->query( "INSERT LOW_PRIORITY INTO wptests_insert_priority (id, value) VALUES (1, 'low')" ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_priority" ("id", "value") VALUES (1, \'low\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( 0, $driver->query( "INSERT HIGH_PRIORITY IGNORE INTO wptests_insert_priority (id, value) VALUES (1, 'ignored')" ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_priority" ("id", "value") VALUES (1, \'ignored\') ON CONFLICT DO NOTHING', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( + 1, + $driver->query( + 'INSERT DELAYED INTO wptests_insert_priority (id, value) + SELECT id, value FROM wptests_insert_priority_source' + ) + ); + $this->assertSame( + 'INSERT INTO wptests_insert_priority (id, value) SELECT id, value FROM wptests_insert_priority_source', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->assertStringNotContainsString( 'DELAYED', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 1, $driver->query( "INSERT LOW_PRIORITY INTO wptests_insert_priority SET id = 3, value = 'low-set'" ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_priority" ("id", "value") VALUES (3, \'low-set\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_insert_priority ORDER BY id' ); + $this->assertSame( array( '1', '2', '3' ), array_column( $rows, 'id' ) ); + $this->assertSame( array( 'low', 'delayed-select', 'low-set' ), array_column( $rows, 'value' ) ); + } + + /** + * Tests unsupported INSERT ... SET shapes fail before backend execution. + */ + public function test_unsupported_insert_set_shapes_fail_closed_before_backend(): void { + $queries = array( + 'INSERT INTO wptests_insert_set SET id = 1, id = 2', + 'INSERT INTO wptests_insert_set SET other_table.id = 1', + 'INSERT INTO wptests_insert_set SET other_db.wptests_insert_set.id = 1', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported INSERT statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported INSERT statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unsupported INSERT shapes fail before backend execution. + */ + public function test_unsupported_insert_shapes_fail_closed_before_backend(): void { + $queries = array( + 'INSERT INTO wptests_insert_unsupported PARTITION (p0) (id) VALUES (1)', + 'INSERT INTO wptests_insert_unsupported (id) VALUES (1) RETURNING id', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported INSERT statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported INSERT statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests non-strict INSERT statements append metadata-derived NOT NULL defaults. + */ + public function test_non_strict_insert_appends_omitted_not_null_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + + $driver->query( + 'CREATE TABLE wptests_comments ( + comment_ID INTEGER PRIMARY KEY, + comment_author TEXT NOT NULL, + comment_author_email TEXT NOT NULL, + comment_content TEXT NOT NULL, + comment_parent INTEGER NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_comments ( + comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_author tinytext NOT NULL, + comment_author_email varchar(100) NOT NULL DEFAULT '', + comment_content text NOT NULL, + comment_parent bigint(20) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (comment_ID) + )" + ); + + $comment_insert = 'INSERT INTO `wptests_comments` (`comment_ID`) VALUES (1)'; + + $this->assertSame( 1, $driver->query( $comment_insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_comments" ("comment_ID", "comment_author", "comment_author_email", "comment_content", "comment_parent") VALUES (1, \'\', \'\', \'\', \'0\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $comments = $driver->query( 'SELECT comment_author, comment_author_email, comment_content, comment_parent FROM wptests_comments WHERE comment_ID = 1' ); + $this->assertSame( '', $comments[0]->comment_author ); + $this->assertSame( '', $comments[0]->comment_author_email ); + $this->assertSame( '', $comments[0]->comment_content ); + $this->assertSame( '0', $comments[0]->comment_parent ); + + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY, + post_date TEXT NOT NULL, + post_content TEXT NOT NULL, + post_title TEXT NOT NULL, + post_excerpt TEXT NOT NULL, + post_status TEXT NOT NULL, + post_parent INTEGER NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_posts ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_date datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + post_content longtext NOT NULL, + post_title text NOT NULL, + post_excerpt text NOT NULL, + post_status varchar(20) NOT NULL DEFAULT 'publish', + post_parent bigint(20) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (ID) + )" + ); + + $post_insert = "INSERT INTO `wptests_posts` (`ID`, `post_title`) VALUES (1, 'Post 1')"; + + $this->assertSame( 1, $driver->query( $post_insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("ID", "post_title", "post_date", "post_content", "post_excerpt", "post_status", "post_parent") VALUES (1, \'Post 1\', \'0000-00-00 00:00:00\', \'\', \'\', \'publish\', \'0\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $posts = $driver->query( 'SELECT post_date, post_content, post_excerpt, post_status, post_parent FROM wptests_posts WHERE ID = 1' ); + $this->assertSame( '0000-00-00 00:00:00', $posts[0]->post_date ); + $this->assertSame( '', $posts[0]->post_content ); + $this->assertSame( '', $posts[0]->post_excerpt ); + $this->assertSame( 'publish', $posts[0]->post_status ); + $this->assertSame( '0', $posts[0]->post_parent ); + } + + /** + * Tests non-strict INSERT translates CURRENT_TIMESTAMP metadata defaults as expressions. + */ + public function test_non_strict_insert_translates_current_timestamp_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + + $driver->query( + 'CREATE TABLE wptests_insert_current_timestamp_defaults ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_insert_current_timestamp_defaults ( + id bigint(20) unsigned NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT (now()), + PRIMARY KEY (id) + )' + ); + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_simple_mysql_insert_query', + 'INSERT INTO `wptests_insert_current_timestamp_defaults` (`id`) VALUES (1)' + ); + + $this->assertIsArray( $translation ); + $this->assertSame( + 'INSERT INTO "wptests_insert_current_timestamp_defaults" ("id", "created_at", "updated_at") VALUES (1, TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\'), TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\'))', + $translation['sql'] + ); + } + + /** + * Tests DML column metadata is cached and invalidated after metadata changes. + */ + public function test_dml_column_metadata_cache_reuses_rows_until_metadata_changes(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $driver->set_sql_mode( '' ); + + $driver->query( + 'CREATE TABLE wptests_cache_dml ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, + status TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_cache_dml ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + label varchar(20) NOT NULL DEFAULT '', + status varchar(20) NOT NULL DEFAULT 'draft', + PRIMARY KEY (id) + )" + ); + + $metadata_select_count = 0; + $connection->set_query_logger( + static function ( string $sql, array $params ) use ( &$metadata_select_count ): void { + if ( + false !== strpos( $sql, 'SELECT column_name, ordinal_position, column_type, is_nullable, column_default, extra' ) + && false !== strpos( $sql, WP_PostgreSQL_Driver::MYSQL_COLUMN_METADATA_TABLE ) + ) { + ++$metadata_select_count; + } + } + ); + + $this->assertSame( 1, $driver->query( 'INSERT INTO `wptests_cache_dml` (`id`) VALUES (1)' ) ); + $this->assertSame( 1, $driver->query( 'INSERT INTO `wptests_cache_dml` (`id`) VALUES (2)' ) ); + $this->assertSame( 1, $metadata_select_count ); + + $driver->query( "ALTER TABLE wptests_cache_dml ALTER COLUMN status SET DEFAULT 'published'" ); + $this->assertSame( 1, $driver->query( 'INSERT INTO `wptests_cache_dml` (`id`) VALUES (3)' ) ); + $this->assertSame( 2, $metadata_select_count ); + + $rows = $driver->query( 'SELECT id, label, status FROM wptests_cache_dml ORDER BY id' ); + $this->assertSame( + array( + array( '1', '', 'draft' ), + array( '2', '', 'draft' ), + array( '3', '', 'published' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->label, $row->status ); + }, + $rows + ) + ); + } + + /** + * Tests DML identity metadata is cached and invalidated after metadata changes. + */ + public function test_dml_identity_metadata_cache_reuses_rows_until_metadata_changes(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_cache_identity', 'id', 'wptests_cache_identity_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_cache_identity (id INTEGER PRIMARY KEY, label TEXT NOT NULL)' ); + + $metadata_select_count = 0; + $connection->set_query_logger( + static function ( string $sql, array $params ) use ( &$metadata_select_count ): void { + if ( false !== strpos( $sql, 'FROM dml_identity_metadata_fixture' ) ) { + ++$metadata_select_count; + } + } + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_cache_identity` (`id`, `label`) VALUES (1, 'first')" ) ); + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_cache_identity` (`id`, `label`) VALUES (2, 'second')" ) ); + $this->assertSame( 1, $metadata_select_count ); + + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_cache_identity ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + label varchar(20) NOT NULL DEFAULT '', + PRIMARY KEY (id) + )" + ); + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_cache_identity` (`id`, `label`) VALUES (3, 'third')" ) ); + $this->assertSame( 2, $metadata_select_count ); + } + + /** + * Tests MySQL tokenization reuses the most recent query token stream. + */ + public function test_mysql_token_cache_reuses_most_recent_query_tokens(): void { + $driver = $this->create_driver(); + $get_tokens = Closure::bind( + function ( string $query ): array { + return $this->get_mysql_tokens( $query ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + $first_tokens = $get_tokens( 'SELECT ID FROM wptests_posts WHERE ID = 1' ); + $second_tokens = $get_tokens( 'SELECT ID FROM wptests_posts WHERE ID = 1' ); + $third_tokens = $get_tokens( 'SELECT ID FROM wptests_posts WHERE ID = 2' ); + + $this->assertSame( $first_tokens, $second_tokens ); + $this->assertNotSame( $first_tokens, $third_tokens ); + + $driver->set_sql_mode( 'ANSI_QUOTES' ); + $ansi_tokens = $get_tokens( 'SELECT "ID" FROM "wptests_posts"' ); + + $this->assertSame( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, $ansi_tokens[1]->id ); + $this->assertNotSame( $first_tokens, $ansi_tokens ); + } + + /** + * Tests non-strict INSERT normalizes invalid date/time literals using MySQL metadata. + */ + public function test_non_strict_insert_normalizes_invalid_date_time_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $insert = "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) VALUES (1, '2020-12-41 14:15:27', '0000-00-00 00:00:00', '2020-00-15 14:15:27', '2020-06-01T12:13:14Z')"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("ID", "post_date", "post_date_gmt", "post_modified", "post_modified_gmt") VALUES (1, \'0000-00-00 00:00:00\', \'0000-00-00 00:00:00\', \'2020-00-15 14:15:27\', \'2020-06-01 12:13:14\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $posts = $driver->query( 'SELECT post_date, post_date_gmt, post_modified, post_modified_gmt FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $posts ); + $this->assertSame( '0000-00-00 00:00:00', $posts[0]->post_date ); + $this->assertSame( '0000-00-00 00:00:00', $posts[0]->post_date_gmt ); + $this->assertSame( '2020-00-15 14:15:27', $posts[0]->post_modified ); + $this->assertSame( '2020-06-01 12:13:14', $posts[0]->post_modified_gmt ); + } + + /** + * Tests strict zero-date SQL modes reject invalid date/time literals before backend execution. + */ + public function test_strict_insert_rejects_zero_date_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + try { + $driver->query( "INSERT INTO `wptests_posts` (`ID`, `post_date`) VALUES (1, '0000-00-00 00:00:00')" ); + $this->fail( 'Expected zero date to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0000-00-00 00:00:00'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests strict zero-date SQL modes reject expression-produced date/time values. + */ + public function test_strict_dml_rejects_expression_produced_zero_dates_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + try { + $driver->query( "INSERT INTO `wptests_posts` (`ID`, `post_date`) VALUES (1, CONCAT('0000-00-00', ' 00:00:00'))" ); + $this->fail( 'Expected expression-produced zero date to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( array(), $driver->query( 'SELECT ID FROM wptests_posts WHERE ID = 1' ) ); + } + + try { + $driver->query( "INSERT INTO `wptests_posts` (`ID`, `post_date`) SELECT 2, CONCAT('0000-00-00', ' 00:00:00')" ); + $this->fail( 'Expected INSERT ... SELECT expression-produced zero date to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( array(), $driver->query( 'SELECT ID FROM wptests_posts WHERE ID = 2' ) ); + } + + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_date_gmt, post_modified, post_modified_gmt) + VALUES (3, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE' ); + + try { + $driver->query( "UPDATE `wptests_posts` SET `post_modified` = CONCAT('2020-00', '-15 14:15:27') WHERE `ID` = 3" ); + $this->fail( 'Expected expression-produced zero-in-date to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 3' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '2020-01-01 00:00:00', $rows[0]->post_modified ); + } + } + + /** + * Tests strict INSERT accepts zero dates when NO_ZERO_DATE is disabled. + */ + public function test_strict_insert_accepts_zero_dates_when_no_zero_date_mode_is_disabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '0000-00-00 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ) + ); + + $rows = $driver->query( 'SELECT post_date FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_date ); + } + + /** + * Tests non-strict INSERT accepts zero dates when NO_ZERO_DATE is enabled without strict mode. + */ + public function test_non_strict_insert_accepts_zero_dates_when_no_zero_date_mode_is_enabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'NO_ZERO_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '0000-00-00 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ) + ); + + $rows = $driver->query( 'SELECT post_date FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_date ); + } + + /** + * Tests strict DATE columns accept zero dates when NO_ZERO_DATE is disabled. + */ + public function test_strict_insert_accepts_zero_dates_for_date_columns_when_no_zero_date_mode_is_disabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES' ); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( "INSERT INTO `wptests_strict_values` (`id`, `date_value`) VALUES (1, '0000-00-00')" ) + ); + + $rows = $driver->query( 'SELECT date_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00', $rows[0]->date_value ); + } + + /** + * Tests strict zero-date SQL modes reject INSERT ... SELECT date literals before backend execution. + */ + public function test_strict_insert_select_rejects_zero_date_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + try { + $driver->query( "INSERT INTO `wptests_posts` (`ID`, `post_date`) SELECT 1, '0000-00-00 00:00:00' FROM DUAL" ); + $this->fail( 'Expected zero date projection to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0000-00-00 00:00:00'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests strict zero-date SQL modes reject INSERT ... SELECT literals without FROM. + */ + public function test_strict_insert_select_without_from_rejects_zero_date_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + try { + $driver->query( "INSERT INTO `wptests_posts` (`ID`, `post_date`) SELECT 1, '0000-00-00 00:00:00'" ); + $this->fail( 'Expected no-FROM zero date projection to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0000-00-00 00:00:00'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests non-strict no-FROM INSERT ... SELECT normalizes partial-zero date/time literals. + */ + public function test_non_strict_insert_select_without_from_normalizes_zero_in_dates_when_no_zero_in_date_mode_is_enabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'NO_ZERO_IN_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + SELECT 1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-00-15 14:15:27', '2020-01-01 00:00:00'" + ) + ); + $insert_sql = $this->get_last_single_postgresql_sql( $driver ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_modified ); + $this->assertSame( + 'INSERT INTO "wptests_posts" ("ID", "post_date", "post_date_gmt", "post_modified", "post_modified_gmt") SELECT 1, \'2020-01-01 00:00:00\', \'2020-01-01 00:00:00\', \'0000-00-00 00:00:00\' , \'2020-01-01 00:00:00\'', + $insert_sql + ); + } + + /** + * Tests strict zero-in-date SQL modes reject partial-zero date/time literals before backend execution. + */ + public function test_strict_update_rejects_zero_in_date_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_date_gmt, post_modified, post_modified_gmt) + VALUES (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE' ); + + try { + $driver->query( "UPDATE `wptests_posts` SET `post_modified` = '2020-00-15 14:15:27' WHERE `ID` = 1" ); + $this->fail( 'Expected zero-in-date to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '2020-00-15 14:15:27'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests strict INSERT accepts partial-zero dates when NO_ZERO_IN_DATE is disabled. + */ + public function test_strict_insert_accepts_zero_in_dates_when_no_zero_in_date_mode_is_disabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES,NO_ZERO_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-00-15 14:15:27', '2020-01-01 00:00:00')" + ) + ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2020-00-15 14:15:27', $rows[0]->post_modified ); + } + + /** + * Tests non-strict INSERT normalizes partial-zero dates when NO_ZERO_IN_DATE is enabled. + */ + public function test_non_strict_insert_normalizes_zero_in_dates_when_no_zero_in_date_mode_is_enabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'NO_ZERO_IN_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-00-15 14:15:27', '2020-01-01 00:00:00')" + ) + ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_modified ); + } + + /** + * Tests strict ON DUPLICATE KEY UPDATE rejects zero-date literals before backend execution. + */ + public function test_strict_upsert_update_assignment_rejects_zero_date_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_date_gmt, post_modified, post_modified_gmt) + VALUES (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ); + + try { + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '2020-01-02 00:00:00', '2020-01-02 00:00:00', '2020-01-02 00:00:00', '2020-01-02 00:00:00') + ON DUPLICATE KEY UPDATE `post_modified` = '0000-00-00 00:00:00'" + ); + $this->fail( 'Expected zero date assignment to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0000-00-00 00:00:00'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests non-strict ON DUPLICATE KEY UPDATE normalizes VALUES() partial-zero dates. + */ + public function test_non_strict_upsert_values_assignment_normalizes_zero_in_dates_when_no_zero_in_date_mode_is_enabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'NO_ZERO_IN_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_date_gmt, post_modified, post_modified_gmt) + VALUES (1, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '2020-01-02 00:00:00', '2020-01-02 00:00:00', '2020-00-15 14:15:27', '2020-01-02 00:00:00') + ON DUPLICATE KEY UPDATE `post_modified` = VALUES(`post_modified`)" + ) + ); + $this->assertSame( + 'INSERT INTO "wptests_posts" ("ID", "post_date", "post_date_gmt", "post_modified", "post_modified_gmt") VALUES (1, \'2020-01-02 00:00:00\', \'2020-01-02 00:00:00\', \'0000-00-00 00:00:00\', \'2020-01-02 00:00:00\') ON CONFLICT ("ID") DO UPDATE SET "post_modified" = excluded."post_modified"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_modified ); + } + + /** + * Tests non-strict REPLACE ... SELECT normalizes partial-zero date/time projections. + */ + public function test_non_strict_replace_select_normalizes_zero_in_dates_when_no_zero_in_date_mode_is_enabled(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'NO_ZERO_IN_DATE' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "REPLACE INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + SELECT 1, '2020-01-02 00:00:00', '2020-01-02 00:00:00', '2020-00-15 14:15:27', '2020-01-02 00:00:00' FROM DUAL" + ) + ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->post_modified ); + } + + /** + * Tests stored zero dates remain readable and comparable regardless of the current SQL mode. + */ + public function test_stored_zero_dates_remain_readable_comparable_and_orderable_after_modes_change(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 3, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES + (1, '0000-00-00 00:00:00', '0000-00-00 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00'), + (2, '2022-06-01 00:00:00', '2022-06-01 00:00:00', '2022-06-01 00:00:00', '2022-06-01 00:00:00'), + (3, '2023-01-01 00:00:00', '2023-01-01 00:00:00', '2023-01-01 00:00:00', '2023-01-01 00:00:00')" + ) + ); + + $driver->set_sql_mode( 'DEFAULT' ); + + $matches = $driver->query( "SELECT ID FROM wptests_posts WHERE post_date = '0000-00-00 00:00:00'" ); + $this->assertSame( + array( '1' ), + array_map( + static function ( $row ) { + return (string) $row->ID; + }, + $matches + ) + ); + + $older = $driver->query( "SELECT ID FROM wptests_posts WHERE post_date < '2000-01-01 00:00:00' ORDER BY post_date" ); + $this->assertSame( + array( '1' ), + array_map( + static function ( $row ) { + return (string) $row->ID; + }, + $older + ) + ); + + $ordered = $driver->query( 'SELECT ID FROM wptests_posts ORDER BY post_date ASC' ); + $this->assertSame( + array( '1', '2', '3' ), + array_map( + static function ( $row ) { + return (string) $row->ID; + }, + $ordered + ) + ); + } + + /** + * Tests strict INSERT normalizes accepted temporal/YEAR values and rejects invalid scalar temporal values. + */ + public function test_strict_insert_normalizes_temporal_and_year_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + try { + $driver->query( 'INSERT INTO `wptests_strict_values` (`id`, `date_value`) VALUES (1, TRUE)' ); + $this->fail( 'Expected invalid date scalar to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect date value: '1'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`, `year_value`) + VALUES (2, '2025-10-23 18:30:00.123456', '2025-10-23', '2025-10-23 18:30:00.123456', 50)" + ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value, year_value FROM wptests_strict_values WHERE id = 2' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2025-10-23', $rows[0]->date_value ); + $this->assertSame( '2025-10-23 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '2025-10-23 18:30:00', $rows[0]->timestamp_value ); + $this->assertSame( '2050', $rows[0]->year_value ); + + foreach ( array( '-1', '1900', '2156' ) as $value ) { + try { + $driver->query( "INSERT INTO `wptests_strict_values` (`id`, `year_value`) VALUES (3, {$value})" ); + $this->fail( 'Expected invalid YEAR value to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Out of range value: '{$value}'", $e->getMessage(), $value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $value ); + } + } + } + + /** + * Tests strict UPDATE normalizes accepted temporal/YEAR values and rejects invalid scalar temporal values. + */ + public function test_strict_update_normalizes_temporal_and_year_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`, `year_value`) + VALUES (1, '2025-01-01', '2025-01-01 00:00:00', '2025-01-01 00:00:00', '2025')" + ); + + $this->assertSame( + 1, + $driver->query( + "UPDATE `wptests_strict_values` + SET `date_value` = '2025-11-24 02:03:04.999999', + `datetime_value` = '2025-11-24', + `timestamp_value` = '2025-11-24 02:03:04.999999', + `year_value` = 70 + WHERE `id` = 1" + ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value, year_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2025-11-24', $rows[0]->date_value ); + $this->assertSame( '2025-11-24 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '2025-11-24 02:03:04', $rows[0]->timestamp_value ); + $this->assertSame( '1970', $rows[0]->year_value ); + + try { + $driver->query( 'UPDATE `wptests_strict_values` SET `datetime_value` = FALSE WHERE `id` = 1' ); + $this->fail( 'Expected invalid datetime scalar to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests non-strict INSERT normalizes temporal boolean and zero literals using MySQL metadata. + */ + public function test_non_strict_insert_normalizes_temporal_boolean_and_zero_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + 'INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, TRUE, FALSE, 0)' + ) + ); + + $this->assertSame( + 'INSERT INTO "wptests_strict_values" ("id", "date_value", "datetime_value", "timestamp_value") VALUES (1, \'0000-00-00\', \'0000-00-00 00:00:00\', \'0000-00-00 00:00:00\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00', $rows[0]->date_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->timestamp_value ); + } + + /** + * Tests non-strict UPDATE normalizes temporal boolean and zero literals using MySQL metadata. + */ + public function test_non_strict_update_normalizes_temporal_boolean_and_zero_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, '2025-01-01', '2025-01-01 00:00:00', '2025-01-01 00:00:00')" + ); + + $this->assertSame( + 1, + $driver->query( + 'UPDATE `wptests_strict_values` + SET `date_value` = TRUE, + `datetime_value` = FALSE, + `timestamp_value` = 0 + WHERE `id` = 1' + ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00', $rows[0]->date_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->timestamp_value ); + } + + /** + * Tests strict integer DML rejects impossible coercions and MySQL range violations. + */ + public function test_strict_integer_literals_reject_invalid_values_and_out_of_range_values(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_strict_ints` (`id`, `int_value`, `tiny_unsigned`, `small_value`, `int_unsigned`) + VALUES (1, '3.0', TRUE, 32767, 4294967295)" + ) + ); + + $rows = $driver->query( 'SELECT int_value, tiny_unsigned, small_value, int_unsigned FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '3', $rows[0]->int_value ); + $this->assertSame( '1', $rows[0]->tiny_unsigned ); + $this->assertSame( '32767', $rows[0]->small_value ); + $this->assertSame( '4294967295', $rows[0]->int_unsigned ); + + $cases = array( + "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (2, 'abc')" => "Incorrect integer value: 'abc'", + "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (2, '12abc')" => "Incorrect integer value: '12abc'", + 'INSERT INTO `wptests_strict_ints` (`id`, `tiny_unsigned`) VALUES (2, -1)' => "Out of range value: '-1'", + 'INSERT INTO `wptests_strict_ints` (`id`, `tiny_unsigned`) VALUES (2, 256)' => "Out of range value: '256'", + 'UPDATE `wptests_strict_ints` SET `small_value` = 32768 WHERE `id` = 1' => "Out of range value: '32768'", + 'UPDATE `wptests_strict_ints` SET `int_unsigned` = -1 WHERE `id` = 1' => "Out of range value: '-1'", + ); + + foreach ( $cases as $query => $message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected invalid integer value to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests strict text DML rejects truncation using MySQL metadata. + */ + public function test_strict_text_literals_reject_truncation_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_strict_text_values_table_with_mysql_metadata( $driver ); + + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_strict_texts` (`id`, `varchar_value`, `char_value`, `tinytext_value`) VALUES (1, 'abc', 'xyz', 'short')" ) ); + + $long_tinytext = str_repeat( 'x', 256 ); + $cases = array( + "INSERT INTO `wptests_strict_texts` (`id`, `varchar_value`) VALUES (2, 'abcd')" => "Data too long for column 'varchar_value'", + "UPDATE `wptests_strict_texts` SET `char_value` = 'abcd' WHERE `id` = 1" => "Data too long for column 'char_value'", + "INSERT INTO `wptests_strict_texts` (`id`, `tinytext_value`) VALUES (2, '{$long_tinytext}')" => "Data too long for column 'tinytext_value'", + ); + + foreach ( $cases as $query => $message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected text truncation to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests strict SQL mode leaves omitted NOT NULL INSERT columns to fail visibly. + */ + public function test_strict_insert_does_not_append_omitted_not_null_defaults(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_comments ( + comment_ID INTEGER PRIMARY KEY, + comment_author TEXT NOT NULL, + comment_content TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_comments ( + comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_author tinytext NOT NULL, + comment_content text NOT NULL, + PRIMARY KEY (comment_ID) + )' + ); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' ); + + $this->expectException( PDOException::class ); + + $driver->query( 'INSERT INTO `wptests_comments` (`comment_ID`) VALUES (1)' ); + } + + /** + * Tests explicit identity INSERT statements repair PostgreSQL sequences after success. + */ + public function test_explicit_identity_insert_repairs_sequence_after_success(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_terms', 'term_id', 'wptests_terms_term_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + + $insert = "INSERT INTO `wptests_terms` (`term_id`, `name`) VALUES (7, 'identity')"; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( 'INSERT INTO "wptests_terms" ("term_id", "name") VALUES (7, \'identity\')', $queries[0]['sql'] ); + $this->assert_sequence_repair_query( $queries[1], 'wptests_terms', 'term_id', 'wptests_terms_term_id_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests implicit identity INSERT statements do not repair PostgreSQL sequences. + */ + public function test_implicit_identity_insert_does_not_repair_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_terms', 'term_id', 'wptests_terms_term_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + + $insert = "INSERT INTO `wptests_terms` (`name`) VALUES ('implicit')"; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( 'INSERT INTO "wptests_terms" ("name") VALUES (\'implicit\')', $queries[0]['sql'] ); + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests INSERT IGNORE no-op conflicts do not repair PostgreSQL sequences. + */ + public function test_insert_ignore_noop_does_not_repair_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_terms', 'term_id', 'wptests_terms_term_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_terms (term_id, name) VALUES (7, \'existing\')' ); + + $insert = "INSERT IGNORE INTO `wptests_terms` (`term_id`, `name`) VALUES (7, 'duplicate')"; + + $this->assertSame( 0, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( 'INSERT INTO "wptests_terms" ("term_id", "name") VALUES (7, \'duplicate\') ON CONFLICT DO NOTHING', $queries[0]['sql'] ); + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests failed explicit identity INSERT statements do not repair PostgreSQL sequences. + */ + public function test_failed_identity_insert_does_not_repair_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_terms', 'term_id', 'wptests_terms_term_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_terms (term_id, name) VALUES (7, \'existing\')' ); + + try { + $driver->query( "INSERT INTO `wptests_terms` (`term_id`, `name`) VALUES (7, 'duplicate')" ); + $this->fail( 'Duplicate explicit identity INSERT should fail before sequence repair.' ); + } catch ( PDOException $e ) { + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + } + } + + /** + * Tests explicit identity upsert insert paths repair PostgreSQL sequences. + */ + public function test_explicit_identity_upsert_insert_repairs_sequence_after_success(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (7, 'identity') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") VALUES (7, \'identity\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $queries[0]['sql'] + ); + $this->assert_sequence_repair_query( $queries[1], 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests explicit identity upsert conflict paths do not repair sequences. + */ + public function test_explicit_identity_upsert_conflict_update_does_not_repair_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_upsert (id, value) VALUES (7, 'existing')" ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (7, 'updated') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") VALUES (7, \'updated\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $queries[0]['sql'] + ); + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT value FROM wptests_identity_upsert WHERE id = 7' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'updated', $rows[0]->value ); + } + + /** + * Tests probe-unsafe identity upsert expressions fail closed. + */ + public function test_probe_unsafe_identity_upsert_expression_returns_null(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_upsert (id, value) VALUES (2, 'existing')" ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (next_identity_value(), 'updated') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ) + ); + + try { + $driver->query( $upsert ); + $this->fail( 'Probe-unsafe upsert expression should fail closed before sequence repair.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage() ); + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + } + } + + /** + * Tests simple WordPress REPLACE statements delete then insert known conflicts. + */ + public function test_simple_wordpress_replace_with_existing_id_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", display_name) VALUES (2, \'Walter Sobchak\')' ); + + $replace = "REPLACE INTO `wptests_users` (`ID`, `display_name`) VALUES (2, 'Walter Replace Sobchak')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assertSame( $replace, $driver->get_last_mysql_query() ); + + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_users" WHERE ("ID" = 2)', + 'INSERT INTO "wptests_users" ("ID", "display_name") VALUES (2, \'Walter Replace Sobchak\')', + ) + ); + + $rows = $driver->query( 'SELECT display_name FROM wptests_users WHERE `ID` = 2' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'Walter Replace Sobchak', $rows[0]->display_name ); + } + + /** + * Tests singular VALUE row-list REPLACE syntax is translated like VALUES. + */ + public function test_value_keyword_replace_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_value_keyword ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_replace_value_keyword ("ID", display_name) VALUES (2, \'old\')' ); + + $replace = "REPLACE INTO `wptests_replace_value_keyword` (`ID`, `display_name`) VALUE (2, 'new')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_value_keyword" WHERE ("ID" = 2)', + 'INSERT INTO "wptests_replace_value_keyword" ("ID", "display_name") VALUES (2, \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT display_name FROM wptests_replace_value_keyword WHERE `ID` = 2' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'new', $rows[0]->display_name ); + } + + /** + * Tests REPLACE accepts MySQL's optional INTO keyword. + */ + public function test_simple_replace_without_into_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_without_into ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_replace_without_into ("ID", display_name) VALUES (2, \'old\')' ); + + $replace = "REPLACE `wptests_replace_without_into` (`ID`, `display_name`) VALUES (2, 'new')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_without_into" WHERE ("ID" = 2)', + 'INSERT INTO "wptests_replace_without_into" ("ID", "display_name") VALUES (2, \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT display_name FROM wptests_replace_without_into WHERE `ID` = 2' ); + $this->assertSame( 'new', $rows[0]->display_name ); + } + + /** + * Tests REPLACE ... SET statements delete then insert known conflicts and keep MySQL row counts. + */ + public function test_replace_set_with_known_conflict_column_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_options ( + option_id INTEGER PRIMARY KEY, + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_options (option_id, option_name, option_value) VALUES (1, 'siteurl', 'old')" ); + + $replace = "REPLACE INTO `wptests_options` SET `option_id` = 8, `option_name` = 'siteurl', `option_value` = 'updated'"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_options" WHERE ("option_name" = \'siteurl\')', + 'INSERT INTO "wptests_options" ("option_id", "option_name", "option_value") VALUES (8, \'siteurl\', \'updated\')', + ) + ); + + $rows = $driver->query( "SELECT option_id, option_value FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertCount( 1, $rows ); + $this->assertSame( '8', $rows[0]->option_id ); + $this->assertSame( 'updated', $rows[0]->option_value ); + + $replace = "REPLACE `wptests_options` SET `option_id` = 9, `option_name` = 'home', `option_value` = 'created'"; + + $this->assertSame( 1, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_options" WHERE ("option_name" = \'home\')', + 'INSERT INTO "wptests_options" ("option_id", "option_name", "option_value") VALUES (9, \'home\', \'created\')', + ) + ); + + $rows = $driver->query( 'SELECT option_name, option_value FROM wptests_options ORDER BY option_id' ); + $this->assertCount( 2, $rows ); + $this->assertSame( 'siteurl', $rows[0]->option_name ); + $this->assertSame( 'updated', $rows[0]->option_value ); + $this->assertSame( 'home', $rows[1]->option_name ); + $this->assertSame( 'created', $rows[1]->option_value ); + + $qualified_replace = "REPLACE INTO `wptests_options` SET `wptests_options`.`option_id` = 10, `wptests_options`.`option_name` = 'home', `wptests`.`wptests_options`.`option_value` = 'qualified'"; + + $this->assertSame( 2, $driver->query( $qualified_replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_options" WHERE ("option_name" = \'home\')', + 'INSERT INTO "wptests_options" ("option_id", "option_name", "option_value") VALUES (10, \'home\', \'qualified\')', + ) + ); + + $rows = $driver->query( "SELECT option_id, option_value FROM wptests_options WHERE option_name = 'home'" ); + $this->assertCount( 1, $rows ); + $this->assertSame( '10', $rows[0]->option_id ); + $this->assertSame( 'qualified', $rows[0]->option_value ); + } + + /** + * Tests REPLACE priority modifiers are no-ops for SET and SELECT forms. + */ + public function test_replace_priority_modifiers_apply_to_set_and_select_forms(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_priority ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_replace_priority_source ( + id INTEGER NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_replace_priority (id, value) VALUES (1, 'old')" ); + $driver->query( "INSERT INTO wptests_replace_priority_source (id, value) VALUES (1, 'selected'), (2, 'new')" ); + + $replace_set = "REPLACE LOW_PRIORITY INTO wptests_replace_priority SET id = 1, value = 'set'"; + + $this->assertSame( 2, $driver->query( $replace_set ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_priority" WHERE ("id" = 1)', + 'INSERT INTO "wptests_replace_priority" ("id", "value") VALUES (1, \'set\')', + ) + ); + + $replace_select = 'REPLACE DELAYED INTO wptests_replace_priority (id, value) + SELECT id, value FROM wptests_replace_priority_source'; + + $this->assertSame( 3, $driver->query( $replace_select ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_priority' ); + $this->assertStringContainsString( + ' AS SELECT id AS "id" , value AS "value" FROM wptests_replace_priority_source', + $sql[1] + ); + $this->assertStringNotContainsString( 'DELAYED', implode( "\n", $sql ) ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_replace_priority ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'selected', + ), + (object) array( + 'id' => '2', + 'value' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests multi-row REPLACE statements delete then insert known conflicts and keep MySQL row counts. + */ + public function test_multi_row_replace_with_known_conflict_column_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_multi ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_replace_multi ("ID", display_name) VALUES (2, \'old\')' ); + + $replace = "REPLACE INTO `wptests_replace_multi` (`ID`, `display_name`) VALUES (2, 'updated'), (3, 'new')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_multi" WHERE ("ID" = 2) OR ("ID" = 3)', + 'INSERT INTO "wptests_replace_multi" ("ID", "display_name") VALUES (2, \'updated\'), (3, \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT `ID`, display_name FROM wptests_replace_multi ORDER BY `ID`' ); + $this->assertEquals( + array( + (object) array( + 'ID' => '2', + 'display_name' => 'updated', + ), + (object) array( + 'ID' => '3', + 'display_name' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests REPLACE uses MySQL unique-key metadata beyond WordPress heuristics. + */ + public function test_replace_uses_metadata_unique_key_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_unique_slug ( + id int(11) NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_unique_slug (id, slug, value) VALUES (1, 'same', 'old')" ); + + $replace = "REPLACE INTO wptests_replace_unique_slug (id, slug, value) VALUES (2, 'same', 'new'), (3, 'other', 'created')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_unique_slug" WHERE (("id" = 2) OR ("slug" = \'same\')) OR (("id" = 3) OR ("slug" = \'other\'))', + 'INSERT INTO "wptests_replace_unique_slug" ("id", "slug", "value") VALUES (2, \'same\', \'new\'), (3, \'other\', \'created\')', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_replace_unique_slug ORDER BY slug' ); + $this->assertEquals( + array( + (object) array( + 'id' => '3', + 'slug' => 'other', + 'value' => 'created', + ), + (object) array( + 'id' => '2', + 'slug' => 'same', + 'value' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests deterministic REPLACE ... VALUES deletes rows matching any unique key. + */ + public function test_replace_values_deletes_conflicts_across_multiple_unique_keys(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_multi_unique_values ( + id int(11) NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_multi_unique_values (id, slug, value) VALUES (1, 'old-slug', 'old-id')" ); + $driver->query( "INSERT INTO wptests_replace_multi_unique_values (id, slug, value) VALUES (2, 'shared-slug', 'old-slug')" ); + + $replace = "REPLACE INTO wptests_replace_multi_unique_values (id, slug, value) VALUES (1, 'shared-slug', 'new')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_multi_unique_values" WHERE (("id" = 1) OR ("slug" = \'shared-slug\'))', + 'INSERT INTO "wptests_replace_multi_unique_values" ("id", "slug", "value") VALUES (1, \'shared-slug\', \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_replace_multi_unique_values' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'shared-slug', + 'value' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests REPLACE VALUES materializes expression-backed conflicts across unique keys. + */ + public function test_multi_row_replace_with_expression_conflict_key_deletes_all_unique_conflicts(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_expr_multi ( + id int(11) NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_expr_multi (id, slug, value) VALUES (1, 'one', 'old-one')" ); + $driver->query( "INSERT INTO wptests_replace_expr_multi (id, slug, value) VALUES (2, 'two', 'old-two')" ); + + $replace = "REPLACE INTO wptests_replace_expr_multi (id, slug, value) + VALUES (1, (SELECT 'two'), 'new'), (3, 'three', 'fresh')"; + + $this->assertSame( 4, $driver->query( $replace ) ); + + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertCount( 5, $sql ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_replace_values_[a-f0-9]{12}"$/', $sql[0] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_replace_values_[a-f0-9]{12}" AS SELECT 1 AS "id", \\(SELECT \'two\'\\) AS "slug", \'new\' AS "value" UNION ALL SELECT 3, \'three\', \'fresh\'$/', $sql[1] ); + $this->assertStringContainsString( 'DELETE FROM "wptests_replace_expr_multi"', $sql[2] ); + $this->assertStringContainsString( '"__wp_pg_replace_target"."id" = "__wp_pg_replace_rows"."id"', $sql[2] ); + $this->assertStringContainsString( '"__wp_pg_replace_target"."slug" = "__wp_pg_replace_rows"."slug"', $sql[2] ); + $this->assertStringContainsString( 'INSERT INTO "wptests_replace_expr_multi"', $sql[3] ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_replace_values_[a-f0-9]{12}"$/', $sql[4] ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_replace_expr_multi ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'two', + 'value' => 'new', + ), + (object) array( + 'id' => '3', + 'slug' => 'three', + 'value' => 'fresh', + ), + ), + $rows + ); + } + + /** + * Tests deterministic REPLACE ... SET deletes rows matching any unique key. + */ + public function test_replace_set_deletes_conflicts_across_multiple_unique_keys(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_multi_unique_set ( + id int(11) NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_multi_unique_set (id, slug, value) VALUES (1, 'old-slug', 'old-id')" ); + $driver->query( "INSERT INTO wptests_replace_multi_unique_set (id, slug, value) VALUES (2, 'shared-slug', 'old-slug')" ); + + $replace = "REPLACE INTO wptests_replace_multi_unique_set SET id = 1, slug = 'shared-slug', value = 'new'"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_multi_unique_set" WHERE (("id" = 1) OR ("slug" = \'shared-slug\'))', + 'INSERT INTO "wptests_replace_multi_unique_set" ("id", "slug", "value") VALUES (1, \'shared-slug\', \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_replace_multi_unique_set' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'shared-slug', + 'value' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests REPLACE uses composite unique-key metadata. + */ + public function test_replace_uses_metadata_composite_unique_key_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_composite_unique ( + site_id int(11) NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + UNIQUE KEY site_slug (site_id, slug) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_composite_unique (site_id, slug, value) VALUES (1, 'same', 'old')" ); + + $replace = "REPLACE INTO wptests_replace_composite_unique (site_id, slug, value) VALUES (1, 'same', 'new'), (2, 'same', 'created')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_composite_unique" WHERE ("site_id" = 1 AND "slug" = \'same\') OR ("site_id" = 2 AND "slug" = \'same\')', + 'INSERT INTO "wptests_replace_composite_unique" ("site_id", "slug", "value") VALUES (1, \'same\', \'new\'), (2, \'same\', \'created\')', + ) + ); + + $rows = $driver->query( 'SELECT site_id, slug, value FROM wptests_replace_composite_unique ORDER BY site_id' ); + $this->assertEquals( + array( + (object) array( + 'site_id' => '1', + 'slug' => 'same', + 'value' => 'new', + ), + (object) array( + 'site_id' => '2', + 'slug' => 'same', + 'value' => 'created', + ), + ), + $rows + ); + } + + /** + * Tests REPLACE uses prefix unique-key metadata. + */ + public function test_replace_uses_metadata_prefix_unique_key_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_prefix_unique ( + slug varchar(255) NOT NULL, + value text NOT NULL, + UNIQUE KEY slug_prefix (slug(10)) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO wptests_replace_prefix_unique (slug, value) VALUES ('existing-slug-one', 'old')" ); + + $replace = "REPLACE INTO wptests_replace_prefix_unique (slug, value) VALUES ('existing-slug-two', 'new')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_prefix_unique" WHERE (SUBSTR(CAST("slug" AS text), 1, 10) = SUBSTR(CAST(\'existing-slug-two\' AS text), 1, 10))', + 'INSERT INTO "wptests_replace_prefix_unique" ("slug", "value") VALUES (\'existing-slug-two\', \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT slug, value FROM wptests_replace_prefix_unique' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'existing-slug-two', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests REPLACE ... SELECT statements use delete-then-insert and MySQL row counts. + */ + public function test_replace_select_with_known_conflict_column_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_select (id INTEGER PRIMARY KEY, name TEXT NOT NULL, color TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_replace_select_source (id INTEGER NOT NULL, name TEXT NOT NULL, color TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_replace_select (id, name, color) VALUES (1, 'old', 'red')" ); + $driver->query( "INSERT INTO wptests_replace_select_source (id, name, color) VALUES (1, 'updated', 'blue'), (2, 'new', 'green')" ); + + $replace = 'REPLACE INTO wptests_replace_select (`id`, `name`, `color`) + SELECT id, name, color FROM wptests_replace_select_source WHERE 1 = 1'; + + $this->assertSame( 3, $driver->query( $replace ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select' ); + $this->assertStringContainsString( + ' AS SELECT id AS "id" , name AS "name" , color AS "color" FROM wptests_replace_select_source WHERE 1 = 1', + $sql[1] + ); + $this->assertStringContainsString( + '("__wp_pg_replace_target"."id" = "__wp_pg_replace_rows"."id")', + $sql[2] + ); + $this->assertStringContainsString( + '("id", "name", "color") SELECT "__wp_pg_replace_rows"."id", "__wp_pg_replace_rows"."name", "__wp_pg_replace_rows"."color"', + $sql[3] + ); + + $rows = $driver->query( 'SELECT id, name, color FROM wptests_replace_select ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'name' => 'updated', + 'color' => 'blue', + ), + (object) array( + 'id' => '2', + 'name' => 'new', + 'color' => 'green', + ), + ), + $rows + ); + } + + /** + * Tests literal REPLACE ... SELECT statements resolve ambiguous targets from source rows. + */ + public function test_replace_select_uses_conflicting_unique_key_for_literal_select(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'existing', 'old')" ); + + $replace = "REPLACE INTO `ambiguous_upsert` + SELECT 2, 'existing', 'new' FROM DUAL"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'ambiguous_upsert' ); + $this->assertStringContainsString( + ' AS SELECT 2 AS "id" , \'existing\' AS "slug" , \'new\' AS "value"', + $sql[1] + ); + $this->assertStringContainsString( + '("__wp_pg_replace_target"."slug" = "__wp_pg_replace_rows"."slug")', + $sql[2] + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '2', + 'slug' => 'existing', + 'value' => 'new', + ), + ), + $rows + ); + } + + /** + * Tests non-strict REPLACE ... SELECT fills omitted NOT NULL defaults from MySQL metadata. + */ + public function test_non_strict_replace_select_appends_omitted_not_null_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + + $driver->query( + 'CREATE TABLE wptests_replace_select_defaults ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 999, + color TEXT + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_replace_select_defaults ( + id int NOT NULL, + name text NOT NULL, + size int NOT NULL DEFAULT 999, + color text, + PRIMARY KEY (id) + )' + ); + $driver->query( + 'CREATE TABLE wptests_replace_select_defaults_source ( + id INTEGER NOT NULL, + size INTEGER NOT NULL, + color TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_replace_select_defaults (id, name, size, color) VALUES (1, 'old', 10, 'red')" ); + $driver->query( "INSERT INTO wptests_replace_select_defaults_source (id, size, color) VALUES (1, 123, 'blue'), (2, 456, 'green')" ); + + $replace = 'REPLACE INTO wptests_replace_select_defaults (`color`, `id`, `size`) + SELECT color, id, size FROM wptests_replace_select_defaults_source'; + + $this->assertSame( 3, $driver->query( $replace ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select_defaults' ); + $postgresql_sql = implode( "\n", $sql ); + $this->assertStringContainsString( + 'INSERT INTO "wptests_replace_select_defaults" ("color", "id", "size", "name")', + $postgresql_sql + ); + $this->assertStringContainsString( + 'SELECT "__wp_pg_replace_rows_source".*, \'\' AS "name" FROM (SELECT', + $postgresql_sql + ); + $this->assertStringContainsString( + '("__wp_pg_replace_target"."id" = "__wp_pg_replace_rows"."id")', + $postgresql_sql + ); + + $rows = $driver->query( 'SELECT id, name, size, color FROM wptests_replace_select_defaults ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'name' => '', + 'size' => '123', + 'color' => 'blue', + ), + (object) array( + 'id' => '2', + 'name' => '', + 'size' => '456', + 'color' => 'green', + ), + ), + $rows + ); + } + + /** + * Tests columnless REPLACE ... SELECT infers target columns from MySQL metadata. + */ + public function test_columnless_replace_select_uses_mysql_metadata_columns(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_select_columnless ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_replace_select_columnless ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + display_name varchar(250) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + $driver->query( 'CREATE TABLE wptests_replace_select_columnless_source ("ID" INTEGER NOT NULL, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_replace_select_columnless ("ID", display_name) VALUES (2, \'old\')' ); + $driver->query( 'INSERT INTO wptests_replace_select_columnless_source ("ID", display_name) VALUES (2, \'updated\'), (3, \'new\')' ); + + $replace = 'REPLACE INTO wptests_replace_select_columnless + SELECT `ID`, display_name FROM wptests_replace_select_columnless_source WHERE 1 = 1'; + + $this->assertSame( 3, $driver->query( $replace ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select_columnless' ); + $this->assertStringContainsString( + ' AS SELECT ' . $this->get_expected_mysql_integer_cast_sql( '"ID"' ) . ' AS "ID" , CAST(display_name AS text) AS "display_name" FROM wptests_replace_select_columnless_source WHERE 1 = 1', + $sql[1] + ); + $this->assertStringContainsString( + '("__wp_pg_replace_target"."ID" = "__wp_pg_replace_rows"."ID")', + $sql[2] + ); + + $rows = $driver->query( 'SELECT `ID`, display_name FROM wptests_replace_select_columnless ORDER BY `ID`' ); + $this->assertSame( 'updated', $rows[0]->display_name ); + $this->assertSame( 'new', $rows[1]->display_name ); + } + + /** + * Tests REPLACE ... SELECT deletes old rows and inserts omitted defaults. + */ + public function test_replace_select_known_unique_conflict_fires_delete_trigger_and_inserts_defaults(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_select_observable ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL, + note TEXT NOT NULL DEFAULT \'default-note\' + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_replace_select_observable ( + id int NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + note text NOT NULL DEFAULT \'default-note\', + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + )' + ); + $driver->query( 'CREATE TABLE wptests_replace_select_source_observable (id INTEGER NOT NULL, slug TEXT NOT NULL, value TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_replace_select_delete_log (old_id INTEGER NOT NULL, old_slug TEXT NOT NULL)' ); + $driver->get_connection()->query( + 'CREATE TRIGGER wptests_replace_select_observable_deleted + AFTER DELETE ON wptests_replace_select_observable + BEGIN + INSERT INTO wptests_replace_select_delete_log (old_id, old_slug) VALUES (OLD.id, OLD.slug); + END' + ); + $driver->query( "INSERT INTO wptests_replace_select_observable (id, slug, value, note) VALUES (1, 'same', 'old', 'custom-note')" ); + $driver->query( "INSERT INTO wptests_replace_select_source_observable (id, slug, value) VALUES (2, 'same', 'new')" ); + + $replace = 'REPLACE INTO wptests_replace_select_observable (id, slug, value) + SELECT id, slug, value FROM wptests_replace_select_source_observable'; + + $this->assertSame( 2, $driver->query( $replace ) ); + $sql = $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select_observable' ); + $this->assertStringContainsString( + '("__wp_pg_replace_target"."slug" = "__wp_pg_replace_rows"."slug")', + $sql[2] + ); + + $rows = $driver->query( 'SELECT id, slug, value, note FROM wptests_replace_select_observable' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->id ); + $this->assertSame( 'same', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + $this->assertSame( 'default-note', $rows[0]->note ); + + $log_rows = $driver->query( 'SELECT old_id, old_slug FROM wptests_replace_select_delete_log' ); + $this->assertCount( 1, $log_rows ); + $this->assertSame( '1', $log_rows[0]->old_id ); + $this->assertSame( 'same', $log_rows[0]->old_slug ); + } + + /** + * Tests REPLACE ... SELECT observes ON DELETE CASCADE. + */ + public function test_replace_select_known_unique_conflict_observes_delete_cascade(): void { + $driver = $this->create_driver(); + $driver->get_connection()->query( 'PRAGMA foreign_keys = ON' ); + + $driver->query( + 'CREATE TABLE wptests_replace_select_cascade_parent ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_replace_select_cascade_parent ( + id int NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + )' + ); + $driver->query( 'CREATE TABLE wptests_replace_select_cascade_source (id INTEGER NOT NULL, slug TEXT NOT NULL, value TEXT NOT NULL)' ); + $driver->query( + 'CREATE TABLE wptests_replace_select_cascade_child ( + parent_id INTEGER NOT NULL, + value TEXT NOT NULL, + FOREIGN KEY (parent_id) REFERENCES wptests_replace_select_cascade_parent (id) ON DELETE CASCADE + )' + ); + $driver->query( "INSERT INTO wptests_replace_select_cascade_parent (id, slug, value) VALUES (1, 'same', 'old')" ); + $driver->query( "INSERT INTO wptests_replace_select_cascade_child (parent_id, value) VALUES (1, 'child')" ); + $driver->query( "INSERT INTO wptests_replace_select_cascade_source (id, slug, value) VALUES (2, 'same', 'new')" ); + + $replace = 'REPLACE INTO wptests_replace_select_cascade_parent (id, slug, value) + SELECT id, slug, value FROM wptests_replace_select_cascade_source'; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select_cascade_parent' ); + + $parents = $driver->query( 'SELECT id, slug, value FROM wptests_replace_select_cascade_parent' ); + $this->assertCount( 1, $parents ); + $this->assertSame( '2', $parents[0]->id ); + $this->assertSame( 'same', $parents[0]->slug ); + $this->assertSame( 'new', $parents[0]->value ); + + $children = $driver->query( 'SELECT parent_id, value FROM wptests_replace_select_cascade_child' ); + $this->assertSame( array(), $children ); + } + + /** + * Tests duplicate REPLACE ... SELECT source keys replay with MySQL row-by-row semantics. + */ + public function test_replace_select_with_duplicate_source_conflict_keys_replays_rows_sequentially(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_select_duplicate (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_replace_select_duplicate_source (id INTEGER NOT NULL, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_replace_select_duplicate_source (id, value) VALUES (1, 'first'), (1, 'second')" ); + + $this->assertSame( + 3, + $driver->query( + 'REPLACE INTO wptests_replace_select_duplicate (id, value) + SELECT id, value FROM wptests_replace_select_duplicate_source' + ) + ); + + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertCount( 9, $sql ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_replace_select_ord_[a-f0-9]{12}" AS SELECT ROW_NUMBER\(\) OVER \(\) AS "__wp_pg_replace_ordinal"/', $sql[2] ); + $this->assertStringContainsString( '"__wp_pg_replace_rows"."__wp_pg_replace_ordinal" = 1', $sql[3] ); + $this->assertStringContainsString( '"__wp_pg_replace_rows"."__wp_pg_replace_ordinal" = 1', $sql[4] ); + $this->assertStringContainsString( '"__wp_pg_replace_rows"."__wp_pg_replace_ordinal" = 2', $sql[5] ); + $this->assertStringContainsString( '"__wp_pg_replace_rows"."__wp_pg_replace_ordinal" = 2', $sql[6] ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_replace_select_ord_[a-f0-9]{12}"$/', $sql[7] ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_replace_select_duplicate' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'second', $rows[0]->value ); + } + + /** + * Tests REPLACE ... SELECT counts every old row deleted by unique-key conflicts. + */ + public function test_replace_select_counts_multiple_deleted_unique_conflicts(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_select_multi_conflict ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_replace_select_multi_conflict ( + id int NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + )' + ); + $driver->query( 'CREATE TABLE wptests_replace_select_multi_conflict_source (id INTEGER NOT NULL, slug TEXT NOT NULL, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_replace_select_multi_conflict (id, slug, value) VALUES (1, 'one', 'old-one'), (2, 'two', 'old-two')" ); + $driver->query( "INSERT INTO wptests_replace_select_multi_conflict_source (id, slug, value) VALUES (1, 'two', 'new')" ); + + $replace = 'REPLACE INTO wptests_replace_select_multi_conflict (id, slug, value) + SELECT id, slug, value FROM wptests_replace_select_multi_conflict_source'; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_replace_select_materialized_sql( $driver, 'wptests_replace_select_multi_conflict' ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_replace_select_multi_conflict' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'two', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests columnless multi-row REPLACE statements infer target columns from MySQL metadata. + */ + public function test_columnless_multi_row_replace_uses_mysql_metadata_columns(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_columnless ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_replace_columnless ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + display_name varchar(250) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + $driver->query( 'INSERT INTO wptests_replace_columnless ("ID", display_name) VALUES (2, \'old\')' ); + + $replace = "REPLACE INTO `wptests_replace_columnless` VALUES (2, 'updated'), (3, 'new')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_columnless" WHERE ("ID" = 2) OR ("ID" = 3)', + 'INSERT INTO "wptests_replace_columnless" ("ID", "display_name") VALUES (2, \'updated\'), (3, \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT `ID`, display_name FROM wptests_replace_columnless ORDER BY `ID`' ); + $this->assertSame( 'updated', $rows[0]->display_name ); + $this->assertSame( 'new', $rows[1]->display_name ); + } + + /** + * Tests duplicate conflict keys in one REPLACE batch run sequentially. + */ + public function test_multi_row_replace_with_duplicate_conflict_values_runs_sequentially(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_duplicate ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + + $replace = "REPLACE INTO `wptests_replace_duplicate` (`ID`, `display_name`) VALUES (4, 'first'), (4, 'second')"; + + $this->assertSame( 3, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_duplicate" WHERE "ID" = 4', + 'INSERT INTO "wptests_replace_duplicate" ("ID", "display_name") VALUES (4, \'first\')', + 'DELETE FROM "wptests_replace_duplicate" WHERE "ID" = 4', + 'INSERT INTO "wptests_replace_duplicate" ("ID", "display_name") VALUES (4, \'second\')', + ) + ); + + $rows = $driver->query( 'SELECT display_name FROM wptests_replace_duplicate WHERE `ID` = 4' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'second', $rows[0]->display_name ); + } + + /** + * Tests multi-row REPLACE without a known conflict column falls back to INSERT. + */ + public function test_multi_row_replace_without_known_conflict_column_is_inserted(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_replace_multi_plain (post_name TEXT NOT NULL, post_status TEXT NOT NULL)' ); + + $replace = "REPLACE INTO `wptests_replace_multi_plain` (`post_name`, `post_status`) VALUES ('hello-world', 'publish'), ('about', 'draft')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assertSame( + 'INSERT INTO "wptests_replace_multi_plain" ("post_name", "post_status") VALUES (\'hello-world\', \'publish\'), (\'about\', \'draft\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT post_name, post_status FROM wptests_replace_multi_plain ORDER BY post_name' ); + $this->assertEquals( + array( + (object) array( + 'post_name' => 'about', + 'post_status' => 'draft', + ), + (object) array( + 'post_name' => 'hello-world', + 'post_status' => 'publish', + ), + ), + $rows + ); + } + + /** + * Tests WooCommerce customer lookup REPLACE statements use customer_id conflicts. + */ + public function test_simple_wordpress_replace_with_customer_id_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_wc_customer_lookup ( + customer_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + email TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_wc_customer_lookup (customer_id, user_id, email) VALUES (1, 1, 'old@example.com')" ); + + $replace = "REPLACE INTO `wptests_wc_customer_lookup` (`user_id`, `email`, `customer_id`) VALUES (2, 'new@example.com', '1')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_wc_customer_lookup" WHERE ("customer_id" = \'1\')', + 'INSERT INTO "wptests_wc_customer_lookup" ("user_id", "email", "customer_id") VALUES (2, \'new@example.com\', \'1\')', + ) + ); + + $rows = $driver->query( 'SELECT user_id, email FROM wptests_wc_customer_lookup WHERE customer_id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->user_id ); + $this->assertSame( 'new@example.com', $rows[0]->email ); + } + + /** + * Tests WooCommerce product lookup REPLACE statements coerce integer values. + */ + public function test_woocommerce_product_lookup_replace_uses_product_id_conflict_and_integer_coercion(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_wc_product_meta_lookup ( + `product_id` bigint(20) unsigned NOT NULL, + `sku` varchar(100) NOT NULL DEFAULT "", + `total_sales` bigint(20) NOT NULL DEFAULT 0, + PRIMARY KEY (`product_id`) + )' + ); + $driver->query( + "INSERT INTO wptests_wc_product_meta_lookup (`product_id`, `sku`, `total_sales`) VALUES (12, 'old-sku', 1)" + ); + + $replace = "REPLACE INTO `wptests_wc_product_meta_lookup` (`product_id`, `sku`, `total_sales`) VALUES ('12', 'DUMMY SKU100000', '4.000000')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( 'DELETE FROM "wptests_wc_product_meta_lookup" WHERE ("product_id" = \'12\')', $queries[0]['sql'] ); + $this->assertStringContainsString( 'INSERT INTO "wptests_wc_product_meta_lookup" ("product_id", "sku", "total_sales") VALUES (\'12\', \'DUMMY SKU100000\', ', $queries[1]['sql'] ); + $this->assertStringContainsString( $this->get_expected_mysql_integer_cast_sql( "'4.000000'" ), $queries[1]['sql'] ); + + $rows = $driver->query( 'SELECT sku, total_sales FROM wptests_wc_product_meta_lookup WHERE product_id = 12' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'DUMMY SKU100000', $rows[0]->sku ); + $this->assertSame( '4', $rows[0]->total_sales ); + } + + /** + * Tests non-strict REPLACE applies omitted NOT NULL defaults on insert and conflict paths. + */ + public function test_non_strict_replace_appends_omitted_not_null_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_options_table_with_mysql_metadata( $driver ); + + $replace = "REPLACE INTO `wptests_options` (`option_name`) VALUES ('siteurl')"; + $expected_statements = array( + 'DELETE FROM "wptests_options" WHERE ("option_name" = \'siteurl\')', + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'\', \'yes\')', + ); + + $this->assertSame( 1, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( $driver, $expected_statements ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertSame( '', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + + $driver->query( "UPDATE wptests_options SET option_value = 'custom', autoload = 'no' WHERE option_name = 'siteurl'" ); + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( $driver, $expected_statements ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertSame( '', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + } + + /** + * Tests known-target REPLACE deletes old rows and inserts omitted defaults. + */ + public function test_replace_known_unique_conflict_fires_delete_trigger_and_inserts_defaults(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_replace_observable ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL, + note TEXT NOT NULL DEFAULT \'default-note\' + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_replace_observable ( + id int NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + note text NOT NULL DEFAULT \'default-note\', + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + )' + ); + $driver->query( 'CREATE TABLE wptests_replace_delete_log (old_id INTEGER NOT NULL, old_slug TEXT NOT NULL)' ); + $driver->get_connection()->query( + 'CREATE TRIGGER wptests_replace_observable_deleted + AFTER DELETE ON wptests_replace_observable + BEGIN + INSERT INTO wptests_replace_delete_log (old_id, old_slug) VALUES (OLD.id, OLD.slug); + END' + ); + $driver->query( "INSERT INTO wptests_replace_observable (id, slug, value, note) VALUES (1, 'same', 'old', 'custom-note')" ); + + $replace = "REPLACE INTO wptests_replace_observable (id, slug, value) VALUES (2, 'same', 'new')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_observable" WHERE (("id" = 2) OR ("slug" = \'same\'))', + 'INSERT INTO "wptests_replace_observable" ("id", "slug", "value") VALUES (2, \'same\', \'new\')', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value, note FROM wptests_replace_observable' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->id ); + $this->assertSame( 'same', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + $this->assertSame( 'default-note', $rows[0]->note ); + + $log_rows = $driver->query( 'SELECT old_id, old_slug FROM wptests_replace_delete_log' ); + $this->assertCount( 1, $log_rows ); + $this->assertSame( '1', $log_rows[0]->old_id ); + $this->assertSame( 'same', $log_rows[0]->old_slug ); + } + + /** + * Tests known-target REPLACE observes ON DELETE CASCADE. + */ + public function test_replace_known_unique_conflict_observes_delete_cascade(): void { + $driver = $this->create_driver(); + $driver->get_connection()->query( 'PRAGMA foreign_keys = ON' ); + + $driver->query( + 'CREATE TABLE wptests_replace_cascade_parent ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_replace_cascade_parent ( + id int NOT NULL, + slug varchar(191) NOT NULL, + value text NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_key (slug) + )' + ); + $driver->query( + 'CREATE TABLE wptests_replace_cascade_child ( + parent_id INTEGER NOT NULL, + value TEXT NOT NULL, + FOREIGN KEY (parent_id) REFERENCES wptests_replace_cascade_parent (id) ON DELETE CASCADE + )' + ); + $driver->query( "INSERT INTO wptests_replace_cascade_parent (id, slug, value) VALUES (1, 'same', 'old')" ); + $driver->query( "INSERT INTO wptests_replace_cascade_child (parent_id, value) VALUES (1, 'child')" ); + + $replace = "REPLACE INTO wptests_replace_cascade_parent (id, slug, value) VALUES (2, 'same', 'new')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_replace_cascade_parent" WHERE (("id" = 2) OR ("slug" = \'same\'))', + 'INSERT INTO "wptests_replace_cascade_parent" ("id", "slug", "value") VALUES (2, \'same\', \'new\')', + ) + ); + + $parents = $driver->query( 'SELECT id, slug, value FROM wptests_replace_cascade_parent' ); + $this->assertCount( 1, $parents ); + $this->assertSame( '2', $parents[0]->id ); + $this->assertSame( 'same', $parents[0]->slug ); + $this->assertSame( 'new', $parents[0]->value ); + + $children = $driver->query( 'SELECT parent_id, value FROM wptests_replace_cascade_child' ); + $this->assertSame( array(), $children ); + } + + /** + * Tests REPLACE insert paths with explicit identity values repair PostgreSQL sequences. + */ + public function test_replace_insert_path_with_explicit_identity_repairs_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_users', 'ID', 'wptests_users_ID_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + + $replace = "REPLACE INTO `wptests_users` (`ID`, `display_name`) VALUES (2, 'Donny Kerabatsos')"; + + $this->assertSame( 1, $driver->query( $replace ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 3, $queries ); + $this->assertSame( 'DELETE FROM "wptests_users" WHERE ("ID" = 2)', $queries[0]['sql'] ); + $this->assertSame( 'INSERT INTO "wptests_users" ("ID", "display_name") VALUES (2, \'Donny Kerabatsos\')', $queries[1]['sql'] ); + $this->assert_sequence_repair_query( $queries[2], 'wptests_users', 'ID', 'wptests_users_ID_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests REPLACE conflict replacement paths repair PostgreSQL sequences after insert. + */ + public function test_replace_conflict_replacement_path_repairs_sequence(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_users', 'ID', 'wptests_users_ID_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, display_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", display_name) VALUES (2, \'Walter Sobchak\')' ); + + $replace = "REPLACE INTO `wptests_users` (`ID`, `display_name`) VALUES (2, 'Walter Replace Sobchak')"; + + $this->assertSame( 2, $driver->query( $replace ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 3, $queries ); + $this->assertSame( 'DELETE FROM "wptests_users" WHERE ("ID" = 2)', $queries[0]['sql'] ); + $this->assertSame( 'INSERT INTO "wptests_users" ("ID", "display_name") VALUES (2, \'Walter Replace Sobchak\')', $queries[1]['sql'] ); + $this->assert_sequence_repair_query( $queries[2], 'wptests_users', 'ID', 'wptests_users_ID_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests simple REPLACE without a known conflict column falls back to INSERT. + */ + public function test_simple_wordpress_replace_without_known_conflict_column_is_inserted(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts (post_name TEXT NOT NULL, post_status TEXT NOT NULL)' ); + + $replace = "REPLACE INTO `wptests_posts` (`post_name`, `post_status`) VALUES ('hello-world', 'publish')"; + + $this->assertSame( 1, $driver->query( $replace ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("post_name", "post_status") VALUES (\'hello-world\', \'publish\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $low_priority_replace = "REPLACE LOW_PRIORITY INTO `wptests_posts` (`post_name`, `post_status`) VALUES ('hello-low', 'publish')"; + + $this->assertSame( 1, $driver->query( $low_priority_replace ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("post_name", "post_status") VALUES (\'hello-low\', \'publish\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $delayed_replace = "REPLACE DELAYED INTO `wptests_posts` (`post_name`, `post_status`) VALUES ('hello-delayed', 'draft')"; + + $this->assertSame( 1, $driver->query( $delayed_replace ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_posts" ("post_name", "post_status") VALUES (\'hello-delayed\', \'draft\')', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests unsupported REPLACE shapes fail before backend execution. + */ + public function test_unsupported_replace_shapes_fail_closed_before_backend(): void { + $queries = array( + "REPLACE INTO wptests_posts SET post_name = 'hello-world', post_name = 'duplicate'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported REPLACE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported REPLACE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests MySQL temporary table cleanup drops are translated for PostgreSQL. + */ + public function test_drop_temporary_table_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TEMPORARY TABLE wptests_temp_cleanup (value TEXT)' ); + + $this->assertTrue( $this->sqlite_table_exists( $driver, 'temp', 'wptests_temp_cleanup' ) ); + $this->assertSame( 0, $driver->query( 'DROP TEMPORARY TABLE wptests_temp_cleanup' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP TABLE temp."wptests_temp_cleanup"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertFalse( $this->sqlite_table_exists( $driver, 'temp', 'wptests_temp_cleanup' ) ); + } + + /** + * Tests temporary drops never delete a permanent table when no temp table exists. + */ + public function test_drop_temporary_table_without_matching_temp_table_does_not_drop_permanent_table(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_permanent_temp_probe (id INTEGER NOT NULL)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_permanent_temp_probe (id int NOT NULL)' ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_permanent_temp_probe' ); + + try { + $driver->query( 'DROP TEMPORARY TABLE wptests_permanent_temp_probe' ); + $this->fail( 'DROP TEMPORARY TABLE without an active temp table should fail without dropping the permanent table.' ); + } catch ( PDOException $exception ) { + $this->assertNotSame( '', $exception->getMessage() ); + } + + $this->assertTrue( $this->sqlite_table_exists( $driver, 'main', 'wptests_permanent_temp_probe' ) ); + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_permanent_temp_probe' ) ); + } + + /** + * Tests temporary IF EXISTS drops no-op without deleting a permanent table. + */ + public function test_drop_temporary_table_if_exists_without_matching_temp_table_does_not_drop_permanent_table(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_permanent_temp_exists_probe (id INTEGER NOT NULL)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_permanent_temp_exists_probe (id int NOT NULL)' ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_permanent_temp_exists_probe' ); + + $driver->query( 'DROP TEMPORARY TABLE IF EXISTS wptests_permanent_temp_exists_probe' ); + $this->assertSame( + array( + array( + 'sql' => 'DROP TABLE IF EXISTS temp."wptests_permanent_temp_exists_probe"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertTrue( $this->sqlite_table_exists( $driver, 'main', 'wptests_permanent_temp_exists_probe' ) ); + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_permanent_temp_exists_probe' ) ); + } + + /** + * Tests temporary table creation with MySQL CHARACTER SET syntax is translated. + */ + public function test_create_temporary_table_with_character_set_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + $query = 'CREATE TEMPORARY TABLE wptests_charset_temp ( a VARCHAR(50) CHARACTER SET big5, b TEXT CHARACTER SET big5 )'; + + $this->assertSame( 0, $driver->query( $query ) ); + $this->assertSame( + array( + array( + 'sql' => "CREATE TEMPORARY TABLE \"wptests_charset_temp\" (\n \"a\" varchar(50),\n \"b\" text\n)", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests temporary DDL does not clobber permanent MySQL schema metadata. + */ + public function test_temporary_create_and_drop_do_not_clobber_permanent_mysql_schema_metadata(): void { + $driver = $this->create_driver(); + + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_shadow_metadata ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + title varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + body longtext CHARACTER SET koi8r COLLATE koi8r_general_ci NOT NULL, + PRIMARY KEY (id), + KEY title (title(20)) + )" + ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_shadow_metadata' ); + $indexes_before = $this->get_mysql_index_metadata_rows( $driver, 'wptests_shadow_metadata' ); + + $this->assertSame( array( 'id', 'title', 'body' ), array_column( $columns_before, 'column_name' ) ); + $this->assertSame( 'longtext', $columns_before[2]['column_type'] ); + $this->assertSame( 'koi8r_general_ci', $columns_before[2]['collation_name'] ); + $this->assertSame( array( 'PRIMARY', 'title' ), array_values( array_unique( array_column( $indexes_before, 'key_name' ) ) ) ); + $this->assertSame( '20', $indexes_before[1]['sub_part'] ); + + $driver->query( 'CREATE TEMPORARY TABLE wptests_shadow_metadata (temp_value varchar(10) CHARACTER SET latin1)' ); + + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_shadow_metadata' ) ); + $this->assertSame( $indexes_before, $this->get_mysql_index_metadata_rows( $driver, 'wptests_shadow_metadata' ) ); + + $driver->query( 'DROP TEMPORARY TABLE wptests_shadow_metadata' ); + + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_shadow_metadata' ) ); + $this->assertSame( $indexes_before, $this->get_mysql_index_metadata_rows( $driver, 'wptests_shadow_metadata' ) ); + } + + /** + * Tests temporary CREATE TABLE stores isolated MySQL metadata for dbDelta introspection. + */ + public function test_temporary_create_stores_temporary_mysql_schema_metadata_for_dbdelta_introspection(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE wptests_dbdelta_temp_probe (permanent_value INTEGER NOT NULL)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_dbdelta_temp_probe (permanent_value int NOT NULL)' ); + + $public_columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_temp_probe' ); + $this->assertSame( array( 'permanent_value' ), array_column( $public_columns_before, 'column_name' ) ); + + $driver->query( + 'CREATE TEMPORARY TABLE wptests_dbdelta_temp_probe ( + `id` bigint(20) NOT NULL, + `references` varchar(255) NOT NULL, + PRIMARY KEY (`id`), + KEY `compound_key` (`id`,`references`(191)) + )' + ); + + $temp_columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_temp_probe', 'temp' ); + $this->assertSame( array( 'id', 'references' ), array_column( $temp_columns, 'column_name' ) ); + $this->assertSame( array( 'bigint(20)', 'varchar(255)' ), array_column( $temp_columns, 'column_type' ) ); + $this->assertSame( $public_columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_temp_probe' ) ); + + $describe = $driver->query( 'DESCRIBE wptests_dbdelta_temp_probe' ); + $this->assertSame( 'id', $describe[0]->Field ); + $this->assertSame( 'PRI', $describe[0]->Key ); + $this->assertSame( 'references', $describe[1]->Field ); + $this->assertSame( 'MUL', $describe[1]->Key ); + $this->assertSame( array( 'temp', 'wptests_dbdelta_temp_probe' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $temp_indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_dbdelta_temp_probe', 'temp' ); + $this->assertSame( array( 'PRIMARY', 'compound_key', 'compound_key' ), array_column( $temp_indexes, 'key_name' ) ); + $this->assertSame( array( 'id', 'id', 'references' ), array_column( $temp_indexes, 'column_name' ) ); + $this->assertSame( '191', $temp_indexes[2]['sub_part'] ); + + $driver->query( 'DROP TEMPORARY TABLE wptests_dbdelta_temp_probe' ); + + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_temp_probe', 'temp' ) ); + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_dbdelta_temp_probe', 'temp' ) ); + $this->assertSame( $public_columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_temp_probe' ) ); + } + + /** + * Tests unqualified DROP TABLE removes active temporary metadata before permanent metadata. + */ + public function test_unqualified_drop_table_removes_temporary_metadata_without_clobbering_permanent_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_dbdelta_shadow_drop (permanent_value INTEGER NOT NULL)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_dbdelta_shadow_drop (permanent_value int NOT NULL)' ); + + $public_columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_shadow_drop' ); + + $driver->query( 'CREATE TEMPORARY TABLE wptests_dbdelta_shadow_drop (`temp_value` varchar(50) NOT NULL)' ); + $temp_columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_shadow_drop', 'temp' ); + $this->assertSame( array( 'temp_value' ), array_column( $temp_columns, 'column_name' ) ); + + $driver->query( 'DROP TABLE IF EXISTS wptests_dbdelta_shadow_drop' ); + + $this->assertFalse( $this->sqlite_table_exists( $driver, 'temp', 'wptests_dbdelta_shadow_drop' ) ); + $this->assertTrue( $this->sqlite_table_exists( $driver, 'main', 'wptests_dbdelta_shadow_drop' ) ); + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_shadow_drop', 'temp' ) ); + $this->assertSame( $public_columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_dbdelta_shadow_drop' ) ); + } + + /** + * Tests CREATE TEMPORARY TABLE IF NOT EXISTS follows MySQL duplicate-table behavior. + */ + public function test_create_temporary_table_if_not_exists_keeps_existing_table(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'CREATE TEMPORARY TABLE wptests_temp_if_not_exists (id INTEGER, name TEXT)' ) ); + $this->assertSame( 0, $driver->query( 'CREATE TEMPORARY TABLE IF NOT EXISTS wptests_temp_if_not_exists (id INTEGER, name TEXT)' ) ); + + try { + $driver->query( 'CREATE TEMPORARY TABLE wptests_temp_if_not_exists (id INTEGER, name TEXT)' ); + $this->fail( 'Expected duplicate temporary CREATE TABLE to fail.' ); + } catch ( PDOException $exception ) { + $this->assertNotSame( '', $exception->getMessage() ); + } + } + + /** + * Tests CREATE TABLE IF NOT EXISTS leaves existing MySQL metadata unchanged. + */ + public function test_create_table_if_not_exists_preserves_existing_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_create_if_existing ( + id bigint(20) unsigned NOT NULL, + title varchar(20) NOT NULL, + PRIMARY KEY (id), + KEY title_key (title) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_create_if_existing' ); + $indexes_before = $this->get_mysql_index_metadata_rows( $driver, 'wptests_create_if_existing' ); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE IF NOT EXISTS wptests_create_if_existing ( + other bigint(20) NOT NULL, + changed varchar(20) NOT NULL, + KEY changed_key (changed) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ) + ); + + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_create_if_existing' ) ); + $this->assertSame( $indexes_before, $this->get_mysql_index_metadata_rows( $driver, 'wptests_create_if_existing' ) ); + } + + /** + * Tests temporary tables shadow standard tables for unqualified MySQL introspection and DDL. + */ + public function test_temporary_table_has_priority_over_standard_table_for_introspection_and_alter(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE wptests_temp_shadow (a INTEGER)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_temp_shadow (a int DEFAULT NULL, KEY ia(a))' ); + + $driver->query( 'CREATE TEMPORARY TABLE wptests_temp_shadow (b INTEGER)' ); + $this->store_mysql_temporary_schema_metadata_for_test( + $driver, + 'CREATE TEMPORARY TABLE wptests_temp_shadow (b int DEFAULT NULL, KEY ib(b))' + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_temp_shadow' )[0]->{'Create Table'}; + $this->assertStringStartsWith( 'CREATE TEMPORARY TABLE `wptests_temp_shadow`', $create_table ); + $this->assertStringContainsString( ' KEY `ib` (`b`)', $create_table ); + $this->assertStringNotContainsString( 'KEY `ia`', $create_table ); + + $columns = $driver->query( 'SHOW COLUMNS FROM wptests_temp_shadow' ); + $this->assertSame( array( 'b' ), array_column( $columns, 'Field' ) ); + + $describe = $driver->query( 'DESCRIBE wptests_temp_shadow' ); + $this->assertSame( array( 'b' ), array_column( $describe, 'Field' ) ); + + $indexes = $driver->query( 'SHOW INDEXES FROM wptests_temp_shadow' ); + $this->assertSame( array( 'ib' ), array_values( array_unique( array_column( $indexes, 'Key_name' ) ) ) ); + + $driver->query( 'ALTER TABLE wptests_temp_shadow ADD COLUMN c INT' ); + $columns = $driver->query( 'SHOW COLUMNS FROM wptests_temp_shadow' ); + $this->assertSame( array( 'b', 'c' ), array_column( $columns, 'Field' ) ); + + $information_schema_columns = $driver->query( + "SELECT column_name FROM information_schema.columns WHERE table_name = 'wptests_temp_shadow' ORDER BY ordinal_position" + ); + $this->assertSame( array( 'a' ), array_column( $information_schema_columns, 'COLUMN_NAME' ) ); + + $driver->query( 'DROP TABLE wptests_temp_shadow' ); + $columns = $driver->query( 'SHOW COLUMNS FROM wptests_temp_shadow' ); + $this->assertSame( array( 'a' ), array_column( $columns, 'Field' ) ); + + $indexes = $driver->query( 'SHOW INDEXES FROM wptests_temp_shadow' ); + $this->assertSame( array( 'ia' ), array_values( array_unique( array_column( $indexes, 'Key_name' ) ) ) ); + } + + /** + * Tests temporary tables keep AUTO_INCREMENT state independent from permanent tables. + */ + public function test_temporary_table_auto_increment_state_is_independent_from_standard_table(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_temp_auto_increment (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_temp_auto_increment ( + id int AUTO_INCREMENT PRIMARY KEY, + name text + )' + ); + $driver->query( "INSERT INTO wptests_temp_auto_increment (name) VALUES ('a'), ('b')" ); + + $driver->get_connection()->query( + 'CREATE TEMPORARY TABLE wptests_temp_auto_increment (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)' + ); + $this->store_mysql_temporary_schema_metadata_for_test( + $driver, + 'CREATE TEMPORARY TABLE wptests_temp_auto_increment ( + id int AUTO_INCREMENT PRIMARY KEY, + name text + )' + ); + + $driver->query( 'ALTER TABLE wptests_temp_auto_increment AUTO_INCREMENT = 500' ); + $driver->query( "INSERT INTO wptests_temp_auto_increment (name) VALUES ('x')" ); + $temp_rows = $driver->query( "SELECT id FROM wptests_temp_auto_increment WHERE name = 'x'" ); + $this->assertSame( '500', $temp_rows[0]->id ); + + $driver->query( 'ALTER TABLE wptests_temp_auto_increment AUTO_INCREMENT = 1000' ); + $driver->query( "INSERT INTO wptests_temp_auto_increment (name) VALUES ('y')" ); + $temp_rows = $driver->query( "SELECT id FROM wptests_temp_auto_increment WHERE name = 'y'" ); + $this->assertSame( '1000', $temp_rows[0]->id ); + + $driver->query( 'DROP TABLE wptests_temp_auto_increment' ); + $permanent_rows = $driver->query( 'SELECT id FROM wptests_temp_auto_increment ORDER BY id' ); + $this->assertSame( array( '1', '2' ), array_column( $permanent_rows, 'id' ) ); + $driver->query( "INSERT INTO wptests_temp_auto_increment (name) VALUES ('c')" ); + $permanent_rows = $driver->query( "SELECT id FROM wptests_temp_auto_increment WHERE name = 'c'" ); + $this->assertSame( '3', $permanent_rows[0]->id ); + } + + /** + * Tests standalone CREATE INDEX updates PostgreSQL schema and MySQL metadata. + */ + public function test_standalone_create_index_updates_postgresql_and_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_standalone_index ( + id int NOT NULL, + value varchar(255) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_standalone_index ( + id int NOT NULL, + value varchar(255) NOT NULL, + PRIMARY KEY (id) + )' + ); + + $this->assertSame( + 0, + $driver->query( 'CREATE INDEX idx_value ON wptests_standalone_index (value(16) DESC) COMMENT "Lookup"' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE INDEX "wptests_standalone_index__idx_value" ON "wptests_standalone_index" ("value" DESC)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_standalone_index' ); + $this->assertSame( array( 'PRIMARY', 'idx_value' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( 'value', $indexes[1]['column_name'] ); + $this->assertSame( '1', $indexes[1]['non_unique'] ); + $this->assertSame( 'BTREE', $indexes[1]['index_type'] ); + $this->assertSame( 'D', $indexes[1]['collation'] ); + $this->assertSame( '16', $indexes[1]['sub_part'] ); + $this->assertSame( '', $indexes[1]['nullable'] ); + + $show_indexes = $driver->query( "SHOW INDEX FROM wptests_standalone_index WHERE Index_comment = 'Lookup'" ); + + $this->assertCount( 1, $show_indexes ); + $this->assertSame( 'idx_value', $show_indexes[0]->Key_name ); + $this->assertSame( 'D', $show_indexes[0]->Collation ); + $this->assertSame( 'Lookup', $show_indexes[0]->Index_comment ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_standalone_index' )[0]->{'Create Table'}; + $this->assertStringContainsString( "KEY `idx_value` (`value`(16) DESC) COMMENT 'Lookup'", $create_table ); + } + + /** + * Tests standalone CREATE UNIQUE INDEX supports MySQL prefix key parts. + */ + public function test_standalone_create_unique_prefix_index_updates_postgresql_and_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_standalone_prefix_unique ( + value varchar(255) NOT NULL, + label varchar(255) NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_standalone_prefix_unique ( + value varchar(255) NOT NULL, + label varchar(255) NOT NULL + )' + ); + + $this->assertSame( + 0, + $driver->query( 'CREATE UNIQUE INDEX value_prefix ON wptests_standalone_prefix_unique (value(16))' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE UNIQUE INDEX "wptests_standalone_prefix_unique__value_prefix" ON "wptests_standalone_prefix_unique" (SUBSTR(CAST("value" AS text), 1, 16))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_standalone_prefix_unique' ); + $this->assertSame( array( 'value_prefix' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( 'value', $indexes[0]['column_name'] ); + $this->assertSame( '0', $indexes[0]['non_unique'] ); + $this->assertSame( '16', $indexes[0]['sub_part'] ); + } + + /** + * Tests standalone CREATE/DROP INDEX ignore supported MySQL-only options. + */ + public function test_standalone_create_and_drop_index_ignore_supported_mysql_options(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_standalone_index_options ( + id int NOT NULL, + value varchar(255) NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_standalone_index_options ( + id int NOT NULL, + value varchar(255) NOT NULL + )' + ); + + $this->assertSame( + 0, + $driver->query( 'CREATE INDEX idx_value USING HASH ON wptests_standalone_index_options (value) KEY_BLOCK_SIZE 8 INVISIBLE ALGORITHM DEFAULT LOCK NONE' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE INDEX "wptests_standalone_index_options__idx_value" ON "wptests_standalone_index_options" ("value")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( + 'BTREE', + $this->get_mysql_index_metadata_rows( $driver, 'wptests_standalone_index_options' )[0]['index_type'] + ); + + $this->assertSame( + 0, + $driver->query( 'DROP INDEX idx_value ON wptests_standalone_index_options ALGORITHM=INPLACE LOCK=SHARED' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'DROP INDEX "wptests_standalone_index_options__idx_value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( + 0, + $driver->query( 'CREATE INDEX idx_visible ON wptests_standalone_index_options (value) KEY_BLOCK_SIZE=16 VISIBLE ALGORITHM=COPY LOCK=EXCLUSIVE' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE INDEX "wptests_standalone_index_options__idx_visible" ON "wptests_standalone_index_options" ("value")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_standalone_index_options' ); + $this->assertSame( array( 'idx_visible' ), array_column( $indexes, 'key_name' ) ); + } + + /** + * Tests standalone FULLTEXT/SPATIAL CREATE INDEX updates MySQL metadata without PostgreSQL index DDL. + */ + public function test_standalone_fulltext_and_spatial_create_index_are_metadata_only(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_search_geo ( + id int NOT NULL, + body longtext NOT NULL, + shape point NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_search_geo ( + id int NOT NULL, + body longtext NOT NULL, + shape point NOT NULL + )' + ); + + $this->assertSame( + 0, + $driver->query( 'CREATE FULLTEXT INDEX body_fulltext ON wptests_search_geo (body)' ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( + 0, + $driver->query( 'CREATE SPATIAL INDEX shape_spatial ON wptests_search_geo (shape)' ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_search_geo' ); + $this->assertSame( array( 'body_fulltext', 'shape_spatial' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( array( 'FULLTEXT', 'SPATIAL' ), array_column( $indexes, 'index_type' ) ); + $this->assertNull( $indexes[0]['sub_part'] ); + $this->assertSame( '32', $indexes[1]['sub_part'] ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_search_geo' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' SPATIAL KEY `shape_spatial` (`shape`(32))', $create_table ); + $this->assertStringContainsString( ' FULLTEXT KEY `body_fulltext` (`body`)', $create_table ); + + $this->assertSame( 0, $driver->query( 'DROP INDEX body_fulltext ON wptests_search_geo' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array( 'shape_spatial' ), array_column( $this->get_mysql_index_metadata_rows( $driver, 'wptests_search_geo' ), 'key_name' ) ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_search_geo DROP KEY shape_spatial' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_search_geo' ) ); + } + + /** + * Tests FULLTEXT search syntax fails before reaching PostgreSQL. + */ + public function test_fulltext_search_syntax_fails_closed_before_backend_execution(): void { + $queries = array( + "SELECT MATCH(body) AGAINST ('needle') AS score FROM wptests_search_geo", + "SELECT * FROM wptests_search_geo WHERE MATCH(body) AGAINST ('needle' IN BOOLEAN MODE)", + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported FULLTEXT search syntax to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL full-text search syntax.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests install-translated quoted CREATE INDEX IF NOT EXISTS DDL updates MySQL metadata. + */ + public function test_standalone_create_index_accepts_install_translated_if_not_exists_quoted_identifiers(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_options ( + option_name varchar(191) NOT NULL, + option_value varchar(255) NOT NULL, + PRIMARY KEY (option_name) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_options ( + option_name varchar(191) NOT NULL, + option_value varchar(255) NOT NULL, + PRIMARY KEY (option_name) + )' + ); + + $this->assertSame( + 0, + $driver->query( 'CREATE INDEX IF NOT EXISTS "wptests_options__option_value" ON "wptests_options" ("option_value")' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE INDEX IF NOT EXISTS "wptests_options__option_value" ON "wptests_options" ("option_value")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_options' ); + $this->assertSame( array( 'PRIMARY', 'option_value' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( 'option_value', $indexes[1]['column_name'] ); + $this->assertSame( '1', $indexes[1]['non_unique'] ); + $this->assertSame( 'BTREE', $indexes[1]['index_type'] ); + } + + /** + * Tests schema-qualified install-translated CREATE INDEX IF NOT EXISTS DDL updates MySQL metadata. + */ + public function test_standalone_create_index_accepts_schema_qualified_install_translated_if_not_exists_quoted_identifiers(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_schema_quoted_index ( + option_name varchar(191) NOT NULL, + option_value varchar(255) NOT NULL, + PRIMARY KEY (option_name) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_schema_quoted_index ( + option_name varchar(191) NOT NULL, + option_value varchar(255) NOT NULL, + PRIMARY KEY (option_name) + )' + ); + + $this->assertSame( + 0, + $driver->query( 'CREATE INDEX IF NOT EXISTS "wptests_schema_quoted_index__option_value" ON "wptests"."wptests_schema_quoted_index" ("option_value")' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE INDEX IF NOT EXISTS "wptests_schema_quoted_index__option_value" ON "wptests_schema_quoted_index" ("option_value")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_schema_quoted_index' ); + $this->assertSame( array( 'PRIMARY', 'option_value' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( 'option_value', $indexes[1]['column_name'] ); + $this->assertSame( '1', $indexes[1]['non_unique'] ); + $this->assertSame( 'BTREE', $indexes[1]['index_type'] ); + } + + /** + * Tests internal MySQL index metadata quotes its PostgreSQL-reserved collation identifier. + */ + public function test_mysql_index_metadata_quotes_collation_identifier_for_postgresql(): void { + $logged_sql = array(); + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + $connection->set_query_logger( + static function ( string $sql ) use ( &$logged_sql ): void { + $logged_sql[] = $sql; + } + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( + 'CREATE TABLE wptests_metadata_collation_quote ( + id bigint(20) unsigned NOT NULL, + user_login varchar(60) NOT NULL DEFAULT \'\', + PRIMARY KEY (id), + KEY user_login (user_login) + )' + ); + $driver->query( 'SHOW INDEX FROM wptests_metadata_collation_quote' ); + + $sql = implode( "\n", $logged_sql ); + $this->assertStringContainsString( '"collation" TEXT', $sql ); + $this->assertStringContainsString( 'index_type, "collation", sub_part', $sql ); + $this->assertStringContainsString( 'COALESCE(im."collation", \'A\')', $sql ); + $this->assertStringNotContainsString( 'index_type, collation, sub_part', $sql ); + $this->assertStringNotContainsString( 'COALESCE(im.collation, \'A\')', $sql ); + } + + /** + * Tests standalone CREATE INDEX keeps the index name unqualified for PostgreSQL. + */ + public function test_standalone_create_index_with_public_schema_qualifies_table_only(): void { + $driver = $this->create_driver(); + $connection = $driver->get_connection(); + $pdo = $connection->get_pdo(); + + $pdo->exec( "ATTACH DATABASE ':memory:' AS public" ); + $pdo->exec( + sprintf( + 'CREATE TABLE %s.%s (%s TEXT)', + $connection->quote_identifier( 'public' ), + $connection->quote_identifier( 'wptests_public_index' ), + $connection->quote_identifier( 'value' ) + ) + ); + + $translate_create_index = new ReflectionMethod( WP_PostgreSQL_Driver::class, 'translate_mysql_create_index_query' ); + if ( PHP_VERSION_ID < 80100 ) { + $translate_create_index->setAccessible( true ); + } + + $translation = $translate_create_index->invoke( + $driver, + 'CREATE INDEX idx_value ON wptests_public_index (value)' + ); + + $this->assertIsArray( $translation ); + $this->assertSame( + array( + 'CREATE INDEX "wptests_public_index__idx_value" ON "public"."wptests_public_index" ("value")', + ), + $translation['statements'] + ); + } + + /** + * Tests standalone CREATE UNIQUE INDEX participates in ON DUPLICATE KEY UPDATE translation. + */ + public function test_standalone_create_unique_index_updates_upsert_conflict_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_standalone_unique_index (slug varchar(191) NOT NULL, value int NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_standalone_unique_index ( + slug varchar(191) NOT NULL, + value int NOT NULL + )' + ); + $driver->query( 'CREATE UNIQUE INDEX slug_lookup ON wptests_standalone_unique_index (slug)' ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO wptests_standalone_unique_index (`slug`, `value`) + VALUES ('alpha', 1) + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)" + ) + ); + + $this->assertSame( + 'INSERT INTO "wptests_standalone_unique_index" ("slug", "value") VALUES (\'alpha\', 1) ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests standalone DROP INDEX removes PostgreSQL schema and MySQL metadata. + */ + public function test_standalone_drop_index_updates_postgresql_and_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_standalone_drop_index ( + id int NOT NULL, + value varchar(255) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_standalone_drop_index ( + id int NOT NULL, + value varchar(255) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( 'CREATE INDEX idx_value ON wptests_standalone_drop_index (value)' ); + + $this->assertSame( 0, $driver->query( 'DROP INDEX idx_value ON wptests_standalone_drop_index' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP INDEX "wptests_standalone_drop_index__idx_value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_standalone_drop_index' ); + $this->assertSame( array( 'PRIMARY' ), array_column( $indexes, 'key_name' ) ); + } + + /** + * Tests standalone DROP INDEX PRIMARY removes the primary-key constraint metadata. + */ + public function test_standalone_drop_index_primary_updates_postgresql_and_mysql_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_standalone_drop_primary ( + id int NOT NULL, + value varchar(255) NOT NULL, + PRIMARY KEY (id), + KEY value_idx (value) + )' + ); + + $this->assertSame( 0, $driver->query( 'DROP INDEX PRIMARY ON wptests_standalone_drop_primary' ) ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_standalone_drop_primary" DROP CONSTRAINT "wptests_standalone_drop_primary_pkey"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_standalone_drop_primary' ); + $this->assertSame( array( 'value_idx' ), array_values( array_unique( array_column( $indexes, 'key_name' ) ) ) ); + } + + /** + * Tests ALTER TABLE DROP INDEX removes PostgreSQL schema and MySQL metadata. + */ + public function test_alter_table_drop_index_updates_postgresql_and_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_alter_drop_index ( + id int NOT NULL, + option_name varchar(191) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_index ( + id int NOT NULL, + option_name varchar(191) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( 'CREATE INDEX option_name ON wptests_alter_drop_index (option_name)' ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_alter_drop_index DROP INDEX option_name' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP INDEX "wptests_alter_drop_index__option_name"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_drop_index' ); + $this->assertSame( array( 'PRIMARY' ), array_column( $indexes, 'key_name' ) ); + } + + /** + * Tests ALTER TABLE DROP INDEX accepts backticked identifiers. + */ + public function test_alter_table_drop_index_accepts_backticked_identifiers(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_alter_drop_backtick_index ( + option_name varchar(191) NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_backtick_index ( + option_name varchar(191) NOT NULL + )' + ); + $driver->query( 'CREATE INDEX option_name ON wptests_alter_drop_backtick_index (option_name)' ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE `wptests_alter_drop_backtick_index` DROP INDEX `option_name`' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP INDEX "wptests_alter_drop_backtick_index__option_name"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_drop_backtick_index' ) ); + } + + /** + * Tests ALTER TABLE DROP KEY removes PostgreSQL schema and MySQL metadata. + */ + public function test_alter_table_drop_key_updates_postgresql_and_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_alter_drop_key ( + option_name varchar(191) NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_key ( + option_name varchar(191) NOT NULL + )' + ); + $driver->query( 'CREATE INDEX option_name ON wptests_alter_drop_key (option_name)' ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_alter_drop_key DROP KEY option_name' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP INDEX "wptests_alter_drop_key__option_name"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_drop_key' ) ); + } + + /** + * Tests ALTER TABLE ADD primary and unique constraints update backend and MySQL metadata. + */ + public function test_alter_table_add_primary_and_unique_constraints_update_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_add_constraints ( + id int NOT NULL, + slug varchar(191) NOT NULL + )' + ); + + $driver->query( + 'ALTER TABLE wptests_alter_add_constraints + ADD CONSTRAINT ignored_primary_name PRIMARY KEY (id), + ADD CONSTRAINT slug_unique UNIQUE (slug)' + ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_add_constraints" ADD PRIMARY KEY ("id")', + 'params' => array(), + ), + array( + 'sql' => 'CREATE UNIQUE INDEX "wptests_alter_add_constraints__slug_unique" ON "wptests_alter_add_constraints" ("slug")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_add_constraints' ); + + $this->assertSame( array( 'PRIMARY', 'slug_unique' ), array_values( array_unique( array_column( $indexes, 'key_name' ) ) ) ); + $this->assertSame( array( '0' ), array_values( array_unique( array_column( $indexes, 'non_unique' ) ) ) ); + } + + /** + * Tests ALTER TABLE DROP primary-key forms update backend and MySQL metadata. + */ + public function test_alter_table_drop_primary_key_forms_update_backend_and_metadata(): void { + $queries = array( + 'ALTER TABLE wptests_alter_drop_primary DROP PRIMARY KEY', + 'ALTER TABLE wptests_alter_drop_primary DROP INDEX PRIMARY', + 'ALTER TABLE wptests_alter_drop_primary DROP KEY `PRIMARY`', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_primary ( + id int NOT NULL, + slug varchar(191) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug (slug) + )' + ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_drop_primary" DROP CONSTRAINT "wptests_alter_drop_primary_pkey"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries(), + $query + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_drop_primary' ); + $this->assertSame( array( 'slug' ), array_values( array_unique( array_column( $indexes, 'key_name' ) ) ), $query ); + } + } + + /** + * Tests ALTER TABLE DROP CONSTRAINT maps unique-key metadata to a PostgreSQL index drop. + */ + public function test_alter_table_drop_constraint_updates_unique_key_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_constraint ( + id int NOT NULL, + name varchar(191) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY name_unique (name) + )' + ); + $driver->get_connection()->get_pdo()->exec( 'CREATE TABLE wptests_alter_drop_constraint (id INTEGER, name TEXT)' ); + $driver->get_connection()->get_pdo()->exec( 'CREATE UNIQUE INDEX "wptests_alter_drop_constraint__name_unique" ON wptests_alter_drop_constraint (name)' ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_alter_drop_constraint DROP CONSTRAINT name_unique' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP INDEX "wptests_alter_drop_constraint__name_unique"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_drop_constraint' ); + + $this->assertSame( array( 'PRIMARY' ), array_values( array_unique( array_column( $indexes, 'key_name' ) ) ) ); + } + + /** + * Tests ALTER TABLE DROP CONSTRAINT fails before backend execution when metadata has no matching constraint. + */ + public function test_alter_table_drop_constraint_fails_for_missing_constraint_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_missing_constraint ( + id int NOT NULL + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_alter_drop_missing_constraint DROP CONSTRAINT missing_constraint' ); + $this->fail( 'Expected unsupported ALTER TABLE exception.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + } + + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests ALTER TABLE DROP CONSTRAINT fails closed when metadata has multiple matching constraint classes. + */ + public function test_alter_table_drop_constraint_fails_for_ambiguous_constraint_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_constraint_parent ( + id int NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_ambiguous_constraint ( + id int NOT NULL, + UNIQUE KEY cnst (id) + )' + ); + $driver->get_connection()->query( + sprintf( + 'INSERT INTO %s + (table_schema, table_name, constraint_name, constraint_ordinal, seq_in_index, column_name, referenced_table_schema, referenced_table_name, referenced_column_name, update_rule, delete_rule) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( + 'public', + 'wptests_alter_drop_ambiguous_constraint', + 'cnst', + 1, + 1, + 'id', + 'public', + 'wptests_alter_drop_constraint_parent', + 'id', + 'NO ACTION', + 'NO ACTION', + ) + ); + + try { + $driver->query( 'ALTER TABLE wptests_alter_drop_ambiguous_constraint DROP CONSTRAINT cnst' ); + $this->fail( 'Expected unsupported ALTER TABLE exception.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + } + + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array( 'cnst' ), array_values( array_unique( array_column( $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_drop_ambiguous_constraint' ), 'key_name' ) ) ) ); + $this->assertSame( array( 'cnst' ), array_values( array_unique( array_column( $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_alter_drop_ambiguous_constraint' ), 'constraint_name' ) ) ) ); + } + + /** + * Tests ALTER TABLE DROP CONSTRAINT does not treat ordinary non-unique keys as constraints. + */ + public function test_alter_table_drop_constraint_fails_for_non_unique_index_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_non_unique_constraint ( + id int NOT NULL, + name varchar(191) NOT NULL, + KEY name_lookup (name) + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_alter_drop_non_unique_constraint DROP CONSTRAINT name_lookup' ); + $this->fail( 'Expected unsupported ALTER TABLE exception.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + } + + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array( 'name_lookup' ), array_values( array_unique( array_column( $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_drop_non_unique_constraint' ), 'key_name' ) ) ) ); + } + + /** + * Tests ALTER TABLE ADD/DROP CHECK forms translate to PostgreSQL constraints. + */ + public function test_alter_table_check_constraint_forms_translate_to_postgresql(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_check ( + id int NOT NULL + )' + ); + + $driver->query( + 'ALTER TABLE wptests_alter_check + ADD CONSTRAINT positive_id CHECK (id > 0), + ADD CHECK (id < 10), + ADD CHECK (id > 1), + ADD CONSTRAINT max_id CHECK (id < 100) NOT ENFORCED' + ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_check" ADD CONSTRAINT "positive_id" CHECK (id > 0)', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_check" ADD CONSTRAINT "wptests_alter_check_chk_1" CHECK (id < 10)', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_check" ADD CONSTRAINT "wptests_alter_check_chk_2" CHECK (id > 1)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( + array( + array( + 'constraint_name' => 'positive_id', + 'check_clause' => 'id > 0', + 'enforced' => 'YES', + ), + array( + 'constraint_name' => 'wptests_alter_check_chk_1', + 'check_clause' => 'id < 10', + 'enforced' => 'YES', + ), + array( + 'constraint_name' => 'wptests_alter_check_chk_2', + 'check_clause' => 'id > 1', + 'enforced' => 'YES', + ), + array( + 'constraint_name' => 'max_id', + 'check_clause' => 'id < 100', + 'enforced' => 'NO', + ), + ), + $this->get_mysql_check_metadata_rows( $driver, 'wptests_alter_check' ) + ); + + $driver->query( + 'ALTER TABLE wptests_alter_check + DROP CONSTRAINT positive_id, + DROP CHECK wptests_alter_check_chk_1, + DROP CHECK wptests_alter_check_chk_2, + DROP CHECK max_id' + ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_check" DROP CONSTRAINT "positive_id"', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_check" DROP CONSTRAINT "wptests_alter_check_chk_1"', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_check" DROP CONSTRAINT "wptests_alter_check_chk_2"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( array(), $this->get_mysql_check_metadata_rows( $driver, 'wptests_alter_check' ) ); + } + + /** + * Tests ALTER TABLE ADD CHECK translates JSON_VALID() for PostgreSQL while preserving MySQL metadata. + */ + public function test_alter_table_json_valid_check_translates_for_postgresql_and_preserves_mysql_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_json_check ( + id int NOT NULL, + data JSON + )' + ); + + $driver->query( 'ALTER TABLE wptests_alter_json_check ADD CONSTRAINT valid_json CHECK (json_valid(data))' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_json_check" ADD CONSTRAINT "valid_json" CHECK ((CASE WHEN data IS NULL THEN NULL ELSE (CAST(data AS jsonb) IS NOT NULL) END))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( + array( + array( + 'constraint_name' => 'valid_json', + 'check_clause' => 'json_valid(data)', + 'enforced' => 'YES', + ), + ), + $this->get_mysql_check_metadata_rows( $driver, 'wptests_alter_json_check' ) + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_alter_json_check' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' CONSTRAINT `valid_json` CHECK (json_valid(data))', $create_table ); + } + + /** + * Tests unsupported ALTER TABLE ADD CHECK JSON_VALID() forms fail before backend execution. + */ + public function test_alter_table_unsupported_json_valid_check_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_json_check_unsupported ( + data JSON + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_alter_json_check_unsupported ADD CHECK (json_valid(data, data))' ); + $this->fail( 'Expected unsupported CHECK constraint expression to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported CHECK constraint expression.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertSame( array(), $this->get_mysql_check_metadata_rows( $driver, 'wptests_alter_json_check_unsupported' ) ); + } + + /** + * Tests ALTER TABLE DROP CHECK fails before backend execution when metadata has no matching constraint. + */ + public function test_alter_table_drop_check_fails_for_missing_constraint_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_drop_missing_check ( + id int NOT NULL + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_alter_drop_missing_check DROP CHECK missing_check' ); + $this->fail( 'Expected unsupported ALTER TABLE exception.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + } + + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests main database-qualified standalone index statements target public table metadata. + */ + public function test_standalone_index_accepts_main_database_qualified_table_names(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_qualified_index (id int NOT NULL, name varchar(191) NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_qualified_index ( + id int NOT NULL, + name varchar(191) NOT NULL + )' + ); + + $this->assertSame( 0, $driver->query( 'CREATE INDEX idx_name ON wptests.wptests_qualified_index (name)' ) ); + $this->assertSame( + 'CREATE INDEX "wptests_qualified_index__idx_name" ON "wptests_qualified_index" ("name")', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + $this->assertSame( + array( 'idx_name' ), + array_column( $this->get_mysql_index_metadata_rows( $driver, 'wptests_qualified_index' ), 'key_name' ) + ); + + $this->assertSame( 0, $driver->query( 'DROP INDEX idx_name ON wptests.wptests_qualified_index' ) ); + $this->assertSame( + 'DROP INDEX "wptests_qualified_index__idx_name"', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_qualified_index' ) ); + } + + /** + * Tests main database-qualified DROP TABLE removes tables and MySQL metadata. + */ + public function test_drop_table_accepts_main_database_qualified_table_names(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_qualified_drop_one (id int NOT NULL, PRIMARY KEY (id))' ); + $driver->query( 'CREATE TABLE wptests_qualified_drop_two (id int NOT NULL, PRIMARY KEY (id))' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_qualified_drop_one (id int NOT NULL, PRIMARY KEY (id))' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_qualified_drop_two (id int NOT NULL, PRIMARY KEY (id))' ); + + $this->assertNotSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_qualified_drop_one' ) ); + $this->assertNotSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_qualified_drop_two' ) ); + + $this->assertSame( + 0, + $driver->query( 'DROP TABLE IF EXISTS wptests.wptests_qualified_drop_one, wptests.wptests_qualified_drop_two' ) + ); + $this->assertSame( + array( + array( + 'sql' => 'DROP TABLE IF EXISTS "wptests_qualified_drop_one"', + 'params' => array(), + ), + array( + 'sql' => 'DROP TABLE IF EXISTS "wptests_qualified_drop_two"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertFalse( $this->sqlite_table_exists( $driver, 'main', 'wptests_qualified_drop_one' ) ); + $this->assertFalse( $this->sqlite_table_exists( $driver, 'main', 'wptests_qualified_drop_two' ) ); + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_qualified_drop_one' ) ); + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_qualified_drop_two' ) ); + } + + /** + * Tests DROP TABLE accepts MySQL RESTRICT/CASCADE suffixes as no-ops. + */ + public function test_drop_table_accepts_restrict_and_cascade_suffixes_as_noops(): void { + foreach ( array( 'RESTRICT', 'CASCADE' ) as $suffix ) { + $driver = $this->create_driver(); + $table_name = 'wptests_drop_table_' . strtolower( $suffix ); + + $driver->query( sprintf( 'CREATE TABLE %s (id int NOT NULL, PRIMARY KEY (id))', $table_name ) ); + $driver->store_mysql_schema_metadata( sprintf( 'CREATE TABLE %s (id int NOT NULL, PRIMARY KEY (id))', $table_name ) ); + + $this->assertNotSame( array(), $this->get_mysql_column_metadata_rows( $driver, $table_name ) ); + $this->assertSame( 0, $driver->query( sprintf( 'DROP TABLE %s %s', $table_name, $suffix ) ) ); + $this->assertSame( + array( + array( + 'sql' => sprintf( 'DROP TABLE "%s"', $table_name ), + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertFalse( $this->sqlite_table_exists( $driver, 'main', $table_name ) ); + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, $table_name ) ); + } + } + + /** + * Tests unsupported standalone index DDL fails before backend execution. + */ + public function test_standalone_index_unsupported_syntax_does_not_reach_backend(): void { + $queries = array( + 'CREATE INDEX idx_value ON wptests_index_fail (value) KEY_BLOCK_SIZE=bad', + 'CREATE INDEX idx_value ON wptests_index_fail (value) ALGORITHM=INSTANT', + 'CREATE INDEX idx_value ON wptests_index_fail (value) LOCK=UNKNOWN', + 'CREATE INDEX idx_value ON wptests_index_fail (value) ENGINE_ATTRIBUTE="{}"', + 'CREATE INDEX idx_value ON wptests_index_fail (value) WITH PARSER ngram', + 'CREATE FULLTEXT INDEX idx_value ON wptests_index_fail (value) COMMENT "Lookup"', + 'CREATE SPATIAL INDEX idx_value ON wptests_index_fail (value) KEY_BLOCK_SIZE=8', + 'CREATE INDEX IF NOT EXISTS "wptests_index_fail__" ON "wptests_index_fail" ("value")', + 'CREATE INDEX idx_value ON information_schema.tables (name)', + 'CREATE INDEX idx_value ON other_db.wptests_index_fail (value)', + 'DROP INDEX idx_value ON wptests_index_fail KEY_BLOCK_SIZE=8', + 'DROP INDEX idx_value ON wptests_index_fail ALGORITHM=INSTANT', + 'DROP INDEX idx_value ON wptests_index_fail LOCK=UNKNOWN', + 'DROP INDEX idx_value ON information_schema.tables', + 'DROP INDEX idx_value ON other_db.wptests_index_fail', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_index_fail (value varchar(255) NOT NULL)' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported standalone index DDL to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertContains( + $e->getMessage(), + array( + 'Unsupported CREATE INDEX statement.', + 'Unsupported DROP INDEX statement.', + 'Unsupported information_schema query.', + ), + $query + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests plain CREATE TABLE secondary KEY/INDEX definitions use the MySQL DDL translator. + */ + public function test_create_table_plain_secondary_indexes_use_mysql_translator(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_plain_secondary_indexes ( + id INT NOT NULL, + slug VARCHAR(20) NOT NULL, + value INT, + KEY slug_lookup (slug), + INDEX value_lookup (value) + )' + ) + ); + + $this->assertSame( + array( + array( + 'sql' => 'CREATE TABLE "wptests_plain_secondary_indexes" ( + "id" integer NOT NULL, + "slug" varchar(20) NOT NULL, + "value" integer +)', + 'params' => array(), + ), + array( + 'sql' => 'CREATE INDEX "wptests_plain_secondary_indexes__slug_lookup" ON "wptests_plain_secondary_indexes" ("slug")', + 'params' => array(), + ), + array( + 'sql' => 'CREATE INDEX "wptests_plain_secondary_indexes__value_lookup" ON "wptests_plain_secondary_indexes" ("value")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $index_rows = $driver->query( 'SHOW INDEX FROM wptests_plain_secondary_indexes' ); + $this->assertSame( array( 'slug_lookup', 'value_lookup' ), array_column( $index_rows, 'Key_name' ) ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_plain_secondary_indexes' )[0]->{'Create Table'}; + $this->assertStringContainsString( 'KEY `slug_lookup` (`slug`)', $create_table ); + $this->assertStringContainsString( 'KEY `value_lookup` (`value`)', $create_table ); + } + + /** + * Tests CREATE TABLE PRIMARY KEY USING BTREE uses the MySQL DDL translator. + */ + public function test_create_table_primary_key_using_btree_uses_mysql_translator(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( 'CREATE TABLE wptests_primary_key_using (id INT NOT NULL, PRIMARY KEY USING BTREE (id))' ) + ); + + $this->assertSame( + array( + array( + 'sql' => 'CREATE TABLE "wptests_primary_key_using" ( + "id" integer NOT NULL, + PRIMARY KEY ("id") +)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_primary_key_using' )[0]->{'Create Table'}; + $this->assertStringContainsString( 'PRIMARY KEY (`id`)', $create_table ); + } + + /** + * Tests CREATE TABLE HASH indexes are normalized to BTREE like the SQLite backend. + */ + public function test_create_table_hash_indexes_are_normalized_to_btree(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_create_hash_index ( + id int NOT NULL, + value varchar(255) NOT NULL, + KEY value_hash USING HASH (value) + )' + ) + ); + + $this->assertSame( + array( + array( + 'sql' => "CREATE TABLE \"wptests_create_hash_index\" (\n \"id\" integer NOT NULL,\n \"value\" varchar(255) NOT NULL\n)", + 'params' => array(), + ), + array( + 'sql' => 'CREATE INDEX "wptests_create_hash_index__value_hash" ON "wptests_create_hash_index" ("value")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_create_hash_index' ); + $this->assertSame( array( 'value_hash' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( array( 'BTREE' ), array_column( $indexes, 'index_type' ) ); + + $show_index = $driver->query( 'SHOW INDEX FROM wptests_create_hash_index' ); + $this->assertSame( 'BTREE', $show_index[0]->Index_type ); + } + + /** + * Tests CREATE TABLE FULLTEXT/SPATIAL indexes update metadata without PostgreSQL index DDL. + */ + public function test_create_table_fulltext_and_spatial_indexes_are_metadata_only(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_create_search_geo ( + id int NOT NULL, + body text, + shape point NOT NULL, + FULLTEXT KEY body_fulltext (body), + SPATIAL KEY shape_spatial (shape) + )' + ) + ); + $this->assertSame( + array( + array( + 'sql' => "CREATE TABLE \"wptests_create_search_geo\" (\n \"id\" integer NOT NULL,\n \"body\" text,\n \"shape\" text NOT NULL\n)", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_create_search_geo' ); + $this->assertSame( array( 'body_fulltext', 'shape_spatial' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( array( 'FULLTEXT', 'SPATIAL' ), array_column( $indexes, 'index_type' ) ); + $this->assertNull( $indexes[0]['sub_part'] ); + $this->assertSame( '32', $indexes[1]['sub_part'] ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_create_search_geo' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' SPATIAL KEY `shape_spatial` (`shape`(32))', $create_table ); + $this->assertStringContainsString( ' FULLTEXT KEY `body_fulltext` (`body`)', $create_table ); + } + + /** + * Tests CREATE TABLE index direction metadata is exposed through MySQL introspection. + */ + public function test_create_table_index_directions_update_mysql_introspection_metadata(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_create_directional_index ( + id int NOT NULL, + score int NOT NULL, + name varchar(255) NOT NULL, + created_at datetime NOT NULL, + body text, + PRIMARY KEY (id), + KEY score_name (score ASC, name(16) DESC, created_at DESC), + FULLTEXT KEY body_fulltext (body) + )' + ) + ); + + $statistics = $driver->query( + "SELECT INDEX_NAME, COLUMN_NAME, COLLATION, SUB_PART + FROM information_schema.statistics + WHERE table_name = 'wptests_create_directional_index' + ORDER BY INDEX_NAME, SEQ_IN_INDEX" + ); + + $statistics_by_part = array(); + foreach ( $statistics as $row ) { + $statistics_by_part[ $row->INDEX_NAME . ':' . $row->COLUMN_NAME ] = array( $row->COLLATION, $row->SUB_PART ); + } + ksort( $statistics_by_part ); + + $this->assertSame( + array( + 'PRIMARY:id' => array( 'A', null ), + 'body_fulltext:body' => array( null, null ), + 'score_name:created_at' => array( 'D', null ), + 'score_name:name' => array( 'D', '16' ), + 'score_name:score' => array( 'A', null ), + ), + $statistics_by_part + ); + + $show_index = $driver->query( "SHOW INDEX FROM wptests_create_directional_index WHERE Key_name = 'score_name'" ); + $this->assertSame( array( 'A', 'D', 'D' ), array_column( $show_index, 'Collation' ) ); + $this->assertSame( array( null, '16', null ), array_column( $show_index, 'Sub_part' ) ); + + $show_create = $driver->query( 'SHOW CREATE TABLE wptests_create_directional_index' )[0]->{'Create Table'}; + $this->assertStringContainsString( + ' KEY `score_name` (`score`, `name`(16) DESC, `created_at` DESC)', + $show_create + ); + $this->assertStringContainsString( ' FULLTEXT KEY `body_fulltext` (`body`)', $show_create ); + } + + /** + * Tests CREATE TABLE zero-date defaults stay text-backed while SHOW CREATE preserves MySQL metadata. + */ + public function test_create_table_zero_date_defaults_are_text_and_show_create_preserves_mysql_shape(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + "CREATE TABLE wptests_zero_dates ( + id int NOT NULL, + created_date date NOT NULL DEFAULT '0000-00-00', + created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + updated_at timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4" + ) + ); + $this->assertSame( + array( + array( + 'sql' => "CREATE TABLE \"wptests_zero_dates\" (\n \"id\" integer NOT NULL,\n \"created_date\" text NOT NULL DEFAULT '0000-00-00',\n \"created_at\" text NOT NULL DEFAULT '0000-00-00 00:00:00',\n \"updated_at\" text NOT NULL DEFAULT '0000-00-00 00:00:00',\n PRIMARY KEY (\"id\")\n)", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_zero_dates' )[0]->{'Create Table'}; + + $this->assertStringContainsString( " `created_date` date NOT NULL DEFAULT '0000-00-00'", $create_table ); + $this->assertStringContainsString( " `created_at` datetime NOT NULL DEFAULT '0000-00-00 00:00:00'", $create_table ); + $this->assertStringContainsString( " `updated_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'", $create_table ); + } + + /** + * Tests CREATE TABLE CHECK constraints route through the MySQL DDL translator. + */ + public function test_create_table_with_plain_char_and_check_preserves_constraint(): void { + $driver = $this->create_driver(); + $query = 'CREATE TABLE plain_char_check (a CHAR(10) CHECK (length(a) > 0))'; + + $this->assertSame( 0, $driver->query( $query ) ); + $this->assertSame( + array( + array( + 'sql' => "CREATE TABLE \"plain_char_check\" (\n \"a\" char(10) CONSTRAINT \"plain_char_check_chk_1\" CHECK (length(a) > 0)\n)", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( + array( + array( + 'constraint_name' => 'plain_char_check_chk_1', + 'check_clause' => 'length(a) > 0', + 'enforced' => 'YES', + ), + ), + $this->get_mysql_check_metadata_rows( $driver, 'plain_char_check' ) + ); + + $this->expectException( PDOException::class ); + $driver->query( "INSERT INTO plain_char_check (a) VALUES ('')" ); + } + + /** + * Tests MySQL json_valid() CHECK constraints are translated for PostgreSQL. + */ + public function test_create_table_json_valid_check_translates_for_postgresql_and_preserves_mysql_metadata(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_json_check ( + id int NOT NULL, + data JSON CHECK (json_valid(data)) + )' + ) + ); + + $this->assertSame( + array( + array( + 'sql' => "CREATE TABLE \"wptests_json_check\" (\n \"id\" integer NOT NULL,\n \"data\" text CONSTRAINT \"wptests_json_check_chk_1\" CHECK ((CASE WHEN data IS NULL THEN NULL ELSE (CAST(data AS jsonb) IS NOT NULL) END))\n)", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( + array( + array( + 'constraint_name' => 'wptests_json_check_chk_1', + 'check_clause' => 'json_valid(data)', + 'enforced' => 'YES', + ), + ), + $this->get_mysql_check_metadata_rows( $driver, 'wptests_json_check' ) + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_json_check' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' `data` json DEFAULT NULL,', $create_table ); + $this->assertStringContainsString( ' CONSTRAINT `wptests_json_check_chk_1` CHECK (json_valid(data))', $create_table ); + } + + /** + * Tests unsupported json_valid() CHECK forms fail before backend execution. + */ + public function test_create_table_unsupported_json_valid_check_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'CREATE TABLE wptests_json_check_unsupported (data JSON CHECK (json_valid(data, data)))' ); + $this->fail( 'Expected unsupported CHECK constraint expression to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported CHECK constraint expression.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests backticked SELECT identifiers are translated to PostgreSQL quoting. + */ + public function test_simple_select_with_backticked_identifiers_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, "post_title" TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", "post_title") VALUES (1, \'Hello\')' ); + + $select = 'SELECT `ID`, `post_title` FROM `wptests_posts` WHERE `ID` = 1'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertSame( 'Hello', $rows[0]->post_title ); + $this->assertSame( $select, $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "ID", "post_title" FROM "wptests_posts" WHERE "ID" = 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests bare uppercase ID SELECT identifiers are quoted for PostgreSQL. + */ + public function test_simple_select_with_bare_uppercase_id_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, user_login TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (1, \'admin\')' ); + + $select = 'SELECT ID, user_login FROM wptests_users WHERE ID = 1'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertSame( 'admin', $rows[0]->user_login ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "ID", user_login FROM wptests_users WHERE "ID" = 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests a simple SELECT with a trailing LIMIT translates uppercase WHERE identifiers. + */ + public function test_simple_select_with_bare_uppercase_id_where_and_limit_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_title TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_title) VALUES (1, \'Hello\')' ); + + $select = 'SELECT * FROM wptests_posts WHERE ID = 1 LIMIT 1'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT * FROM wptests_posts WHERE "ID" = 1 LIMIT 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests COUNT projections translate uppercase aggregate identifiers. + */ + public function test_simple_select_count_with_bare_uppercase_id_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, user_login TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (1, \'admin\')' ); + + $select = 'SELECT COUNT(ID) as c FROM wptests_users'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->c ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT COUNT("ID") as c FROM wptests_users', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests mixed-case comment SELECT identifiers are quoted for PostgreSQL. + */ + public function test_simple_select_with_mixed_case_comment_identifiers_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, "comment_post_ID" INTEGER NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID") VALUES (7, 1)' ); + + $select = 'SELECT comment_ID FROM wptests_comments WHERE comment_post_ID = 1 ORDER BY comment_ID DESC'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->comment_ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "comment_ID" FROM wptests_comments WHERE "comment_post_ID" = 1 ORDER BY "comment_ID" DESC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests approved comments ordered by GMT date use comment_ID as a tie-breaker. + */ + public function test_simple_select_approved_comments_order_uses_comment_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, "comment_post_ID" INTEGER NOT NULL, comment_date_gmt TEXT NOT NULL, comment_approved TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (184, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (180, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (181, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (183, 8, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (185, 7, \'2024-01-01 00:00:00\', \'0\')' ); + + $select = "SELECT * + FROM wptests_comments + WHERE comment_post_ID = 7 AND comment_approved = '1' + ORDER BY wptests_comments.comment_date_gmt ASC"; + $rows = $driver->query( $select ); + + $this->assertSame( + array( '180', '181', '184' ), + array_map( + static function ( $row ) { + return $row->comment_ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT * FROM wptests_comments WHERE "comment_post_ID" = 7 AND comment_approved = \'1\' ORDER BY wptests_comments.comment_date_gmt ASC, "comment_ID" ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests approved comment ID lookups ordered by GMT date use comment_ID as a tie-breaker. + */ + public function test_simple_select_approved_comment_ids_order_uses_comment_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, "comment_post_ID" INTEGER NOT NULL, comment_date_gmt TEXT NOT NULL, comment_approved TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (184, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (180, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (181, 7, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (183, 8, \'2024-01-01 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_date_gmt, comment_approved) VALUES (185, 7, \'2024-01-01 00:00:00\', \'0\')' ); + + $select = "SELECT wptests_comments.comment_ID + FROM wptests_comments + WHERE comment_post_ID = 7 AND comment_approved = '1' + ORDER BY wptests_comments.comment_date_gmt ASC"; + $rows = $driver->query( $select ); + + $this->assertSame( + array( '180', '181', '184' ), + array_map( + static function ( $row ) { + return $row->comment_ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_comments."comment_ID" FROM wptests_comments WHERE "comment_post_ID" = 7 AND comment_approved = \'1\' ORDER BY wptests_comments.comment_date_gmt ASC, "comment_ID" ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests approved comment aggregate lookups are not rewritten with a row tie-breaker. + */ + public function test_simple_select_approved_comments_order_does_not_rewrite_count_projection(): void { + $driver = $this->create_driver(); + + $select = "SELECT COUNT(comment_ID) as c + FROM wptests_comments + WHERE comment_post_ID = 7 AND comment_approved = '1' + ORDER BY wptests_comments.comment_date_gmt ASC"; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_wordpress_approved_comments_query', + $select + ) + ); + } + + /** + * Tests approved comment projections must belong to the selected comments table. + */ + public function test_simple_select_approved_comments_order_does_not_rewrite_foreign_projection_qualifier(): void { + $driver = $this->create_driver(); + + $select = "SELECT other.comment_ID + FROM wptests_comments + WHERE comment_post_ID = 7 AND comment_approved = '1' + ORDER BY wptests_comments.comment_date_gmt ASC"; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_wordpress_approved_comments_query', + $select + ) + ); + } + + /** + * Tests MySQL offset,count LIMIT syntax is translated to PostgreSQL. + */ + public function test_simple_select_with_mysql_offset_count_limit_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, "comment_post_ID" INTEGER NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID") VALUES (7, 1)' ); + + $select = 'SELECT comment_ID FROM wptests_comments WHERE comment_post_ID = 1 ORDER BY comment_ID ASC LIMIT 0,500'; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->comment_ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "comment_ID" FROM wptests_comments WHERE "comment_post_ID" = 1 ORDER BY "comment_ID" ASC LIMIT 500 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests MySQL offset,count LIMIT syntax is translated in broader SELECT queries. + */ + public function test_complex_select_with_mysql_offset_count_limit_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, "comment_post_ID" INTEGER NOT NULL, comment_approved TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_approved) VALUES (1, 7, \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", "comment_post_ID", comment_approved) VALUES (2, 7, \'1\')' ); + + $select = "SELECT comment_post_ID, COUNT(comment_ID) as num_comments + FROM wptests_comments + WHERE comment_post_ID IN (7) AND comment_approved = '1' + GROUP BY comment_post_ID + ORDER BY comment_post_ID ASC + LIMIT 0, 10"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->comment_post_ID ); + $this->assertSame( '2', $rows[0]->num_comments ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "comment_post_ID", COUNT ("comment_ID") as num_comments FROM wptests_comments WHERE "comment_post_ID" IN (7) AND comment_approved = \'1\' GROUP BY "comment_post_ID" ORDER BY "comment_post_ID" ASC LIMIT 10 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests MySQL offset,count LIMIT variants are translated to LIMIT/OFFSET. + */ + public function test_mysql_offset_count_limit_variants_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $cases = array( + 'SELECT * FROM wptests_posts LIMIT 0, 10' => 'SELECT * FROM wptests_posts LIMIT 10 OFFSET 0', + 'SELECT * FROM wptests_posts LIMIT 5, 10' => 'SELECT * FROM wptests_posts LIMIT 10 OFFSET 5', + "SELECT * FROM wptests_posts LIMIT\n0 ,\n10" => 'SELECT * FROM wptests_posts LIMIT 10 OFFSET 0', + 'SELECT * FROM wptests_posts LIMIT ?, ?' => 'SELECT * FROM wptests_posts LIMIT ? OFFSET ?', + ); + + foreach ( $cases as $mysql_sql => $postgresql_sql ) { + $this->assertSame( + $postgresql_sql, + $this->translate_driver_query_with_private_method( $driver, 'translate_simple_mysql_select_query', $mysql_sql ) + ); + } + } + + /** + * Tests PostgreSQL LIMIT count OFFSET offset syntax is preserved. + */ + public function test_existing_limit_offset_clause_is_preserved(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts (ID INTEGER)' ); + + $select = 'SELECT * FROM wptests_posts LIMIT 10 OFFSET 5'; + + $driver->query( $select ); + + $this->assertSame( + array( + array( + 'sql' => $select, + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests successive queries reset result metadata and backend query logs. + */ + public function test_query_resets_per_query_state(): void { + $driver = $this->create_driver(); + + $driver->query( 'SELECT 1 AS id' ); + $this->assertSame( 1, $driver->get_last_column_count() ); + $this->assertCount( 1, $driver->get_last_postgresql_queries() ); + + $result = $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + + $this->assertSame( $result, $driver->get_query_results() ); + $this->assertSame( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)', $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + + /** + * Tests insert IDs are cast to integers when numeric. + */ + public function test_get_insert_id_casts_numeric_strings(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE t ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO t (`value`) VALUES ('first')" ); + + $this->assertSame( 1, $driver->get_insert_id() ); + } + + /** + * Tests INSERT ... SELECT statements expose generated AUTO_INCREMENT insert IDs. + */ + public function test_insert_select_from_dual_sets_generated_insert_id(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_actionscheduler_actions ( + action_id INTEGER PRIMARY KEY AUTOINCREMENT, + hook TEXT NOT NULL, + status TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_actionscheduler_actions ( + action_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + hook varchar(191) NOT NULL, + status varchar(20) NOT NULL, + PRIMARY KEY (action_id) + )' + ); + + $insert = "INSERT INTO wptests_actionscheduler_actions (`hook`, `status`) + SELECT 'action_scheduler/migration_hook', 'pending' FROM DUAL + WHERE ( SELECT NULL FROM DUAL ) IS NULL"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO wptests_actionscheduler_actions ("hook", "status") SELECT \'action_scheduler/migration_hook\', \'pending\' WHERE (SELECT NULL) IS NULL', + $queries[0]['sql'] + ); + } + + /** + * Tests INSERT ... SELECT accepts MySQL's optional INTO keyword. + */ + public function test_insert_select_without_into_is_translated_with_postgresql_into(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_insert_select_without_into ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + + $this->assertSame( + 1, + $driver->query( "INSERT wptests_insert_select_without_into (`id`, `value`) SELECT 1, 'one' FROM DUAL" ) + ); + $this->assertSame( + 'INSERT INTO wptests_insert_select_without_into ("id", "value") SELECT 1, \'one\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_insert_select_without_into' ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'one', $rows[0]->value ); + } + + /** + * Tests columnless INSERT ... SELECT infers target columns from MySQL metadata. + */ + public function test_columnless_insert_select_uses_mysql_metadata_columns(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_insert_select_columnless ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_insert_select_columnless ( + id bigint(20) unsigned NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( + 'CREATE TABLE wptests_insert_select_columnless_source ( + id INTEGER NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_insert_select_columnless_source (id, value) VALUES (1, 'one'), (2, 'two')" ); + + $insert = 'INSERT INTO wptests_insert_select_columnless + SELECT id, value FROM wptests_insert_select_columnless_source WHERE 1 = 1'; + + $this->assertSame( 2, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO wptests_insert_select_columnless ("id", "value") SELECT ' . $this->get_expected_mysql_integer_cast_sql( 'id' ) . ' , CAST(value AS text) FROM wptests_insert_select_columnless_source WHERE 1 = 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_insert_select_columnless ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'one', + ), + (object) array( + 'id' => '2', + 'value' => 'two', + ), + ), + $rows + ); + } + + /** + * Tests unsupported generic DML shapes fail closed in the narrow translators. + */ + public function test_unsupported_simple_dml_shapes_return_null_translation(): void { + $driver = $this->create_driver(); + + $unsupported_query_methods = array( + 'UPDATE wptests_unsupported, wptests_other SET wptests_other.id = wptests_unsupported.id, wptests_unsupported.id = wptests_other.id WHERE wptests_other.id = 1' => 'translate_simple_mysql_update_query', + ); + + foreach ( $unsupported_query_methods as $query => $method_name ) { + if ( 'translate_simple_mysql_insert_query' === $method_name || 'translate_simple_mysql_replace_query' === $method_name ) { + $this->assertNull( + $this->translate_driver_query_data_with_private_method( $driver, $method_name, $query ), + $query + ); + continue; + } + + $this->assertNull( + $this->translate_driver_query_with_private_method( $driver, $method_name, $query ), + $query + ); + } + } + + /** + * Tests explicit MySQL AUTO_INCREMENT values are exposed as the insert ID. + */ + public function test_get_insert_id_uses_explicit_mysql_auto_increment_value(): void { + $driver = $this->create_driver_with_stale_connection_insert_id(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY AUTOINCREMENT, + post_title TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title varchar(255) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_title`) VALUES (587, 'explicit')" ); + + $this->assertSame( 587, $driver->get_insert_id() ); + $rows = $driver->query( 'SELECT ID, post_title FROM wptests_posts WHERE ID = 587' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'explicit', $rows[0]->post_title ); + } + + /** + * Tests AUTO_INCREMENT zero values generate IDs unless NO_AUTO_VALUE_ON_ZERO is active. + */ + public function test_auto_increment_zero_respects_no_auto_value_on_zero_sql_mode(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY AUTOINCREMENT, + post_title TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title varchar(255) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_posts (`ID`, `post_title`) VALUES (0, 'zero')" ) ); + $this->assertSame( + 'INSERT INTO "wptests_posts" ("ID", "post_title") VALUES (NULL, \'zero\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_posts (`ID`, `post_title`) VALUES ('0', 'quoted zero')" ) ); + $this->assertSame( 2, $driver->get_insert_id() ); + + $driver->set_sql_mode( 'NO_AUTO_VALUE_ON_ZERO' ); + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_posts (`ID`, `post_title`) VALUES (0, 'literal zero')" ) ); + $this->assertSame( + 'INSERT INTO "wptests_posts" ("ID", "post_title") VALUES (0, \'literal zero\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT ID, post_title FROM wptests_posts ORDER BY ID' ); + $this->assertSame( + array( + array( '0', 'literal zero' ), + array( '1', 'zero' ), + array( '2', 'quoted zero' ), + ), + array_map( + static function ( $row ): array { + return array( $row->ID, $row->post_title ); + }, + $rows + ) + ); + } + + /** + * Tests AUTO_INCREMENT zero handling applies to ON DUPLICATE KEY UPDATE VALUES rows. + */ + public function test_auto_increment_zero_respects_sql_mode_for_upsert_values(): void { + $driver = $this->create_driver(); + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (0, 'generated') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") VALUES (NULL, \'generated\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $driver->set_sql_mode( 'NO_AUTO_VALUE_ON_ZERO' ); + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (0, 'literal zero') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") VALUES (0, \'literal zero\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_identity_upsert ORDER BY id' ); + $this->assertSame( + array( + array( '0', 'literal zero' ), + array( '1', 'generated' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests AUTO_INCREMENT zero handling applies to INSERT ... SET and REPLACE rows. + */ + public function test_auto_increment_zero_respects_sql_mode_for_insert_set_and_replace(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_zero_dml ( + "ID" INTEGER PRIMARY KEY AUTOINCREMENT, + post_title TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_zero_dml ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title varchar(255) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_zero_dml SET ID = 0, post_title = 'set-generated'" ) ); + $this->assertSame( + 'INSERT INTO "wptests_zero_dml" ("ID", "post_title") VALUES (NULL, \'set-generated\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $this->assertSame( 1, $driver->query( "REPLACE INTO wptests_zero_dml (`ID`, `post_title`) VALUES ('0', 'replace-generated')" ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_zero_dml" ("ID", "post_title") VALUES (NULL, \'replace-generated\')', + ) + ); + $this->assertSame( 2, $driver->get_insert_id() ); + + $driver->set_sql_mode( 'NO_AUTO_VALUE_ON_ZERO' ); + + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_zero_dml SET ID = 0, post_title = 'set-literal'" ) ); + $this->assertSame( + 'INSERT INTO "wptests_zero_dml" ("ID", "post_title") VALUES (0, \'set-literal\')', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $this->assertSame( 2, $driver->query( "REPLACE INTO wptests_zero_dml (`ID`, `post_title`) VALUES (0, 'replace-literal')" ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'DELETE FROM "wptests_zero_dml" WHERE ("ID" = 0)', + 'INSERT INTO "wptests_zero_dml" ("ID", "post_title") VALUES (0, \'replace-literal\')', + ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT `ID` AS id, post_title FROM wptests_zero_dml ORDER BY `ID`' ); + $this->assertSame( + array( + array( '0', 'replace-literal' ), + array( '1', 'set-generated' ), + array( '2', 'replace-generated' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->post_title ); + }, + $rows + ) + ); + } + + /** + * Tests AUTO_INCREMENT zero handling applies to INSERT ... SELECT projections. + */ + public function test_auto_increment_zero_respects_sql_mode_for_insert_select(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_insert_select_posts ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + post_title TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_insert_select_posts ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title varchar(255) NOT NULL DEFAULT "", + PRIMARY KEY (ID) + )' + ); + + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_insert_select_posts` (`ID`, `post_title`) SELECT 0, 'generated' FROM DUAL" ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_select_posts" ("ID", "post_title") SELECT NULL , \'generated\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->assertSame( 1, $driver->get_insert_id() ); + + $driver->set_sql_mode( 'NO_AUTO_VALUE_ON_ZERO' ); + $this->assertSame( 1, $driver->query( "INSERT INTO `wptests_insert_select_posts` (`ID`, `post_title`) SELECT 0, 'literal zero' FROM DUAL" ) ); + $this->assertSame( + 'INSERT INTO "wptests_insert_select_posts" ("ID", "post_title") SELECT 0, \'literal zero\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT ID, post_title FROM wptests_insert_select_posts ORDER BY ID' ); + $this->assertSame( + array( + array( '0', 'literal zero' ), + array( '1', 'generated' ), + ), + array_map( + static function ( $row ): array { + return array( $row->ID, $row->post_title ); + }, + $rows + ) + ); + } + + /** + * Tests real source INSERT ... SELECT repairs explicit AUTO_INCREMENT targets. + */ + public function test_insert_select_with_auto_increment_target_repairs_real_source_table(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + $driver->query( + 'CREATE TABLE wptests_identity_insert_select_source ( + id INTEGER NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_identity_insert_select_source ( + id bigint(20) unsigned NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_identity_insert_select_source (id, value) VALUES (7, 'selected'), (8, 'created')" ); + + $insert = 'INSERT INTO `wptests_identity_upsert` (`id`, `value`) + SELECT `id`, `value` FROM `wptests_identity_insert_select_source` WHERE `id` > 0'; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_simple_mysql_insert_select_query', + $insert + ); + + $this->assertIsArray( $translation ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT "id", "value" FROM "wptests_identity_insert_select_source" WHERE "id" > 0', + $translation['sql'] + ); + $this->assertNull( $translation['value_rows'] ); + $this->assertNull( $translation['insert_id_value_rows'] ); + $this->assertTrue( $translation['insert_id_unknown'] ); + $this->assertSame( array( 'id' => true ), $translation['explicit_identity_columns'] ); + + $this->assertSame( 2, $driver->query( $insert ) ); + $this->assertSame( 0, $driver->get_insert_id() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT "id", "value" FROM "wptests_identity_insert_select_source" WHERE "id" > 0', + $queries[0]['sql'] + ); + $this->assert_sequence_repair_query( $queries[1], 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_identity_upsert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '7', + 'value' => 'selected', + ), + (object) array( + 'id' => '8', + 'value' => 'created', + ), + ), + $rows + ); + } + + /** + * Tests upsert conflict updates expose explicit AUTO_INCREMENT values as the insert ID. + */ + public function test_get_insert_id_returns_explicit_auto_increment_value_for_upsert_conflict_update(): void { + $driver = $this->create_driver_with_stale_connection_insert_id(); + + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_upsert (id, value) VALUES (7, 'existing')" ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) VALUES (7, 'updated') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + + $rows = $driver->query( 'SELECT value FROM wptests_identity_upsert WHERE id = 7' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'updated', $rows[0]->value ); + } + + /** + * Tests LAST_INSERT_ID(id) upsert updates expose an existing AUTO_INCREMENT id. + */ + public function test_upsert_last_insert_id_assignment_exposes_existing_auto_increment_value(): void { + $driver = $this->create_driver_with_stale_connection_insert_id(); + + $this->install_identity_unique_upsert_table_with_mysql_metadata( $driver ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_unique_upsert (id, slug, value) VALUES (7, 'existing', 'old')" ); + + $upsert = "INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + VALUES ('existing', 'updated') + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`), + `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + $this->assertSame( + 'INSERT INTO "wptests_identity_unique_upsert" ("slug", "value") VALUES (\'existing\', \'updated\') ON CONFLICT ("slug") DO UPDATE SET "id" = "wptests_identity_unique_upsert"."id", "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_identity_unique_upsert ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->id ); + $this->assertSame( 'existing', $rows[0]->slug ); + $this->assertSame( 'updated', $rows[0]->value ); + + $select_upsert = "INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + SELECT 'existing', 'selected' FROM DUAL + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`), + `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $select_upsert ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + $this->assertSame( + 'INSERT INTO "wptests_identity_unique_upsert" ("slug", "value") SELECT \'existing\', \'selected\' ON CONFLICT ("slug") DO UPDATE SET "id" = "wptests_identity_unique_upsert"."id", "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_identity_unique_upsert ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->id ); + $this->assertSame( 'existing', $rows[0]->slug ); + $this->assertSame( 'selected', $rows[0]->value ); + + $insert_driver = $this->create_driver(); + $this->install_identity_unique_upsert_table_with_mysql_metadata( $insert_driver ); + $insert = "INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + VALUES ('new', 'created') + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`), + `value` = VALUES(`value`)"; + + $this->assertSame( 1, $insert_driver->query( $insert ) ); + $this->assertSame( 1, $insert_driver->get_insert_id() ); + } + + /** + * Tests LAST_INSERT_ID(id) duplicate updates preserve target-row references in expressions. + */ + public function test_upsert_last_insert_id_values_and_row_count_readback_after_duplicate_update(): void { + $driver = $this->create_driver_with_stale_connection_insert_id(); + + $driver->query( + 'CREATE TABLE wp_acid_upsert ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + hits INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wp_acid_upsert ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + slug varchar(191) NOT NULL, + hits int(11) NOT NULL DEFAULT 0, + updated_at datetime NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug (slug) + )' + ); + $driver->get_connection()->query( "INSERT INTO wp_acid_upsert (id, slug, hits, updated_at) VALUES (7, 'same', 1, '0000-00-00 00:00:00')" ); + + $upsert = "INSERT INTO wp_acid_upsert (slug, hits, updated_at) VALUES ('same', 2, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), hits = hits + VALUES(hits), updated_at = VALUES(updated_at)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + $this->assertSame( + 'INSERT INTO "wp_acid_upsert" ("slug", "hits", "updated_at") VALUES (\'same\', 2, \'2001-01-01 00:00:00\') ON CONFLICT ("slug") DO UPDATE SET "id" = "wp_acid_upsert"."id", "hits" = "wp_acid_upsert"."hits" + excluded."hits", "updated_at" = excluded."updated_at"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( + "SELECT LAST_INSERT_ID() AS last_id, ROW_COUNT() AS row_count_value, hits, updated_at + FROM wp_acid_upsert + WHERE slug = 'same'" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->last_id ); + $this->assertSame( '1', $rows[0]->row_count_value ); + $this->assertSame( '3', $rows[0]->hits ); + $this->assertSame( '2001-01-01 00:00:00', $rows[0]->updated_at ); + + $now_upsert = "INSERT INTO wp_acid_upsert (slug, hits, updated_at) VALUES ('same', 2, NOW()) + ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), hits = hits + VALUES(hits), updated_at = VALUES(updated_at)"; + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $now_upsert + ); + + $this->assertNotNull( $translation ); + $this->assertSame( + 'INSERT INTO "wp_acid_upsert" ("slug", "hits", "updated_at") VALUES (\'same\', 2, __wp_pg_mysql_validate_temporal(CAST(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\') AS text), \'datetime\', 1, 1)) ON CONFLICT ("slug") DO UPDATE SET "id" = "wp_acid_upsert"."id", "hits" = "wp_acid_upsert"."hits" + excluded."hits", "updated_at" = excluded."updated_at"', + $translation['sql'] + ); + } + + /** + * Tests multi-row LAST_INSERT_ID(id) upsert updates report the final deterministic existing id. + */ + public function test_multi_row_upsert_last_insert_id_assignment_exposes_existing_auto_increment_value(): void { + $driver = $this->create_driver_with_stale_connection_insert_id(); + + $this->install_identity_unique_upsert_table_with_mysql_metadata( $driver ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_unique_upsert (id, slug, value) VALUES (7, 'one', 'old-one'), (8, 'two', 'old-two')" ); + + $upsert = "INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + VALUES ('one', 'updated-one'), ('two', 'updated-two') + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`), + `value` = VALUES(`value`)"; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $this->assertSame( 8, $driver->get_insert_id() ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_identity_unique_upsert" ("slug", "value") VALUES (\'one\', \'updated-one\'), (\'two\', \'updated-two\') ON CONFLICT ("slug") DO UPDATE SET "id" = "wptests_identity_unique_upsert"."id", "value" = excluded."value"', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_identity_unique_upsert ORDER BY id' ); + $this->assertSame( + array( + array( '7', 'one', 'updated-one' ), + array( '8', 'two', 'updated-two' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->slug, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests real SELECT-sourced LAST_INSERT_ID(id) upserts report deterministic existing ids. + */ + public function test_insert_select_upsert_last_insert_id_assignment_exposes_existing_auto_increment_value(): void { + $driver = $this->create_driver_with_stale_connection_insert_id(); + + $this->install_identity_unique_upsert_table_with_mysql_metadata( $driver ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_unique_upsert (id, slug, value) VALUES (7, 'one', 'old-one'), (8, 'two', 'old-two')" ); + $driver->query( + 'CREATE TABLE wptests_identity_unique_upsert_source ( + seq INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_identity_unique_upsert_source ( + seq int(11) NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_identity_unique_upsert_source (seq, slug, value) VALUES (1, 'one', 'selected-one'), (2, 'two', 'selected-two')" ); + + $upsert = 'INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + SELECT `slug`, `value` FROM `wptests_identity_unique_upsert_source` ORDER BY `seq` + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`), + `value` = VALUES(`value`)'; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $this->assertSame( 8, $driver->get_insert_id() ); + + $materialized_sql = $this->assert_last_upsert_select_materialized_sql( $driver, 'wptests_identity_unique_upsert' ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE SET "id" = "wptests_identity_unique_upsert"."id", "value" = excluded."value"', $materialized_sql[2] ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_identity_unique_upsert ORDER BY id' ); + $this->assertSame( + array( + array( '7', 'one', 'selected-one' ), + array( '8', 'two', 'selected-two' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->slug, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests unsupported LAST_INSERT_ID(expr) upsert assignments fail closed. + */ + public function test_upsert_last_insert_id_assignment_unsupported_shapes_fail_closed(): void { + $driver = $this->create_driver(); + + $this->install_identity_unique_upsert_table_with_mysql_metadata( $driver ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_unique_upsert (id, slug, value) VALUES (7, 'existing', 'old')" ); + + $queries = array( + "INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + VALUES ('existing', 'updated') + ON DUPLICATE KEY UPDATE `value` = LAST_INSERT_ID(`id`)", + "INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + VALUES ('existing', 'updated') + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id` + 1)", + "INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + VALUES ('existing', 'updated'), ('new', 'created') + ON DUPLICATE KEY UPDATE `id` = LAST_INSERT_ID(`id`)", + ); + + foreach ( $queries as $query ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported LAST_INSERT_ID() upsert assignment to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests inserts into tables without AUTO_INCREMENT do not expose stale IDs. + */ + public function test_get_insert_id_is_zero_for_non_auto_increment_insert(): void { + $driver = $this->create_driver_with_stale_connection_insert_id(); + + $driver->query( + 'CREATE TABLE wptests_term_relationships ( + object_id INTEGER NOT NULL DEFAULT 0, + term_taxonomy_id INTEGER NOT NULL DEFAULT 0, + term_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (object_id, term_taxonomy_id) + )' + ); + $driver->store_mysql_schema_metadata( + '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) + )' + ); + + $driver->query( 'INSERT INTO wptests_term_relationships (`object_id`, `term_taxonomy_id`, `term_order`) VALUES (587, 1, 0)' ); + + $this->assertSame( 0, $driver->get_insert_id() ); + } + + /** + * Tests transaction methods delegate to PDO. + */ + public function test_transaction_methods_delegate_to_pdo(): void { + $driver = $this->create_driver(); + + $driver->beginTransaction(); + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $driver->rollback(); + + $stmt = $driver->get_connection()->query( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 't'" ); + $this->assertFalse( $stmt->fetchColumn() ); + } + + /** + * Tests the transaction alias and commit delegate to PDO. + */ + public function test_transaction_alias_and_commit_delegate_to_pdo(): void { + $driver = $this->create_driver(); + + $driver->begin_transaction(); + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $driver->query( "INSERT INTO t (value) VALUES ('first')" ); + $driver->commit(); + + $rows = $driver->query( 'SELECT value FROM t' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'first', $rows[0]->value ); + } + + /** + * Tests WordPress options upserts are translated to PostgreSQL ON CONFLICT. + */ + public function test_wordpress_options_upsert_is_translated_to_postgresql_on_conflict(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + + $unique_index_metadata_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$unique_index_metadata_queries ): void { + if ( false !== strpos( $sql, "non_unique = '0'" ) ) { + ++$unique_index_metadata_queries; + } + } + ); + + $insert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.org', 'yes') + ON DUPLICATE KEY UPDATE `option_name` = VALUES(`option_name`), + `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`);"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( $insert, $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.org\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_name" = excluded."option_name", "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( 1, $unique_index_metadata_queries ); + + $update = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.net', 'no') + ON DUPLICATE KEY UPDATE `option_name` = VALUES(`option_name`), + `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`);"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.net\', \'no\') ON CONFLICT ("option_name") DO UPDATE SET "option_name" = excluded."option_name", "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( 1, $unique_index_metadata_queries ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'http://example.net', $rows[0]->option_value ); + $this->assertSame( 'no', $rows[0]->autoload ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_options ( + option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + option_name varchar(191) NOT NULL DEFAULT "", + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL DEFAULT "yes", + PRIMARY KEY (option_id) + )' + ); + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $update + ) + ); + $this->assertSame( 2, $unique_index_metadata_queries ); + } + + /** + * Tests INSERT ... SET upserts are normalized into PostgreSQL ON CONFLICT statements. + */ + public function test_insert_set_upsert_is_translated_to_postgresql_on_conflict(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + + $insert = "INSERT INTO `wptests_options` SET `option_name` = 'siteurl', + `option_value` = 'http://example.org', + `autoload` = 'yes' + ON DUPLICATE KEY UPDATE `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`)"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.org\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $update = "INSERT INTO `wptests_options` SET `option_name` = 'siteurl', + `option_value` = 'http://example.net', + `autoload` = 'no' + ON DUPLICATE KEY UPDATE `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.net\', \'no\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertSame( 'http://example.net', $rows[0]->option_value ); + $this->assertSame( 'no', $rows[0]->autoload ); + } + + /** + * Tests INSERT priority modifiers are accepted on ON DUPLICATE KEY UPDATE statements. + */ + public function test_insert_priority_modifier_upsert_is_translated_to_postgresql_on_conflict(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + + $insert = "INSERT LOW_PRIORITY INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.org', 'yes') + ON DUPLICATE KEY UPDATE `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`)"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.org\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $update = "INSERT HIGH_PRIORITY INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.net', 'no') + ON DUPLICATE KEY UPDATE `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.net\', \'no\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + $this->assertSame( 'http://example.net', $rows[0]->option_value ); + $this->assertSame( 'no', $rows[0]->autoload ); + } + + /** + * Tests VALUES upserts without a column list infer table metadata order. + */ + public function test_values_upsert_without_column_list_uses_table_metadata_order(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE metadata_order_upsert ( + value TEXT NOT NULL, + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE metadata_order_upsert ( + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + + $insert = "INSERT INTO `metadata_order_upsert` VALUES (1, 'first', 'old') + ON DUPLICATE KEY UPDATE `slug` = VALUES(`slug`), + `value` = VALUES(`value`)"; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $insert + ); + + $this->assertIsArray( $translation ); + $this->assertSame( array( 'id', 'slug', 'value' ), $translation['columns'] ); + $this->assertSame( + 'INSERT INTO "metadata_order_upsert" ("id", "slug", "value") VALUES (1, \'first\', \'old\') ON CONFLICT ("id") DO UPDATE SET "slug" = excluded."slug", "value" = excluded."value"', + $translation['sql'] + ); + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "metadata_order_upsert" ("id", "slug", "value") VALUES (1, \'first\', \'old\') ON CONFLICT ("id") DO UPDATE SET "slug" = excluded."slug", "value" = excluded."value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $update = "INSERT INTO `metadata_order_upsert` VALUES (1, 'second', 'new') + ON DUPLICATE KEY UPDATE `slug` = VALUES(`slug`), + `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + + $rows = $driver->query( 'SELECT id, slug, value FROM metadata_order_upsert' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'second', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests serialized feed option upserts quote PostgreSQL-safe text. + */ + public function test_options_upsert_quotes_serialized_feed_payload_for_postgresql(): void { + $driver = $this->create_driver_with_postgresql_quote_translation(); + + $this->install_options_table_with_mysql_metadata( $driver ); + + $payload = serialize( + array( + "\0*\0data" => "single ' double \" backslash \\ marker E'\nnext line", + ) + ); + $insert = sprintf( + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('_transient_feed_quote_test', %s, 'off') + ON DUPLICATE KEY UPDATE `option_name` = VALUES(`option_name`), + `option_value` = VALUES(`option_value`), + `autoload` = VALUES(`autoload`);", + $this->quote_mysql_string_literal_for_test( $payload ) + ); + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $insert + ); + + $this->assertIsArray( $translation ); + $sql = $translation['sql']; + + $this->assertStringNotContainsString( "\0", $sql ); + $this->assertStringContainsString( 'WP_MYSQL_TEXT_V1:', $sql ); + $this->assertStringContainsString( bin2hex( $payload ), $sql ); + $this->assertStringContainsString( '"option_value" = excluded."option_value"', $sql ); + $this->assertStringContainsString( 'ON CONFLICT ("option_name") DO UPDATE', $sql ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports literal assignment expressions. + */ + public function test_options_upsert_literal_assignments_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('siteurl', 'old', 'yes')" ); + + $upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'inserted', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = 'http://example.net', + `autoload` = \"no\""; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'inserted\', \'ignored\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = \'http://example.net\', "autoload" = \'no\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'http://example.net', $rows[0]->option_value ); + $this->assertSame( 'no', $rows[0]->autoload ); + } + + /** + * Tests unnamed MySQL UNIQUE KEY metadata participates in ON DUPLICATE KEY UPDATE. + */ + public function test_upsert_uses_unnamed_unique_key_metadata(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_unnamed_unique_upsert ( + id int(11) NOT NULL, + name varchar(255) DEFAULT NULL, + other varchar(255) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY (name) + )' + ) + ); + + $insert = 'INSERT INTO wptests_unnamed_unique_upsert (id, `name`, other) + VALUES (1, "name", "test") + ON DUPLICATE KEY UPDATE `other` = values(other)'; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $upsert = 'INSERT INTO wptests_unnamed_unique_upsert (id, `name`, other) + VALUES (2, "name", "updated") + ON DUPLICATE KEY UPDATE `other` = values(other)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_unnamed_unique_upsert" ("id", "name", "other") VALUES (2, \'name\', \'updated\') ON CONFLICT (SUBSTR(CAST("name" AS text), 1, 191)) DO UPDATE SET "other" = excluded."other"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, name, other FROM wptests_unnamed_unique_upsert ORDER BY id' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'name', $rows[0]->name ); + $this->assertSame( 'updated', $rows[0]->other ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports INSERT IGNORE, qualified targets, and DEFAULT. + */ + public function test_options_upsert_supports_ignore_qualified_assignment_targets_and_default(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('siteurl', 'old', 'no')" ); + + $upsert = "INSERT IGNORE INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'inserted', 'off') + ON DUPLICATE KEY UPDATE `wptests_options`.`option_value` = VALUES(`option_value`), + `wptests`.`wptests_options`.`autoload` = DEFAULT"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'inserted\', \'off\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = \'yes\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'inserted', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports MySQL DEFAULT(column) assignments. + */ + public function test_upsert_update_assignments_support_default_column_function(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_upsert_defaults ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, + note TEXT, + counter INTEGER NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_upsert_defaults ( + id bigint(20) unsigned NOT NULL, + label varchar(20) NOT NULL DEFAULT 'untitled', + note longtext DEFAULT NULL, + counter int(11) NOT NULL DEFAULT 3, + PRIMARY KEY (id) + )" + ); + $driver->query( "INSERT INTO wptests_upsert_defaults (id, label, note, counter) VALUES (1, 'old', 'old-note', 9)" ); + + $upsert = "INSERT INTO `wptests_upsert_defaults` (`id`, `label`, `note`, `counter`) + VALUES (1, 'incoming', 'incoming-note', 99) + ON DUPLICATE KEY UPDATE `label` = DEFAULT(`label`), + `note` = DEFAULT(`note`), + `counter` = DEFAULT(`counter`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_defaults" ("id", "label", "note", "counter") VALUES (1, \'incoming\', \'incoming-note\', 99) ON CONFLICT ("id") DO UPDATE SET "label" = \'untitled\', "note" = NULL, "counter" = \'3\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT label, note, counter FROM wptests_upsert_defaults WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'untitled', $rows[0]->label ); + $this->assertNull( $rows[0]->note ); + $this->assertSame( '3', $rows[0]->counter ); + + try { + $driver->query( + "INSERT INTO `wptests_upsert_defaults` (`id`, `label`, `note`, `counter`) + VALUES (1, 'incoming', 'incoming-note', 99) + ON DUPLICATE KEY UPDATE `label` = DEFAULT(`missing`)" + ); + $this->fail( 'Expected unsupported DEFAULT(column) upsert assignment to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports MySQL DEFAULT(column) inside expressions. + */ + public function test_upsert_update_assignments_support_default_column_inside_expressions(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_upsert_default_expr ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, + counter INTEGER NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_upsert_default_expr ( + id bigint(20) unsigned NOT NULL, + label varchar(20) NOT NULL DEFAULT 'untitled', + counter int(11) NOT NULL DEFAULT 3, + PRIMARY KEY (id) + )" + ); + $driver->query( "INSERT INTO wptests_upsert_default_expr (id, label, counter) VALUES (1, 'old', 9)" ); + + $upsert = "INSERT INTO `wptests_upsert_default_expr` (`id`, `label`, `counter`) + VALUES (1, 'incoming', 4) + ON DUPLICATE KEY UPDATE `counter` = DEFAULT(`counter`) + VALUES(`counter`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_default_expr" ("id", "label", "counter") VALUES (1, \'incoming\', 4) ON CONFLICT ("id") DO UPDATE SET "counter" = \'3\' + excluded."counter"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT label, counter FROM wptests_upsert_default_expr WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'old', $rows[0]->label ); + $this->assertSame( '7', $rows[0]->counter ); + + foreach ( + array( + "INSERT INTO `wptests_upsert_default_expr` (`id`, `label`, `counter`) VALUES (1, 'incoming', 4) ON DUPLICATE KEY UPDATE `counter` = DEFAULT(`missing`) + 1", + "INSERT INTO `wptests_upsert_default_expr` (`id`, `label`, `counter`) VALUES (1, 'incoming', 4) ON DUPLICATE KEY UPDATE `counter` = DEFAULT + 1", + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported DEFAULT(column) expression to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE translates CURRENT_TIMESTAMP metadata defaults as expressions. + */ + public function test_upsert_default_assignments_translate_current_timestamp_defaults_from_mysql_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_upsert_current_timestamp_defaults ( + id INTEGER PRIMARY KEY, + updated_at TEXT NOT NULL, + touched_at TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_upsert_current_timestamp_defaults ( + id bigint(20) unsigned NOT NULL, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + touched_at timestamp NOT NULL DEFAULT (now()), + PRIMARY KEY (id) + )' + ); + + $default_column_upsert = "INSERT INTO `wptests_upsert_current_timestamp_defaults` (`id`, `updated_at`, `touched_at`) + VALUES (1, '2001-01-01 00:00:00', '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = DEFAULT(`updated_at`), + `touched_at` = DEFAULT(`touched_at`)"; + + $default_column_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $default_column_upsert + ); + + $this->assertIsArray( $default_column_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_current_timestamp_defaults" ("id", "updated_at", "touched_at") VALUES (1, \'2001-01-01 00:00:00\', \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\'), "touched_at" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\')', + $default_column_translation['sql'] + ); + + $default_keyword_upsert = "INSERT INTO `wptests_upsert_current_timestamp_defaults` (`id`, `updated_at`, `touched_at`) + VALUES (1, '2001-01-01 00:00:00', '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = DEFAULT"; + + $default_keyword_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $default_keyword_upsert + ); + + $this->assertIsArray( $default_keyword_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_current_timestamp_defaults" ("id", "updated_at", "touched_at") VALUES (1, \'2001-01-01 00:00:00\', \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\')', + $default_keyword_translation['sql'] + ); + } + + /** + * Tests INSERT ... SET upserts support qualified insert assignment targets. + */ + public function test_insert_set_upsert_supports_qualified_insert_assignment_targets(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('siteurl', 'old', 'no')" ); + + $upsert = "INSERT INTO `wptests_options` + SET `wptests_options`.`option_name` = 'siteurl', + `wptests`.`wptests_options`.`option_value` = 'from-set', + `autoload` = 'off' + ON DUPLICATE KEY UPDATE `option_value` = VALUES(`option_value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'from-set\', \'off\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'from-set', $rows[0]->option_value ); + $this->assertSame( 'no', $rows[0]->autoload ); + } + + /** + * Tests INSERT ... SET upserts support MySQL VALUES-row aliases. + */ + public function test_insert_set_upsert_supports_values_row_alias_expressions(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('siteurl', 'old', 'no')" ); + + $upsert = "INSERT INTO `wptests_options` + SET `option_name` = 'siteurl', + `option_value` = 'from-set-alias', + `autoload` = 'off' + AS incoming(name_alias, value_alias, autoload_alias) + ON DUPLICATE KEY UPDATE `option_value` = incoming.`value_alias`, + `autoload` = autoload_alias"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'from-set-alias\', \'off\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = excluded."option_value", "autoload" = excluded."autoload"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'from-set-alias', $rows[0]->option_value ); + $this->assertSame( 'off', $rows[0]->autoload ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE fails closed for unknown assignment qualifiers. + */ + public function test_options_upsert_rejects_unknown_assignment_qualifier(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'inserted', 'off') + ON DUPLICATE KEY UPDATE `other_table`.`option_value` = VALUES(`option_value`)"; + + try { + $driver->query( $upsert ); + $this->fail( 'Expected unsupported qualified upsert assignment to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE decodes text-targeted hex literals. + */ + public function test_upsert_update_assignments_decode_hex_literals_for_text_columns(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_hex_values (value TEXT UNIQUE)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_hex_values ( + value varchar(20) NOT NULL, + UNIQUE KEY value (value) + )' + ); + $driver->query( "INSERT INTO wptests_hex_values (value) VALUES ('test')" ); + + $upsert = "INSERT INTO wptests_hex_values (value) + VALUES ('test') + ON DUPLICATE KEY UPDATE value = 0x61"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_hex_values" ("value") VALUES (\'test\') ON CONFLICT ("value") DO UPDATE SET "value" = \'a\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT value FROM wptests_hex_values' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'a', $rows[0]->value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE assignment literals use strict target-column coercion. + */ + public function test_upsert_update_assignments_use_strict_target_column_coercion(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 1)' ); + + $upsert = "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 2) + ON DUPLICATE KEY UPDATE `int_value` = '4.0'"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '4', $rows[0]->int_value ); + + try { + $driver->query( + "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 2) + ON DUPLICATE KEY UPDATE `int_value` = '12abc'" + ); + $this->fail( 'Expected invalid upsert assignment value to be rejected in strict SQL mode.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect integer value: '12abc'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests non-strict ON DUPLICATE KEY UPDATE integer assignments use MySQL coercion. + */ + public function test_non_strict_upsert_update_assignments_coerce_integer_string_literals(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 123)' ); + + $upsert = "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 999) + ON DUPLICATE KEY UPDATE `int_value` = 'test'"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 999) ON CONFLICT ("id") DO UPDATE SET "int_value" = ' . $this->get_expected_mysql_integer_cast_sql( "'test'" ), + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0', $rows[0]->int_value ); + } + + /** + * Tests non-strict scalar subquery upsert assignments use MySQL integer coercion. + */ + public function test_non_strict_upsert_scalar_subquery_assignments_coerce_integer_values(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 123)' ); + + $constant_upsert = "INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 999) + ON DUPLICATE KEY UPDATE `int_value` = (SELECT 'test' FROM DUAL)"; + + $this->assertSame( 1, $driver->query( $constant_upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 999) ON CONFLICT ("id") DO UPDATE SET "int_value" = ' . $this->get_expected_mysql_integer_cast_sql( "(SELECT 'test')" ), + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0', $rows[0]->int_value ); + + $driver->query( + 'CREATE TABLE wptests_upsert_int_source ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_upsert_int_source ( + id int(11) NOT NULL, + label varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_upsert_int_source (id, label) VALUES (1, '42suffix')" ); + + $table_upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 999) + ON DUPLICATE KEY UPDATE `int_value` = (SELECT `label` FROM `wptests_upsert_int_source` WHERE `id` = 1)'; + + $this->assertSame( 1, $driver->query( $table_upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 999) ON CONFLICT ("id") DO UPDATE SET "int_value" = ' . $this->get_expected_mysql_integer_cast_sql( '(SELECT "label" FROM "wptests_upsert_int_source" WHERE "id" = 1)' ), + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '42', $rows[0]->int_value ); + } + + /** + * Tests non-strict ON DUPLICATE KEY UPDATE normalizes temporal scalar assignment literals. + */ + public function test_non_strict_upsert_update_assignments_normalize_temporal_boolean_and_zero_literals(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, '2025-01-01', '2025-01-01 00:00:00', '2025-01-01 00:00:00')" + ); + + $upsert = "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, '2026-01-01', '2026-01-01 00:00:00', '2026-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `date_value` = TRUE, + `datetime_value` = FALSE, + `timestamp_value` = 0"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_values" ("id", "date_value", "datetime_value", "timestamp_value") VALUES (1, \'2026-01-01\', \'2026-01-01 00:00:00\', \'2026-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "date_value" = \'0000-00-00\', "datetime_value" = \'0000-00-00 00:00:00\', "timestamp_value" = \'0000-00-00 00:00:00\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00', $rows[0]->date_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->timestamp_value ); + } + + /** + * Tests non-strict ON DUPLICATE KEY UPDATE normalizes temporal scalar VALUES rows. + */ + public function test_non_strict_upsert_values_rows_normalize_temporal_boolean_and_zero_literals(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_strict_dml_values_table_with_mysql_metadata( $driver ); + $driver->query( + "INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, '2025-01-01', '2025-01-01 00:00:00', '2025-01-01 00:00:00')" + ); + + $upsert = 'INSERT INTO `wptests_strict_values` (`id`, `date_value`, `datetime_value`, `timestamp_value`) + VALUES (1, TRUE, FALSE, 0) + ON DUPLICATE KEY UPDATE `date_value` = VALUES(`date_value`), + `datetime_value` = VALUES(`datetime_value`), + `timestamp_value` = VALUES(`timestamp_value`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_values" ("id", "date_value", "datetime_value", "timestamp_value") VALUES (1, \'0000-00-00\', \'0000-00-00 00:00:00\', \'0000-00-00 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "date_value" = excluded."date_value", "datetime_value" = excluded."datetime_value", "timestamp_value" = excluded."timestamp_value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT date_value, datetime_value, timestamp_value FROM wptests_strict_values WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0000-00-00', $rows[0]->date_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->datetime_value ); + $this->assertSame( '0000-00-00 00:00:00', $rows[0]->timestamp_value ); + } + + /** + * Tests non-strict ON DUPLICATE KEY UPDATE NULL assignments still fail for NOT NULL columns. + */ + public function test_non_strict_upsert_null_assignment_does_not_coerce_not_null_columns(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + + $driver->query( + 'CREATE TABLE wptests_upsert_not_null ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + size INTEGER DEFAULT 123, + color TEXT + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_upsert_not_null ( + id int(11) NOT NULL, + name text NOT NULL, + size int(11) DEFAULT 123, + color text DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_upsert_not_null (id, name, size, color) VALUES (1, 'A', 10, 'red')" ); + + $upsert = "INSERT INTO `wptests_upsert_not_null` (`id`, `name`, `size`, `color`) + VALUES (1, 'B', 20, 'blue') + ON DUPLICATE KEY UPDATE `name` = NULL"; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ); + + $this->assertSame( + 'INSERT INTO "wptests_upsert_not_null" ("id", "name", "size", "color") VALUES (1, \'B\', 20, \'blue\') ON CONFLICT ("id") DO UPDATE SET "name" = NULL', + $translation['sql'] + ); + + try { + $driver->query( $upsert ); + $this->fail( 'Expected NOT NULL upsert assignment to fail.' ); + } catch ( PDOException $e ) { + $this->assertStringContainsString( 'NOT NULL', $e->getMessage() ); + } + + $rows = $driver->query( 'SELECT name, size, color FROM wptests_upsert_not_null WHERE id = 1' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'A', $rows[0]->name ); + $this->assertSame( '10', $rows[0]->size ); + $this->assertSame( 'red', $rows[0]->color ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports current-row assignment expressions. + */ + public function test_upsert_update_assignments_support_current_row_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 2) + ON DUPLICATE KEY UPDATE `int_value` = `int_value` + 1'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 2) ON CONFLICT ("id") DO UPDATE SET "int_value" = "wptests_strict_ints"."int_value" + 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '5', $rows[0]->int_value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports MySQL timestamp runtime functions. + */ + public function test_upsert_update_assignments_support_timestamp_runtime_functions(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_upsert_timestamps ( + id INTEGER PRIMARY KEY, + updated_at TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_upsert_timestamps ( + id bigint(20) unsigned NOT NULL, + updated_at datetime NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_upsert_timestamps (id, updated_at) VALUES (1, '2000-01-01 00:00:00')" ); + + $now_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = NOW()"; + + $now_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $now_upsert + ); + $this->assertNotNull( $now_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = __wp_pg_mysql_validate_temporal(CAST(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\') AS text), \'datetime\', 1, 1)', + $now_translation['sql'] + ); + + $current_timestamp_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP()"; + + $current_timestamp_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $current_timestamp_upsert + ); + $this->assertNotNull( $current_timestamp_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = __wp_pg_mysql_validate_temporal(CAST(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\') AS text), \'datetime\', 1, 1)', + $current_timestamp_translation['sql'] + ); + + $current_timestamp_keyword_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP"; + + $current_timestamp_keyword_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $current_timestamp_keyword_upsert + ); + $this->assertNotNull( $current_timestamp_keyword_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = __wp_pg_mysql_validate_temporal(CAST(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\') AS text), \'datetime\', 1, 1)', + $current_timestamp_keyword_translation['sql'] + ); + + $driver->query( + 'CREATE TABLE wptests_upsert_temporal_keywords ( + id INTEGER PRIMARY KEY, + date_value TEXT NOT NULL, + time_value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_upsert_temporal_keywords ( + id bigint(20) unsigned NOT NULL, + date_value date NOT NULL, + time_value time NOT NULL, + PRIMARY KEY (id) + )' + ); + + $temporal_keyword_upsert = "INSERT INTO `wptests_upsert_temporal_keywords` (`id`, `date_value`, `time_value`) + VALUES (1, '2001-01-01', '01:02:03') + ON DUPLICATE KEY UPDATE `date_value` = CURRENT_DATE, `time_value` = CURRENT_TIME"; + + $temporal_keyword_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $temporal_keyword_upsert + ); + $this->assertNotNull( $temporal_keyword_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_temporal_keywords" ("id", "date_value", "time_value") VALUES (1, \'2001-01-01\', \'01:02:03\') ON CONFLICT ("id") DO UPDATE SET "date_value" = __wp_pg_mysql_validate_temporal(CAST(TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD\') AS text), \'date\', 1, 1), "time_value" = TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'HH24:MI:SS\')', + $temporal_keyword_translation['sql'] + ); + + $fractional_timestamp_upsert = "INSERT INTO `wptests_upsert_timestamps` (`id`, `updated_at`) + VALUES (1, '2001-01-01 00:00:00') + ON DUPLICATE KEY UPDATE `updated_at` = NOW(6)"; + + $fractional_timestamp_translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $fractional_timestamp_upsert + ); + $this->assertNotNull( $fractional_timestamp_translation ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_timestamps" ("id", "updated_at") VALUES (1, \'2001-01-01 00:00:00\') ON CONFLICT ("id") DO UPDATE SET "updated_at" = __wp_pg_mysql_validate_temporal(CAST(LEFT(TO_CHAR(CURRENT_TIMESTAMP(6) AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS.US\'), 26) AS text), \'datetime\', 1, 1)', + $fractional_timestamp_translation['sql'] + ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports runtime functions around VALUES(column). + */ + public function test_upsert_update_assignments_support_runtime_functions_around_values_references(): void { + $driver = $this->create_driver_with_postgresql_text_runtime_functions(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('runtime_values', 'old', 'old')" ); + + $upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('runtime_values', 'incoming', 'new') + ON DUPLICATE KEY UPDATE `option_value` = CONCAT(VALUES(`option_value`), '-', CHAR_LENGTH(VALUES(`option_value`))), + `autoload` = IFNULL(NULL, VALUES(`autoload`))"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringNotContainsString( 'CONCAT', $sql ); + $this->assertStringNotContainsString( 'IFNULL', $sql ); + $this->assertStringContainsString( 'CHAR_LENGTH(CAST(excluded."option_value" AS text))', $sql ); + $this->assertStringContainsString( 'COALESCE(NULL, excluded."autoload")', $sql ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'runtime_values'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'incoming-8', $rows[0]->option_value ); + $this->assertSame( 'new', $rows[0]->autoload ); + + $conditional_upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('runtime_values', '', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = IF(VALUES(`option_value`) = '', `option_value`, VALUES(`option_value`)), + `autoload` = IF(VALUES(`autoload`) = 'ignored', `autoload`, VALUES(`autoload`))"; + + $this->assertSame( 1, $driver->query( $conditional_upsert ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringNotContainsString( 'IF(', $sql ); + $this->assertStringContainsString( 'CASE WHEN (excluded."option_value" = \'\') THEN "wptests_options"."option_value" ELSE excluded."option_value" END', $sql ); + $this->assertStringContainsString( 'CASE WHEN (excluded."autoload" = \'ignored\') THEN "wptests_options"."autoload" ELSE excluded."autoload" END', $sql ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'runtime_values'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'incoming-8', $rows[0]->option_value ); + $this->assertSame( 'new', $rows[0]->autoload ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports VALUES(column) inside simple expressions. + */ + public function test_upsert_update_assignments_support_values_inside_simple_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 3) + ON DUPLICATE KEY UPDATE `int_value` = `int_value` + VALUES(`int_value`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 3) ON CONFLICT ("id") DO UPDATE SET "int_value" = "wptests_strict_ints"."int_value" + excluded."int_value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->int_value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE validates current-row columns inside simple expressions. + */ + public function test_upsert_update_assignments_support_resolved_column_references_inside_simple_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 3) + ON DUPLICATE KEY UPDATE `int_value` = COALESCE(`int_value`, 0) + VALUES(`int_value`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 3) ON CONFLICT ("id") DO UPDATE SET "int_value" = COALESCE("wptests_strict_ints"."int_value", 0) + excluded."int_value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->int_value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports CASE expressions around VALUES(column). + */ + public function test_upsert_update_assignments_support_case_expressions_around_values_references(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 3), (1, 9) + ON DUPLICATE KEY UPDATE `int_value` = CASE + WHEN `int_value` > VALUES(`int_value`) THEN `int_value` + ELSE VALUES(`int_value`) + END'; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 3) ON CONFLICT ("id") DO UPDATE SET "int_value" = CASE WHEN "wptests_strict_ints"."int_value" > excluded."int_value" THEN "wptests_strict_ints"."int_value" ELSE excluded."int_value" END', + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 9) ON CONFLICT ("id") DO UPDATE SET "int_value" = CASE WHEN "wptests_strict_ints"."int_value" > excluded."int_value" THEN "wptests_strict_ints"."int_value" ELSE excluded."int_value" END', + ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '9', $rows[0]->int_value ); + + try { + $driver->query( + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 5) + ON DUPLICATE KEY UPDATE `int_value` = CASE + WHEN `missing_column` > VALUES(`int_value`) THEN `int_value` + ELSE VALUES(`int_value`) + END' + ); + $this->fail( 'Expected unresolved CASE upsert expression column to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE rejects unknown current-row expression columns. + */ + public function test_upsert_update_assignments_reject_unknown_current_row_expression_columns(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + foreach ( + array( + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (1, 3) ON DUPLICATE KEY UPDATE `int_value` = COALESCE(`missing_column`, 0) + VALUES(`int_value`)', + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (1, 3) ON DUPLICATE KEY UPDATE `int_value` = `other`.`int_value` + VALUES(`int_value`)', + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unknown upsert expression column to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests singular VALUE row-list upsert syntax is translated like VALUES. + */ + public function test_value_keyword_upsert_is_translated_to_postgresql_on_conflict(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUE (1, 3) + ON DUPLICATE KEY UPDATE `int_value` = VALUES(`int_value`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 3) ON CONFLICT ("id") DO UPDATE SET "int_value" = excluded."int_value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '3', $rows[0]->int_value ); + } + + /** + * Tests empty/default-row ON DUPLICATE KEY UPDATE forms fail closed. + */ + public function test_empty_default_row_upsert_forms_fail_closed(): void { + $driver = $this->create_driver(); + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + foreach ( + array( + 'INSERT INTO `wptests_identity_upsert` VALUES () ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)', + 'INSERT INTO `wptests_identity_upsert` () VALUES () ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)', + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected empty/default-row upsert to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports VALUES() for omitted table columns. + */ + public function test_upsert_update_assignments_support_values_for_omitted_columns(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_omitted_values_upsert ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, + note TEXT, + counter INTEGER NOT NULL, + bonus INTEGER NOT NULL DEFAULT 0 + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_omitted_values_upsert ( + id bigint(20) unsigned NOT NULL, + label varchar(191) NOT NULL, + note longtext DEFAULT NULL, + counter int(11) NOT NULL, + bonus int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_omitted_values_upsert (id, label, note, counter, bonus) VALUES (1, 'old', 'old-note', 5, 7)" ); + + $upsert = "INSERT INTO `wptests_omitted_values_upsert` (`id`, `label`, `counter`) + VALUES (1, 'new', 1) + ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), + `note` = VALUES(`note`), + `counter` = `counter` + VALUES(`bonus`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_omitted_values_upsert" ("id", "label", "counter") VALUES (1, \'new\', 1) ON CONFLICT ("id") DO UPDATE SET "label" = excluded."label", "note" = excluded."note", "counter" = "wptests_omitted_values_upsert"."counter" + excluded."bonus"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT label, note, counter, bonus FROM wptests_omitted_values_upsert WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'new', $rows[0]->label ); + $this->assertNull( $rows[0]->note ); + $this->assertSame( '5', $rows[0]->counter ); + $this->assertSame( '7', $rows[0]->bonus ); + } + + /** + * Tests omitted unique-key columns can use table defaults as upsert arbiters. + */ + public function test_upsert_uses_defaulted_omitted_unique_key_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->get_connection()->query( + "CREATE TABLE wptests_default_unique_upsert ( + slug TEXT NOT NULL DEFAULT 'shared', + value TEXT NOT NULL, + UNIQUE (slug) + )" + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_default_unique_upsert ( + slug varchar(191) NOT NULL DEFAULT 'shared', + value longtext NOT NULL, + UNIQUE KEY slug (slug) + )" + ); + $driver->get_connection()->query( "INSERT INTO wptests_default_unique_upsert (slug, value) VALUES ('shared', 'old')" ); + + $update = "INSERT INTO `wptests_default_unique_upsert` (`value`) + VALUES ('updated') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + 'INSERT INTO "wptests_default_unique_upsert" ("value") VALUES (\'updated\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT slug, value FROM wptests_default_unique_upsert' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'shared', $rows[0]->slug ); + $this->assertSame( 'updated', $rows[0]->value ); + + $driver->get_connection()->query( 'DELETE FROM wptests_default_unique_upsert' ); + + $insert = "INSERT INTO `wptests_default_unique_upsert` (`value`) + VALUES ('inserted') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_default_unique_upsert" ("value") VALUES (\'inserted\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT slug, value FROM wptests_default_unique_upsert' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'shared', $rows[0]->slug ); + $this->assertSame( 'inserted', $rows[0]->value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports MySQL VALUES-row alias expressions. + */ + public function test_upsert_update_assignments_support_values_row_alias_expressions(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 3) AS incoming + ON DUPLICATE KEY UPDATE `int_value` = `int_value` + incoming.`int_value`'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 3) ON CONFLICT ("id") DO UPDATE SET "int_value" = "wptests_strict_ints"."int_value" + excluded."int_value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->int_value ); + + $column_alias_upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 11) AS incoming(row_id, new_value) + ON DUPLICATE KEY UPDATE `int_value` = new_value'; + + $this->assertSame( 1, $driver->query( $column_alias_upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 11) ON CONFLICT ("id") DO UPDATE SET "int_value" = excluded."int_value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '11', $rows[0]->int_value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE preserves no-op self-assignment row counts. + */ + public function test_upsert_update_assignments_support_no_op_self_assignments(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + $driver->query( 'INSERT INTO wptests_strict_ints (id, int_value) VALUES (1, 4)' ); + + $upsert = 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) + VALUES (1, 99) + ON DUPLICATE KEY UPDATE `int_value` = `int_value`'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_strict_ints" ("id", "int_value") VALUES (1, 99) ON CONFLICT ("id") DO UPDATE SET "int_value" = "wptests_strict_ints"."int_value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT int_value FROM wptests_strict_ints WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '4', $rows[0]->int_value ); + } + + /** + * Tests malformed VALUES-row aliases fail closed before reaching PostgreSQL. + */ + public function test_upsert_update_assignments_reject_malformed_values_row_aliases(): void { + $driver = $this->create_driver(); + $this->install_strict_integer_values_table_with_mysql_metadata( $driver ); + + foreach ( + array( + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (1, 2) AS incoming(row_id) ON DUPLICATE KEY UPDATE `int_value` = row_id', + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (1, 2) AS incoming ON DUPLICATE KEY UPDATE `int_value` = incoming.missing', + 'INSERT INTO `wptests_strict_ints` (`id`, `int_value`) VALUES (1, 2) AS incoming ON DUPLICATE KEY UPDATE `int_value` = incoming', + 'INSERT INTO `wptests_strict_ints` SET `id` = 1, `int_value` = 2 AS incoming(row_id) ON DUPLICATE KEY UPDATE `int_value` = row_id', + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected malformed VALUES-row alias upsert to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests metadata-backed multi-row ON DUPLICATE KEY UPDATE statements. + */ + public function test_multi_row_on_duplicate_key_update_uses_metadata_conflict_target(): void { + $driver = $this->create_driver(); + + $this->install_term_relationships_table_with_mysql_metadata( $driver, 'custom_term_relationships' ); + + $insert = 'INSERT INTO `custom_term_relationships` (`object_id`, `term_taxonomy_id`, `term_order`) + VALUES (227, 709, 1), (227, 710, 2) + ON DUPLICATE KEY UPDATE `term_order` = VALUES(`term_order`)'; + + $this->assertSame( 2, $driver->query( $insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "custom_term_relationships" ("object_id", "term_taxonomy_id", "term_order") VALUES (227, 709, 1), (227, 710, 2) ON CONFLICT ("object_id", "term_taxonomy_id") DO UPDATE SET "term_order" = excluded."term_order"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $upsert = 'INSERT INTO `custom_term_relationships` (`object_id`, `term_taxonomy_id`, `term_order`) + VALUES (227, 709, 7), (227, 711, 3) + ON DUPLICATE KEY UPDATE `term_order` = VALUES(`term_order`)'; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "custom_term_relationships" ("object_id", "term_taxonomy_id", "term_order") VALUES (227, 709, 7), (227, 711, 3) ON CONFLICT ("object_id", "term_taxonomy_id") DO UPDATE SET "term_order" = excluded."term_order"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( + 'SELECT object_id, term_taxonomy_id, term_order + FROM custom_term_relationships + ORDER BY term_taxonomy_id' + ); + + $this->assertCount( 3, $rows ); + $this->assertSame( '227', $rows[0]->object_id ); + $this->assertSame( '709', $rows[0]->term_taxonomy_id ); + $this->assertSame( '7', $rows[0]->term_order ); + $this->assertSame( '710', $rows[1]->term_taxonomy_id ); + $this->assertSame( '2', $rows[1]->term_order ); + $this->assertSame( '711', $rows[2]->term_taxonomy_id ); + $this->assertSame( '3', $rows[2]->term_order ); + } + + /** + * Tests duplicate conflict keys in one ON DUPLICATE KEY UPDATE batch run sequentially. + */ + public function test_multi_row_on_duplicate_key_update_with_duplicate_conflict_values_runs_sequentially(): void { + $driver = $this->create_driver(); + + $this->install_term_relationships_table_with_mysql_metadata( $driver, 'custom_term_relationships' ); + + $insert = 'INSERT INTO `custom_term_relationships` (`object_id`, `term_taxonomy_id`, `term_order`) + VALUES (227, 709, 1), (227, 709, 2) + ON DUPLICATE KEY UPDATE `term_order` = VALUES(`term_order`)'; + + $this->assertSame( 2, $driver->query( $insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "custom_term_relationships" ("object_id", "term_taxonomy_id", "term_order") VALUES (227, 709, 1) ON CONFLICT ("object_id", "term_taxonomy_id") DO UPDATE SET "term_order" = excluded."term_order"', + 'params' => array(), + ), + array( + 'sql' => 'INSERT INTO "custom_term_relationships" ("object_id", "term_taxonomy_id", "term_order") VALUES (227, 709, 2) ON CONFLICT ("object_id", "term_taxonomy_id") DO UPDATE SET "term_order" = excluded."term_order"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( + 'SELECT object_id, term_taxonomy_id, term_order + FROM custom_term_relationships' + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '227', $rows[0]->object_id ); + $this->assertSame( '709', $rows[0]->term_taxonomy_id ); + $this->assertSame( '2', $rows[0]->term_order ); + } + + /** + * Tests INSERT ... SELECT ON DUPLICATE KEY UPDATE uses MySQL unique-key metadata. + */ + public function test_insert_select_on_duplicate_key_update_uses_metadata_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_plugin_lookup ( + source TEXT NOT NULL, + external_id TEXT NOT NULL, + attempts INTEGER NOT NULL, + payload TEXT NOT NULL, + UNIQUE (source, external_id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_plugin_lookup ( + source varchar(64) NOT NULL, + external_id varchar(64) NOT NULL, + attempts int(11) NOT NULL DEFAULT 0, + payload longtext NOT NULL, + UNIQUE KEY source_external_id (source, external_id) + )' + ); + + $insert = "INSERT INTO `wptests_plugin_lookup` (`source`, `external_id`, `attempts`, `payload`) + SELECT 'feed', 'abc', 1, 'first' FROM DUAL + ON DUPLICATE KEY UPDATE `attempts` = `attempts` + VALUES(`attempts`), + `payload` = VALUES(`payload`)"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_plugin_lookup" ("source", "external_id", "attempts", "payload") SELECT \'feed\', \'abc\', 1, \'first\' ON CONFLICT ("source", "external_id") DO UPDATE SET "attempts" = "wptests_plugin_lookup"."attempts" + excluded."attempts", "payload" = excluded."payload"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $update = "INSERT INTO `wptests_plugin_lookup` (`source`, `external_id`, `attempts`, `payload`) + SELECT 'feed', 'abc', 3, 'second' FROM DUAL + ON DUPLICATE KEY UPDATE `attempts` = `attempts` + VALUES(`attempts`), + `payload` = VALUES(`payload`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + + $rows = $driver->query( "SELECT attempts, payload FROM wptests_plugin_lookup WHERE source = 'feed' AND external_id = 'abc'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '4', $rows[0]->attempts ); + $this->assertSame( 'second', $rows[0]->payload ); + } + + /** + * Tests duplicate SELECT source keys replay with MySQL row-by-row upsert semantics. + */ + public function test_insert_select_on_duplicate_key_update_with_duplicate_source_conflict_keys_replays_rows_sequentially(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_plugin_lookup_duplicate ( + source TEXT NOT NULL, + external_id TEXT NOT NULL, + attempts INTEGER NOT NULL, + payload TEXT NOT NULL, + UNIQUE (source, external_id) + )' + ); + $driver->query( + 'CREATE TABLE wptests_plugin_lookup_duplicate_source ( + seq INTEGER NOT NULL, + source TEXT NOT NULL, + external_id TEXT NOT NULL, + attempts INTEGER NOT NULL, + payload TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_plugin_lookup_duplicate ( + source varchar(64) NOT NULL, + external_id varchar(64) NOT NULL, + attempts int(11) NOT NULL DEFAULT 0, + payload longtext NOT NULL, + UNIQUE KEY source_external_id (source, external_id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_plugin_lookup_duplicate_source ( + seq int(11) NOT NULL, + source varchar(64) NOT NULL, + external_id varchar(64) NOT NULL, + attempts int(11) NOT NULL, + payload longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_plugin_lookup_duplicate (source, external_id, attempts, payload) VALUES ('feed', 'abc', 10, 'old')" ); + $driver->query( "INSERT INTO wptests_plugin_lookup_duplicate_source (seq, source, external_id, attempts, payload) VALUES (1, 'feed', 'abc', 1, 'first'), (2, 'feed', 'abc', 2, 'second')" ); + + $upsert = 'INSERT INTO `wptests_plugin_lookup_duplicate` (`source`, `external_id`, `attempts`, `payload`) + SELECT `source`, `external_id`, `attempts`, `payload` FROM `wptests_plugin_lookup_duplicate_source` ORDER BY `seq` + ON DUPLICATE KEY UPDATE `attempts` = `attempts` + VALUES(`attempts`), + `payload` = VALUES(`payload`)'; + + $this->assertSame( 2, $driver->query( $upsert ) ); + + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertCount( 7, $sql ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_upsert_select_[a-f0-9]{12}"$/', $sql[0] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_upsert_select_[a-f0-9]{12}" AS SELECT /', $sql[1] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_upsert_select_ord_[a-f0-9]{12}" AS SELECT ROW_NUMBER\(\) OVER \(\) AS "__wp_pg_upsert_ordinal"/', $sql[2] ); + $this->assertStringContainsString( '"__wp_pg_upsert_rows"."__wp_pg_upsert_ordinal" = 1', $sql[3] ); + $this->assertStringContainsString( '"__wp_pg_upsert_rows"."__wp_pg_upsert_ordinal" = 2', $sql[4] ); + $this->assertStringContainsString( 'ON CONFLICT ("source", "external_id") DO UPDATE SET "attempts" = "wptests_plugin_lookup_duplicate"."attempts" + excluded."attempts", "payload" = excluded."payload"', $sql[3] ); + $this->assertStringContainsString( 'ON CONFLICT ("source", "external_id") DO UPDATE SET "attempts" = "wptests_plugin_lookup_duplicate"."attempts" + excluded."attempts", "payload" = excluded."payload"', $sql[4] ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_upsert_select_ord_[a-f0-9]{12}"$/', $sql[5] ); + $this->assertSame( $sql[0], $sql[6] ); + + $rows = $driver->query( "SELECT attempts, payload FROM wptests_plugin_lookup_duplicate WHERE source = 'feed' AND external_id = 'abc'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '13', $rows[0]->attempts ); + $this->assertSame( 'second', $rows[0]->payload ); + } + + /** + * Tests columnless INSERT ... SELECT upserts infer target columns from MySQL metadata. + */ + public function test_columnless_insert_select_on_duplicate_key_update_uses_metadata_columns(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_columnless_plugin_lookup ( + source TEXT NOT NULL, + external_id TEXT NOT NULL, + attempts INTEGER NOT NULL, + payload TEXT NOT NULL, + UNIQUE (source, external_id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_columnless_plugin_lookup ( + source varchar(64) NOT NULL, + external_id varchar(64) NOT NULL, + attempts int(11) NOT NULL DEFAULT 0, + payload longtext NOT NULL, + UNIQUE KEY source_external_id (source, external_id) + )' + ); + + $insert = "INSERT INTO `wptests_columnless_plugin_lookup` + SELECT 'feed', 'abc', 1, 'first' FROM DUAL + ON DUPLICATE KEY UPDATE `attempts` = `attempts` + VALUES(`attempts`), + `payload` = VALUES(`payload`)"; + + $this->assertSame( 1, $driver->query( $insert ) ); + $this->assertSame( + 'INSERT INTO "wptests_columnless_plugin_lookup" ("source", "external_id", "attempts", "payload") SELECT \'feed\', \'abc\', 1, \'first\' ON CONFLICT ("source", "external_id") DO UPDATE SET "attempts" = "wptests_columnless_plugin_lookup"."attempts" + excluded."attempts", "payload" = excluded."payload"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $update = "INSERT INTO `wptests_columnless_plugin_lookup` + (SELECT 'feed', 'abc', 3, 'second' FROM DUAL) + ON DUPLICATE KEY UPDATE `attempts` = `attempts` + VALUES(`attempts`), + `payload` = VALUES(`payload`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + 'INSERT INTO "wptests_columnless_plugin_lookup" ("source", "external_id", "attempts", "payload") SELECT \'feed\', \'abc\', 3, \'second\' ON CONFLICT ("source", "external_id") DO UPDATE SET "attempts" = "wptests_columnless_plugin_lookup"."attempts" + excluded."attempts", "payload" = excluded."payload"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( "SELECT attempts, payload FROM wptests_columnless_plugin_lookup WHERE source = 'feed' AND external_id = 'abc'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '4', $rows[0]->attempts ); + $this->assertSame( 'second', $rows[0]->payload ); + } + + /** + * Tests SELECT-sourced upserts with literal AUTO_INCREMENT targets preserve identity semantics. + */ + public function test_insert_select_on_duplicate_key_update_with_auto_increment_target_uses_metadata_conflict_target(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) + SELECT 7, 'selected' FROM DUAL + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT 7, \'selected\' ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $queries[0]['sql'] + ); + $this->assert_sequence_repair_query( $queries[1], 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_identity_upsert' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->id ); + $this->assertSame( 'selected', $rows[0]->value ); + + $update = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) + SELECT 7, 'updated' FROM DUAL + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT 7, \'updated\' ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_identity_upsert' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->id ); + $this->assertSame( 'updated', $rows[0]->value ); + } + + /** + * Tests SELECT-sourced upserts allow constant non-key projections for AUTO_INCREMENT targets. + */ + public function test_insert_select_on_duplicate_key_update_with_auto_increment_target_allows_constant_value_expression(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + $upsert = 'INSERT INTO `wptests_identity_upsert` (`id`, `value`) + SELECT 7, 1 + 2 FROM DUAL + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT 7, CAST(1 + 2 AS text) ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $queries[0]['sql'] + ); + $this->assert_sequence_repair_query( $queries[1], 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + + $update = 'INSERT INTO `wptests_identity_upsert` (`id`, `value`) + SELECT 7, 5 + 6 FROM DUAL + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT 7, CAST(5 + 6 AS text) ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_identity_upsert' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->id ); + $this->assertSame( '11', $rows[0]->value ); + } + + /** + * Tests SELECT-sourced upserts support constant AUTO_INCREMENT expressions. + */ + public function test_insert_select_on_duplicate_key_update_with_auto_increment_expression_target(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) + SELECT 3 + 4, 'selected' FROM DUAL + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ); + + $this->assertIsArray( $translation ); + $id_expression_sql = $this->get_expected_mysql_integer_cast_sql( '3 + 4' ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT ' . $id_expression_sql . ' , \'selected\' ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $translation['sql'] + ); + $this->assertSame( array( array( '7', "'selected'" ) ), $translation['value_rows'] ); + $this->assertSame( array( array( '7', "'selected'" ) ), $translation['insert_id_value_rows'] ); + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT ' . $id_expression_sql . ' , \'selected\' ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $queries[0]['sql'] + ); + $this->assert_sequence_repair_query( $queries[1], 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + + $update = "INSERT INTO `wptests_identity_upsert` (`id`, `value`) + SELECT (3 * 2) + 1, 'updated' FROM DUAL + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + $updated_id_expression_sql = $this->get_expected_mysql_integer_cast_sql( '(3 * 2) + 1' ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT ' . $updated_id_expression_sql . ' , \'updated\' ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_identity_upsert' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->id ); + $this->assertSame( 'updated', $rows[0]->value ); + } + + /** + * Tests real SELECT-sourced upserts may omit AUTO_INCREMENT for non-AUTO_INCREMENT keys. + */ + public function test_insert_select_on_duplicate_key_update_omitting_auto_increment_target_uses_non_auto_unique_key(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_unique_upsert', 'id', 'wptests_identity_unique_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_identity_unique_upsert_table_with_mysql_metadata( $driver ); + + $driver->query( + 'CREATE TABLE wptests_identity_unique_upsert_source ( + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_identity_unique_upsert_source ( + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_identity_unique_upsert (slug, value) VALUES ('existing', 'old')" ); + $driver->query( "INSERT INTO wptests_identity_unique_upsert_source (slug, value) VALUES ('existing', 'updated'), ('new', 'created')" ); + + $upsert = 'INSERT INTO `wptests_identity_unique_upsert` (`slug`, `value`) + SELECT `slug`, `value` FROM `wptests_identity_unique_upsert_source` WHERE 1 = 1 + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ); + + $this->assertIsArray( $translation ); + $this->assertSame( + 'INSERT INTO "wptests_identity_unique_upsert" ("slug", "value") SELECT "slug", "value" FROM "wptests_identity_unique_upsert_source" WHERE 1 = 1 ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $translation['sql'] + ); + $this->assertNull( $translation['value_rows'] ); + $this->assertNull( $translation['insert_id_value_rows'] ); + + $this->assertSame( 2, $driver->query( $upsert ) ); + $materialized_sql = $this->assert_last_upsert_select_materialized_sql( $driver, 'wptests_identity_unique_upsert' ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', $materialized_sql[2] ); + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_identity_unique_upsert ORDER BY slug' ); + + $this->assertCount( 2, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'existing', $rows[0]->slug ); + $this->assertSame( 'updated', $rows[0]->value ); + $this->assertSame( '2', $rows[1]->id ); + $this->assertSame( 'new', $rows[1]->slug ); + $this->assertSame( 'created', $rows[1]->value ); + } + + /** + * Tests columnless SELECT-sourced upserts keep AUTO_INCREMENT metadata behavior. + */ + public function test_columnless_insert_select_on_duplicate_key_update_with_auto_increment_target_uses_metadata_columns(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `wptests_identity_upsert` + SELECT 7, 'selected' FROM DUAL + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT 7, \'selected\' ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $queries[0]['sql'] + ); + $this->assert_sequence_repair_query( $queries[1], 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_identity_upsert' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->id ); + $this->assertSame( 'selected', $rows[0]->value ); + + $update = "INSERT INTO `wptests_identity_upsert` + SELECT 7, 'updated' FROM DUAL + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT 7, \'updated\' ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_identity_upsert' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '7', $rows[0]->id ); + $this->assertSame( 'updated', $rows[0]->value ); + } + + /** + * Tests columnless real SELECT-sourced upserts repair AUTO_INCREMENT targets. + */ + public function test_columnless_insert_select_on_duplicate_key_update_with_auto_increment_target_repairs_real_source_table(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_identity_upsert_table_with_mysql_metadata( $driver ); + + $driver->query( + 'CREATE TABLE wptests_identity_upsert_source ( + id INTEGER NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_identity_upsert_source ( + id bigint(20) unsigned NOT NULL, + value longtext NOT NULL + )' + ); + $driver->get_connection()->query( "INSERT INTO wptests_identity_upsert (id, value) VALUES (7, 'old')" ); + $driver->query( "INSERT INTO wptests_identity_upsert_source (id, value) VALUES (7, 'updated'), (8, 'created')" ); + + $upsert = 'INSERT INTO `wptests_identity_upsert` + SELECT `id`, `value` FROM `wptests_identity_upsert_source` WHERE 1 = 1 + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ); + + $this->assertIsArray( $translation ); + $this->assertSame( + 'INSERT INTO "wptests_identity_upsert" ("id", "value") SELECT "id", "value" FROM "wptests_identity_upsert_source" WHERE 1 = 1 ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + $translation['sql'] + ); + $this->assertNull( $translation['value_rows'] ); + $this->assertNull( $translation['insert_id_value_rows'] ); + $this->assertTrue( $translation['insert_id_unknown'] ); + $this->assertSame( array( 'id' => true ), $translation['explicit_identity_columns'] ); + + $this->assertSame( 2, $driver->query( $upsert ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 5, $queries ); + $materialized_sql = $this->assert_last_upsert_select_materialized_sql( $driver, 'wptests_identity_upsert' ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', $materialized_sql[2] ); + $this->assert_sequence_repair_query( $queries[4], 'wptests_identity_upsert', 'id', 'wptests_identity_upsert_id_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_identity_upsert ORDER BY id' ); + + $this->assertEquals( + array( + (object) array( + 'id' => '7', + 'value' => 'updated', + ), + (object) array( + 'id' => '8', + 'value' => 'created', + ), + ), + $rows + ); + } + + /** + * Tests ambiguous duplicate-key arbiters use the key that actually conflicts. + */ + public function test_ambiguous_on_duplicate_key_update_uses_conflicting_unique_key(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'existing', 'old')" ); + + $upsert = "INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) VALUES (2, 'existing', 'new') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (2, \'existing\', \'new\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'existing', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests multi-row upserts replay rows that conflict on different unique keys. + */ + public function test_multi_row_on_duplicate_key_update_replays_distinct_ambiguous_conflict_targets(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'one', 'old-id'), (2, 'two', 'old-slug')" ); + + $upsert = "INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + VALUES (1, 'fresh', 'updated-id'), (3, 'two', 'updated-slug') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (1, \'fresh\', \'updated-id\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (3, \'two\', \'updated-slug\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'one', + 'value' => 'updated-id', + ), + (object) array( + 'id' => '2', + 'slug' => 'two', + 'value' => 'updated-slug', + ), + ), + $rows + ); + } + + /** + * Tests multi-row upserts replay incoming duplicates on a secondary unique key. + */ + public function test_multi_row_on_duplicate_key_update_replays_incoming_secondary_unique_conflicts(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + VALUES (1, 'shared', 'first'), (2, 'shared', 'second') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $this->assert_last_postgresql_sql_statements( + $driver, + array( + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (1, \'shared\', \'first\') ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (2, \'shared\', \'second\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'shared', + 'value' => 'second', + ), + ), + $rows + ); + } + + /** + * Tests SELECT-sourced upserts resolve ambiguous targets from literal source rows. + */ + public function test_insert_select_on_duplicate_key_update_uses_conflicting_unique_key_for_literal_select(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'existing', 'old')" ); + + $upsert = "INSERT INTO `ambiguous_upsert` + SELECT 2, 'existing', 'new' FROM DUAL + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") SELECT 2, \'existing\', \'new\' ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'existing', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests real SELECT-sourced upserts replay rows that conflict on different unique keys. + */ + public function test_insert_select_on_duplicate_key_update_replays_distinct_ambiguous_conflict_targets(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE ambiguous_upsert_source ( + seq INTEGER NOT NULL, + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE ambiguous_upsert_source ( + seq int(11) NOT NULL, + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'one', 'old-id'), (2, 'two', 'old-slug')" ); + $driver->query( "INSERT INTO ambiguous_upsert_source (seq, id, slug, value) VALUES (1, 1, 'fresh', 'updated-id'), (2, 3, 'two', 'updated-slug')" ); + + $upsert = 'INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + SELECT `id`, `slug`, `value` FROM `ambiguous_upsert_source` ORDER BY `seq` + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ); + $this->assertIsArray( $translation ); + $this->assertTrue( $translation['upsert_select_ambiguous_conflict_targets'] ); + $this->assertSame( array( 'id', 'slug' ), $translation['conflict_columns'] ); + + $this->assertSame( 2, $driver->query( $upsert ) ); + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertCount( 7, $sql ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_upsert_select_[a-f0-9]{12}"$/', $sql[0] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_upsert_select_[a-f0-9]{12}" AS SELECT /', $sql[1] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_upsert_select_ord_[a-f0-9]{12}" AS SELECT ROW_NUMBER\(\) OVER \(\) AS "__wp_pg_upsert_ordinal"/', $sql[2] ); + $this->assertStringContainsString( '"__wp_pg_upsert_rows"."__wp_pg_upsert_ordinal" = 1', $sql[3] ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', $sql[3] ); + $this->assertStringContainsString( '"__wp_pg_upsert_rows"."__wp_pg_upsert_ordinal" = 2', $sql[4] ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', $sql[4] ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_upsert_select_ord_[a-f0-9]{12}"$/', $sql[5] ); + $this->assertSame( $sql[0], $sql[6] ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'one', + 'value' => 'updated-id', + ), + (object) array( + 'id' => '2', + 'slug' => 'two', + 'value' => 'updated-slug', + ), + ), + $rows + ); + } + + /** + * Tests columnless real SELECT-sourced upserts infer metadata columns for ambiguous arbiters. + */ + public function test_columnless_insert_select_on_duplicate_key_update_replays_ambiguous_conflict_targets(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE ambiguous_upsert_source ( + seq INTEGER NOT NULL, + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE ambiguous_upsert_source ( + seq int(11) NOT NULL, + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'one', 'old-id'), (2, 'two', 'old-slug')" ); + $driver->query( "INSERT INTO ambiguous_upsert_source (seq, id, slug, value) VALUES (1, 1, 'fresh', 'updated-id'), (2, 3, 'two', 'updated-slug')" ); + + $upsert = 'INSERT INTO ambiguous_upsert + SELECT id, slug, value FROM ambiguous_upsert_source ORDER BY seq + ON DUPLICATE KEY UPDATE value = VALUES(value)'; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ); + $this->assertIsArray( $translation ); + $this->assertTrue( $translation['upsert_select_ambiguous_conflict_targets'] ); + $this->assertStringContainsString( + 'INSERT INTO ambiguous_upsert ("id", "slug", "value") SELECT id, slug, value', + $translation['sql'] + ); + + $this->assertSame( 2, $driver->query( $upsert ) ); + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', $sql[3] ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', $sql[4] ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertSame( array( 'updated-id', 'updated-slug' ), array_column( $rows, 'value' ) ); + } + + /** + * Tests real SELECT-sourced ambiguous upserts repair explicit AUTO_INCREMENT targets. + */ + public function test_insert_select_on_duplicate_key_update_replays_ambiguous_auto_increment_conflict_targets(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_identity_unique_upsert', 'id', 'wptests_identity_unique_upsert_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_identity_unique_upsert_table_with_mysql_metadata( $driver ); + + $driver->query( + 'CREATE TABLE wptests_identity_unique_upsert_source ( + seq INTEGER NOT NULL, + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_identity_unique_upsert_source ( + seq int(11) NOT NULL, + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_identity_unique_upsert (id, slug, value) VALUES (7, 'one', 'old-id'), (8, 'two', 'old-slug')" ); + $driver->query( "INSERT INTO wptests_identity_unique_upsert_source (seq, id, slug, value) VALUES (1, 7, 'fresh', 'updated-id'), (2, 9, 'two', 'updated-slug'), (3, 10, 'ten', 'created')" ); + + $upsert = 'INSERT INTO `wptests_identity_unique_upsert` (`id`, `slug`, `value`) + SELECT `id`, `slug`, `value` FROM `wptests_identity_unique_upsert_source` ORDER BY `seq` + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ); + $this->assertIsArray( $translation ); + $this->assertTrue( $translation['upsert_select_ambiguous_conflict_targets'] ); + $this->assertSame( array( 'id', 'slug' ), $translation['conflict_columns'] ); + $this->assertTrue( $translation['insert_id_unknown'] ); + $this->assertSame( array( 'id' => true ), $translation['explicit_identity_columns'] ); + + $sequence_sync_count = $connection->get_sequence_sync_query_count(); + $this->assertSame( 3, $driver->query( $upsert ) ); + $this->assertSame( 7, $driver->get_insert_id() ); + + $queries = $driver->get_last_postgresql_queries(); + $sql = array_column( $queries, 'sql' ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', $sql[3] ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', $sql[4] ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', $sql[5] ); + $this->assert_sequence_repair_query( $queries[8], 'wptests_identity_unique_upsert', 'id', 'wptests_identity_unique_upsert_id_seq' ); + $this->assertSame( $sequence_sync_count + 1, $connection->get_sequence_sync_query_count() ); + + $rows = $driver->query( 'SELECT id, slug, value FROM wptests_identity_unique_upsert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '7', + 'slug' => 'one', + 'value' => 'updated-id', + ), + (object) array( + 'id' => '8', + 'slug' => 'two', + 'value' => 'updated-slug', + ), + (object) array( + 'id' => '10', + 'slug' => 'ten', + 'value' => 'created', + ), + ), + $rows + ); + } + + /** + * Tests real SELECT-sourced ambiguous upserts support scalar subquery assignments. + */ + public function test_insert_select_on_duplicate_key_update_replays_ambiguous_conflict_targets_with_subquery_assignment(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE ambiguous_upsert_source ( + seq INTEGER NOT NULL, + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE ambiguous_upsert_source ( + seq int(11) NOT NULL, + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'one', 'old-id'), (2, 'two', 'old-slug')" ); + $driver->query( "INSERT INTO ambiguous_upsert_source (seq, id, slug, value) VALUES (1, 1, 'fresh', 'incoming-id'), (2, 3, 'two', 'incoming-slug')" ); + + $upsert = "INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + SELECT `id`, `slug`, `value` FROM `ambiguous_upsert_source` ORDER BY `seq` + ON DUPLICATE KEY UPDATE `value` = (SELECT 'subquery' FROM DUAL)"; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "value" = CAST((SELECT \'subquery\') AS text)', $sql[3] ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE SET "value" = CAST((SELECT \'subquery\') AS text)', $sql[4] ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertSame( array( 'subquery', 'subquery' ), array_column( $rows, 'value' ) ); + } + + /** + * Tests real SELECT-sourced upserts replay incoming conflicts on secondary unique keys. + */ + public function test_insert_select_on_duplicate_key_update_replays_incoming_secondary_unique_conflicts(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE ambiguous_upsert_source ( + seq INTEGER NOT NULL, + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE ambiguous_upsert_source ( + seq int(11) NOT NULL, + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO ambiguous_upsert_source (seq, id, slug, value) VALUES (1, 1, 'shared', 'first'), (2, 2, 'shared', 'second')" ); + + $upsert = 'INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + SELECT `id`, `slug`, `value` FROM `ambiguous_upsert_source` ORDER BY `seq` + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + $this->assertSame( 2, $driver->query( $upsert ) ); + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertStringContainsString( 'ON CONFLICT ("id") DO UPDATE SET "value" = excluded."value"', $sql[3] ); + $this->assertStringContainsString( 'ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', $sql[4] ); + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'shared', + 'value' => 'second', + ), + ), + $rows + ); + } + + /** + * Tests real SELECT-sourced upserts use prefix unique expression conflict targets. + */ + public function test_insert_select_prefix_unique_on_duplicate_key_update_replays_expression_conflict_target(): void { + $driver = $this->create_driver(); + + $this->install_prefix_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE prefix_ambiguous_source ( + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE prefix_ambiguous_source ( + id bigint(20) unsigned NOT NULL, + slug varchar(255) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO prefix_ambiguous (id, slug, value) VALUES (1, 'existing-slug-one', 'old')" ); + $driver->query( "INSERT INTO prefix_ambiguous_source (id, slug, value) VALUES (2, 'existing-slug-two', 'new')" ); + + $upsert = 'INSERT INTO `prefix_ambiguous` (`id`, `slug`, `value`) + SELECT `id`, `slug`, `value` FROM `prefix_ambiguous_source` + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertStringContainsString( + 'ON CONFLICT (SUBSTR(CAST("slug" AS text), 1, 10)) DO UPDATE SET "value" = excluded."value"', + $sql[3] + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM prefix_ambiguous' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'existing-slug-one', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests real SELECT-sourced rows that match multiple unique keys fail closed. + */ + public function test_insert_select_on_duplicate_key_update_rejects_rows_matching_multiple_unique_keys(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE ambiguous_upsert_source ( + id INTEGER NOT NULL, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE ambiguous_upsert_source ( + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'one', 'old-id'), (2, 'two', 'old-slug')" ); + $driver->query( "INSERT INTO ambiguous_upsert_source (id, slug, value) VALUES (1, 'two', 'unsupported')" ); + + $upsert = 'INSERT INTO `ambiguous_upsert` (`id`, `slug`, `value`) + SELECT `id`, `slug`, `value` FROM `ambiguous_upsert_source` + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)'; + + try { + $driver->query( $upsert ); + $this->fail( 'Expected multi-conflict SELECT-sourced upsert row to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported INSERT SELECT ON DUPLICATE KEY UPDATE statement.', $e->getMessage() ); + } + + $rows = $driver->query( 'SELECT id, slug, value FROM ambiguous_upsert ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'slug' => 'one', + 'value' => 'old-id', + ), + (object) array( + 'id' => '2', + 'slug' => 'two', + 'value' => 'old-slug', + ), + ), + $rows + ); + } + + /** + * Tests prefix unique duplicate-key arbiters use PostgreSQL expression conflicts. + */ + public function test_prefix_unique_on_duplicate_key_update_uses_expression_conflict_target(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE prefix_unique_upsert ( + slug varchar(255) NOT NULL, + value text NOT NULL, + UNIQUE KEY slug_prefix (slug(10)) + ) DEFAULT CHARACTER SET utf8mb4' + ); + $driver->query( "INSERT INTO prefix_unique_upsert (`slug`, `value`) VALUES ('existing-slug-one', 'old')" ); + + $upsert = "INSERT INTO `prefix_unique_upsert` (`slug`, `value`) VALUES ('existing-slug-two', 'new') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + + $this->assertSame( + 'INSERT INTO "prefix_unique_upsert" ("slug", "value") VALUES (\'existing-slug-two\', \'new\') ON CONFLICT (SUBSTR(CAST("slug" AS text), 1, 10)) DO UPDATE SET "value" = excluded."value"', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + $rows = $driver->query( 'SELECT slug, value FROM prefix_unique_upsert' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'existing-slug-one', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests missing column-list upserts infer columns before resolving ambiguous targets. + */ + public function test_missing_column_list_upsert_with_ambiguous_conflict_targets_uses_conflicting_key(): void { + $driver = $this->create_driver(); + + $this->install_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO ambiguous_upsert (id, slug, value) VALUES (1, 'existing', 'old')" ); + $ambiguous_upsert = "INSERT INTO `ambiguous_upsert` VALUES (2, 'existing', 'new') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $ambiguous_upsert ) ); + $this->assertSame( + 'INSERT INTO "ambiguous_upsert" ("id", "slug", "value") VALUES (2, \'existing\', \'new\') ON CONFLICT ("slug") DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->install_prefix_ambiguous_upsert_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO prefix_ambiguous (id, slug, value) VALUES (1, 'existing-slug-one', 'old')" ); + $prefix_upsert = "INSERT INTO `prefix_ambiguous` VALUES (2, 'existing-slug', 'new') + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + + $this->assertSame( 1, $driver->query( $prefix_upsert ) ); + $this->assertSame( + 'INSERT INTO "prefix_ambiguous" ("id", "slug", "value") VALUES (2, \'existing-slug\', \'new\') ON CONFLICT (SUBSTR(CAST("slug" AS text), 1, 10)) DO UPDATE SET "value" = excluded."value"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, slug, value FROM prefix_ambiguous ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'existing-slug-one', $rows[0]->slug ); + $this->assertSame( 'new', $rows[0]->value ); + } + + /** + * Tests simple WordPress UPDATE statements are translated to PostgreSQL. + */ + public function test_simple_wordpress_update_with_backticks_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wp_options ( + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('key1', 'value1')" ); + + $update = "UPDATE `wp_options` SET `option_value` = 'value2' WHERE `option_name` = 'key1'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( $update, $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wp_options" SET "option_value" = \'value2\' WHERE ("option_name" = \'key1\') AND ("option_value" IS DISTINCT FROM (\'value2\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value FROM wp_options WHERE option_name = 'key1'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'value2', $rows[0]->option_value ); + } + + /** + * Tests UPDATE IGNORE skips rows that would violate unique constraints. + */ + public function test_update_ignore_unique_conflict_returns_zero_and_preserves_rows(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_ignore_unique ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + note TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_ignore_unique (id, slug, note) VALUES (1, 'a', 'first'), (2, 'b', 'second')" ); + + $update = "UPDATE IGNORE wptests_update_ignore_unique SET slug = 'a', note = 'changed' WHERE slug = 'b'"; + + $this->assertSame( 0, $driver->query( $update ) ); + $this->assertSame( + 'UPDATE "wptests_update_ignore_unique" SET "slug" = \'a\', "note" = \'changed\' WHERE (slug = \'b\') AND ("slug" IS DISTINCT FROM (\'a\') OR "note" IS DISTINCT FROM (\'changed\'))', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, slug, note FROM wptests_update_ignore_unique ORDER BY id' ); + + $this->assertSame( array( 'a', 'b' ), array_column( $rows, 'slug' ) ); + $this->assertSame( array( 'first', 'second' ), array_column( $rows, 'note' ) ); + } + + /** + * Tests simple WordPress UPDATE statements return changed rows, not matched rows. + */ + public function test_simple_wordpress_update_returns_zero_for_noop_update(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wp_comments ( + comment_ID INTEGER PRIMARY KEY, + comment_parent INTEGER NOT NULL, + comment_content TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wp_comments (comment_ID, comment_parent, comment_content) VALUES (1, 0, 'first')" ); + + $update = "UPDATE `wp_comments` SET `comment_parent` = 2, `comment_content` = 'updated' WHERE `comment_ID` = 1"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wp_comments" SET "comment_parent" = 2, "comment_content" = \'updated\' WHERE ("comment_ID" = 1) AND ("comment_parent" IS DISTINCT FROM (2) OR "comment_content" IS DISTINCT FROM (\'updated\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT comment_parent, comment_content FROM wp_comments WHERE `comment_ID` = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->comment_parent ); + $this->assertSame( 'updated', $rows[0]->comment_content ); + } + + /** + * Tests WordPress UPDATE statements support IS NULL conditions. + */ + public function test_simple_wordpress_update_supports_is_null_where(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_null_where ( + meta_id INTEGER PRIMARY KEY, + meta_key TEXT NOT NULL, + meta_value TEXT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_null_where (meta_id, meta_key, meta_value) VALUES (1, 'null_update_where_key', NULL)" ); + + $update = "UPDATE `wptests_update_null_where` SET `meta_value` = 'null_update_where_key' WHERE `meta_key` = 'null_update_where_key' AND `meta_value` IS NULL"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_null_where" SET "meta_value" = \'null_update_where_key\' WHERE ("meta_key" = \'null_update_where_key\' AND "meta_value" IS NULL) AND ("meta_value" IS DISTINCT FROM (\'null_update_where_key\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT meta_value FROM wptests_update_null_where WHERE meta_key = 'null_update_where_key'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'null_update_where_key', $rows[0]->meta_value ); + } + + /** + * Tests WordPress DELETE statements support IS NULL conditions. + */ + public function test_simple_wordpress_delete_supports_is_null_where(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_delete_null_where ( + meta_id INTEGER PRIMARY KEY, + meta_key TEXT NOT NULL, + meta_value TEXT NULL + )' + ); + $driver->query( "INSERT INTO wptests_delete_null_where (meta_id, meta_key, meta_value) VALUES (1, 'null_update_where_key', NULL)" ); + + $delete = "DELETE FROM `wptests_delete_null_where` WHERE `meta_key` = 'null_update_where_key' AND `meta_value` IS NULL"; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( + array( + array( + 'sql' => 'DELETE FROM "wptests_delete_null_where" WHERE "meta_key" = \'null_update_where_key\' AND "meta_value" IS NULL', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT meta_id FROM wptests_delete_null_where WHERE meta_key = 'null_update_where_key'" ); + + $this->assertSame( array(), $rows ); + } + + /** + * Tests simple single-table UPDATE aliases and qualified assignment targets. + */ + public function test_simple_update_with_alias_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_alias ( + id INTEGER PRIMARY KEY, + value INTEGER NOT NULL + )' + ); + $driver->query( 'INSERT INTO wptests_update_alias (id, value) VALUES (1, 4), (2, 8)' ); + + $update = 'UPDATE `wptests_update_alias` AS ua SET ua.value = ua.value + 1 WHERE ua.id = 1'; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_alias" AS "ua" SET "value" = ua.value + 1 WHERE (ua.id = 1) AND ("ua"."value" IS DISTINCT FROM (ua.value + 1))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_update_alias ORDER BY id' ); + $this->assertSame( '5', $rows[0]->value ); + $this->assertSame( '8', $rows[1]->value ); + } + + /** + * Tests leading WITH clauses are preserved for supported UPDATE statements. + */ + public function test_cte_prefixed_update_with_cte_predicate_subquery_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_cte ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + priority INTEGER NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_update_cte (id, status, priority) VALUES + (1, 'queued', 20), + (2, 'queued', 10), + (3, 'queued', 30)" + ); + + $update = "WITH picked (picked_id) AS ( + SELECT `id` FROM `wptests_update_cte` WHERE `priority` <= 20 + ) + UPDATE `wptests_update_cte` + SET `status` = 'claimed' + WHERE `id` IN (SELECT picked_id FROM picked)"; + + $this->assertSame( 2, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringStartsWith( 'WITH picked (picked_id) AS (SELECT "id" FROM "wptests_update_cte" WHERE "priority" <= 20) UPDATE "wptests_update_cte" SET "status" = \'claimed\'', $sql ); + $this->assertStringContainsString( '"id" IN (SELECT picked_id FROM picked)', $sql ); + $this->assertStringContainsString( '"status" IS DISTINCT FROM (\'claimed\')', $sql ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_cte ORDER BY id' ); + $this->assertSame( 'claimed', $rows[0]->status ); + $this->assertSame( 'claimed', $rows[1]->status ); + $this->assertSame( 'queued', $rows[2]->status ); + } + + /** + * Tests CTE-prefixed UPDATE keeps unsupported nested app-table SELECTs fail-closed. + */ + public function test_cte_prefixed_update_with_non_cte_nested_select_fails_closed(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_cte_unsupported ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + + try { + $driver->query( + "WITH picked AS (SELECT id FROM wptests_update_cte_unsupported) + UPDATE wptests_update_cte_unsupported + SET status = 'claimed' + WHERE id IN (SELECT id FROM wptests_update_cte_unsupported)" + ); + $this->fail( 'Expected unsupported CTE-prefixed UPDATE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests MySQL inner joined UPDATE statements translate through PostgreSQL UPDATE FROM. + */ + public function test_inner_join_update_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_joined ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_joined_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_joined (id, status) VALUES (1, 'draft'), (2, 'draft')" ); + $driver->query( "INSERT INTO wptests_update_joined_meta (post_id, meta_key, meta_value) VALUES (1, '_status', 'publish'), (2, '_other', 'private')" ); + + $update = "UPDATE wptests_update_joined AS p JOIN wptests_update_joined_meta AS pm ON p.id = pm.post_id SET p.status = pm.meta_value WHERE pm.meta_key = '_status'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_joined" AS "p" SET "status" = pm.meta_value FROM "wptests_update_joined_meta" AS "pm" WHERE (p.id = pm.post_id) AND (pm.meta_key = \'_status\') AND ("p"."status" IS DISTINCT FROM (pm.meta_value))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_joined ORDER BY id' ); + $this->assertSame( 'publish', $rows[0]->status ); + $this->assertSame( 'draft', $rows[1]->status ); + } + + /** + * Tests joined UPDATE ORDER BY without LIMIT is preserved in a derived source. + */ + public function test_joined_update_order_by_without_limit_preserves_ordering(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_joined_order ( + ctid INTEGER UNIQUE NOT NULL, + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_joined_order_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_joined_order (ctid, id, status) VALUES (1, 1, 'draft'), (2, 2, 'draft'), (3, 3, 'publish')" ); + $driver->query( "INSERT INTO wptests_update_joined_order_meta (post_id, meta_key, meta_value) VALUES (1, '_status', 'private'), (2, '_status', 'scheduled'), (3, '_other', 'ignore')" ); + + $update = "UPDATE wptests_update_joined_order AS p + JOIN wptests_update_joined_order_meta AS pm ON p.id = pm.post_id + SET p.status = pm.meta_value + WHERE pm.meta_key = '_status' + ORDER BY pm.meta_value ASC, p.id DESC"; + + $this->assertSame( 2, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'UPDATE "wptests_update_joined_order" AS "p" SET "status" = "mysql_update_values"."mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'FROM (SELECT "p".ctid AS "mysql_update_target_ctid", pm.meta_value AS "mysql_update_value_0"', $sql ); + $this->assertStringContainsString( "WHERE (p.id = pm.post_id) AND (pm.meta_key = '_status') ORDER BY pm.meta_value ASC, p.id DESC", $sql ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_joined_order ORDER BY id' ); + $this->assertSame( 'private', $rows[0]->status ); + $this->assertSame( 'scheduled', $rows[1]->status ); + $this->assertSame( 'publish', $rows[2]->status ); + } + + /** + * Tests joined UPDATE expression ORDER BY clauses without LIMIT are translated. + */ + public function test_joined_update_expression_order_by_without_limit_is_translated(): void { + $driver = $this->create_driver(); + + $update = "UPDATE wptests_update_joined_order AS p + JOIN wptests_update_joined_order_meta AS pm ON p.id = pm.post_id + SET p.status = pm.meta_value + WHERE pm.meta_key = '_status' + ORDER BY LENGTH(pm.meta_value), p.id + 0 DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertStringContainsString( 'FROM (SELECT "p".ctid AS "mysql_update_target_ctid", pm.meta_value AS "mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'ORDER BY CASE WHEN ', $sql ); + $this->assertStringContainsString( 'ELSE OCTET_LENGTH(CONVERT_TO(CAST(pm.meta_value AS text), \'UTF8\')) END, p.id + 0 DESC', $sql ); + } + + /** + * Tests bounded joined UPDATE ORDER BY/LIMIT forms update the intended matched row slice. + */ + public function test_joined_update_order_by_limit_updates_ordered_slice(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_joined_limit ( + ctid INTEGER UNIQUE NOT NULL, + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_joined_limit_meta ( + post_id INTEGER NOT NULL, + priority INTEGER NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_update_joined_limit (ctid, id, status) VALUES + (1, 1, 'queued'), + (2, 2, 'queued'), + (3, 3, 'queued'), + (4, 4, 'done')" + ); + $driver->query( + "INSERT INTO wptests_update_joined_limit_meta (post_id, priority, meta_value) VALUES + (1, 30, 'third'), + (2, 10, 'first'), + (3, 20, 'second'), + (4, 1, 'skip')" + ); + + $update = "UPDATE wptests_update_joined_limit AS p + JOIN wptests_update_joined_limit_meta AS pm ON pm.post_id = p.id + SET p.status = pm.meta_value + WHERE p.status = 'queued' + ORDER BY pm.priority ASC, p.id DESC + LIMIT 1, 2"; + + $this->assertSame( 2, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'FROM (SELECT "p".ctid AS "mysql_update_target_ctid", pm.meta_value AS "mysql_update_value_0"', $sql ); + $this->assertStringContainsString( "WHERE (pm.post_id = p.id) AND (p.status = 'queued') ORDER BY pm.priority ASC, p.id DESC LIMIT 2 OFFSET 1", $sql ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_joined_limit ORDER BY id' ); + $this->assertSame( 'third', $rows[0]->status ); + $this->assertSame( 'queued', $rows[1]->status ); + $this->assertSame( 'second', $rows[2]->status ); + $this->assertSame( 'done', $rows[3]->status ); + + $comma_update = "UPDATE wptests_update_joined_limit AS p, wptests_update_joined_limit_meta AS pm + SET p.status = 'limited' + WHERE pm.post_id = p.id AND p.status = 'queued' + LIMIT 1"; + + $this->assertSame( 1, $driver->query( $comma_update ) ); + $this->assertStringContainsString( 'LIMIT 1', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests MySQL inner joined UPDATE statements support derived-table sources. + */ + public function test_inner_join_update_with_derived_source_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_joined_derived ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_joined_derived_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_joined_derived (id, status) VALUES (1, 'draft'), (2, 'draft')" ); + $driver->query( "INSERT INTO wptests_update_joined_derived_meta (post_id, meta_key, meta_value) VALUES (1, '_status', 'publish'), (2, '_other', 'private')" ); + + $update = "UPDATE wptests_update_joined_derived AS p + JOIN ( + SELECT post_id, meta_value + FROM wptests_update_joined_derived_meta + WHERE meta_key = '_status' + ) AS src ON p.id = src.post_id + SET p.status = src.meta_value + WHERE p.id IN (1, 2)"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'FROM (SELECT post_id, meta_value FROM wptests_update_joined_derived_meta WHERE meta_key = \'_status\') AS "src"', $sql ); + $this->assertStringContainsString( '(p.id = src.post_id)', $sql ); + $this->assertStringContainsString( '("p"."status" IS DISTINCT FROM (src.meta_value))', $sql ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_joined_derived ORDER BY id' ); + $this->assertSame( 'publish', $rows[0]->status ); + $this->assertSame( 'draft', $rows[1]->status ); + } + + /** + * Tests Action Scheduler claim UPDATE uses ordered limited derived sources. + */ + public function test_action_scheduler_claim_update_with_ordered_limited_derived_source(): void { + $driver = $this->create_driver(); + + $driver->set_sql_mode( '' ); + $driver->query( + "CREATE TABLE wptests_actionscheduler_actions ( + action_id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + scheduled_date_gmt TEXT NULL default '0000-00-00 00:00:00', + scheduled_date_local TEXT NULL default '0000-00-00 00:00:00', + priority INTEGER NOT NULL default '10', + attempts INTEGER NOT NULL default '0', + last_attempt_gmt TEXT NULL default '0000-00-00 00:00:00', + last_attempt_local TEXT NULL default '0000-00-00 00:00:00', + claim_id INTEGER NOT NULL default '0' + )" + ); + $driver->query( + "INSERT INTO wptests_actionscheduler_actions + (action_id, status, scheduled_date_gmt, scheduled_date_local, priority, attempts, claim_id) + VALUES + (1, 'pending', '2025-09-03 12:00:00', '2025-09-03 12:00:00', 20, 1, 0), + (2, 'pending', '2025-09-03 11:00:00', '2025-09-03 11:00:00', 5, 2, 0), + (3, 'complete', '2025-09-03 10:00:00', '2025-09-03 10:00:00', 1, 0, 0), + (4, 'pending', '2025-09-03 09:00:00', '2025-09-03 09:00:00', 1, 0, 9), + (5, 'pending', '2025-09-04 09:00:00', '2025-09-04 09:00:00', 1, 0, 0)" + ); + + $update = "UPDATE wptests_actionscheduler_actions t1 + JOIN ( + SELECT action_id + FROM wptests_actionscheduler_actions + WHERE claim_id = 0 AND scheduled_date_gmt <= '2025-09-03 12:23:55' AND status = 'pending' + ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC + LIMIT 2 + FOR UPDATE + ) t2 ON t1.action_id = t2.action_id + SET claim_id = 37, last_attempt_gmt = '2025-09-03 12:23:55', last_attempt_local = '2025-09-03 12:23:55'"; + + $this->assertSame( 2, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC LIMIT 2', $sql ); + $this->assertStringNotContainsString( 'FOR UPDATE', $sql ); + + $rows = $driver->query( 'SELECT action_id, claim_id, last_attempt_gmt, last_attempt_local FROM wptests_actionscheduler_actions ORDER BY action_id' ); + $this->assertSame( array( '37', '37', '0', '9', '0' ), array_column( $rows, 'claim_id' ) ); + $this->assertSame( '2025-09-03 12:23:55', $rows[0]->last_attempt_gmt ); + $this->assertSame( '2025-09-03 12:23:55', $rows[1]->last_attempt_local ); + $this->assertSame( '0000-00-00 00:00:00', $rows[2]->last_attempt_gmt ); + } + + /** + * Tests MySQL inner joined UPDATE ... USING statements translate to PostgreSQL predicates. + */ + public function test_inner_join_update_using_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_joined_using ( + post_id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_joined_using_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_joined_using (post_id, status) VALUES (1, 'draft'), (2, 'draft')" ); + $driver->query( "INSERT INTO wptests_update_joined_using_meta (post_id, meta_key, meta_value) VALUES (1, '_status', 'publish'), (2, '_other', 'private')" ); + + $update = "UPDATE wptests_update_joined_using AS p INNER JOIN wptests_update_joined_using_meta AS pm USING (post_id) SET p.status = pm.meta_value WHERE pm.meta_key = '_status'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_joined_using" AS "p" SET "status" = pm.meta_value FROM "wptests_update_joined_using_meta" AS "pm" WHERE (p.post_id = pm.post_id) AND (pm.meta_key = \'_status\') AND ("p"."status" IS DISTINCT FROM (pm.meta_value))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT post_id, status FROM wptests_update_joined_using ORDER BY post_id' ); + $this->assertSame( 'publish', $rows[0]->status ); + $this->assertSame( 'draft', $rows[1]->status ); + } + + /** + * Tests joined UPDATE can target a non-leftmost table and MySQL join modifiers. + */ + public function test_joined_update_non_leftmost_target_and_join_modifiers_translate_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_join_source ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_join_target ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_join_source (id, value) VALUES (1, 'one'), (2, 'two'), (3, 'three')" ); + $driver->query( "INSERT INTO wptests_update_join_target (id, value) VALUES (1, 'old'), (2, 'old'), (3, 'old')" ); + + $this->assertSame( + 1, + $driver->query( + 'UPDATE wptests_update_join_source AS s + JOIN wptests_update_join_target AS t ON t.id = s.id + SET t.value = s.value + WHERE s.id = 1' + ) + ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'UPDATE "wptests_update_join_target" AS "t" SET "value" = s.value FROM "wptests_update_join_source" AS "s"', $sql ); + $this->assertStringContainsString( '(t.id = s.id)', $sql ); + + $this->assertSame( + 1, + $driver->query( + 'UPDATE LOW_PRIORITY wptests_update_join_target AS t + CROSS JOIN wptests_update_join_source AS s + SET t.value = s.value + WHERE t.id = s.id AND t.id = 2' + ) + ); + + $this->assertSame( + 1, + $driver->query( + 'UPDATE IGNORE wptests_update_join_target AS t + STRAIGHT_JOIN wptests_update_join_source AS s ON s.id = t.id + SET t.value = s.value + WHERE t.id = 3' + ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_update_join_target ORDER BY id' ); + $this->assertSame( 'one', $rows[0]->value ); + $this->assertSame( 'two', $rows[1]->value ); + $this->assertSame( 'three', $rows[2]->value ); + } + + /** + * Tests joined UPDATE IGNORE skips rows that would violate unique constraints. + */ + public function test_joined_update_ignore_unique_conflict_returns_zero_and_preserves_rows(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_ignore_join_target ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + note TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_ignore_join_source ( + id INTEGER PRIMARY KEY, + new_slug TEXT NOT NULL, + new_note TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_ignore_join_target (id, slug, note) VALUES (1, 'a', 'first'), (2, 'b', 'second')" ); + $driver->query( "INSERT INTO wptests_update_ignore_join_source (id, new_slug, new_note) VALUES (2, 'a', 'changed')" ); + + $update = 'UPDATE IGNORE wptests_update_ignore_join_target AS t + JOIN wptests_update_ignore_join_source AS s ON s.id = t.id + SET t.slug = s.new_slug, t.note = s.new_note + WHERE t.id = 2'; + + $this->assertSame( 0, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'UPDATE "wptests_update_ignore_join_target" AS "t" SET "slug" = s.new_slug, "note" = s.new_note', $sql ); + $this->assertStringContainsString( 'FROM "wptests_update_ignore_join_source" AS "s"', $sql ); + + $rows = $driver->query( 'SELECT id, slug, note FROM wptests_update_ignore_join_target ORDER BY id' ); + + $this->assertSame( array( 'a', 'b' ), array_column( $rows, 'slug' ) ); + $this->assertSame( array( 'first', 'second' ), array_column( $rows, 'note' ) ); + } + + /** + * Tests MySQL multi-target UPDATE statements translate to PostgreSQL writable CTEs. + */ + public function test_multi_target_update_is_translated_to_writable_ctes(): void { + $driver = $this->create_driver(); + + $update = 'UPDATE wptests_update_source AS s, wptests_update_target AS t + SET s.value = t.value, t.value = s.value + WHERE t.id = s.id AND s.id = 1'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $update + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_update_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "s".ctid AS "mysql_update_0_ctid", t.value AS "mysql_update_value_0", "t".ctid AS "mysql_update_1_ctid", s.value AS "mysql_update_value_1"', $sql ); + $this->assertStringContainsString( 'FROM "wptests_update_source" AS "s", "wptests_update_target" AS "t"', $sql ); + $this->assertStringContainsString( 'WHERE (t.id = s.id AND s.id = 1)', $sql ); + $this->assertStringContainsString( 'mysql_update_target_0 AS (UPDATE "wptests_update_source" AS "s" SET "value" = mysql_update_rows."mysql_update_value_0" FROM mysql_update_rows WHERE ("s".ctid = mysql_update_rows."mysql_update_0_ctid")', $sql ); + $this->assertStringContainsString( 'mysql_update_target_1 AS (UPDATE "wptests_update_target" AS "t" SET "value" = mysql_update_rows."mysql_update_value_1" FROM mysql_update_rows WHERE ("t".ctid = mysql_update_rows."mysql_update_1_ctid")', $sql ); + $this->assertStringContainsString( 'SELECT (SELECT COUNT(*) FROM mysql_update_target_0) + (SELECT COUNT(*) FROM mysql_update_target_1) AS affected_rows', $sql ); + + $join_update = 'UPDATE wptests_update_source AS s + JOIN wptests_update_target AS t ON t.id = s.id + SET s.value = t.value, t.value = s.value + WHERE s.id = 1'; + $join_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $join_update + ); + + $this->assertNotNull( $join_sql ); + $this->assertStringContainsString( 'WHERE (t.id = s.id) AND (s.id = 1)', $join_sql ); + $this->assertStringNotContainsString( 'ORDER BY', $join_sql ); + + $ordered_update = 'UPDATE wptests_update_source AS s, wptests_update_target AS t + SET s.value = t.value, t.value = s.value + WHERE t.id = s.id + ORDER BY t.id DESC, LENGTH(s.value)'; + $ordered_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $ordered_update + ); + + $this->assertNotNull( $ordered_sql ); + $this->assertStringContainsString( 'WHERE (t.id = s.id)', $ordered_sql ); + $this->assertStringContainsString( 'ORDER BY t.id DESC, CASE WHEN ', $ordered_sql ); + $this->assertStringContainsString( 'ELSE OCTET_LENGTH(CONVERT_TO(CAST(s.value AS text), \'UTF8\')) END', $ordered_sql ); + + $limited_update = 'UPDATE wptests_update_source AS s, wptests_update_target AS t + SET s.value = t.value, t.value = s.value + WHERE t.id = s.id + ORDER BY t.id DESC, s.value ASC + LIMIT 1, 2'; + $limited_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $limited_update + ); + + $this->assertNotNull( $limited_sql ); + $this->assertStringContainsString( 'WITH mysql_update_rows AS MATERIALIZED', $limited_sql ); + $this->assertStringContainsString( 'WHERE (t.id = s.id) ORDER BY t.id DESC, s.value ASC LIMIT 2 OFFSET 1', $limited_sql ); + + $limit_only_update = 'UPDATE wptests_update_source AS s, wptests_update_target AS t + SET s.value = t.value, t.value = s.value + WHERE t.id = s.id + LIMIT 2'; + $limit_only_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $limit_only_update + ); + + $this->assertNotNull( $limit_only_sql ); + $this->assertStringContainsString( 'WHERE (t.id = s.id) LIMIT 2', $limit_only_sql ); + $this->assertStringNotContainsString( 'ORDER BY', $limit_only_sql ); + + $single_target_update = 'UPDATE wptests_update_source AS s, wptests_update_target AS t + SET s.value = t.value + WHERE t.id = s.id'; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_update_query', + $single_target_update + ) + ); + } + + /** + * Tests joined UPDATE statements can read from information_schema sources. + */ + public function test_joined_update_can_read_information_schema_sources(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $update = 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET o.option_value = it.table_type + WHERE it.table_schema = DATABASE() + ORDER BY it.table_name'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'UPDATE "wptests_options" AS "o" SET "option_value" = "mysql_update_values"."mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'SELECT "o".ctid AS "mysql_update_target_ctid", "it"."TABLE_TYPE" AS "mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'FROM "wptests_options" AS "o" JOIN (', $sql ); + $this->assertStringContainsString( ') AS "it" ON "o"."option_name" = "it"."TABLE_NAME"', $sql ); + $this->assertStringContainsString( 'WHERE "it"."TABLE_SCHEMA" = \'wptests\'', $sql ); + $this->assertStringContainsString( 'ORDER BY "it"."TABLE_NAME"', $sql ); + $this->assertStringContainsString( '"o".ctid = "mysql_update_values"."mysql_update_target_ctid"', $sql ); + $this->assertStringContainsString( '"o"."option_value" IS DISTINCT FROM ("mysql_update_values"."mysql_update_value_0")', $sql ); + } + + /** + * Tests unsupported information_schema joined UPDATE ORDER BY sources fail closed. + */ + public function test_joined_update_rejects_unsupported_information_schema_order_by_sources(): void { + $queries = array( + 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET o.option_value = it.table_type + WHERE it.table_schema = DATABASE() + ORDER BY missing_alias.table_name', + 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET o.option_value = it.table_type + WHERE it.table_schema = DATABASE() + ORDER BY it.missing_column', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported UPDATE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests information_schema aliases remain read-only in joined UPDATE statements. + */ + public function test_joined_update_rejects_information_schema_set_targets(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $update = 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET it.table_name = o.option_name'; + + try { + $driver->query( $update ); + $this->fail( 'Expected unsupported UPDATE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests information_schema joined UPDATE predicates can use nested current-database SELECTs. + */ + public function test_joined_update_can_read_information_schema_predicate_subqueries(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $update = 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET o.option_value = it.table_type + WHERE it.table_schema IN (SELECT DATABASE())'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'UPDATE "wptests_options" AS "o" SET "option_value" = "mysql_update_values"."mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'WHERE "it"."TABLE_SCHEMA" IN ( SELECT \'wptests\' )', $sql ); + $this->assertStringNotContainsString( 'DATABASE()', $sql ); + $this->assertStringNotContainsString( 'information_schema.tables', $sql ); + } + + /** + * Tests unsupported information_schema joined UPDATE predicates fail before backend execution. + */ + public function test_joined_update_rejects_unsupported_information_schema_predicates(): void { + $queries = array( + 'UPDATE wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + SET o.option_value = it.table_type + WHERE it.table_schema IN (SELECT DATABASE() UNION SELECT DATABASE())', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported UPDATE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests RIGHT JOIN UPDATE statements translate through a derived ctid source. + */ + public function test_right_join_update_is_translated_through_derived_ctid_source(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_right_source ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_right_target ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + + $update = 'UPDATE wptests_update_right_source AS s + RIGHT JOIN wptests_update_right_target AS t ON t.id = s.id + SET s.value = t.value'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertStringStartsWith( 'UPDATE "wptests_update_right_source" AS "s" SET "value" = "mysql_update_values"."mysql_update_value_0"', $sql ); + $this->assertStringContainsString( 'FROM (SELECT "s".ctid AS "mysql_update_target_ctid", t.value AS "mysql_update_value_0" FROM wptests_update_right_source AS s RIGHT JOIN wptests_update_right_target AS t ON t.id = s.id', $sql ); + $this->assertStringContainsString( '"s".ctid = "mysql_update_values"."mysql_update_target_ctid"', $sql ); + } + + /** + * Tests joined UPDATE unsupported ORDER BY/LIMIT shapes fail before backend execution. + */ + public function test_joined_update_unsupported_order_by_and_limit_shapes_fail_closed_before_backend_execution(): void { + $driver = $this->create_driver(); + + $updates = array( + 'multi_target_bad_count' => 'UPDATE wptests_update_joined_order AS p, wptests_update_joined_order_meta AS pm + SET p.status = pm.meta_value, pm.meta_value = p.status + WHERE pm.post_id = p.id + ORDER BY p.id ASC + LIMIT bad', + 'multi_target_bad_offset' => 'UPDATE wptests_update_joined_order AS p, wptests_update_joined_order_meta AS pm + SET p.status = pm.meta_value, pm.meta_value = p.status + WHERE pm.post_id = p.id + ORDER BY p.id ASC + LIMIT 1, bad', + 'joined_bad_order_alias' => 'UPDATE wptests_update_joined_order AS p + JOIN wptests_update_joined_order_meta AS pm ON p.id = pm.post_id + SET p.status = pm.meta_value + WHERE pm.post_id = p.id + ORDER BY missing_alias.id', + 'multi_target_bad_order_alias' => 'UPDATE wptests_update_joined_order AS p, wptests_update_joined_order_meta AS pm + SET p.status = pm.meta_value, pm.meta_value = p.status + WHERE pm.post_id = p.id + ORDER BY missing_alias.id', + ); + + foreach ( $updates as $label => $update ) { + try { + $driver->query( $update ); + $this->fail( 'Expected unsupported UPDATE statement to throw for ' . $label . '.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage(), $label ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $label ); + } + } + } + + /** + * Tests MySQL comma/join UPDATE statements translate through PostgreSQL UPDATE FROM. + */ + public function test_comma_join_update_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_multi_source ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + comment TEXT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_multi_source_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_multi_source_terms ( + post_id INTEGER NOT NULL, + term TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_multi_source (id, status) VALUES (1, 'draft'), (2, 'draft'), (3, 'draft')" ); + $driver->query( "INSERT INTO wptests_update_multi_source_meta (post_id, meta_key, meta_value) VALUES (1, '_status', 'publish'), (2, '_status', 'private'), (3, '_other', 'ignore')" ); + $driver->query( "INSERT INTO wptests_update_multi_source_terms (post_id, term) VALUES (1, 'publish'), (2, 'skip'), (3, 'publish')" ); + + $update = "UPDATE wptests_update_multi_source AS p, wptests_update_multi_source_meta AS pm + JOIN wptests_update_multi_source_terms AS tt ON tt.post_id = p.id + SET p.status = pm.meta_value + WHERE pm.post_id = p.id + AND pm.meta_key = '_status' + AND tt.term = 'publish'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_multi_source" AS "p" SET "status" = pm.meta_value FROM "wptests_update_multi_source_meta" AS "pm", "wptests_update_multi_source_terms" AS "tt" WHERE (tt.post_id = p.id) AND (pm.post_id = p.id AND pm.meta_key = \'_status\' AND tt.term = \'publish\') AND ("p"."status" IS DISTINCT FROM (pm.meta_value))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_multi_source ORDER BY id' ); + $this->assertSame( 'publish', $rows[0]->status ); + $this->assertSame( 'draft', $rows[1]->status ); + $this->assertSame( 'draft', $rows[2]->status ); + + $unqualified_update = "UPDATE wptests_update_multi_source AS p, wptests_update_multi_source_meta AS pm + JOIN wptests_update_multi_source_terms AS tt ON tt.post_id = p.id + SET comment = 'review' + WHERE pm.post_id = p.id + AND pm.meta_key = '_status' + AND tt.term = 'publish'"; + + $this->assertSame( 1, $driver->query( $unqualified_update ) ); + $this->assertSame( 0, $driver->query( $unqualified_update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'UPDATE "wptests_update_multi_source" AS "p" SET "comment" = \'review\'', $sql ); + $this->assertStringContainsString( 'FROM "wptests_update_multi_source_meta" AS "pm", "wptests_update_multi_source_terms" AS "tt"', $sql ); + + $rows = $driver->query( 'SELECT id, comment FROM wptests_update_multi_source ORDER BY id' ); + $this->assertSame( 'review', $rows[0]->comment ); + $this->assertNull( $rows[1]->comment ); + $this->assertNull( $rows[2]->comment ); + } + + /** + * Tests mixed comma/JOIN UPDATE resolves unqualified SET targets by column ownership. + */ + public function test_comma_join_update_unqualified_set_target_resolves_non_leftmost_table(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_mixed_source ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_mixed_target ( + id INTEGER PRIMARY KEY, + comment TEXT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_mixed_filter ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_mixed_source (id, status) VALUES (1, 'draft'), (2, 'publish'), (3, 'publish')" ); + $driver->query( 'INSERT INTO wptests_update_mixed_target (id, comment) VALUES (1, NULL), (2, NULL), (3, NULL)' ); + $driver->query( "INSERT INTO wptests_update_mixed_filter (id, name) VALUES (1, 'update'), (2, 'skip'), (3, 'update')" ); + + $update = "UPDATE wptests_update_mixed_source AS s, wptests_update_mixed_target AS t + JOIN wptests_update_mixed_filter AS f ON f.id = s.id + SET comment = 'updated' + WHERE t.id = s.id + AND t.id = f.id + AND s.status = 'publish' + AND f.name = 'update'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( 0, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'UPDATE "wptests_update_mixed_target" AS "t" SET "comment" = \'updated\'', $sql ); + $this->assertStringContainsString( 'FROM "wptests_update_mixed_source" AS "s", "wptests_update_mixed_filter" AS "f"', $sql ); + $this->assertStringContainsString( '(f.id = s.id)', $sql ); + $this->assertStringContainsString( '("t"."comment" IS DISTINCT FROM (\'updated\'))', $sql ); + + $rows = $driver->query( 'SELECT id, comment FROM wptests_update_mixed_target ORDER BY id' ); + $this->assertNull( $rows[0]->comment ); + $this->assertNull( $rows[1]->comment ); + $this->assertSame( 'updated', $rows[2]->comment ); + } + + /** + * Tests ambiguous unqualified joined UPDATE SET targets fail closed. + */ + public function test_comma_join_update_ambiguous_unqualified_set_target_fails_closed(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_mixed_ambiguous_source ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_mixed_ambiguous_target ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_mixed_ambiguous_filter ( + id INTEGER PRIMARY KEY + )' + ); + + try { + $driver->query( + "UPDATE wptests_update_mixed_ambiguous_source AS s, wptests_update_mixed_ambiguous_target AS t + JOIN wptests_update_mixed_ambiguous_filter AS f ON f.id = s.id + SET status = 'updated' + WHERE t.id = s.id + AND t.id = f.id" + ); + $this->fail( 'Expected unsupported UPDATE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests MySQL LEFT JOIN UPDATE statements preserve unmatched source rows. + */ + public function test_left_join_update_is_translated_through_derived_ctid_source(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_left_joined ( + id INTEGER PRIMARY KEY, + author_id INTEGER NOT NULL, + status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_update_left_joined_meta ( + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_left_joined (id, author_id, status) VALUES (1, 10, 'draft'), (2, 20, 'draft'), (3, 30, 'publish')" ); + $driver->query( "INSERT INTO wptests_update_left_joined_meta (post_id, meta_key, meta_value) VALUES (10, '_status', 'scheduled'), (10, '_other', 'ignored')" ); + + $update = "UPDATE wptests_update_left_joined AS p + LEFT JOIN wptests_update_left_joined_meta AS pm + ON pm.post_id = p.author_id AND pm.meta_key = '_status' + SET p.status = IFNULL(pm.meta_value, 'orphan') + WHERE p.status = 'draft' + ORDER BY LENGTH(p.status), p.id DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertStringStartsWith( + 'UPDATE "wptests_update_left_joined" AS "p" SET "status" = "mysql_update_values"."mysql_update_value_0" FROM (SELECT "p".ctid AS "mysql_update_target_ctid", COALESCE(pm.meta_value, \'orphan\') AS "mysql_update_value_0"', + $sql + ); + $this->assertStringContainsString( "WHERE p.status = 'draft' ORDER BY CASE WHEN ", $sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST(p.status AS text), 'UTF8')) END, p.id DESC", $sql ); + $this->assertStringContainsString( '"p".ctid = "mysql_update_values"."mysql_update_target_ctid"', $sql ); + } + + /** + * Tests bounded UPDATE ORDER BY/LIMIT forms translate through PostgreSQL ctid. + */ + public function test_simple_update_order_by_limit_translates_to_ctid_subquery(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE wptests_update_limited SET `value` = 4 WHERE value > 0 LIMIT 2' + ); + + $this->assertSame( + 'UPDATE "wptests_update_limited" SET "value" = 4 WHERE (ctid IN (SELECT ctid FROM "wptests_update_limited" WHERE value > 0 LIMIT 2)) AND ("value" IS DISTINCT FROM (4))', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE wptests_update_limited SET `value` = 3 LIMIT 1' + ); + + $this->assertSame( + 'UPDATE "wptests_update_limited" SET "value" = 3 WHERE (ctid IN (SELECT ctid FROM "wptests_update_limited" LIMIT 1)) AND ("value" IS DISTINCT FROM (3))', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE wptests_update_limited SET `value` = 9 WHERE id > 0 ORDER BY id DESC LIMIT 1' + ); + + $this->assertSame( + 'UPDATE "wptests_update_limited" SET "value" = 9 WHERE (ctid IN (SELECT ctid FROM "wptests_update_limited" WHERE id > 0 ORDER BY id DESC LIMIT 1)) AND ("value" IS DISTINCT FROM (9))', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE wptests_update_limited SET `value` = 7 WHERE id > 0 ORDER BY id DESC LIMIT 1, 2' + ); + + $this->assertSame( + 'UPDATE "wptests_update_limited" SET "value" = 7 WHERE (ctid IN (SELECT ctid FROM "wptests_update_limited" WHERE id > 0 ORDER BY id DESC LIMIT 2 OFFSET 1)) AND ("value" IS DISTINCT FROM (7))', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + 'UPDATE wptests_update_limited SET `value` = 5 WHERE id > 0 ORDER BY id DESC LIMIT 2 OFFSET 1' + ); + + $this->assertSame( + 'UPDATE "wptests_update_limited" SET "value" = 5 WHERE (ctid IN (SELECT ctid FROM "wptests_update_limited" WHERE id > 0 ORDER BY id DESC LIMIT 2 OFFSET 1)) AND ("value" IS DISTINCT FROM (5))', + $sql + ); + } + + /** + * Tests unsupported bounded UPDATE ORDER BY/LIMIT forms fail before backend execution. + */ + public function test_unsupported_simple_update_order_by_limit_shapes_fail_closed_before_backend(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_unsupported_limit ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + + foreach ( + array( + "UPDATE wptests_update_unsupported_limit SET status = 'x' LIMIT bad", + "UPDATE wptests_update_unsupported_limit SET status = 'x' ORDER BY id LIMIT bad", + "UPDATE wptests_update_unsupported_limit SET status = 'x' ORDER BY id LIMIT 1, bad", + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported UPDATE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests bounded UPDATE ORDER BY/LIMIT/OFFSET updates the intended ordered slice. + */ + public function test_simple_update_order_by_limit_executes_ordered_offset_slice(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_order_limit_exec ( + ctid INTEGER UNIQUE NOT NULL, + id INTEGER PRIMARY KEY, + priority INTEGER NOT NULL, + status TEXT NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_update_order_limit_exec (ctid, id, priority, status) VALUES + (1, 1, 40, 'queued'), + (2, 2, 20, 'queued'), + (3, 3, 10, 'queued'), + (4, 4, 30, 'queued')" + ); + + $update = "UPDATE wptests_update_order_limit_exec AS q + SET q.status = 'claimed' + WHERE q.status = 'queued' + ORDER BY q.priority ASC, q.id ASC + LIMIT 1, 2"; + + $this->assertSame( 2, $driver->query( $update ) ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'ctid IN (SELECT "q".ctid FROM "wptests_update_order_limit_exec" AS "q"', $sql ); + $this->assertStringContainsString( 'ORDER BY q.priority ASC, q.id ASC LIMIT 2 OFFSET 1', $sql ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_order_limit_exec ORDER BY id' ); + $this->assertSame( 'queued', $rows[0]->status ); + $this->assertSame( 'claimed', $rows[1]->status ); + $this->assertSame( 'queued', $rows[2]->status ); + $this->assertSame( 'claimed', $rows[3]->status ); + } + + /** + * Tests simple UPDATE ORDER BY without LIMIT uses a ctid subquery. + */ + public function test_simple_update_order_by_without_limit_uses_ctid_subquery(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_update_ordered ( + ctid INTEGER UNIQUE NOT NULL, + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_update_ordered (ctid, id, status) VALUES (1, 1, 'draft'), (2, 2, 'publish'), (3, 3, 'draft')" ); + + $update = "UPDATE `wptests_update_ordered` SET `status` = 'archived' WHERE `status` = 'draft' ORDER BY `id` DESC"; + + $this->assertSame( 2, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_update_ordered" SET "status" = \'archived\' WHERE (ctid IN (SELECT ctid FROM "wptests_update_ordered" WHERE "status" = \'draft\' ORDER BY "id" DESC)) AND ("status" IS DISTINCT FROM (\'archived\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_update_ordered ORDER BY id' ); + $this->assertSame( 'archived', $rows[0]->status ); + $this->assertSame( 'publish', $rows[1]->status ); + $this->assertSame( 'archived', $rows[2]->status ); + } + + /** + * Tests simple UPDATE translates expression ORDER BY clauses without LIMIT. + */ + public function test_simple_update_expression_order_by_without_limit_uses_ctid_subquery(): void { + $driver = $this->create_driver(); + + $update = "UPDATE wptests_update_order_expression SET `status` = 'archived' WHERE `status` = 'draft' ORDER BY LENGTH(`status`), `id` + 0 DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_update_query', + $update + ); + + $this->assertStringStartsWith( 'UPDATE "wptests_update_order_expression" SET "status" = \'archived\' WHERE (ctid IN (SELECT ctid FROM "wptests_update_order_expression" WHERE "status" = \'draft\' ORDER BY ', $sql ); + $this->assertStringContainsString( 'OCTET_LENGTH(CONVERT_TO(CAST("status" AS text), \'UTF8\'))', $sql ); + $this->assertStringContainsString( ', "id" + 0 DESC)) AND ("status" IS DISTINCT FROM (\'archived\'))', $sql ); + } + + /** + * Tests simple UPDATE preserves a MySQL literal ending in an escaped backslash. + */ + public function test_simple_update_preserves_trailing_escaped_backslash_literal(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_commentmeta ( + comment_id TEXT NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES ('8', 'slash_test_2', 'foo')" ); + + $expected_value = 'String with 3 slashes ' . '\\'; + $mysql_literal_value = 'String with 3 slashes ' . '\\\\'; + $update = "UPDATE `wptests_commentmeta` SET `meta_value` = '{$mysql_literal_value}' WHERE `comment_id` = '8' AND `meta_key` = 'slash_test_2'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_commentmeta" SET "meta_value" = ' . $driver->get_connection()->quote( $expected_value ) . ' WHERE ("comment_id" = \'8\' AND "meta_key" = \'slash_test_2\') AND ("meta_value" IS DISTINCT FROM (' . $driver->get_connection()->quote( $expected_value ) . '))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT meta_value FROM wptests_commentmeta WHERE comment_id = '8' AND meta_key = 'slash_test_2'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( $expected_value, $rows[0]->meta_value ); + } + + /** + * Tests placeholder-like bytes in literal-only UPDATE statements remain data. + */ + public function test_simple_update_literal_placeholder_bytes_are_not_bound_parameters(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_commentmeta ( + comment_id TEXT NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES ('8', 'slash_test_2', 'foo')" ); + + $expected_value = 'literal ? :name ::text ' . '\\'; + $mysql_literal_value = 'literal ? :name ::text ' . '\\\\'; + $update = "UPDATE `wptests_commentmeta` SET `meta_value` = '{$mysql_literal_value}' WHERE `comment_id` = '8' AND `meta_key` = 'slash_test_2'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_commentmeta" SET "meta_value" = ' . $driver->get_connection()->quote( $expected_value ) . ' WHERE ("comment_id" = \'8\' AND "meta_key" = \'slash_test_2\') AND ("meta_value" IS DISTINCT FROM (' . $driver->get_connection()->quote( $expected_value ) . '))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT meta_value FROM wptests_commentmeta WHERE comment_id = '8' AND meta_key = 'slash_test_2'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( $expected_value, $rows[0]->meta_value ); + } + + /** + * Tests non-strict UPDATE coerces exact NULL assignments for NOT NULL columns. + */ + public function test_non_strict_update_null_coerces_not_null_columns_to_metadata_defaults(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_options_table_with_mysql_metadata( $driver ); + + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('cron', 'serialized', 'no')" ); + + $update = "UPDATE `wptests_options` SET `option_value` = NULL, `autoload` = NULL WHERE `option_name` = 'cron'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_options" SET "option_value" = \'\', "autoload" = \'yes\' WHERE ("option_name" = \'cron\') AND ("option_value" IS DISTINCT FROM (\'\') OR "autoload" IS DISTINCT FROM (\'yes\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'cron'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + } + + /** + * Tests non-strict UPDATE normalizes invalid date/time literals using MySQL metadata. + */ + public function test_non_strict_update_normalizes_invalid_date_time_literals_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( '' ); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $driver->query( + "INSERT INTO wptests_posts (ID, post_date, post_date_gmt, post_modified, post_modified_gmt) + VALUES (1, '2020-01-01 01:02:03', '2020-01-01 01:02:03', '2020-01-01 01:02:03', '2020-01-01 01:02:03')" + ); + + $update = "UPDATE `wptests_posts` SET `post_date_gmt` = '2020-02-31 14:15:27', `post_modified_gmt` = '2020-07-04T01:02:03Z' WHERE `ID` = 1"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wptests_posts" SET "post_date_gmt" = \'0000-00-00 00:00:00\', "post_modified_gmt" = \'2020-07-04 01:02:03\' WHERE ("ID" = 1) AND ("post_date_gmt" IS DISTINCT FROM (\'0000-00-00 00:00:00\') OR "post_modified_gmt" IS DISTINCT FROM (\'2020-07-04 01:02:03\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $posts = $driver->query( 'SELECT post_date_gmt, post_modified_gmt FROM wptests_posts WHERE ID = 1' ); + + $this->assertCount( 1, $posts ); + $this->assertSame( '0000-00-00 00:00:00', $posts[0]->post_date_gmt ); + $this->assertSame( '2020-07-04 01:02:03', $posts[0]->post_modified_gmt ); + } + + /** + * Tests strict SQL mode leaves UPDATE NULL assignments to fail visibly. + */ + public function test_strict_update_null_does_not_coerce_not_null_columns(): void { + $driver = $this->create_driver(); + $this->install_options_table_with_mysql_metadata( $driver ); + + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('cron', 'serialized', 'no')" ); + $driver->set_sql_mode( 'STRICT_ALL_TABLES' ); + + $this->expectException( PDOException::class ); + + $driver->query( "UPDATE `wptests_options` SET `option_value` = NULL WHERE `option_name` = 'cron'" ); + } + + /** + * Tests simple WordPress DELETE statements are translated to PostgreSQL. + */ + public function test_simple_wordpress_delete_with_backticks_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wp_options ( + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('siteurl', 'http://example.org')" ); + $driver->query( "INSERT INTO wp_options (option_name, option_value) VALUES ('home', 'http://example.org')" ); + + $delete = "DELETE FROM `wp_options` WHERE `option_name` = 'siteurl'"; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( $delete, $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'DELETE FROM "wp_options" WHERE "option_name" = \'siteurl\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT option_name FROM wp_options ORDER BY option_name' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'home', $rows[0]->option_name ); + } + + /** + * Tests simple DELETE without WHERE is translated instead of passed through raw. + */ + public function test_simple_delete_without_where_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_delete_all (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_delete_all (id, value) VALUES (1, 'one'), (2, 'two')" ); + + $delete = 'DELETE FROM wptests_delete_all'; + + $this->assertSame( 2, $driver->query( $delete ) ); + $this->assertSame( 'DELETE FROM "wptests_delete_all"', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT id FROM wptests_delete_all' ); + $this->assertSame( array(), $rows ); + } + + /** + * Tests simple DELETE modifiers are accepted as compatibility no-ops. + */ + public function test_simple_delete_modifiers_are_accepted_as_compatibility_noops(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_delete_modifiers (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_delete_modifiers (id, value) VALUES (1, 'one'), (2, 'two'), (3, 'three'), (4, 'four')" ); + + $queries = array( + 'DELETE LOW_PRIORITY FROM wptests_delete_modifiers WHERE id = 1' => 'DELETE FROM "wptests_delete_modifiers" WHERE id = 1', + 'DELETE QUICK FROM wptests_delete_modifiers WHERE id = 2' => 'DELETE FROM "wptests_delete_modifiers" WHERE id = 2', + 'DELETE IGNORE FROM wptests_delete_modifiers WHERE id = 3' => 'DELETE FROM "wptests_delete_modifiers" WHERE id = 3', + 'DELETE LOW_PRIORITY QUICK IGNORE FROM wptests_delete_modifiers WHERE id = 4' => 'DELETE FROM "wptests_delete_modifiers" WHERE id = 4', + ); + + foreach ( $queries as $query => $expected_sql ) { + $this->assertSame( 1, $driver->query( $query ), $query ); + $this->assertSame( $expected_sql, $this->get_last_single_postgresql_sql( $driver ), $query ); + } + + $rows = $driver->query( 'SELECT id FROM wptests_delete_modifiers' ); + $this->assertSame( array(), $rows ); + } + + /** + * Tests simple single-table DELETE aliases are translated to PostgreSQL aliases. + */ + public function test_simple_delete_with_alias_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_delete_alias (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_delete_alias (id, value) VALUES (1, 'one'), (2, 'two')" ); + + $delete = 'DELETE FROM `wptests_delete_alias` AS d WHERE d.id = 1'; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( + 'DELETE FROM "wptests_delete_alias" AS "d" WHERE d.id = 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_delete_alias ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->id ); + } + + /** + * Tests simple DELETE ORDER BY without LIMIT uses a ctid subquery. + */ + public function test_simple_delete_order_by_without_limit_uses_ctid_subquery(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_delete_ordered ( + ctid INTEGER UNIQUE NOT NULL, + id INTEGER PRIMARY KEY, + status TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_delete_ordered (ctid, id, status) VALUES (1, 1, 'stale'), (2, 2, 'keep'), (3, 3, 'stale')" ); + + $delete = "DELETE FROM `wptests_delete_ordered` WHERE `status` = 'stale' ORDER BY `id` DESC"; + + $this->assertSame( 2, $driver->query( $delete ) ); + $this->assertSame( + array( + array( + 'sql' => 'DELETE FROM "wptests_delete_ordered" WHERE ctid IN (SELECT ctid FROM "wptests_delete_ordered" WHERE "status" = \'stale\' ORDER BY "id" DESC)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( 'SELECT id, status FROM wptests_delete_ordered ORDER BY id' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->id ); + $this->assertSame( 'keep', $rows[0]->status ); + } + + /** + * Tests simple DELETE translates expression ORDER BY clauses without LIMIT. + */ + public function test_simple_delete_expression_order_by_without_limit_uses_ctid_subquery(): void { + $driver = $this->create_driver(); + + $delete = "DELETE FROM wptests_delete_order_expression WHERE `status` = 'stale' ORDER BY LENGTH(`status`), `id` + 0 DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + $delete + ); + + $this->assertStringStartsWith( 'DELETE FROM "wptests_delete_order_expression" WHERE ctid IN (SELECT ctid FROM "wptests_delete_order_expression" WHERE "status" = \'stale\' ORDER BY ', $sql ); + $this->assertStringContainsString( 'OCTET_LENGTH(CONVERT_TO(CAST("status" AS text), \'UTF8\'))', $sql ); + $this->assertStringContainsString( ', "id" + 0 DESC)', $sql ); + } + + /** + * Tests bounded DELETE ORDER BY/LIMIT forms translate through PostgreSQL ctid. + */ + public function test_simple_delete_order_by_limit_translates_to_ctid_subquery(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + "DELETE FROM wptests_delete_limited AS d WHERE d.value = 'stale' ORDER BY d.id ASC LIMIT 1" + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM "wptests_delete_limited" AS "d" WHERE d.value = \'stale\' ORDER BY d.id ASC LIMIT 1)', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + "DELETE FROM wptests_delete_limited AS d WHERE d.value = 'stale' ORDER BY d.id ASC LIMIT 1, 2" + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM "wptests_delete_limited" AS "d" WHERE d.value = \'stale\' ORDER BY d.id ASC LIMIT 2 OFFSET 1)', + $sql + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + "DELETE FROM wptests_delete_limited AS d WHERE d.value = 'stale' ORDER BY d.id ASC LIMIT 2 OFFSET 1" + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM "wptests_delete_limited" AS "d" WHERE d.value = \'stale\' ORDER BY d.id ASC LIMIT 2 OFFSET 1)', + $sql + ); + } + + /** + * Tests bounded DELETE with multi-column ORDER BY and offset/count LIMIT. + */ + public function test_simple_delete_multi_column_order_by_limit_offset_translates_to_ctid_subquery(): void { + $driver = $this->create_driver(); + + $delete = "DELETE FROM wptests_delete_order_multi + WHERE state = 'stale' + ORDER BY priority ASC, id DESC + LIMIT 1, 2"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_order_multi" WHERE ctid IN (SELECT ctid FROM "wptests_delete_order_multi" WHERE state = \'stale\' ORDER BY priority ASC, id DESC LIMIT 2 OFFSET 1)', + $sql + ); + } + + /** + * Tests bounded DELETE ORDER BY/LIMIT offset,count deletes the intended ordered row slice. + */ + public function test_simple_delete_order_by_limit_offset_executes_expected_row_slice(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_delete_order_exec ( + ctid INTEGER PRIMARY KEY, + id INTEGER NOT NULL, + priority INTEGER NOT NULL, + state TEXT NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_delete_order_exec (ctid, id, priority, state) VALUES + (1, 1, 10, 'stale'), + (2, 2, 20, 'stale'), + (3, 3, 30, 'stale'), + (4, 4, 40, 'stale'), + (5, 5, 50, 'keep')" + ); + + $delete = "DELETE FROM wptests_delete_order_exec + WHERE state = 'stale' + ORDER BY priority ASC, id ASC + LIMIT 1, 2"; + + $this->assertSame( 2, $driver->query( $delete ) ); + $this->assertSame( + 'DELETE FROM "wptests_delete_order_exec" WHERE ctid IN (SELECT ctid FROM "wptests_delete_order_exec" WHERE state = \'stale\' ORDER BY priority ASC, id ASC LIMIT 2 OFFSET 1)', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT id, state FROM wptests_delete_order_exec ORDER BY id' ); + + $this->assertCount( 3, $rows ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'stale', $rows[0]->state ); + $this->assertSame( '4', $rows[1]->id ); + $this->assertSame( 'stale', $rows[1]->state ); + $this->assertSame( '5', $rows[2]->id ); + $this->assertSame( 'keep', $rows[2]->state ); + } + + /** + * Tests simple DELETE LIMIT without WHERE or ORDER BY uses a ctid subquery. + */ + public function test_simple_delete_limit_without_where_or_order_by_uses_ctid_subquery(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_simple_mysql_delete_query', + 'DELETE FROM wptests_delete_limit_only LIMIT 1, 2' + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limit_only" WHERE ctid IN (SELECT ctid FROM "wptests_delete_limit_only" LIMIT 2 OFFSET 1)', + $sql + ); + + $driver->query( + 'CREATE TABLE wptests_delete_limit_only ( + ctid INTEGER PRIMARY KEY, + id INTEGER NOT NULL + )' + ); + $driver->query( 'INSERT INTO wptests_delete_limit_only (ctid, id) VALUES (1, 1), (2, 2), (3, 3), (4, 4)' ); + + $this->assertSame( 2, $driver->query( 'DELETE FROM wptests_delete_limit_only LIMIT 1, 2' ) ); + + $rows = $driver->query( 'SELECT COUNT(*) AS remaining FROM wptests_delete_limit_only' ); + $this->assertSame( '2', $rows[0]->remaining ); + } + + /** + * Tests MySQL multi-target DELETE statements translate to PostgreSQL writable CTEs. + */ + public function test_mysql_multi_target_delete_is_translated_to_writable_ctes(): void { + $driver = $this->create_driver(); + + $delete = "DELETE p, c + FROM wptests_delete_multi_parent AS p + JOIN wptests_delete_multi_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "p".ctid AS "mysql_delete_target_0_ctid", "c".ctid AS "mysql_delete_target_1_ctid"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_multi_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_multi_child" AS "c"', $sql ); + $this->assertStringContainsString( 'SELECT (SELECT COUNT(*) FROM mysql_delete_target_0) + (SELECT COUNT(*) FROM mysql_delete_target_1) AS affected_rows', $sql ); + + $ordered_delete = "DELETE p, c + FROM wptests_delete_multi_parent AS p + JOIN wptests_delete_multi_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale' + ORDER BY LENGTH(c.reason), p.id DESC"; + + $ordered_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $ordered_delete + ); + + $this->assertNotNull( $ordered_sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $ordered_sql ); + $this->assertStringContainsString( "WHERE p.status = 'stale' ORDER BY CASE WHEN ", $ordered_sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST(c.reason AS text), 'UTF8')) END, p.id DESC", $ordered_sql ); + + $limited_delete = "DELETE p, c + FROM wptests_delete_multi_parent AS p + JOIN wptests_delete_multi_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale' + ORDER BY c.reason ASC, p.id DESC + LIMIT 1, 2"; + + $limited_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $limited_delete + ); + + $this->assertNotNull( $limited_sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $limited_sql ); + $this->assertStringContainsString( "WHERE p.status = 'stale' ORDER BY c.reason ASC, p.id DESC LIMIT 2 OFFSET 1", $limited_sql ); + + $limit_only_delete = "DELETE p, c + FROM wptests_delete_multi_parent AS p + JOIN wptests_delete_multi_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale' + LIMIT 2"; + + $limit_only_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $limit_only_delete + ); + + $this->assertNotNull( $limit_only_sql ); + $this->assertStringContainsString( "WHERE p.status = 'stale' LIMIT 2", $limit_only_sql ); + $this->assertStringNotContainsString( 'ORDER BY', $limit_only_sql ); + } + + /** + * Tests MySQL multi-target DELETE modifiers are accepted as compatibility no-ops. + */ + public function test_mysql_multi_target_delete_modifiers_are_accepted_as_compatibility_noops(): void { + $driver = $this->create_driver(); + + $delete = "DELETE LOW_PRIORITY QUICK IGNORE p, c + FROM wptests_delete_multi_modifier_parent AS p + JOIN wptests_delete_multi_modifier_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "p".ctid AS "mysql_delete_target_0_ctid", "c".ctid AS "mysql_delete_target_1_ctid"', $sql ); + $this->assertStringContainsString( 'FROM wptests_delete_multi_modifier_parent AS p JOIN wptests_delete_multi_modifier_child AS c ON c.parent_id = p.id', $sql ); + $this->assertStringContainsString( "WHERE p.status = 'stale'", $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_multi_modifier_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_multi_modifier_child" AS "c"', $sql ); + $this->assertStringNotContainsString( 'LOW_PRIORITY', $sql ); + $this->assertStringNotContainsString( 'QUICK', $sql ); + $this->assertStringNotContainsString( 'IGNORE', $sql ); + } + + /** + * Tests single-target joined DELETE without WHERE uses the target-list rewrite. + */ + public function test_mysql_single_target_join_delete_without_where_is_translated_to_writable_cte(): void { + $driver = $this->create_driver(); + + $delete = 'DELETE p + FROM wptests_delete_join_parent AS p + JOIN wptests_delete_join_child AS c ON c.parent_id = p.id'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "p".ctid AS "mysql_delete_target_0_ctid"', $sql ); + $this->assertStringContainsString( 'FROM wptests_delete_join_parent AS p JOIN wptests_delete_join_child AS c ON c.parent_id = p.id', $sql ); + $this->assertStringNotContainsString( 'ON c.parent_id = p.id WHERE', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_join_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'SELECT (SELECT COUNT(*) FROM mysql_delete_target_0) AS affected_rows', $sql ); + } + + /** + * Tests MySQL multi-target DELETE USING statements share the writable CTE path. + */ + public function test_mysql_multi_target_delete_using_is_translated_to_writable_ctes(): void { + $driver = $this->create_driver(); + + $delete = "DELETE FROM p, c + USING wptests_delete_using_parent AS p + JOIN wptests_delete_using_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "p".ctid AS "mysql_delete_target_0_ctid", "c".ctid AS "mysql_delete_target_1_ctid"', $sql ); + $this->assertStringContainsString( 'FROM wptests_delete_using_parent AS p JOIN wptests_delete_using_child AS c ON c.parent_id = p.id', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_using_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_using_child" AS "c"', $sql ); + } + + /** + * Tests MySQL FROM ... USING multi-target DELETE modifiers are accepted. + */ + public function test_mysql_multi_target_delete_using_modifiers_are_accepted_as_compatibility_noops(): void { + $driver = $this->create_driver(); + + $delete = "DELETE LOW_PRIORITY QUICK IGNORE FROM p, c + USING wptests_delete_using_modifier_parent AS p + JOIN wptests_delete_using_modifier_child AS c ON c.parent_id = p.id + WHERE p.status = 'stale'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "p".ctid AS "mysql_delete_target_0_ctid", "c".ctid AS "mysql_delete_target_1_ctid"', $sql ); + $this->assertStringContainsString( 'FROM wptests_delete_using_modifier_parent AS p JOIN wptests_delete_using_modifier_child AS c ON c.parent_id = p.id', $sql ); + $this->assertStringContainsString( "WHERE p.status = 'stale'", $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_using_modifier_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_using_modifier_child" AS "c"', $sql ); + $this->assertStringNotContainsString( 'LOW_PRIORITY', $sql ); + $this->assertStringNotContainsString( 'QUICK', $sql ); + $this->assertStringNotContainsString( 'IGNORE', $sql ); + } + + /** + * Tests same-table multi-target DELETE statements delete each physical table once. + */ + public function test_mysql_same_table_multi_target_delete_groups_physical_targets(): void { + $driver = $this->create_driver(); + + $delete = "DELETE FROM a, b + USING wptests_delete_same AS a, wptests_delete_same AS b + WHERE a.option_name = CONCAT('_transient_', b.option_name)"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "a".ctid AS "mysql_delete_target_0_ctid", "b".ctid AS "mysql_delete_target_1_ctid"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_same" AS "a" USING mysql_delete_rows', $sql ); + $this->assertStringContainsString( + 'WHERE "a".ctid IN (SELECT "mysql_delete_target_0_ctid" FROM mysql_delete_rows UNION SELECT "mysql_delete_target_1_ctid" FROM mysql_delete_rows) RETURNING 1', + $sql + ); + $this->assertSame( 1, substr_count( $sql, 'DELETE FROM "wptests_delete_same"' ) ); + $this->assertStringContainsString( 'SELECT (SELECT COUNT(*) FROM mysql_delete_target_0) AS affected_rows', $sql ); + } + + /** + * Tests generic multi-target DELETE predicates support common MySQL expressions. + */ + public function test_mysql_multi_target_delete_supports_complex_predicates(): void { + $driver = $this->create_driver(); + + $delete = "DELETE p, c + FROM wptests_delete_complex_parent AS p + JOIN wptests_delete_complex_child AS c ON c.parent_id = p.id + WHERE (p.status LIKE 'stale%' OR c.reason = CONCAT('old_', SUBSTRING(p.status, 1, 5))) + AND c.note NOT LIKE 'keep%'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( "p.status LIKE 'stale%'", $sql ); + $this->assertStringContainsString( "CAST('old_' AS text) || CAST(", $sql ); + $this->assertStringContainsString( 'SUBSTRING(CAST(p.status AS text)', $sql ); + $this->assertStringContainsString( "c.note NOT LIKE 'keep%'", $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_complex_parent" AS "p"', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_delete_complex_child" AS "c"', $sql ); + } + + /** + * Tests joined DELETE statements can read from information_schema sources. + */ + public function test_mysql_delete_joined_to_information_schema_is_translated(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $delete = 'DELETE o + FROM wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + WHERE it.table_schema = DATABASE()'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_multi_target_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH mysql_delete_rows AS MATERIALIZED', $sql ); + $this->assertStringContainsString( 'SELECT "o".ctid AS "mysql_delete_target_0_ctid"', $sql ); + $this->assertStringContainsString( 'FROM "wptests_options" AS "o" JOIN (', $sql ); + $this->assertStringContainsString( ') AS "it" ON "o"."option_name" = "it"."TABLE_NAME"', $sql ); + $this->assertStringContainsString( 'WHERE "it"."TABLE_SCHEMA" = \'wptests\'', $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_options" AS "o"', $sql ); + } + + /** + * Tests information_schema aliases remain read-only in joined DELETE statements. + */ + public function test_mysql_delete_joined_to_information_schema_rejects_catalog_target(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $delete = 'DELETE it + FROM wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name'; + + try { + $driver->query( $delete ); + $this->fail( 'Expected unsupported DELETE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported DELETE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests bare uppercase ID in simple DELETE WHERE clauses is quoted. + */ + public function test_simple_delete_with_bare_uppercase_id_where_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, user_login TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (1, \'admin\')' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (2, \'editor\')' ); + + $delete = 'DELETE FROM wptests_users WHERE ID != 1'; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( + array( + array( + 'sql' => 'DELETE FROM "wptests_users" WHERE "ID" != 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests WordPress expired transient cleanup DELETE statements are translated. + */ + public function test_wordpress_expired_transients_delete_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_options ( + option_name TEXT NOT NULL, + option_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value) VALUES ('_transient_expired', 'value')" ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value) VALUES ('_transient_timeout_expired', '100')" ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value) VALUES ('_transient_fresh', 'value')" ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value) VALUES ('_transient_timeout_fresh', '9999999999')" ); + + $delete = "DELETE a, b FROM wptests_options a, wptests_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 < 200"; + + $driver->query( $delete ); + + $this->assertSame( $delete, $driver->get_last_mysql_query() ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WITH expired_transients AS', $queries[0]['sql'] ); + $this->assertStringContainsString( 'DELETE FROM "wptests_options"', $queries[0]['sql'] ); + $this->assertStringContainsString( 'SUBSTR(a.option_name, 12)', $queries[0]['sql'] ); + + $rows = $driver->query( 'SELECT option_name FROM wptests_options ORDER BY option_name' ); + + $this->assertSame( + array( '_transient_fresh', '_transient_timeout_fresh' ), + array_map( + function ( $row ) { + return $row->option_name; + }, + $rows + ) + ); + } + + /** + * Tests WooCommerce orphan cleanup DELETE statements are translated to anti-joins. + */ + public function test_mysql_left_join_orphan_delete_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + meta_id INTEGER PRIMARY KEY, + post_id INTEGER NOT NULL + )' + ); + $driver->query( 'INSERT INTO wptests_posts ("ID") VALUES (1)' ); + $driver->query( 'INSERT INTO wptests_postmeta (meta_id, post_id) VALUES (1, 1)' ); + $driver->query( 'INSERT INTO wptests_postmeta (meta_id, post_id) VALUES (2, 999)' ); + + $delete = 'DELETE meta FROM wptests_postmeta meta LEFT JOIN wptests_posts posts ON posts.ID = meta.post_id WHERE posts.ID IS NULL;'; + + $this->assertSame( 1, $driver->query( $delete ) ); + $this->assertSame( $delete, $driver->get_last_mysql_query() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'DELETE FROM "wptests_postmeta" AS meta WHERE NOT EXISTS (SELECT 1 FROM "wptests_posts" AS posts WHERE posts."ID" = meta.post_id)', + $queries[0]['sql'] + ); + + $rows = $driver->query( 'SELECT meta_id, post_id FROM wptests_postmeta ORDER BY meta_id' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->meta_id ); + $this->assertSame( '1', $rows[0]->post_id ); + } + + /** + * Tests MySQL joined DELETE statements with AS aliases are translated. + */ + public function test_mysql_join_delete_with_as_alias_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $delete = "DELETE `postmeta` FROM `wptests_postmeta` AS `postmeta` + LEFT JOIN `wptests_posts` AS `posts` ON `posts`.`ID` = `postmeta`.`post_id` + WHERE `posts`.`post_type` = 'forum' + AND `postmeta`.`meta_key` = '_bbp_reply_count' + OR `postmeta`.`meta_key` = '_bbp_total_reply_count'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_postmeta" AS "postmeta" WHERE "postmeta".ctid IN (SELECT "postmeta".ctid FROM "wptests_postmeta" AS "postmeta" LEFT JOIN "wptests_posts" AS "posts" ON "posts"."ID" = "postmeta"."post_id" WHERE "posts"."post_type" = \'forum\' AND "postmeta"."meta_key" = \'_bbp_reply_count\' OR "postmeta"."meta_key" = \'_bbp_total_reply_count\')', + $sql + ); + } + + /** + * Tests joined DELETE REGEXP predicates are translated to PostgreSQL regex operators. + */ + public function test_mysql_join_delete_regexp_predicate_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $delete = "DELETE d FROM wptests_delete_regexp d + JOIN wptests_delete_regexp_related r ON r.id = d.related_id + WHERE d.name REGEXP '^x' AND r.status = 'old'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_regexp" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_regexp d JOIN wptests_delete_regexp_related r ON r.id = d.related_id WHERE d.name ~* \'^x\' AND r.status = \'old\')', + $sql + ); + } + + /** + * Tests joined DELETE ORDER BY/LIMIT clauses are applied inside the ctid subquery. + */ + public function test_mysql_join_delete_order_by_limit_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $delete = "DELETE d FROM wptests_delete_limited d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY d.id DESC + LIMIT 1"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_limited d JOIN wptests_related r ON r.id = d.related_id WHERE d.status = \'old\' ORDER BY d.id DESC LIMIT 1)', + $sql + ); + } + + /** + * Tests joined DELETE ORDER BY without LIMIT is preserved in the ctid subquery. + */ + public function test_mysql_join_delete_order_by_without_limit_preserves_ordering(): void { + $driver = $this->create_driver(); + + $delete = "DELETE d FROM wptests_delete_ordered d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY d.id DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_ordered" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_ordered d JOIN wptests_related r ON r.id = d.related_id WHERE d.status = \'old\' ORDER BY d.id DESC)', + $sql + ); + } + + /** + * Tests joined DELETE expression ORDER BY without LIMIT is translated. + */ + public function test_mysql_join_delete_expression_order_by_without_limit_is_translated(): void { + $driver = $this->create_driver(); + + $delete = "DELETE d FROM wptests_delete_order_expression d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY LENGTH(d.status), d.id + 0 DESC"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertStringStartsWith( + 'DELETE FROM "wptests_delete_order_expression" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_order_expression d JOIN wptests_related r ON r.id = d.related_id WHERE d.status = \'old\' ORDER BY ', + $sql + ); + $this->assertStringContainsString( 'ELSE OCTET_LENGTH(CONVERT_TO(CAST(d.status AS text), \'UTF8\')) END, d.id + 0 DESC)', $sql ); + } + + /** + * Tests joined DELETE supports MySQL LIMIT offset,count syntax. + */ + public function test_mysql_join_delete_limit_offsets_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $delete = "DELETE d FROM wptests_delete_limited d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY d.id ASC + LIMIT 2, 3"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_limited d JOIN wptests_related r ON r.id = d.related_id WHERE d.status = \'old\' ORDER BY d.id ASC LIMIT 3 OFFSET 2)', + $sql + ); + + $delete = "DELETE d FROM wptests_delete_limited d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY d.id ASC + LIMIT 3 OFFSET 2"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertSame( + 'DELETE FROM "wptests_delete_limited" AS "d" WHERE "d".ctid IN (SELECT "d".ctid FROM wptests_delete_limited d JOIN wptests_related r ON r.id = d.related_id WHERE d.status = \'old\' ORDER BY d.id ASC LIMIT 3 OFFSET 2)', + $sql + ); + } + + /** + * Tests joined DELETE statements can read from information_schema sources. + */ + public function test_joined_delete_can_read_information_schema_sources(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $delete = "DELETE o + FROM wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + WHERE it.table_schema = DATABASE() + AND o.autoload = 'yes'"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_options" AS "o" WHERE "o".ctid IN (SELECT "o".ctid FROM "wptests_options" AS "o" JOIN (', $sql ); + $this->assertStringContainsString( ') AS "it" ON "o"."option_name" = "it"."TABLE_NAME"', $sql ); + $this->assertStringContainsString( 'WHERE "it"."TABLE_SCHEMA" = \'wptests\'', $sql ); + $this->assertStringContainsString( '"o"."autoload" = \'yes\'', $sql ); + $this->assertStringNotContainsString( 'information_schema.tables', $sql ); + } + + /** + * Tests information_schema joined DELETE predicates can use nested current-database SELECTs. + */ + public function test_joined_delete_can_read_information_schema_predicate_subqueries(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $delete = 'DELETE o + FROM wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + WHERE it.table_schema IN (SELECT DATABASE())'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_single_target_join_delete_query', + $delete + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'DELETE FROM "wptests_options" AS "o" WHERE "o".ctid IN (SELECT "o".ctid FROM "wptests_options" AS "o" JOIN (', $sql ); + $this->assertStringContainsString( 'WHERE "it"."TABLE_SCHEMA" IN ( SELECT \'wptests\' )', $sql ); + $this->assertStringNotContainsString( 'DATABASE()', $sql ); + $this->assertStringNotContainsString( 'information_schema.tables', $sql ); + } + + /** + * Tests unsupported information_schema joined DELETE predicates fail before backend execution. + */ + public function test_joined_delete_rejects_unsupported_information_schema_predicates(): void { + $driver = $this->create_driver(); + $this->install_direct_information_schema_options_metadata( $driver ); + + $delete = 'DELETE o + FROM wptests_options AS o + JOIN information_schema.tables AS it ON o.option_name = it.table_name + WHERE it.table_schema IN (SELECT DATABASE() UNION SELECT DATABASE())'; + + try { + $driver->query( $delete ); + $this->fail( 'Expected unsupported DELETE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported DELETE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests unsupported DELETE shapes fail before backend execution. + */ + public function test_unsupported_delete_shapes_fail_closed_before_backend(): void { + $queries = array( + 'DELETE FROM wptests_delete_order_bad + ORDER BY missing_alias.id', + 'DELETE FROM wptests_delete_order_bad + ORDER BY id + LIMIT bad', + 'DELETE FROM wptests_delete_order_bad + ORDER BY id + LIMIT 1, bad', + "DELETE d, r FROM wptests_delete d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY d.id LIMIT bad", + "DELETE d FROM wptests_delete d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY missing_alias.id", + "DELETE d, r FROM wptests_delete d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old' + ORDER BY missing_alias.id", + "DELETE d FROM other_db.wptests_delete d + JOIN wptests_related r ON r.id = d.related_id + WHERE d.status = 'old'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported DELETE statement.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported DELETE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests MySQL DUAL table references are erased in PostgreSQL-compatible SELECTs. + */ + public function test_mysql_dual_table_reference_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SELECT 1 AS output FROM DUAL' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->output ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT 1 AS output', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests MySQL DUAL table references are erased in INSERT ... SELECT queries. + */ + public function test_insert_select_from_dual_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_actionscheduler_actions ( + hook TEXT NOT NULL, + status TEXT NOT NULL + )' + ); + + $insert = "INSERT INTO wptests_actionscheduler_actions (`hook`, `status`) + SELECT 'action_scheduler/migration_hook', 'pending' FROM DUAL + WHERE ( SELECT NULL FROM DUAL ) IS NULL"; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO wptests_actionscheduler_actions ("hook", "status") SELECT \'action_scheduler/migration_hook\', \'pending\' WHERE (SELECT NULL) IS NULL', + $queries[0]['sql'] + ); + + $rows = $driver->query( 'SELECT hook, status FROM wptests_actionscheduler_actions' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'action_scheduler/migration_hook', $rows[0]->hook ); + $this->assertSame( 'pending', $rows[0]->status ); + } + + /** + * Tests bbPress-style INSERT ... SELECT repair queries coerce target types. + */ + public function test_parenthesized_insert_select_coerces_target_columns_and_grouped_projections(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY, + post_parent INTEGER NOT NULL, + post_author INTEGER NOT NULL, + post_type TEXT NOT NULL, + post_status TEXT NOT NULL + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + meta_id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + meta_key TEXT NOT NULL, + meta_value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_parent` bigint(20) unsigned NOT NULL DEFAULT 0, + `post_author` bigint(20) unsigned NOT NULL DEFAULT 0, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_postmeta ( + `meta_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `post_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL, + PRIMARY KEY (`meta_id`) + )' + ); + + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `post_author`, `post_type`, `post_status`) VALUES (10, 0, 1, 'forum', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `post_author`, `post_type`, `post_status`) VALUES (100, 10, 3, 'topic', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `post_author`, `post_type`, `post_status`) VALUES (101, 100, 4, 'reply', 'publish')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (100, '_bbp_topic_id', '100')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (101, '_bbp_topic_id', '100')" ); + + $engagements_sql = "INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) ( + SELECT postmeta.meta_value, '_bbp_engagement', posts.post_author + FROM wptests_posts AS posts + LEFT JOIN wptests_postmeta AS postmeta + ON posts.ID = postmeta.post_id + AND postmeta.meta_key = '_bbp_topic_id' + WHERE posts.post_type IN ('topic', 'reply') + AND posts.post_status IN ('publish', 'closed') + GROUP BY postmeta.meta_value, posts.post_author)"; + + $this->assertSame( 2, $driver->query( $engagements_sql ) ); + + $engagement_queries = $driver->get_last_postgresql_queries(); + $engagement_sql = $engagement_queries[0]['sql']; + $this->assertStringContainsString( 'CASE WHEN CAST(postmeta.meta_value AS text) IS NULL THEN NULL ELSE CAST(COALESCE(SUBSTRING(CAST(postmeta.meta_value AS text)', $engagement_sql ); + $this->assertStringContainsString( 'CAST(posts.post_author AS text)', $engagement_sql ); + + $engagements = $driver->query( "SELECT post_id, meta_key, meta_value FROM wptests_postmeta WHERE meta_key = '_bbp_engagement' ORDER BY meta_value" ); + $this->assertCount( 2, $engagements ); + $this->assertSame( '100', $engagements[0]->post_id ); + $this->assertSame( '3', $engagements[0]->meta_value ); + $this->assertSame( '100', $engagements[1]->post_id ); + $this->assertSame( '4', $engagements[1]->meta_value ); + + $forum_meta_sql = "INSERT INTO `wptests_postmeta` (`post_id`, `meta_key`, `meta_value`) + ( SELECT `reply`.`ID`, '_bbp_forum_id', `topic`.`post_parent` + FROM `wptests_posts` + AS `reply` + INNER JOIN `wptests_posts` + AS `topic` + ON `reply`.`post_parent` = `topic`.`ID` + WHERE `topic`.`post_type` = 'topic' + AND `reply`.`post_type` = 'reply' + GROUP BY `reply`.`ID` )"; + + $this->assertSame( 1, $driver->query( $forum_meta_sql ) ); + + $forum_meta_queries = $driver->get_last_postgresql_queries(); + $this->assertStringContainsString( + 'CAST(MIN("topic"."post_parent") AS text)', + $forum_meta_queries[0]['sql'] + ); + + $forum_meta = $driver->query( "SELECT post_id, meta_value FROM wptests_postmeta WHERE meta_key = '_bbp_forum_id'" ); + $this->assertCount( 1, $forum_meta ); + $this->assertSame( '101', $forum_meta[0]->post_id ); + $this->assertSame( '10', $forum_meta[0]->meta_value ); + + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `post_author`, `post_type`, `post_status`) VALUES (102, 100, 5, 'reply', 'spam')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `post_author`, `post_type`, `post_status`) VALUES (103, 100, 6, 'reply', 'pending')" ); + $hidden_reply_count_sql = "INSERT INTO `wptests_postmeta` (`post_id`, `meta_key`, `meta_value`) + (SELECT `post_parent`, '_bbp_reply_count_hidden', COUNT(`post_status`) as `meta_value` + FROM `wptests_posts` + WHERE `post_type` = 'reply' + AND `post_status` IN ('trash','spam','pending') + GROUP BY `post_parent`)"; + + $this->assertSame( 1, $driver->query( $hidden_reply_count_sql ) ); + + $hidden_reply_count_queries = $driver->get_last_postgresql_queries(); + $this->assertStringContainsString( + 'CAST(COUNT ("post_status") AS text)', + $hidden_reply_count_queries[0]['sql'] + ); + $this->assertStringNotContainsString( 'AS "meta_value" AS text', $hidden_reply_count_queries[0]['sql'] ); + + $hidden_reply_count = $driver->query( "SELECT post_id, meta_value FROM wptests_postmeta WHERE meta_key = '_bbp_reply_count_hidden'" ); + $this->assertCount( 1, $hidden_reply_count ); + $this->assertSame( '100', $hidden_reply_count[0]->post_id ); + $this->assertSame( '2', $hidden_reply_count[0]->meta_value ); + } + + /** + * Tests multi-assignment WordPress UPDATE statements are translated to PostgreSQL. + */ + public function test_multi_assignment_wordpress_update_with_backticks_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wp_options ( + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL, + autoload TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('key1', 'value1', 'no')" ); + + $update = "UPDATE `wp_options` SET `option_value` = 'value2', `autoload` = 'yes' WHERE `option_name` = 'key1'"; + + $this->assertSame( 1, $driver->query( $update ) ); + $this->assertSame( + array( + array( + 'sql' => 'UPDATE "wp_options" SET "option_value" = \'value2\', "autoload" = \'yes\' WHERE ("option_name" = \'key1\') AND ("option_value" IS DISTINCT FROM (\'value2\') OR "autoload" IS DISTINCT FROM (\'yes\'))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wp_options WHERE option_name = 'key1'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'value2', $rows[0]->option_value ); + $this->assertSame( 'yes', $rows[0]->autoload ); + } + + /** + * Tests complex SELECT statements quote mixed-case WordPress identifiers. + */ + public function test_complex_select_quotes_mixed_case_wordpress_identifiers(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_comments ( + "comment_post_ID" INTEGER NOT NULL, + comment_approved TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_comments (\"comment_post_ID\", comment_approved) VALUES (1, '1')" ); + + $select = "SELECT COUNT(*) FROM wptests_comments WHERE comment_post_ID = 1 AND comment_approved = '1'"; + $rows = $driver->query( $select ); + + $this->assertSame( '1', array_values( get_object_vars( $rows[0] ) )[0] ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT COUNT (*) FROM wptests_comments WHERE "comment_post_ID" = 1 AND comment_approved = \'1\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests complex JOIN queries quote qualified mixed-case WordPress identifiers. + */ + public function test_complex_join_select_quotes_qualified_mixed_case_wordpress_identifiers(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_postmeta (post_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES (1, 'nav_menu_item', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (1, '_menu_item_object_id', '2')" ); + + $select = "SELECT wptests_posts.* + FROM wptests_posts INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE wptests_postmeta.meta_key = '_menu_item_object_id' + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts.* FROM wptests_posts INNER JOIN wptests_postmeta ON (wptests_posts."ID" = wptests_postmeta.post_id) WHERE wptests_postmeta.meta_key = \'_menu_item_object_id\' GROUP BY wptests_posts."ID" ORDER BY wptests_posts.post_date DESC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests CONVERT(expr USING charset) expressions are translated to PostgreSQL. + */ + public function test_convert_using_expression_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_convert (value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_convert (value) VALUES ('Customer')" ); + $driver->query( "INSERT INTO wptests_convert (value) VALUES ('Other')" ); + + $select = "SELECT CONVERT(value USING utf8mb4) AS converted + FROM wptests_convert + WHERE CONVERT(value USING utf8mb4) = 'Customer' + ORDER BY CONVERT(value USING utf8mb4)"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'Customer', $rows[0]->converted ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT (value) AS converted FROM wptests_convert WHERE (value) = \'Customer\' ORDER BY (value)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests direct MySQL collations on CONVERT(expr USING charset) are omitted. + */ + public function test_convert_using_expression_omits_direct_mysql_collation(): void { + $driver = $this->create_driver(); + + $select = "SELECT CONVERT('Customer' USING utf8mb4) COLLATE utf8mb4_bin AS value"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'Customer', $rows[0]->value ); + $this->assertSame( + array( + array( + 'sql' => "SELECT ('Customer') AS value", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests compound CONVERT(expr USING charset) expressions preserve grouping. + */ + public function test_convert_using_compound_expression_preserves_grouping(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SELECT CONVERT(1 + 2 USING utf8mb4) * 3 AS value' ); + + $this->assertSame( '9', $rows[0]->value ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT (1 + 2) * 3 AS value', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests right-hand compound CONVERT(expr USING charset) expressions preserve grouping. + */ + public function test_convert_using_right_hand_compound_expression_preserves_grouping(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SELECT 10 - CONVERT(1 + 2 USING utf8mb4) AS value' ); + + $this->assertSame( '7', $rows[0]->value ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT 10 - (1 + 2) AS value', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests CONVERT(expr, SIGNED) expressions use MySQL integer coercion. + */ + public function test_convert_signed_expression_uses_mysql_integer_coercion(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( 'CREATE TABLE wptests_bp_groups_groupmeta (meta_value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_bp_groups_groupmeta (meta_value) VALUES ('10members')" ); + + $rows = $driver->query( + 'SELECT CONVERT(meta_value, SIGNED) AS member_count + FROM wptests_bp_groups_groupmeta + ORDER BY CONVERT(meta_value, SIGNED) DESC' + ); + + $meta_value_cast_sql = $this->get_expected_mysql_integer_cast_sql( 'meta_value' ); + $this->assertSame( '10', $rows[0]->member_count ); + $this->assertStringContainsString( + 'SELECT ' . $meta_value_cast_sql . ' AS member_count', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + $this->assertStringContainsString( + 'ORDER BY ' . $meta_value_cast_sql . ' DESC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests CONVERT(expr, CHAR/BINARY) expressions are translated to PostgreSQL. + */ + public function test_convert_char_and_binary_expressions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SELECT CONVERT('abc', CHAR) AS char_value, CONVERT('abc', BINARY) AS binary_value" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'abc', $rows[0]->char_value ); + $this->assertSame( 'abc', $rows[0]->binary_value ); + $this->assertSame( + "SELECT CAST('abc' AS text) AS char_value, CAST('abc' AS text) AS binary_value", + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests CONVERT(expr, CHAR/BINARY) expressions translate in predicates and ordering. + */ + public function test_convert_char_and_binary_column_references_translate_in_predicates_and_ordering(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_convert_typed (value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_convert_typed (value) VALUES ('abc')" ); + $driver->query( "INSERT INTO wptests_convert_typed (value) VALUES ('ABC')" ); + + $rows = $driver->query( + "SELECT CONVERT(value, CHAR) AS value_text + FROM wptests_convert_typed + WHERE CONVERT(value, BINARY) = 'abc' + ORDER BY CONVERT(value, CHAR)" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'abc', $rows[0]->value_text ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'SELECT CAST(value AS text) AS value_text', $sql ); + $this->assertStringContainsString( "WHERE CAST(value AS text) = 'abc'", $sql ); + $this->assertStringContainsString( 'ORDER BY CAST(value AS text)', $sql ); + $this->assertStringNotContainsString( 'CONVERT', $sql ); + } + + /** + * Tests CONVERT(expr, DECIMAL/DATE) expressions use explicit PostgreSQL semantics. + */ + public function test_convert_decimal_and_date_expressions_are_translated_to_postgresql(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $rows = $driver->query( + "SELECT CONVERT('123.456tail', DECIMAL) AS decimal_value" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '123.456', $rows[0]->decimal_value ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( ' AS decimal_value', $sql ); + $this->assertStringNotContainsString( 'CONVERT', $sql ); + + $date_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT CONVERT('2025-10-05 14:05:28', DATE) AS date_value" + ); + + $this->assertNotNull( $date_sql ); + $this->assertStringContainsString( 'TO_CHAR', $date_sql ); + $this->assertStringContainsString( ' AS date_value', $date_sql ); + $this->assertStringNotContainsString( 'CONVERT', $date_sql ); + } + + /** + * Tests unsupported CONVERT(expr, type) forms fail before backend execution. + */ + public function test_unsupported_convert_typed_forms_fail_closed_before_backend_execution(): void { + $queries = array( + "SELECT CONVERT('12:34:56', TIME) AS time_value", + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $query + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported CONVERT() runtime form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests MySQL FIELD() expressions are translated for PostgreSQL ordering. + */ + public function test_field_function_is_translated_to_postgresql_case_expression(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_name) VALUES (1, \'alpha\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_name) VALUES (2, \'beta\')' ); + + $select = 'SELECT ID FROM wptests_posts WHERE ID IN (1, 2) ORDER BY FIELD(ID, 2, 1)'; + $rows = $driver->query( $select ); + + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( '1', $rows[1]->ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "ID" FROM wptests_posts WHERE "ID" IN (1, 2) ORDER BY CASE WHEN "ID" IS NULL THEN 0 WHEN CAST("ID" AS text) = CAST(2 AS text) THEN 1 WHEN CAST("ID" AS text) = CAST(1 AS text) THEN 2 ELSE 0 END', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests lowercase field() calls trigger PostgreSQL compatibility translation. + */ + public function test_lowercase_field_function_triggers_postgresql_rewrite(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts (post_name TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts (post_name) VALUES (\'alpha\')' ); + $driver->query( 'INSERT INTO wptests_posts (post_name) VALUES (\'beta\')' ); + + $rows = $driver->query( "SELECT post_name FROM wptests_posts ORDER BY field(post_name, 'beta', 'alpha')" ); + + $this->assertSame( 'beta', $rows[0]->post_name ); + $this->assertSame( 'alpha', $rows[1]->post_name ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT post_name FROM wptests_posts ORDER BY CASE WHEN post_name IS NULL THEN 0 WHEN CAST(post_name AS text) = CAST(\'beta\' AS text) THEN 1 WHEN CAST(post_name AS text) = CAST(\'alpha\' AS text) THEN 2 ELSE 0 END', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests FIELD() returns zero for NULL and missing values. + */ + public function test_field_function_returns_zero_for_null_and_missing_values(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SELECT FIELD(NULL, 1) AS null_position, FIELD('missing', 'alpha') AS missing_position, FIELD('alpha', 'beta', 'alpha') AS alpha_position" ); + + $this->assertSame( '0', $rows[0]->null_position ); + $this->assertSame( '0', $rows[0]->missing_position ); + $this->assertSame( '2', $rows[0]->alpha_position ); + } + + /** + * Tests SELECT VERSION() matches the emulated MySQL version and output label. + */ + public function test_version_runtime_function_matches_emulated_mysql_version(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SELECT VERSION()' ); + + $this->assertSame( '8.0.38', $rows[0]->{'VERSION()'} ); + $this->assertSame( array( 'VERSION()' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT \'8.0.38\' AS "VERSION()"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests MySQL session runtime functions use the emulated session identity. + */ + public function test_mysql_session_runtime_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( + 'SELECT CURRENT_USER AS current_user_bare, + CURRENT_USER() AS current_user_call, + USER() AS user_value, + SESSION_USER() AS session_user_value, + SYSTEM_USER() AS system_user_value, + CONNECTION_ID() AS connection_id, + LAST_INSERT_ID() AS last_insert_id' + ); + + $this->assertSame( 'root@%', $rows[0]->current_user_bare ); + $this->assertSame( 'root@%', $rows[0]->current_user_call ); + $this->assertSame( 'root@%', $rows[0]->user_value ); + $this->assertSame( 'root@%', $rows[0]->session_user_value ); + $this->assertSame( 'root@%', $rows[0]->system_user_value ); + $this->assertSame( '1', $rows[0]->connection_id ); + $this->assertSame( '0', $rows[0]->last_insert_id ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( "'root@%' AS current_user_bare", $sql ); + $this->assertStringContainsString( "'root@%' AS current_user_call", $sql ); + $this->assertStringContainsString( "'root@%' AS user_value", $sql ); + $this->assertStringContainsString( "'root@%' AS session_user_value", $sql ); + $this->assertStringContainsString( "'root@%' AS system_user_value", $sql ); + $this->assertStringContainsString( '1 AS connection_id', $sql ); + $this->assertStringContainsString( '0 AS last_insert_id', $sql ); + } + + /** + * Tests LAST_INSERT_ID() reflects mutable session state instead of cached SQL. + */ + public function test_last_insert_id_runtime_function_is_not_cached_between_inserts(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_runtime_insert_id (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_runtime_insert_id ( + id bigint(20) unsigned NOT NULL auto_increment, + value varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + + $driver->query( "INSERT INTO wptests_runtime_insert_id (value) VALUES ('first')" ); + $first = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + + $driver->query( "INSERT INTO wptests_runtime_insert_id (value) VALUES ('second')" ); + $second = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + + $this->assertSame( '1', $first[0]->last_insert_id ); + $this->assertSame( '2', $second[0]->last_insert_id ); + $this->assertSame( 'SELECT 2 AS last_insert_id', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests LAST_INSERT_ID(expr) sets mutable session state for safe standalone integer literals. + */ + public function test_last_insert_id_assignment_runtime_function_sets_and_reads_back(): void { + $driver = $this->create_driver(); + + $assigned = $driver->query( 'SELECT LAST_INSERT_ID(123) AS assigned_id' ); + + $this->assertSame( '123', $assigned[0]->assigned_id ); + $this->assertSame( 123, $driver->get_insert_id() ); + $this->assertSame( 'SELECT 123 AS assigned_id', $this->get_last_single_postgresql_sql( $driver ) ); + + $readback = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + + $this->assertSame( '123', $readback[0]->last_insert_id ); + $this->assertSame( 'SELECT 123 AS last_insert_id', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests LAST_INSERT_ID() readbacks are not cached across LAST_INSERT_ID(expr) assignments. + */ + public function test_last_insert_id_assignment_runtime_function_invalidates_readback_cache(): void { + $driver = $this->create_driver(); + + $driver->query( 'SELECT LAST_INSERT_ID(10) AS assigned_id' ); + $first = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + + $driver->query( 'SELECT LAST_INSERT_ID(20) AS assigned_id' ); + $second = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + + $this->assertSame( '10', $first[0]->last_insert_id ); + $this->assertSame( '20', $second[0]->last_insert_id ); + $this->assertSame( 20, $driver->get_insert_id() ); + $this->assertSame( 'SELECT 20 AS last_insert_id', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests LAST_INSERT_ID(expr) projections preserve aliases and left-to-right session reads. + */ + public function test_last_insert_id_assignment_runtime_function_preserves_projection_order_and_aliases(): void { + $driver = $this->create_driver(); + + $driver->query( 'SELECT LAST_INSERT_ID(7) AS seed_id' ); + + $rows = $driver->query( + 'SELECT LAST_INSERT_ID() AS before_id, + LAST_INSERT_ID(456) AS assigned_id, + LAST_INSERT_ID() AS after_id, + 9 AS literal_value' + ); + + $this->assertSame( '7', $rows[0]->before_id ); + $this->assertSame( '456', $rows[0]->assigned_id ); + $this->assertSame( '456', $rows[0]->after_id ); + $this->assertSame( '9', $rows[0]->literal_value ); + $this->assertSame( 456, $driver->get_insert_id() ); + $this->assertSame( + array( 'before_id', 'assigned_id', 'after_id', 'literal_value' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + $this->assertSame( + 'SELECT 7 AS before_id, 456 AS assigned_id, 456 AS after_id, 9 AS literal_value', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests unsupported LAST_INSERT_ID(expr) SELECT forms fail before backend execution. + */ + public function test_last_insert_id_assignment_runtime_function_unsupported_forms_fail_closed(): void { + $queries = array( + "SELECT LAST_INSERT_ID('123') AS invalid_last_insert_id", + 'SELECT LAST_INSERT_ID(123 + 1) AS invalid_last_insert_id', + 'SELECT LAST_INSERT_ID(id) AS invalid_last_insert_id', + 'SELECT LAST_INSERT_ID(-1) AS invalid_last_insert_id', + 'SELECT LAST_INSERT_ID(18446744073709551615) AS invalid_last_insert_id', + 'SELECT LAST_INSERT_ID(123) + 1 AS invalid_last_insert_id', + 'SELECT LAST_INSERT_ID(123) AS invalid_last_insert_id FROM runtime_names', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported LAST_INSERT_ID(expr) runtime form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests unsupported LAST_INSERT_ID(expr) SELECT forms do not mutate session state. + */ + public function test_last_insert_id_assignment_runtime_function_unsupported_forms_do_not_mutate_state(): void { + $driver = $this->create_driver(); + + $driver->query( 'SELECT LAST_INSERT_ID(321) AS assigned_id' ); + + try { + $driver->query( "SELECT LAST_INSERT_ID('123') AS invalid_last_insert_id" ); + $this->fail( 'Expected unsupported LAST_INSERT_ID(expr) runtime form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage() ); + } + + $this->assertSame( 321, $driver->get_insert_id() ); + + $readback = $driver->query( 'SELECT LAST_INSERT_ID() AS last_insert_id' ); + $this->assertSame( '321', $readback[0]->last_insert_id ); + } + + /** + * Tests ROW_COUNT() reflects mutable last-result state instead of cached SQL. + */ + public function test_row_count_runtime_function_tracks_last_result_and_is_not_cached(): void { + $driver = $this->create_driver(); + + $initial = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + $this->assertSame( '0', $initial[0]->row_count ); + $this->assertSame( 'SELECT 0 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'CREATE TABLE wptests_runtime_row_count (id INTEGER PRIMARY KEY, value TEXT)' ); + $driver->query( "INSERT INTO wptests_runtime_row_count (id, value) VALUES (1, 'first'), (2, 'second')" ); + $after_insert = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + + $this->assertSame( '2', $after_insert[0]->row_count ); + $this->assertSame( 'SELECT 2 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( "UPDATE wptests_runtime_row_count SET value = 'updated' WHERE id = 1" ); + $after_update = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + + $this->assertSame( '1', $after_update[0]->row_count ); + $this->assertSame( 'SELECT 1 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'DELETE FROM wptests_runtime_row_count WHERE id = 99' ); + $after_delete = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + + $this->assertSame( '0', $after_delete[0]->row_count ); + $this->assertSame( 'SELECT 0 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'SELECT id FROM wptests_runtime_row_count WHERE id = 1' ); + $after_result_set = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + + $this->assertSame( '-1', $after_result_set[0]->row_count ); + $this->assertSame( 'SELECT -1 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests ROW_COUNT() reflects failed statement state. + */ + public function test_row_count_runtime_function_tracks_failed_statement_state(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_runtime_row_count_failure (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_runtime_row_count_failure (id, value) VALUES (1, 'first'), (2, 'second')" ); + + $after_insert = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + $this->assertSame( '2', $after_insert[0]->row_count ); + + try { + $driver->query( 'INSERT INTO wptests_runtime_row_count_failure (id, value) VALUES (3, NULL)' ); + $this->fail( 'Expected backend insert failure.' ); + } catch ( PDOException $e ) { + $this->assertStringContainsString( 'NOT NULL', strtoupper( $e->getMessage() ) ); + } + + $after_backend_failure = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + $this->assertSame( '-1', $after_backend_failure[0]->row_count ); + $this->assertSame( 'SELECT -1 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + + try { + $driver->query( 'SELECT ROW_COUNT(123) AS invalid_row_count' ); + $this->fail( 'Expected unsupported ROW_COUNT() form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage() ); + } + + $after_unsupported_failure = $driver->query( 'SELECT ROW_COUNT() AS row_count' ); + $this->assertSame( '-1', $after_unsupported_failure[0]->row_count ); + $this->assertSame( 'SELECT -1 AS row_count', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests common MySQL runtime functions from the SQLite compatibility layer are translated. + */ + public function test_common_mysql_runtime_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT MD5('abc') AS md5_hash, + LEFT('Lorem ipsum', 5) AS left_part, + UCASE('abc') AS upper_part, + LCASE('ABC') AS lower_part, + ISNULL(NULL) AS is_null_value, + IF(1, 'yes', 'no') AS if_numeric, + IF(1 = 0, 'yes', 'no') AS if_predicate, + IFNULL(NULL, 'fallback') AS ifnull_value, + NULLIF('same', 'same') AS nullif_value, + COALESCE(NULL, 'first', 'second') AS coalesce_value, + CONCAT('wp', '_', 'db') AS concat_value, + CONCAT_WS('-', 'wp', NULL, 'db') AS concat_ws_value, + CHAR_LENGTH('hello') AS char_length_value, + CHARACTER_LENGTH('hello') AS character_length_value, + SUBSTRING('abcdef', 2, 3) AS substring_value, + SUBSTRING('abcdef' FROM 2 FOR 3) AS substring_from_value, + SUBSTR('abcdef', 4) AS substr_value, + MID('abcdef', 2, 2) AS mid_value, + REPLACE('banana', 'na', 'NA') AS replace_value, + LEAST(3, 9, 4) AS least_value, + GREATEST(3, 9, 4) AS greatest_value, + LOG(8) AS natural_log_value, + LOG(2, 8) AS based_log_value, + DATEDIFF('2024-01-05', '2024-01-02') AS day_diff, + LENGTH('hello') AS byte_length, + LOCATE('or', 'WordPress') AS locate_value, + LOCATE('r', 'WordPress', 4) AS locate_with_position, + HEX('Az') AS hex_value, + UNHEX('417a') AS unhex_value, + TO_BASE64('wp') AS base64_value, + FROM_BASE64('d3A=') AS base64_decoded, + INET_ATON('127.0.0.1') AS inet_number, + INET_NTOA(2130706433) AS inet_address, + FROM_UNIXTIME(0) AS epoch_datetime, + FROM_UNIXTIME(0, '%Y') AS epoch_year, + UNIX_TIMESTAMP('1970-01-02 00:00:00') AS epoch_seconds"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $select + ); + + $this->assertStringContainsString( "MD5(CAST('abc' AS text)) AS md5_hash", $sql ); + $this->assertStringContainsString( "LEFT(CAST('Lorem ipsum' AS text), CAST(5 AS integer)) AS left_part", $sql ); + $this->assertStringContainsString( "UPPER(CAST('abc' AS text)) AS upper_part", $sql ); + $this->assertStringContainsString( "LOWER(CAST('ABC' AS text)) AS lower_part", $sql ); + $this->assertStringContainsString( 'CASE WHEN NULL IS NULL THEN 1 ELSE 0 END AS is_null_value', $sql ); + $this->assertStringContainsString( "THEN 'yes' ELSE 'no' END AS if_numeric", $sql ); + $this->assertStringContainsString( "CASE WHEN (1 = 0) THEN 'yes' ELSE 'no' END AS if_predicate", $sql ); + $this->assertStringContainsString( "COALESCE(NULL, 'fallback') AS ifnull_value", $sql ); + $this->assertStringContainsString( "NULLIF('same', 'same') AS nullif_value", $sql ); + $this->assertStringContainsString( "COALESCE(NULL, 'first', 'second') AS coalesce_value", $sql ); + $this->assertStringContainsString( "(CAST('wp' AS text) || CAST('_' AS text) || CAST('db' AS text)) AS concat_value", $sql ); + $this->assertStringContainsString( "CASE WHEN '-' IS NULL THEN NULL ELSE", $sql ); + $this->assertStringContainsString( "COALESCE(CAST('wp' AS text), '')", $sql ); + $this->assertStringContainsString( "THEN CAST('-' AS text) ELSE '' END", $sql ); + $this->assertStringContainsString( "COALESCE(CAST('db' AS text), '')", $sql ); + $this->assertStringContainsString( 'END AS concat_ws_value', $sql ); + $this->assertStringContainsString( "CHAR_LENGTH(CAST('hello' AS text)) AS char_length_value", $sql ); + $this->assertStringContainsString( "CHAR_LENGTH(CAST('hello' AS text)) AS character_length_value", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST('abcdef' AS text) FROM CASE WHEN CAST(2 AS integer) > 0 THEN CAST(2 AS integer) WHEN CAST(2 AS integer) < 0 THEN CHAR_LENGTH(CAST('abcdef' AS text)) + CAST(2 AS integer) + 1 ELSE 0 END FOR CAST(3 AS integer)) END AS substring_value", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST('abcdef' AS text) FROM CASE WHEN CAST(2 AS integer) > 0 THEN CAST(2 AS integer) WHEN CAST(2 AS integer) < 0 THEN CHAR_LENGTH(CAST('abcdef' AS text)) + CAST(2 AS integer) + 1 ELSE 0 END FOR CAST(3 AS integer)) END AS substring_from_value", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST('abcdef' AS text) FROM CASE WHEN CAST(4 AS integer) > 0 THEN CAST(4 AS integer) WHEN CAST(4 AS integer) < 0 THEN CHAR_LENGTH(CAST('abcdef' AS text)) + CAST(4 AS integer) + 1 ELSE 0 END) END AS substr_value", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST('abcdef' AS text) FROM CASE WHEN CAST(2 AS integer) > 0 THEN CAST(2 AS integer) WHEN CAST(2 AS integer) < 0 THEN CHAR_LENGTH(CAST('abcdef' AS text)) + CAST(2 AS integer) + 1 ELSE 0 END FOR CAST(2 AS integer)) END AS mid_value", $sql ); + $this->assertStringContainsString( "REPLACE(CAST('banana' AS text), CAST('na' AS text), CAST('NA' AS text)) AS replace_value", $sql ); + $this->assertStringContainsString( 'CASE WHEN 3 IS NULL OR 9 IS NULL OR 4 IS NULL THEN NULL ELSE LEAST(3, 9, 4) END AS least_value', $sql ); + $this->assertStringContainsString( 'CASE WHEN 3 IS NULL OR 9 IS NULL OR 4 IS NULL THEN NULL ELSE GREATEST(3, 9, 4) END AS greatest_value', $sql ); + $this->assertStringContainsString( 'CASE WHEN CAST(8 AS double precision) IS NULL OR CAST(8 AS double precision) <= 0 THEN NULL ELSE LN(CAST(8 AS double precision)) END AS natural_log_value', $sql ); + $this->assertStringContainsString( 'CASE WHEN CAST(2 AS double precision) IS NULL OR CAST(8 AS double precision) IS NULL OR CAST(2 AS double precision) <= 1 OR CAST(8 AS double precision) <= 0 THEN NULL ELSE LN(CAST(8 AS double precision)) / LN(CAST(2 AS double precision)) END AS based_log_value', $sql ); + $this->assertStringContainsString( $this->get_expected_mysql_datediff_sql( "'2024-01-05'", "'2024-01-02'" ) . ' AS day_diff', $sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST('hello' AS text), 'UTF8')) END AS byte_length", $sql ); + $this->assertStringContainsString( "STRPOS(CAST('WordPress' AS text), CAST('or' AS text)) AS locate_value", $sql ); + $this->assertStringContainsString( "STRPOS(SUBSTRING(CAST('WordPress' AS text) FROM CAST(4 AS integer)), CAST('r' AS text)) + CAST(4 AS integer) - 1 END AS locate_with_position", $sql ); + $this->assertStringContainsString( "UPPER(ENCODE(CONVERT_TO(CAST('Az' AS text), 'UTF8'), 'hex')) AS hex_value", $sql ); + $this->assertStringContainsString( "CONVERT_FROM(DECODE(CAST('417a' AS text), 'hex'), 'UTF8') AS unhex_value", $sql ); + $this->assertStringContainsString( "ENCODE(CONVERT_TO(CAST('wp' AS text), 'UTF8'), 'base64') AS base64_value", $sql ); + $this->assertStringContainsString( "CASE WHEN CAST('d3A=' AS text) IS NULL OR CAST('d3A=' AS text) !~", $sql ); + $this->assertStringContainsString( "ELSE CONVERT_FROM(DECODE(CAST('d3A=' AS text), 'base64'), 'UTF8') END AS base64_decoded", $sql ); + $this->assertStringContainsString( "SPLIT_PART(CAST('127.0.0.1' AS text), '.', 1)", $sql ); + $this->assertStringContainsString( '((CAST(2130706433 AS bigint) >> 24) & 255)::text', $sql ); + $this->assertStringContainsString( "THEN TO_CHAR(TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') ELSE", $sql ); + $this->assertStringContainsString( "TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC'", $sql ); + $this->assertStringContainsString( "'YYYY'", $sql ); + $this->assertStringContainsString( 'CAST(FLOOR(EXTRACT(EPOCH FROM CAST(CASE WHEN CAST(\'1970-01-02 00:00:00\' AS text)', $sql ); + $this->assertStringNotContainsString( 'UNIX_TIMESTAMP', $sql ); + $this->assertStringNotContainsString( 'FROM_UNIXTIME', $sql ); + $this->assertStringNotContainsString( 'INET_ATON', $sql ); + $this->assertStringNotContainsString( 'INET_NTOA', $sql ); + $this->assertStringNotContainsString( 'FROM_BASE64', $sql ); + $this->assertStringNotContainsString( 'TO_BASE64', $sql ); + $this->assertStringNotContainsString( 'IFNULL', $sql ); + $this->assertStringNotContainsString( 'CONCAT(', $sql ); + $this->assertStringNotContainsString( 'CONCAT_WS', $sql ); + } + + /** + * Tests temporal, schema, and lock compatibility runtime functions are translated. + */ + public function test_sqlite_udf_runtime_compatibility_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT CURDATE() AS current_date_value, + CURRENT_DATE AS current_date_keyword_value, + CURRENT_TIME AS current_time_keyword_value, + UTC_DATE() AS utc_date_value, + UTC_TIME() AS utc_time_value, + NOW() AS now_value, + CURRENT_TIMESTAMP AS current_timestamp_keyword_value, + LOCALTIME() AS localtime_value, + LOCALTIME AS localtime_keyword_value, + LOCALTIMESTAMP() AS localtimestamp_value, + LOCALTIMESTAMP AS localtimestamp_keyword_value, + UTC_TIMESTAMP() AS utc_timestamp_value, + DATABASE() AS database_value, + SCHEMA() AS schema_value, + GET_LOCK('plugin_lock', 1) AS get_lock_value, + RELEASE_LOCK('plugin_lock') AS release_lock_value" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS current_date_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS current_date_keyword_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:MI:SS') AS current_time_keyword_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS utc_date_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'HH24:MI:SS') AS utc_time_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS now_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS current_timestamp_keyword_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS localtime_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS localtime_keyword_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS localtimestamp_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS localtimestamp_keyword_value", $sql ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS utc_timestamp_value", $sql ); + $this->assertStringContainsString( "'wptests' AS database_value", $sql ); + $this->assertStringContainsString( "'wptests' AS schema_value", $sql ); + $this->assertStringContainsString( '1 AS get_lock_value', $sql ); + $this->assertStringContainsString( '1 AS release_lock_value', $sql ); + $this->assertStringNotContainsString( 'CURDATE', $sql ); + $this->assertStringNotContainsString( 'UTC_DATE', $sql ); + $this->assertStringNotContainsString( 'UTC_TIME', $sql ); + $this->assertStringNotContainsString( 'NOW', $sql ); + $this->assertStringNotContainsString( 'LOCALTIME', $sql ); + $this->assertStringNotContainsString( 'LOCALTIMESTAMP', $sql ); + $this->assertStringNotContainsString( 'UTC_TIMESTAMP', $sql ); + $this->assertStringNotContainsString( 'DATABASE', $sql ); + $this->assertStringNotContainsString( 'SCHEMA', $sql ); + $this->assertStringNotContainsString( 'GET_LOCK', $sql ); + $this->assertStringNotContainsString( 'RELEASE_LOCK', $sql ); + } + + /** + * Tests fractional temporal runtime functions are translated with bounded MySQL precision. + */ + public function test_fractional_temporal_runtime_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT CURRENT_TIMESTAMP(6) AS current_timestamp_fsp, + NOW(3) AS now_fsp, + CURRENT_TIME(2) AS current_time_fsp, + UTC_TIME(1) AS utc_time_fsp, + LOCALTIMESTAMP(4) AS localtimestamp_fsp' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(6) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US'), 26) AS current_timestamp_fsp", $sql ); + $this->assertStringContainsString( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(3) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US'), 23) AS now_fsp", $sql ); + $this->assertStringContainsString( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(2) AT TIME ZONE 'UTC', 'HH24:MI:SS.US'), 11) AS current_time_fsp", $sql ); + $this->assertStringContainsString( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(1) AT TIME ZONE 'UTC', 'HH24:MI:SS.US'), 10) AS utc_time_fsp", $sql ); + $this->assertStringContainsString( "LEFT(TO_CHAR(CURRENT_TIMESTAMP(4) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US'), 24) AS localtimestamp_fsp", $sql ); + $this->assertStringNotContainsString( 'CURRENT_TIMESTAMP(6) AS current_timestamp_fsp', $sql ); + $this->assertStringNotContainsString( 'NOW(3)', $sql ); + $this->assertStringNotContainsString( 'CURRENT_TIME(2)', $sql ); + } + + /** + * Tests DATE() and DATEDIFF() guard zero-date values before PostgreSQL casts. + */ + public function test_mysql_date_and_datediff_runtime_functions_guard_zero_date_casts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE('0000-00-00 13:05:00') AS zero_date, + DATE('2020-00-15 13:05:00') AS partial_date, + DATEDIFF('2024-01-05', '0000-00-00') AS diff_zero, + DATEDIFF('2020-00-15', '2020-01-15') AS diff_partial"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $select + ); + + $this->assertStringContainsString( $this->get_expected_mysql_date_sql( "'0000-00-00 13:05:00'" ) . ' AS zero_date', $sql ); + $this->assertStringContainsString( $this->get_expected_mysql_date_sql( "'2020-00-15 13:05:00'" ) . ' AS partial_date', $sql ); + $this->assertStringContainsString( $this->get_expected_mysql_datediff_sql( "'2024-01-05'", "'0000-00-00'" ) . ' AS diff_zero', $sql ); + $this->assertStringContainsString( $this->get_expected_mysql_datediff_sql( "'2020-00-15'", "'2020-01-15'" ) . ' AS diff_partial', $sql ); + $this->assertStringContainsString( "THEN SUBSTRING(CAST('0000-00-00 13:05:00' AS text) FROM 1 FOR 10)", $sql ); + $this->assertStringContainsString( "THEN SUBSTRING(CAST('2020-00-15 13:05:00' AS text) FROM 1 FOR 10)", $sql ); + $this->assertStringNotContainsString( "TO_CHAR(CAST('0000-00-00 13:05:00' AS timestamp)", $sql ); + $this->assertStringNotContainsString( "CAST('0000-00-00' AS date)", $sql ); + $this->assertStringNotContainsString( "CAST('2020-00-15' AS date)", $sql ); + } + + /** + * Tests SQLite UDF-style REGEXP(pattern, value) runtime calls are translated. + */ + public function test_regexp_runtime_function_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + REGEXP('^rss_.+$', 'RSS_123') AS case_insensitive_match, + REGEXP('^rss_.+$', 'feed_123') AS no_match, + REGEXP(NULL, 'RSS_123') AS null_pattern, + REGEXP('^rss', NULL) AS null_value" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( + "CASE WHEN CAST('^rss_.+$' AS text) IS NULL OR CAST('RSS_123' AS text) IS NULL THEN NULL WHEN CAST('RSS_123' AS text) ~* CAST('^rss_.+$' AS text) THEN 1 ELSE 0 END AS case_insensitive_match", + $sql + ); + $this->assertStringContainsString( + "CASE WHEN CAST('^rss_.+$' AS text) IS NULL OR CAST('feed_123' AS text) IS NULL THEN NULL WHEN CAST('feed_123' AS text) ~* CAST('^rss_.+$' AS text) THEN 1 ELSE 0 END AS no_match", + $sql + ); + $this->assertStringContainsString( + "CASE WHEN CAST(NULL AS text) IS NULL OR CAST('RSS_123' AS text) IS NULL THEN NULL WHEN CAST('RSS_123' AS text) ~* CAST(NULL AS text) THEN 1 ELSE 0 END AS null_pattern", + $sql + ); + $this->assertStringContainsString( + "CASE WHEN CAST('^rss' AS text) IS NULL OR CAST(NULL AS text) IS NULL THEN NULL WHEN CAST(NULL AS text) ~* CAST('^rss' AS text) THEN 1 ELSE 0 END AS null_value", + $sql + ); + $this->assertStringNotContainsString( 'REGEXP(', $sql ); + } + + /** + * Tests nested MySQL base64 runtime functions from the SQLite parity suite are translated. + */ + public function test_nested_base64_runtime_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT + TO_BASE64(FROM_BASE64('dGVzdA==')) AS encoded_round_trip, + FROM_BASE64(TO_BASE64('binary')) AS decoded_round_trip, + COALESCE(FROM_BASE64(''), 'fallback') AS empty_decoded"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $select + ); + + $this->assertStringContainsString( "ENCODE(CONVERT_TO(CAST(CASE WHEN CAST('dGVzdA==' AS text) IS NULL OR CAST('dGVzdA==' AS text) !~", $sql ); + $this->assertStringContainsString( "ELSE CONVERT_FROM(DECODE(CAST('dGVzdA==' AS text), 'base64'), 'UTF8') END AS text), 'UTF8'), 'base64') AS encoded_round_trip", $sql ); + $this->assertStringContainsString( "CASE WHEN CAST(ENCODE(CONVERT_TO(CAST('binary' AS text), 'UTF8'), 'base64') AS text) IS NULL OR CAST(ENCODE(CONVERT_TO(CAST('binary' AS text), 'UTF8'), 'base64') AS text) !~", $sql ); + $this->assertStringContainsString( "ELSE CONVERT_FROM(DECODE(CAST(ENCODE(CONVERT_TO(CAST('binary' AS text), 'UTF8'), 'base64') AS text), 'base64'), 'UTF8') END AS decoded_round_trip", $sql ); + $this->assertStringContainsString( "COALESCE(CASE WHEN CAST('' AS text) IS NULL OR CAST('' AS text) !~", $sql ); + $this->assertStringContainsString( "ELSE CONVERT_FROM(DECODE(CAST('' AS text), 'base64'), 'UTF8') END, 'fallback') AS empty_decoded", $sql ); + $this->assertStringNotContainsString( 'FROM_BASE64', $sql ); + $this->assertStringNotContainsString( 'TO_BASE64', $sql ); + } + + /** + * Tests NULL-only MySQL base64 runtime calls still trigger PostgreSQL translation. + */ + public function test_null_base64_runtime_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT FROM_BASE64(NULL) AS decoded_null, TO_BASE64(NULL) AS encoded_null' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'CASE WHEN CAST(NULL AS text) IS NULL OR CAST(NULL AS text) !~', $sql ); + $this->assertStringContainsString( "ELSE CONVERT_FROM(DECODE(CAST(NULL AS text), 'base64'), 'UTF8') END AS decoded_null", $sql ); + $this->assertStringContainsString( "ENCODE(CONVERT_TO(CAST(NULL AS text), 'UTF8'), 'base64') AS encoded_null", $sql ); + $this->assertStringNotContainsString( 'FROM_BASE64', $sql ); + $this->assertStringNotContainsString( 'TO_BASE64', $sql ); + } + + /** + * Tests malformed MySQL FROM_BASE64() input returns NULL instead of surfacing a PostgreSQL DECODE error. + */ + public function test_invalid_from_base64_runtime_function_is_guarded_for_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT FROM_BASE64('not base64!') AS decoded_invalid, LENGTH(FROM_BASE64('abc')) AS invalid_length" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "CASE WHEN CAST('not base64!' AS text) IS NULL OR CAST('not base64!' AS text) !~", $sql ); + $this->assertStringContainsString( "THEN NULL ELSE CONVERT_FROM(DECODE(CAST('not base64!' AS text), 'base64'), 'UTF8') END AS decoded_invalid", $sql ); + $this->assertStringContainsString( "CASE WHEN CAST('abc' AS text) IS NULL OR CAST('abc' AS text) !~", $sql ); + $this->assertStringContainsString( "THEN NULL ELSE OCTET_LENGTH(DECODE(CAST('abc' AS text), 'base64')) END AS invalid_length", $sql ); + $this->assertStringNotContainsString( 'FROM_BASE64', $sql ); + } + + /** + * Tests JSON_VALID() runtime calls are translated to the driver helper. + */ + public function test_json_valid_runtime_function_is_translated_to_postgresql_helper(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT JSON_VALID(\'{"ok":true}\') AS object_valid, JSON_VALID(NULL) AS null_valid, JSON_VALID(payload) AS payload_valid FROM runtime_names' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( '__wp_pg_mysql_json_valid(CAST(\'{"ok":true}\' AS text)) AS object_valid', $sql ); + $this->assertStringContainsString( '__wp_pg_mysql_json_valid(CAST(NULL AS text)) AS null_valid', $sql ); + $this->assertStringContainsString( '__wp_pg_mysql_json_valid(CAST(payload AS text)) AS payload_valid', $sql ); + $this->assertStringNotContainsString( 'JSON_VALID', $sql ); + $this->assertStringNotContainsString( 'pg_input_is_valid', $sql ); + } + + /** + * Tests JSON_VALID() runtime execution preserves MySQL NULL/0/1 semantics. + */ + public function test_json_valid_runtime_function_executes_with_mysql_semantics(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_json_valid_runtime (id INTEGER PRIMARY KEY, payload TEXT)' ); + $driver->query( "INSERT INTO wptests_json_valid_runtime (id, payload) VALUES (1, '{\"ok\":true}'), (2, 'not json'), (3, NULL), (4, 'null')" ); + + $rows = $driver->query( 'SELECT id, JSON_VALID(payload) AS payload_valid FROM wptests_json_valid_runtime ORDER BY id' ); + + $this->assertSame( + array( + array( '1', '1' ), + array( '2', '0' ), + array( '3', null ), + array( '4', '1' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->payload_valid ); + }, + $rows + ) + ); + + $literal_result = $driver->query( + 'SELECT JSON_VALID(\'{"ok":true}\') AS object_valid, + JSON_VALID(\'[1,2]\') AS array_valid, + JSON_VALID(\'not json\') AS invalid_json, + JSON_VALID(NULL) AS null_json, + JSON_VALID(\'null\') AS null_literal_valid, + JSON_VALID(123) AS number_valid' + ); + + $this->assertCount( 1, $literal_result ); + $this->assertSame( '1', $literal_result[0]->object_valid ); + $this->assertSame( '1', $literal_result[0]->array_valid ); + $this->assertSame( '0', $literal_result[0]->invalid_json ); + $this->assertNull( $literal_result[0]->null_json ); + $this->assertSame( '1', $literal_result[0]->null_literal_valid ); + $this->assertSame( '1', $literal_result[0]->number_valid ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( '__wp_pg_mysql_json_valid(CAST(\'{"ok":true}\' AS text)) AS object_valid', $sql ); + $this->assertStringContainsString( '__wp_pg_mysql_json_valid(CAST(NULL AS text)) AS null_json', $sql ); + $this->assertStringNotContainsString( 'JSON_VALID', $sql ); + $this->assertStringNotContainsString( 'pg_input_is_valid', $sql ); + } + + /** + * Tests common MySQL runtime functions trigger rewrite without literal arguments. + */ + public function test_common_mysql_runtime_functions_with_column_arguments_trigger_postgresql_rewrite(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT IFNULL(primary_value, fallback_value) AS selected_value, NULLIF(primary_value, fallback_value) AS nullif_value, CONCAT(prefix, suffix) AS joined_value, CONCAT_WS('-', prefix, NULL, suffix) AS joined_ws_value, CHAR_LENGTH(display_name) AS name_length, LENGTH(display_name) AS byte_length FROM runtime_names" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'COALESCE(primary_value, fallback_value) AS selected_value', $sql ); + $this->assertStringContainsString( 'NULLIF(primary_value, fallback_value) AS nullif_value', $sql ); + $this->assertStringContainsString( '(CAST(prefix AS text) || CAST(suffix AS text)) AS joined_value', $sql ); + $this->assertStringContainsString( "CASE WHEN '-' IS NULL THEN NULL ELSE", $sql ); + $this->assertStringContainsString( "COALESCE(CAST(prefix AS text), '')", $sql ); + $this->assertStringContainsString( "COALESCE(CAST(suffix AS text), '')", $sql ); + $this->assertStringContainsString( 'END AS joined_ws_value', $sql ); + $this->assertStringContainsString( 'CHAR_LENGTH(CAST(display_name AS text)) AS name_length', $sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST(display_name AS text), 'UTF8')) END AS byte_length", $sql ); + $this->assertStringNotContainsString( 'IFNULL', $sql ); + $this->assertStringNotContainsString( 'CONCAT(', $sql ); + $this->assertStringNotContainsString( 'CONCAT_WS', $sql ); + } + + /** + * Tests MySQL CONCAT_WS() skips NULL values while preserving empty strings. + */ + public function test_concat_ws_runtime_function_executes_mysql_null_semantics(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( + "SELECT + CONCAT_WS('-', 'wp', NULL, 'db') AS skipped_null, + CONCAT_WS(',', '', NULL, 'tail') AS empty_string_kept, + CONCAT_WS(NULL, 'a', 'b') AS null_separator, + CONCAT_WS('|', NULL, NULL) AS all_values_null" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'wp-db', $rows[0]->skipped_null ); + $this->assertSame( ',tail', $rows[0]->empty_string_kept ); + $this->assertNull( $rows[0]->null_separator ); + $this->assertSame( '', $rows[0]->all_values_null ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringNotContainsString( 'CONCAT_WS', $sql ); + } + + /** + * Tests MySQL LENGTH() counts bytes for UTF-8 text. + */ + public function test_length_runtime_function_counts_utf8_bytes_for_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + LENGTH(UNHEX('c3a9')) AS utf8_byte_length, + LENGTH(UNHEX('ff')) AS binary_byte_length, + LENGTH(x'c3a9') AS raw_hex_byte_length, + LENGTH(0xC3A9) AS prefixed_hex_byte_length" + ); + + $this->assertSame( + "SELECT OCTET_LENGTH(DECODE(CAST('c3a9' AS text), 'hex')) AS utf8_byte_length, OCTET_LENGTH(DECODE(CAST('ff' AS text), 'hex')) AS binary_byte_length, 2 AS raw_hex_byte_length, 2 AS prefixed_hex_byte_length", + $sql + ); + } + + /** + * Tests CHAR_LENGTH() counts bytes for binary-producing runtime functions. + */ + public function test_char_length_binary_runtime_functions_count_bytes_for_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + CHAR_LENGTH(UNHEX('c3a9')) AS unhex_char_bytes, + CHARACTER_LENGTH(UNHEX('ff')) AS unhex_raw_bytes, + CHAR_LENGTH(FROM_BASE64('w6k=')) AS base64_char_bytes, + CHAR_LENGTH(x'c3a9') AS raw_hex_char_bytes, + CHARACTER_LENGTH(0xC3A9) AS prefixed_hex_char_bytes" + ); + + $this->assertSame( + "SELECT OCTET_LENGTH(DECODE(CAST('c3a9' AS text), 'hex')) AS unhex_char_bytes, OCTET_LENGTH(DECODE(CAST('ff' AS text), 'hex')) AS unhex_raw_bytes, CASE WHEN CAST('w6k=' AS text) IS NULL OR CAST('w6k=' AS text) !~ '^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' THEN NULL ELSE OCTET_LENGTH(DECODE(CAST('w6k=' AS text), 'base64')) END AS base64_char_bytes, 2 AS raw_hex_char_bytes, 2 AS prefixed_hex_char_bytes", + $sql + ); + } + + /** + * Tests MySQL LENGTH() counts text bytes while CHAR_LENGTH() counts characters. + */ + public function test_length_runtime_function_counts_text_utf8_bytes_when_executed(): void { + $driver = $this->create_driver_with_postgresql_text_runtime_functions(); + $literal = $this->quote_mysql_string_literal_for_test( "\xC3\xA9" ); + + $result = $driver->query( + sprintf( + 'SELECT LENGTH(%1$s) AS byte_length, CHAR_LENGTH(%1$s) AS char_length, CHARACTER_LENGTH(%1$s) AS character_length', + $literal + ) + ); + + $this->assertCount( 1, $result ); + $this->assertSame( '2', $result[0]->byte_length ); + $this->assertSame( '1', $result[0]->char_length ); + $this->assertSame( '1', $result[0]->character_length ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST($literal AS text), 'UTF8')) END AS byte_length", $sql ); + $this->assertStringContainsString( "CHAR_LENGTH(CAST($literal AS text)) AS char_length", $sql ); + $this->assertStringContainsString( "CHAR_LENGTH(CAST($literal AS text)) AS character_length", $sql ); + } + + /** + * Tests MySQL binary strings make CHAR_LENGTH() count bytes. + */ + public function test_binary_length_runtime_functions_count_utf8_bytes_when_executed(): void { + $driver = $this->create_driver_with_postgresql_text_runtime_functions(); + $literal = $this->quote_mysql_string_literal_for_test( "\xC3\xA9" ); + + $result = $driver->query( + sprintf( + 'SELECT + LENGTH(%1$s) AS text_byte_length, + CHAR_LENGTH(%1$s) AS text_char_length, + CHAR_LENGTH(CAST(%1$s AS BINARY)) AS cast_binary_char_length, + CHARACTER_LENGTH(BINARY %1$s) AS operator_binary_char_length, + LENGTH(CONVERT(%1$s, BINARY)) AS convert_binary_byte_length, + CHAR_LENGTH(CONVERT(%1$s, BINARY)) AS convert_binary_char_length', + $literal + ) + ); + + $this->assertCount( 1, $result ); + $this->assertSame( '2', $result[0]->text_byte_length ); + $this->assertSame( '1', $result[0]->text_char_length ); + $this->assertSame( '2', $result[0]->cast_binary_char_length ); + $this->assertSame( '2', $result[0]->operator_binary_char_length ); + $this->assertSame( '2', $result[0]->convert_binary_byte_length ); + $this->assertSame( '2', $result[0]->convert_binary_char_length ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( "CHAR_LENGTH(CAST($literal AS text)) AS text_char_length", $sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST($literal AS text), 'UTF8')) END AS cast_binary_char_length", $sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST($literal AS text), 'UTF8')) END AS operator_binary_char_length", $sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST($literal AS text), 'UTF8')) END AS convert_binary_byte_length", $sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST($literal AS text), 'UTF8')) END AS convert_binary_char_length", $sql ); + $this->assertStringNotContainsString( "CAST($literal AS BINARY)", $sql ); + $this->assertStringNotContainsString( "CONVERT($literal, BINARY)", $sql ); + } + + /** + * Tests MySQL LENGTH() counts decoded PostgreSQL-safe text envelope bytes. + */ + public function test_length_runtime_function_counts_postgresql_text_envelope_bytes_when_executed(): void { + $driver = $this->create_driver_with_postgresql_quote_translation_and_text_runtime_functions(); + $connection = $driver->get_connection(); + + $driver->query( 'CREATE TABLE wptests_length_text_envelope (value TEXT NOT NULL)' ); + $connection->query( 'INSERT INTO wptests_length_text_envelope (value) VALUES (' . $connection->quote( "a\0\xC3\xA9" ) . ')' ); + + $stored_rows = $connection->query( 'SELECT value FROM wptests_length_text_envelope' )->fetchAll( PDO::FETCH_OBJ ); + $this->assertCount( 1, $stored_rows ); + $this->assertStringContainsString( 'WP_MYSQL_TEXT_V1:', $stored_rows[0]->value ); + + $result = $driver->query( 'SELECT LENGTH(value) AS byte_length FROM wptests_length_text_envelope' ); + + $this->assertCount( 1, $result ); + $this->assertSame( '4', (string) $result[0]->byte_length ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'STRPOS(SUBSTR(CAST(value AS text),', $sql ); + $this->assertStringContainsString( "ELSE OCTET_LENGTH(CONVERT_TO(CAST(value AS text), 'UTF8')) END AS byte_length", $sql ); + } + + /** + * Tests formatted FROM_UNIXTIME() shares DATE_FORMAT coverage and NULL semantics. + */ + public function test_from_unixtime_formatted_runtime_function_is_translated_with_null_guard(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + FROM_UNIXTIME(0.123456, '%Y-%m-%d %H:%i:%s.%f') AS formatted_epoch, + FROM_UNIXTIME(0, '%H.%i') AS formatted_hour_minute, + FROM_UNIXTIME(0, '%H.%i%s') AS formatted_hour_minute_second, + FROM_UNIXTIME(0, '0.%i%s') AS formatted_minute_second_fraction, + FROM_UNIXTIME(1609632000, '%U %u %V %v %X %x') AS formatted_week_modes, + FROM_UNIXTIME(NULL, 'literal') AS null_literal, + FROM_UNIXTIME(NULL, '') AS null_empty_from_unixtime, + DATE_FORMAT(NULL, '%%') AS null_percent, + DATE_FORMAT(NULL, '') AS null_empty_format" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "TO_TIMESTAMP(CAST(0.123456 AS double precision)) AT TIME ZONE 'UTC'", $sql ); + $this->assertStringContainsString( "'HH24') || '.' || TO_CHAR", $sql ); + $this->assertStringContainsString( "'MI') END AS formatted_hour_minute", $sql ); + $this->assertStringContainsString( "'MI') || TO_CHAR", $sql ); + $this->assertStringContainsString( "'SS') END AS formatted_hour_minute_second", $sql ); + $this->assertStringContainsString( "'0.' || TO_CHAR", $sql ); + $this->assertStringContainsString( "'SS') END AS formatted_minute_second_fraction", $sql ); + $week_timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( "TO_TIMESTAMP(CAST(1609632000 AS double precision)) AT TIME ZONE 'UTC'" ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_sunday_week_mode_zero_sql( $week_timestamp_sql ) ), + $sql + ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_week_mode_one_timestamp_sql( $week_timestamp_sql ) ), + $sql + ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_sunday_week_mode_two_sql( $week_timestamp_sql ) ), + $sql + ); + $this->assertStringContainsString( 'TO_CHAR(' . $week_timestamp_sql . ", 'IW')", $sql ); + $this->assertStringContainsString( $this->get_expected_mysql_sunday_week_mode_two_year_sql( $week_timestamp_sql ), $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $week_timestamp_sql . ", 'IYYY')", $sql ); + $this->assertStringNotContainsString( "CAST(TO_CHAR(TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC', 'HH24.MI') AS double precision)", $sql ); + $this->assertStringNotContainsString( "CAST(TO_CHAR(TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC', 'HH24.MISS') AS double precision)", $sql ); + $this->assertStringNotContainsString( "CAST('0.' || TO_CHAR(TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC', 'MISS') AS double precision)", $sql ); + $this->assertStringContainsString( "'YYYY'", $sql ); + $this->assertStringContainsString( "'MM'", $sql ); + $this->assertStringContainsString( "'DD'", $sql ); + $this->assertStringContainsString( "'HH24'", $sql ); + $this->assertStringContainsString( "'MI'", $sql ); + $this->assertStringContainsString( "'SS'", $sql ); + $this->assertStringContainsString( "'US'", $sql ); + $this->assertStringContainsString( "CASE WHEN CAST(TO_TIMESTAMP(CAST(NULL AS double precision)) AT TIME ZONE 'UTC' AS text) IS NULL OR", $sql ); + $this->assertStringContainsString( "THEN NULL ELSE '' END AS null_empty_from_unixtime", $sql ); + $this->assertStringContainsString( "CASE WHEN CAST(NULL AS text) IS NULL OR CAST(NULL AS text) = '' THEN NULL WHEN", $sql ); + $this->assertStringContainsString( "THEN '' ELSE '' END AS null_empty_format", $sql ); + $this->assertStringNotContainsString( 'FROM_UNIXTIME', $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests FROM_UNIXTIME() honors session time_zone and preserves fractional seconds. + */ + public function test_from_unixtime_runtime_function_honors_time_zone_and_fractional_seconds(): void { + $driver = $this->create_driver(); + $driver->query( "SET SESSION time_zone = '+02:30'" ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + FROM_UNIXTIME(0) AS whole_epoch, + FROM_UNIXTIME(0.123456) AS fractional_epoch, + FROM_UNIXTIME(0, '%q %%') AS unknown_specifier" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "(TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC' + INTERVAL '150 minutes')", $sql ); + $this->assertStringContainsString( "TO_CHAR((TO_TIMESTAMP(CAST(0.123456 AS double precision)) AT TIME ZONE 'UTC' + INTERVAL '150 minutes'), 'YYYY-MM-DD HH24:MI:SS.US')", $sql ); + $this->assertStringContainsString( "'q ' || '%'", $sql ); + $this->assertStringNotContainsString( "'%q '", $sql ); + $this->assertStringNotContainsString( 'FROM_UNIXTIME', $sql ); + } + + /** + * Tests DATE_FORMAT() supports runtime format expressions like SQLite's UDF. + */ + public function test_mysql_date_format_runtime_format_expressions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT + DATE_FORMAT(post_date, format_mask) AS dynamic_column_format, + DATE_FORMAT(post_date, CONCAT('%Y', '-%m')) AS dynamic_expression_format, + DATE_FORMAT(NULL, format_mask) AS null_date_format, + DATE_FORMAT(post_date, NULL) AS null_mask_format + FROM wptests_dynamic_date_formats" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH RECURSIVE "__wp_pg_mysql_date_format"', $sql ); + $this->assertStringContainsString( 'CAST(format_mask AS text)', $sql ); + $this->assertStringContainsString( "(CAST('%Y' AS text) || CAST('-%m' AS text))", $sql ); + $this->assertStringContainsString( "WHEN 'Y' THEN TO_CHAR", $sql ); + $this->assertStringContainsString( "WHEN 'D' THEN CAST(CAST(EXTRACT(DAY FROM", $sql ); + $this->assertStringContainsString( "WHEN 'w' THEN CAST(CAST(EXTRACT(DOW FROM", $sql ); + $this->assertStringContainsString( "WHEN 'U' THEN LPAD(CAST(CASE WHEN", $sql ); + $this->assertStringContainsString( "WHEN 'u' THEN LPAD(CAST(CASE WHEN", $sql ); + $this->assertStringContainsString( "WHEN 'V' THEN LPAD(CAST(CASE WHEN", $sql ); + $this->assertStringContainsString( "WHEN 'v' THEN TO_CHAR", $sql ); + $this->assertStringContainsString( "WHEN 'X' THEN CASE WHEN", $sql ); + $this->assertStringContainsString( "WHEN 'x' THEN TO_CHAR", $sql ); + $this->assertStringContainsString( 'ELSE SUBSTRING', $sql ); + $this->assertStringNotContainsString( "ELSE '%' || SUBSTRING", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT DATE_FORMAT(post_date, format_mask) AS dynamic_column_format FROM wptests_dynamic_date_formats' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH RECURSIVE "__wp_pg_mysql_date_format"', $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests runtime DATE_FORMAT() masks preserve derivable zero-date parts. + */ + public function test_mysql_date_format_runtime_format_preserves_zero_date_numeric_and_time_parts_for_postgresql(): void { + $driver = $this->create_driver(); + $expression_sql = "CAST('2006-06-00 13:04:05.123' AS text)"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT('2006-06-00 13:04:05.123', format_mask) AS dynamic_zero_format + FROM wptests_dynamic_date_formats" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WHEN ' . $this->get_expected_zero_date_condition_sql( $expression_sql ) . ' THEN (WITH RECURSIVE "__wp_pg_mysql_date_format"', $sql ); + $this->assertStringContainsString( 'WHEN \'Y\' THEN SUBSTRING(' . $expression_sql . ' FROM 1 FOR 4)', $sql ); + $this->assertStringContainsString( 'WHEN \'m\' THEN SUBSTRING(' . $expression_sql . ' FROM 6 FOR 2)', $sql ); + $this->assertStringContainsString( 'WHEN \'d\' THEN SUBSTRING(' . $expression_sql . ' FROM 9 FOR 2)', $sql ); + $this->assertStringContainsString( "WHEN 'H' THEN CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN SUBSTRING($expression_sql FROM 12 FOR 2) ELSE '00' END", $sql ); + $this->assertStringContainsString( "WHEN 'i' THEN CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN SUBSTRING($expression_sql FROM 15 FOR 2) ELSE '00' END", $sql ); + $this->assertStringContainsString( "WHEN 's' THEN CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN SUBSTRING($expression_sql FROM 18 FOR 2) ELSE '00' END", $sql ); + $this->assertStringContainsString( "WHEN 'f' THEN CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}[.][0-9]+' THEN LEFT(RPAD(SUBSTRING($expression_sql FROM '[.]([0-9]+)'), 6, '0'), 6) ELSE '000000' END", $sql ); + $this->assertStringContainsString( "WHEN 'W' THEN NULL", $sql ); + $this->assertStringContainsString( 'ELSE SUBSTRING(CAST(format_mask AS text) FROM "__wp_pg_mysql_date_format"."position" + 1 FOR 1) END', $sql ); + $this->assertStringNotContainsString( "ELSE '%' || SUBSTRING", $sql ); + $this->assertStringNotContainsString( "CAST('2006-06-00 13:04:05.123' AS timestamp)", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests formatted FROM_UNIXTIME() supports runtime format expressions. + */ + public function test_from_unixtime_runtime_format_expression_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT FROM_UNIXTIME(0, format_mask) AS formatted_epoch + FROM wptests_unix_time_formats' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WITH RECURSIVE "__wp_pg_mysql_date_format"', $sql ); + $this->assertStringContainsString( "TO_TIMESTAMP(CAST(0 AS double precision)) AT TIME ZONE 'UTC'", $sql ); + $this->assertStringContainsString( 'CAST(format_mask AS text)', $sql ); + $this->assertStringNotContainsString( 'FROM_UNIXTIME', $sql ); + } + + /** + * Tests unsupported common MySQL runtime function forms are left without compatibility translations. + */ + public function test_unsupported_common_mysql_runtime_function_forms_return_null_translation(): void { + $driver = $this->create_driver(); + $queries = array( + 'SELECT CONCAT() AS empty_concat', + "SELECT CONCAT_WS('-') AS invalid_concat_ws", + 'SELECT IFNULL(primary_value) AS invalid_ifnull FROM runtime_names', + 'SELECT NULLIF(primary_value) AS invalid_nullif FROM runtime_names', + 'SELECT NULLIF(primary_value, fallback_value, third_value) AS invalid_nullif FROM runtime_names', + 'SELECT JSON_VALID() AS invalid_json', + 'SELECT JSON_VALID(payload, fallback_value) AS invalid_json FROM runtime_names', + 'SELECT LOG() AS invalid_log', + 'SELECT CURRENT_TIMESTAMP(7) AS invalid_fractional_timestamp', + 'SELECT CURRENT_USER(1) AS invalid_current_user', + 'SELECT ROW_COUNT(123) AS rows_changed', + 'SELECT UUID() AS uuid_value', + ); + + foreach ( $queries as $query ) { + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $query + ); + } + } + + /** + * Tests unsupported known MySQL runtime functions fail before backend execution. + */ + public function test_unsupported_common_mysql_runtime_function_forms_fail_closed_before_backend_execution(): void { + $queries = array( + 'SELECT CONCAT() AS empty_concat', + "SELECT CONCAT_WS('-') AS invalid_concat_ws", + 'SELECT IFNULL(primary_value) AS invalid_ifnull FROM runtime_names', + 'SELECT NULLIF(primary_value) AS invalid_nullif FROM runtime_names', + 'SELECT NULLIF(primary_value, fallback_value, third_value) AS invalid_nullif FROM runtime_names', + 'SELECT JSON_VALID() AS invalid_json', + 'SELECT JSON_VALID(payload, fallback_value) AS invalid_json FROM runtime_names', + 'SELECT LOG() AS invalid_log', + 'SELECT CURRENT_TIMESTAMP(7) AS invalid_fractional_timestamp', + "SELECT FROM_UNIXTIME(0, '%Y', 'extra') AS invalid_from_unixtime", + "SELECT LAST_INSERT_ID('123') AS invalid_last_insert_id", + 'SELECT CURRENT_USER(1) AS invalid_current_user', + 'SELECT USER(1) AS invalid_user', + 'SELECT ROW_COUNT(123) AS rows_changed', + 'SELECT UUID() AS uuid_value', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported MySQL runtime function form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests unsupported DATE_FORMAT() and RAND() forms fail before backend execution. + */ + public function test_unsupported_date_format_and_rand_runtime_function_forms_fail_closed_before_backend_execution(): void { + $queries = array( + 'SELECT DATE_FORMAT() AS invalid_date_format', + "SELECT DATE_FORMAT('2024-01-01') AS invalid_date_format", + "SELECT DATE_FORMAT('2024-01-01', '%Y', 'extra') AS invalid_date_format", + 'SELECT RAND(1, 2) AS invalid_rand', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $query + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported MySQL runtime function form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests WordPress sticky base queries get MySQL's posts date ID tie-breaker. + */ + public function test_wordpress_posts_post_date_desc_order_uses_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + for ( $id = 1; $id <= 5; $id++ ) { + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES ($id, 'post', 'publish', '2024-01-01 00:00:00')" ); + } + + $select = "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1 = 1 AND ((wptests_posts.post_type = 'post' AND (wptests_posts.post_status = 'publish'))) + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 5"; + $rows = $driver->query( $select ); + + $this->assertSame( + array( '5', '4', '3', '2', '1' ), + array_map( + static function ( $row ) { + return $row->ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID", COUNT(*) OVER() AS "__wp_pg_found_rows" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\'))) ORDER BY wptests_posts.post_date DESC, wptests_posts."ID" DESC LIMIT 5 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests leading-comment SELECTs still use the SELECT translator chain. + */ + public function test_leading_comment_select_uses_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + for ( $id = 1; $id <= 3; $id++ ) { + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES ($id, 'post', 'publish', '2024-01-01 00:00:00')" ); + } + + $select = "/* cache gate */ SELECT wptests_posts.ID + FROM wptests_posts + WHERE wptests_posts.post_type = 'post' AND wptests_posts.post_status = 'publish' + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 3"; + $rows = $driver->query( $select ); + + $this->assertSame( + array( '3', '2', '1' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID" FROM wptests_posts WHERE wptests_posts.post_type = \'post\' AND wptests_posts.post_status = \'publish\' ORDER BY wptests_posts.post_date DESC, wptests_posts."ID" DESC LIMIT 3 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests non-descending posts date order does not get the sticky tie-breaker. + */ + public function test_wordpress_posts_post_date_asc_order_does_not_add_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (1, \'post\', \'publish\', \'2024-01-01 00:00:00\')' ); + + $select = "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE wptests_posts.post_type = 'post' + ORDER BY wptests_posts.post_date ASC + LIMIT 0, 5"; + $driver->query( $select ); + + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID", COUNT(*) OVER() AS "__wp_pg_found_rows" FROM wptests_posts WHERE wptests_posts.post_type = \'post\' ORDER BY wptests_posts.post_date ASC LIMIT 5 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests grouped posts post_date DESC order keeps MySQL's ID tie-breaker. + */ + public function test_wordpress_grouped_posts_post_date_desc_order_uses_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + for ( $id = 1; $id <= 3; $id++ ) { + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES ($id, 'post', 'publish', '2024-01-01 00:00:00')" ); + } + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 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, 3" + ); + + $this->assertSame( + array( '3', '2', '1' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID" FROM wptests_posts WHERE wptests_posts.post_type = \'post\' AND wptests_posts.post_status = \'publish\' GROUP BY wptests_posts."ID" ORDER BY MAX(wptests_posts.post_date) DESC, wptests_posts."ID" DESC LIMIT 3 OFFSET 0', + 'params' => array(), + ), + array( + 'sql' => 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT wptests_posts."ID" FROM wptests_posts WHERE wptests_posts.post_type = \'post\' AND wptests_posts.post_status = \'publish\' GROUP BY wptests_posts."ID") AS "__wp_pg_found_rows"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests admin page search ordering uses MySQL-compatible ID tie-breakers. + */ + public function test_wordpress_admin_page_search_menu_order_title_order_uses_id_tiebreaker(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_parent` bigint(20) unsigned NOT NULL DEFAULT 0, + `menu_order` int(11) NOT NULL DEFAULT 0, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_excerpt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `post_content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `post_password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `menu_order`, `post_title`, `post_excerpt`, `post_content`, `post_password`, `post_type`, `post_status`) VALUES (12, 5, 0, 'Child 1', '', '', '', 'page', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `menu_order`, `post_title`, `post_excerpt`, `post_content`, `post_password`, `post_type`, `post_status`) VALUES (9, 4, 0, 'Child 1', '', '', '', 'page', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_parent`, `menu_order`, `post_title`, `post_excerpt`, `post_content`, `post_password`, `post_type`, `post_status`) VALUES (10, 4, 0, 'Child 2', '', '', '', 'page', 'publish')" ); + + $rows = $driver->query( + "SELECT wptests_posts.* + FROM wptests_posts + WHERE 1=1 + AND (((wptests_posts.post_title LIKE '%Child%') OR (wptests_posts.post_excerpt LIKE '%Child%') OR (wptests_posts.post_content LIKE '%Child%'))) + AND (wptests_posts.post_password = '') + AND ((wptests_posts.post_type = 'page' AND (wptests_posts.post_status = 'publish'))) + ORDER BY wptests_posts.menu_order ASC, wptests_posts.post_title ASC" + ); + + $this->assertSame( + array( '9', '12', '10' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + $this->assertStringContainsString( + 'ORDER BY wptests_posts.menu_order ASC, LOWER(wptests_posts.post_title) ASC, wptests_posts."ID" ASC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests available post MIME type lookups use MySQL-compatible first posts.ID ordering. + */ + public function test_wordpress_available_post_mime_types_distinct_orders_by_first_post_id(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_mime_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (1, 'attachment', 'image/jpeg')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (2, 'attachment', 'application/pdf')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (3, 'attachment', 'image/jpeg')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (4, 'post', 'text/plain')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_mime_type`) VALUES (5, 'attachment', '')" ); + + $rows = $driver->query( + "SELECT DISTINCT post_mime_type + FROM wptests_posts + WHERE post_type = 'attachment' AND post_mime_type != ''" + ); + + $this->assertSame( + array( 'image/jpeg', 'application/pdf' ), + array_map( + static function ( $row ): string { + return $row->post_mime_type; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT post_mime_type FROM wptests_posts WHERE post_type = \'attachment\' AND post_mime_type != \'\' GROUP BY post_mime_type ORDER BY MIN("ID") ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests broader posts DISTINCT MIME type queries keep the generic path. + */ + public function test_wordpress_available_post_mime_types_distinct_rewrite_requires_exact_where_shape(): void { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_mime_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_wordpress_available_post_mime_types_query', + "SELECT DISTINCT post_mime_type FROM wptests_posts WHERE post_type = 'attachment'" + ); + + $this->assertNull( $sql ); + } + + /** + * Tests WordPress postmeta key lookups with HAVING but no GROUP BY match SQLite parity. + */ + public function test_wordpress_postmeta_distinct_meta_key_having_without_group_by_is_rewritten(): void { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + meta_id INTEGER PRIMARY KEY, + meta_key TEXT NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_postmeta (meta_id, meta_key) VALUES + (1, '_hidden'), + (2, 'visible_meta_key_03'), + (3, 'visible_meta_key_01'), + (4, 'visible_meta_key_02'), + (5, 'visible_meta_key_02')" + ); + + $queries = array( + "SELECT DISTINCT meta_key FROM wptests_postmeta WHERE meta_key NOT BETWEEN '_' AND '_z' HAVING meta_key NOT LIKE '\\_%' ORDER BY meta_key LIMIT 3", + "SELECT DISTINCT meta_key FROM wptests_postmeta WHERE meta_key NOT BETWEEN '_' AND '_z' HAVING meta_key NOT LIKE CONCAT('\\_', '%') ORDER BY meta_key LIMIT 3", + ); + + foreach ( $queries as $query ) { + $rows = $driver->query( $query ); + + $this->assertSame( + array( 'visible_meta_key_01', 'visible_meta_key_02', 'visible_meta_key_03' ), + array_map( + static function ( $row ): string { + return $row->meta_key; + }, + $rows + ), + $query + ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'SELECT DISTINCT meta_key FROM wptests_postmeta WHERE (meta_key NOT BETWEEN', $sql, $query ); + $this->assertStringContainsString( ') AND (meta_key NOT LIKE', $sql, $query ); + $this->assertStringContainsString( 'ORDER BY meta_key LIMIT 3', $sql, $query ); + $this->assertStringNotContainsString( ' HAVING ', $sql, $query ); + $this->assertStringNotContainsString( 'CONCAT(', $sql, $query ); + } + } + + /** + * Tests integer-column IN predicates coerce string literals using stored MySQL metadata. + */ + public function test_integer_column_in_string_literals_use_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_comments ( + `comment_ID` bigint(20) unsigned NOT NULL, + `comment_post_ID` bigint(20) unsigned NOT NULL DEFAULT 0, + `comment_approved` varchar(20) NOT NULL DEFAULT "1", + PRIMARY KEY (`comment_ID`) + )' + ); + $driver->query( + 'INSERT INTO wptests_comments (`comment_ID`, `comment_post_ID`, `comment_approved`) ' . + 'VALUES (1, 0, \'0\')' + ); + $driver->query( + 'INSERT INTO wptests_comments (`comment_ID`, `comment_post_ID`, `comment_approved`) ' . + 'VALUES (2, 1, \'0\')' + ); + $driver->query( + 'INSERT INTO wptests_comments (`comment_ID`, `comment_post_ID`, `comment_approved`) ' . + 'VALUES (3, 0, \'1\')' + ); + + $select = "SELECT comment_post_ID, COUNT(comment_ID) as num_comments + FROM wptests_comments + WHERE comment_post_ID IN ('') AND comment_approved = '0' + GROUP BY comment_post_ID"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '0', $rows[0]->comment_post_ID ); + $this->assertSame( '1', $rows[0]->num_comments ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringContainsString( + '"comment_post_ID" IN (' . $this->get_expected_mysql_integer_cast_sql( "''" ) . ')', + $sql + ); + $this->assertStringContainsString( "comment_approved = '0'", $sql ); + $this->assertStringNotContainsString( 'CAST(comment_approved AS text)', $sql ); + } + + /** + * Tests SQL_CALC_FOUND_ROWS user searches coerce bad ID terms without breaking LIKE terms. + */ + public function test_sql_calc_found_rows_user_search_coerces_integer_id_string_predicate(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_users ( + `ID` bigint(20) unsigned NOT NULL, + `user_login` varchar(60) NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( 'INSERT INTO wptests_users (`ID`, `user_login`) VALUES (1, \'admin\')' ); + $driver->query( 'INSERT INTO wptests_users (`ID`, `user_login`) VALUES (2, \'match-yololololo\')' ); + + $select = "SELECT SQL_CALC_FOUND_ROWS ID, user_login + FROM wptests_users + WHERE ID = 'yololololo' OR user_login LIKE '%yololololo%' + ORDER BY ID ASC"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( 'match-yololololo', $rows[0]->user_login ); + + $queries = $driver->get_last_postgresql_queries(); + $sql = $queries[0]['sql']; + $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $sql ); + $this->assertStringContainsString( + '"ID" = ' . $this->get_expected_mysql_integer_cast_sql( "'yololololo'" ), + $sql + ); + $this->assertStringContainsString( "LOWER(user_login) LIKE LOWER('%yololololo%')", $sql ); + $this->assertStringContainsString( + 'COUNT(*) OVER() AS "__wp_pg_found_rows"', + $sql + ); + } + + /** + * Tests SQL_CALC_FOUND_ROWS user searches coerce bad ID terms after author subqueries. + */ + public function test_sql_calc_found_rows_user_search_coerces_integer_id_string_predicate_after_subquery(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_users ( + `ID` bigint(20) unsigned NOT NULL, + `user_login` varchar(60) NOT NULL DEFAULT "", + `user_nicename` varchar(50) NOT NULL DEFAULT "", + `display_name` varchar(250) NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_author` bigint(20) unsigned NOT NULL DEFAULT 0, + `post_status` varchar(20) NOT NULL DEFAULT "publish", + `post_type` varchar(20) NOT NULL DEFAULT "post", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'INSERT INTO wptests_users (`ID`, `user_login`, `user_nicename`, `display_name`) ' . + 'VALUES (1, \'admin\', \'admin\', \'Admin\')' + ); + $driver->query( + 'INSERT INTO wptests_users (`ID`, `user_login`, `user_nicename`, `display_name`) ' . + 'VALUES (2, \'match-yololololo\', \'match-yololololo\', \'Match Yololololo\')' + ); + $driver->query( + 'INSERT INTO wptests_posts (`ID`, `post_author`, `post_status`, `post_type`) ' . + 'VALUES (1, 2, \'publish\', \'post\')' + ); + + $select = "SELECT SQL_CALC_FOUND_ROWS wptests_users.ID + FROM wptests_users + WHERE 1=1 + AND wptests_users.ID IN ( + SELECT DISTINCT wptests_posts.post_author + FROM wptests_posts + WHERE wptests_posts.post_status = 'publish' + AND wptests_posts.post_type IN ( 'post', 'page' ) + ) + AND (ID = 'yololololo' + OR user_login LIKE '%yololololo%' + OR user_nicename LIKE '%yololololo%' + OR display_name LIKE '%yololololo%') + ORDER BY display_name ASC + LIMIT 0, 10"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + + $queries = $driver->get_last_postgresql_queries(); + foreach ( $queries as $query ) { + $sql = $query['sql']; + $this->assertStringContainsString( + '"ID" = ' . $this->get_expected_mysql_integer_cast_sql( "'yololololo'" ), + $sql + ); + $this->assertStringContainsString( "LOWER(user_login) LIKE LOWER('%yololololo%')", $sql ); + $this->assertStringContainsString( "LOWER(user_nicename) LIKE LOWER('%yololololo%')", $sql ); + $this->assertStringContainsString( "LOWER(display_name) LIKE LOWER('%yololololo%')", $sql ); + } + } + + /** + * Tests text columns keep lexical string comparisons even when numeric-looking values are present. + */ + public function test_text_columns_preserve_lexical_string_comparisons(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_users ( + `ID` bigint(20) unsigned NOT NULL, + `user_login` varchar(60) NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `meta_id` bigint(20) unsigned NOT NULL, + `meta_value` longtext NOT NULL, + PRIMARY KEY (`meta_id`) + )' + ); + $driver->query( 'INSERT INTO wptests_users (`ID`, `user_login`) VALUES (7, \'007\')' ); + $driver->query( 'INSERT INTO wptests_users (`ID`, `user_login`) VALUES (8, \'7\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (`meta_id`, `meta_value`) VALUES (1, \'10abc\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (`meta_id`, `meta_value`) VALUES (2, \'10\')' ); + + $user_rows = $driver->query( "SELECT ID FROM wptests_users WHERE user_login = '007'" ); + + $this->assertCount( 1, $user_rows ); + $this->assertSame( '7', $user_rows[0]->ID ); + $this->assertStringNotContainsString( 'SUBSTRING(CAST', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $meta_rows = $driver->query( "SELECT meta_id FROM wptests_postmeta WHERE meta_value = '10abc'" ); + + $this->assertCount( 1, $meta_rows ); + $this->assertSame( '1', $meta_rows[0]->meta_id ); + $this->assertStringNotContainsString( 'SUBSTRING(CAST', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $like_rows = $driver->query( "SELECT meta_id FROM wptests_postmeta WHERE meta_value LIKE '10%' ORDER BY meta_id" ); + + $this->assertCount( 2, $like_rows ); + $this->assertSame( '1', $like_rows[0]->meta_id ); + $this->assertSame( '2', $like_rows[1]->meta_id ); + $this->assertStringContainsString( "meta_value LIKE '10%'", $driver->get_last_postgresql_queries()[0]['sql'] ); + $this->assertStringNotContainsString( 'SUBSTRING(CAST', $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests text metadata columns use MySQL numeric coercion when compared with numeric literals. + */ + public function test_text_metadata_numeric_literal_comparisons_use_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'score', '100')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'score', '')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'score', 'abc')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (4, 'score', '20abc')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (5, 'other', '1')" ); + + $rows = $driver->query( + "SELECT post_id FROM wptests_postmeta WHERE meta_key = 'score' AND meta_value < 50 ORDER BY post_id" + ); + + $this->assertSame( + array( '2', '3', '4' ), + array_map( + static function ( $row ): string { + return $row->post_id; + }, + $rows + ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'meta_value' ); + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringContainsString( $meta_value_cast_sql . ' < 50', $sql ); + $this->assertStringContainsString( "meta_key = 'score'", $sql ); + + $mirrored_rows = $driver->query( + "SELECT post_id FROM wptests_postmeta WHERE meta_key = 'score' AND 50 > meta_value ORDER BY post_id" + ); + + $this->assertSame( + array( '2', '3', '4' ), + array_map( + static function ( $row ): string { + return $row->post_id; + }, + $mirrored_rows + ) + ); + $this->assertStringContainsString( '50 > ' . $meta_value_cast_sql, $driver->get_last_postgresql_queries()[0]['sql'] ); + + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (6, 'score', '1.7')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (7, 'score', '1.2')" ); + + $decimal_rows = $driver->query( + "SELECT post_id FROM wptests_postmeta WHERE meta_key = 'score' AND meta_value < 1.5 ORDER BY post_id" + ); + + $this->assertSame( + array( '2', '3', '7' ), + array_map( + static function ( $row ): string { + return $row->post_id; + }, + $decimal_rows + ) + ); + $this->assertStringContainsString( $meta_value_cast_sql . ' < 1.5', $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests text metadata columns use MySQL numeric coercion for ORDER BY column + 0. + */ + public function test_text_metadata_plus_zero_order_by_uses_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'score', '10')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'score', '2')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'score', 'abc')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (4, 'score', '')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (5, 'decimal', '1.7')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (6, 'decimal', '1.2')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (7, 'decimal', '2')" ); + + $rows = $driver->query( + "SELECT post_id FROM wptests_postmeta WHERE meta_key = 'score' ORDER BY meta_value+0 ASC, post_id ASC" + ); + + $this->assertSame( + array( '3', '4', '2', '1' ), + array_map( + static function ( $row ): string { + return $row->post_id; + }, + $rows + ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'meta_value' ); + $this->assertStringContainsString( + 'ORDER BY ' . $meta_value_cast_sql . ' ASC, post_id ASC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $decimal_rows = $driver->query( + "SELECT post_id FROM wptests_postmeta WHERE meta_key = 'decimal' ORDER BY meta_value+0 ASC, post_id ASC" + ); + + $this->assertSame( + array( '6', '5', '7' ), + array_map( + static function ( $row ): string { + return $row->post_id; + }, + $decimal_rows + ) + ); + $this->assertStringContainsString( + 'ORDER BY ' . $meta_value_cast_sql . ' ASC, post_id ASC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests text metadata UPDATE additions use MySQL numeric coercion before text assignment. + */ + public function test_text_metadata_update_addition_uses_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, '_order_total', '10')" ); + + $this->assertSame( + 1, + $driver->query( "UPDATE wptests_postmeta SET meta_value = meta_value + 4.000000 WHERE post_id = 1 AND meta_key = '_order_total'" ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'meta_value' ); + $this->assertStringContainsString( + '"meta_value" = CAST(' . $meta_value_cast_sql . ' + 4.000000 AS text)', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $rows = $driver->query( "SELECT meta_value FROM wptests_postmeta WHERE post_id = 1 AND meta_key = '_order_total'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '14.0', $rows[0]->meta_value ); + } + + /** + * Tests text metadata UPDATE subtractions use MySQL numeric coercion. + */ + public function test_text_metadata_update_subtraction_uses_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_usermeta ( + `user_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_usermeta (`user_id`, `meta_key`, `meta_value`) VALUES (107, 'total_group_count', '7')" ); + + $this->assertSame( + 1, + $driver->query( "UPDATE wptests_usermeta SET meta_value = meta_value - 1 WHERE meta_key = 'total_group_count' AND user_id IN ( 107 )" ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'meta_value' ); + $this->assertStringContainsString( + '"meta_value" = CAST(' . $meta_value_cast_sql . ' - 1 AS text)', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $rows = $driver->query( "SELECT meta_value FROM wptests_usermeta WHERE user_id = 107 AND meta_key = 'total_group_count'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '6', $rows[0]->meta_value ); + } + + /** + * Tests text metadata SUM aggregates use MySQL numeric coercion from metadata. + */ + public function test_text_metadata_sum_aggregate_uses_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_parent` bigint(20) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_parent`) VALUES (2, 'shop_order_refund', 1)" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_parent`) VALUES (3, 'shop_order_refund', 1)" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, '_refund_amount', '2.25')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, '_refund_amount', '3')" ); + + $rows = $driver->query( + "SELECT SUM( postmeta.meta_value ) AS refunded + FROM wptests_postmeta AS postmeta + INNER JOIN wptests_posts AS posts ON ( posts.post_type = 'shop_order_refund' AND posts.post_parent = 1 ) + WHERE postmeta.meta_key = '_refund_amount' + AND postmeta.post_id = posts.ID" + ); + + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'postmeta.meta_value' ); + $this->assertStringContainsString( + 'SUM(' . $meta_value_cast_sql . ') AS refunded', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '5.25', $rows[0]->refunded ); + } + + /** + * Tests DISTINCT ORDER BY rewrites keep numeric metadata ordering safe. + */ + public function test_distinct_text_metadata_plus_zero_order_by_uses_mysql_numeric_coercion_from_metadata(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_terms ( + `term_id` bigint(20) unsigned NOT NULL, + `name` varchar(200) NOT NULL DEFAULT "", + PRIMARY KEY (`term_id`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_termmeta ( + `meta_id` bigint(20) unsigned NOT NULL, + `term_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_terms (`term_id`, `name`) VALUES (1, 'one')" ); + $driver->query( "INSERT INTO wptests_terms (`term_id`, `name`) VALUES (2, 'two')" ); + $driver->query( "INSERT INTO wptests_terms (`term_id`, `name`) VALUES (3, 'three')" ); + $driver->query( + "INSERT INTO wptests_termmeta (`meta_id`, `term_id`, `meta_key`, `meta_value`) VALUES (1, 1, 'score', '10')" + ); + $driver->query( + "INSERT INTO wptests_termmeta (`meta_id`, `term_id`, `meta_key`, `meta_value`) VALUES (2, 2, 'score', '2')" + ); + $driver->query( + "INSERT INTO wptests_termmeta (`meta_id`, `term_id`, `meta_key`, `meta_value`) VALUES (3, 3, 'score', 'abc')" + ); + + $rows = $driver->query( + "SELECT DISTINCT t.term_id + FROM wptests_terms AS t INNER JOIN wptests_termmeta ON ( t.term_id = wptests_termmeta.term_id ) + WHERE wptests_termmeta.meta_key = 'score' + ORDER BY wptests_termmeta.meta_value+0 ASC" + ); + + $this->assertSame( + array( '3', '2', '1' ), + array_map( + static function ( $row ): string { + return $row->term_id; + }, + $rows + ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_numeric_cast_sql( 'wptests_termmeta.meta_value' ); + $this->assertStringContainsString( + 'MIN(' . $meta_value_cast_sql . ') AS "__wp_pg_order_0"', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests WordPress term and post-search predicates preserve MySQL case-insensitive collation behavior. + */ + public function test_wordpress_term_and_post_search_text_predicates_use_case_insensitive_mysql_collation_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_excerpt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `post_content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_terms ( + `term_id` bigint(20) unsigned NOT NULL, + `name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `slug` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`term_id`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_term_taxonomy ( + `term_taxonomy_id` bigint(20) unsigned NOT NULL, + `term_id` bigint(20) unsigned NOT NULL, + `taxonomy` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `description` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`term_taxonomy_id`) + )' + ); + $driver->query( + "INSERT INTO wptests_posts (`ID`, `post_title`, `post_excerpt`, `post_content`) VALUES (7, 'Search & Test', '', 'Body')" + ); + $driver->query( "INSERT INTO wptests_terms (`term_id`, `name`, `slug`) VALUES (1, 'burrito', 'burrito')" ); + $driver->query( "INSERT INTO wptests_terms (`term_id`, `name`, `slug`) VALUES (2, 'taco', 'taco')" ); + $driver->query( + "INSERT INTO wptests_term_taxonomy (`term_taxonomy_id`, `term_id`, `taxonomy`, `description`) VALUES (10, 1, 'post_tag', 'This is a burrito.')" + ); + $driver->query( + "INSERT INTO wptests_term_taxonomy (`term_taxonomy_id`, `term_id`, `taxonomy`, `description`) VALUES (20, 2, 'post_tag', 'Burning man.')" + ); + + $post_rows = $driver->query( + "SELECT ID + FROM wptests_posts + WHERE wptests_posts.post_title LIKE '%test%'" + ); + + $this->assertSame( + array( '7' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $post_rows + ) + ); + $this->assertStringContainsString( + "LOWER(wptests_posts.post_title) LIKE LOWER('%test%')", + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $name_rows = $driver->query( + "SELECT t.term_id + FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id + WHERE tt.taxonomy = 'post_tag' AND t.name = 'BURRITO'" + ); + + $this->assertSame( + array( '1' ), + array_map( + static function ( $row ): string { + return $row->term_id; + }, + $name_rows + ) + ); + $this->assertStringContainsString( + "LOWER(t.name) = LOWER('BURRITO')", + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $name_in_rows = $driver->query( + "SELECT t.term_id + FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id + WHERE tt.taxonomy = 'post_tag' AND t.name IN ('BURRITO')" + ); + + $this->assertSame( + array( '1' ), + array_map( + static function ( $row ): string { + return $row->term_id; + }, + $name_in_rows + ) + ); + $this->assertStringContainsString( + "LOWER(t.name) IN (LOWER('BURRITO'))", + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $description_rows = $driver->query( + "SELECT t.term_id + FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id + WHERE tt.taxonomy IN ('post_tag') AND tt.description LIKE '%Bur%' + ORDER BY t.term_id ASC" + ); + + $this->assertSame( + array( '1', '2' ), + array_map( + static function ( $row ): string { + return $row->term_id; + }, + $description_rows + ) + ); + $this->assertStringContainsString( + "LOWER(tt.description) LIKE LOWER('%Bur%')", + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests column-reference metadata lookups are cached until table metadata changes. + */ + public function test_wordpress_column_reference_metadata_cache_reuses_lookups_until_metadata_changes(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_terms ( + `term_id` bigint(20) unsigned NOT NULL, + `name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`term_id`) + )' + ); + + $column_type_queries = 0; + $column_collation_queries = 0; + $table_has_metadata_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$column_type_queries, &$column_collation_queries, &$table_has_metadata_queries ): void { + if ( false !== strpos( $sql, 'SELECT column_type FROM "__wp_postgresql_mysql_column_metadata"' ) ) { + ++$column_type_queries; + } + if ( false !== strpos( $sql, 'SELECT collation_name FROM "__wp_postgresql_mysql_column_metadata"' ) ) { + ++$column_collation_queries; + } + if ( false !== strpos( $sql, 'SELECT 1 FROM "__wp_postgresql_mysql_column_metadata"' ) ) { + ++$table_has_metadata_queries; + } + } + ); + + $query = "SELECT post_title FROM wptests_posts, wptests_terms WHERE post_title LIKE '%test%'"; + + $driver->query( $query ); + + $type_queries_after_first = $column_type_queries; + $collation_queries_after_first = $column_collation_queries; + $metadata_queries_after_first = $table_has_metadata_queries; + $this->assertGreaterThan( 0, $type_queries_after_first ); + $this->assertGreaterThan( 0, $collation_queries_after_first ); + $this->assertGreaterThan( 0, $metadata_queries_after_first ); + + $driver->query( $query ); + + $this->assertSame( $type_queries_after_first, $column_type_queries ); + $this->assertSame( $collation_queries_after_first, $column_collation_queries ); + $this->assertSame( $metadata_queries_after_first, $table_has_metadata_queries ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( $query ); + + $this->assertGreaterThan( $type_queries_after_first, $column_type_queries ); + $this->assertGreaterThan( $collation_queries_after_first, $column_collation_queries ); + $this->assertGreaterThan( $metadata_queries_after_first, $table_has_metadata_queries ); + } + + /** + * Tests qualified column-name metadata lookups are cached until table metadata changes. + */ + public function test_wordpress_column_name_metadata_cache_reuses_lookups_until_metadata_changes(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + + $column_name_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$column_name_queries ): void { + if ( false !== strpos( $sql, 'SELECT column_name FROM "__wp_postgresql_mysql_column_metadata"' ) ) { + ++$column_name_queries; + } + } + ); + + $query = 'SELECT p.ID FROM wptests_posts AS p WHERE p.ID > 0'; + + $driver->query( $query ); + $column_name_queries_after_first = $column_name_queries; + $this->assertGreaterThan( 0, $column_name_queries_after_first ); + + $driver->query( $query ); + $this->assertSame( $column_name_queries_after_first, $column_name_queries ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( $query ); + $this->assertGreaterThan( $column_name_queries_after_first, $column_name_queries ); + } + + /** + * Tests exact SELECT translations are cached until table metadata changes. + */ + public function test_select_translation_cache_reuses_exact_sql_until_metadata_changes(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( "INSERT INTO wptests_posts (ID, post_title) VALUES (1, 'Hello')" ); + + $query = "SELECT p.ID FROM wptests_posts AS p WHERE p.ID > '0'"; + + $driver->query( $query ); + + $cache = $this->get_driver_private_property( $driver, 'mysql_select_translation_cache' ); + $this->assertCount( 1, $cache ); + + $entry = reset( $cache ); + $this->assertIsArray( $entry ); + $this->assertSame( $query, $entry['query'] ); + $this->assertTrue( $entry['translated'] ); + $this->assertStringContainsString( 'p."ID"', $entry['sql'] ); + + $driver->query( $query ); + + $this->assertSame( + $cache, + $this->get_driver_private_property( $driver, 'mysql_select_translation_cache' ) + ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + + $this->assertSame( + array(), + $this->get_driver_private_property( $driver, 'mysql_select_translation_cache' ) + ); + } + + /** + * Tests exact SQL_CALC_FOUND_ROWS count SQL is cached until table metadata changes. + */ + public function test_sql_calc_found_rows_count_query_cache_reuses_exact_sql_until_metadata_changes(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( "INSERT INTO wptests_posts (ID, post_title) VALUES (1, 'Hello')" ); + $driver->query( "INSERT INTO wptests_posts (ID, post_title) VALUES (2, 'World')" ); + + $query = "SELECT SQL_CALC_FOUND_ROWS p.ID + FROM wptests_posts AS p + WHERE p.ID > '0' + ORDER BY p.ID ASC + LIMIT 10, 1"; + + $driver->query( $query ); + + $count_cache = $this->get_driver_private_property( $driver, 'mysql_sql_calc_found_rows_count_query_cache' ); + $this->assertCount( 1, $count_cache ); + + $entry = reset( $count_cache ); + $this->assertIsArray( $entry ); + $this->assertSame( $query, $entry['query'] ); + $this->assertStringStartsWith( 'SELECT COUNT(*) AS "__wp_pg_found_rows"', $entry['sql'] ); + + $postgresql_queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $postgresql_queries ); + $count_sql = $postgresql_queries[1]['sql']; + + $driver->query( $query ); + + $this->assertSame( + $count_cache, + $this->get_driver_private_property( $driver, 'mysql_sql_calc_found_rows_count_query_cache' ) + ); + $this->assertSame( $count_sql, $driver->get_last_postgresql_queries()[1]['sql'] ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + + $this->assertSame( + array(), + $this->get_driver_private_property( $driver, 'mysql_sql_calc_found_rows_count_query_cache' ) + ); + } + + /** + * Tests MySQL DATETIME casts in grouped postmeta ordering are translated. + */ + public function test_sql_calc_grouped_postmeta_order_by_datetime_cast_uses_postgresql_timestamp(): void { + $driver = $this->create_driver(); + + $translation = $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_select_query_for_postgresql', + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE wptests_postmeta.meta_key = '_bbp_last_active_time' + AND wptests_posts.post_type = 'topic' + AND wptests_posts.post_status = 'publish' + GROUP BY wptests_posts.ID + ORDER BY CAST(wptests_postmeta.meta_value AS DATETIME) DESC + LIMIT 0, 15" + ); + + $this->assertIsArray( $translation ); + $this->assertTrue( $translation['translated'] ); + $sql = $translation['sql']; + $this->assertStringContainsString( 'ORDER BY MAX(CAST(CASE WHEN', $sql ); + $this->assertStringContainsString( 'AS timestamp)) DESC', $sql ); + $this->assertStringNotContainsString( ' AS DATETIME', $sql ); + } + + /** + * Tests WordPress user text predicates and ordering preserve MySQL collation behavior. + */ + public function test_wordpress_user_text_predicates_and_ordering_use_case_insensitive_mysql_collation_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_users ( + `ID` bigint(20) unsigned NOT NULL, + `user_login` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `user_nicename` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `user_email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `user_url` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `display_name` varchar(250) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + "INSERT INTO wptests_users (`ID`, `user_login`, `user_nicename`, `user_email`, `user_url`, `display_name`) VALUES (2, 'subscriber', 'subscriber', 'subscriber@example.com', '', 'subscriber')" + ); + $driver->query( + "INSERT INTO wptests_users (`ID`, `user_login`, `user_nicename`, `user_email`, `user_url`, `display_name`) VALUES (33, 'zzzz', 'zzzz', 'zzzz@example.com', '', 'ZZZZ')" + ); + + $email_rows = $driver->query( "SELECT ID FROM wptests_users WHERE user_email = 'Subscriber@Example.com'" ); + + $this->assertSame( + array( '2' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $email_rows + ) + ); + $this->assertStringContainsString( + "LOWER(user_email) = LOWER('Subscriber@Example.com')", + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $order_rows = $driver->query( 'SELECT ID FROM wptests_users ORDER BY display_name DESC LIMIT 1' ); + + $this->assertStringContainsString( + 'ORDER BY LOWER(display_name) DESC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + $this->assertSame( '33', $order_rows[0]->ID ); + } + + /** + * Tests WordPress post-search relevance CASE ordering uses case-insensitive text predicates. + */ + public function test_wordpress_post_search_relevance_order_by_case_uses_case_insensitive_mysql_collation_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + `post_excerpt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `post_content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + "INSERT INTO wptests_posts (`ID`, `post_title`, `post_excerpt`, `post_content`) VALUES (1, 'This post has foo', '', '')" + ); + $driver->query( + "INSERT INTO wptests_posts (`ID`, `post_title`, `post_excerpt`, `post_content`) VALUES (2, '', '', 'This post has foo')" + ); + $driver->query( + "INSERT INTO wptests_posts (`ID`, `post_title`, `post_excerpt`, `post_content`) VALUES (3, '', 'This post has foo', '')" + ); + + $rows = $driver->query( + "SELECT ID + FROM wptests_posts + ORDER BY (CASE + WHEN wptests_posts.post_title LIKE '%this post has foo%' THEN 1 + WHEN wptests_posts.post_title LIKE '%this%' AND wptests_posts.post_title LIKE '%post%' AND wptests_posts.post_title LIKE '%has%' AND wptests_posts.post_title LIKE '%foo%' THEN 2 + WHEN wptests_posts.post_title LIKE '%this%' OR wptests_posts.post_title LIKE '%post%' OR wptests_posts.post_title LIKE '%has%' OR wptests_posts.post_title LIKE '%foo%' THEN 3 + WHEN wptests_posts.post_excerpt LIKE '%this post has foo%' THEN 4 + WHEN wptests_posts.post_content LIKE '%this post has foo%' THEN 5 + ELSE 6 + END), wptests_posts.ID ASC" + ); + + $this->assertSame( + array( '1', '3', '2' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringContainsString( + "WHEN LOWER(wptests_posts.post_title) LIKE LOWER('%this post has foo%') THEN 1", + $sql + ); + $this->assertStringContainsString( + "WHEN LOWER(wptests_posts.post_excerpt) LIKE LOWER('%this post has foo%') THEN 4", + $sql + ); + $this->assertStringContainsString( + "WHEN LOWER(wptests_posts.post_content) LIKE LOWER('%this post has foo%') THEN 5", + $sql + ); + } + + /** + * Tests schema-qualified WordPress text predicates do not rewrite qualified-reference suffixes. + */ + public function test_schema_qualified_wordpress_text_predicates_fail_closed_without_suffix_rewrite(): void { + $driver = $this->create_driver(); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_terms ( + `term_id` bigint(20) unsigned NOT NULL, + `name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "", + PRIMARY KEY (`term_id`) + )' + ); + + foreach ( + array( + "SELECT term_id FROM public.wptests_terms WHERE public.wptests_terms.name = 'BURRITO'", + "SELECT term_id FROM public.wptests_terms WHERE public.wptests_terms.name LIKE '%Bur%'", + "SELECT term_id FROM public.wptests_terms WHERE public.wptests_terms.name IN ('BURRITO')", + ) as $query + ) { + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $query ); + + if ( null !== $sql ) { + $this->assertSame( $query, $sql ); + } + $this->assertStringNotContainsString( 'public. LOWER(', (string) $sql ); + } + } + + /** + * Tests ambiguous unqualified integer references do not guess a table. + */ + public function test_ambiguous_unqualified_integer_reference_fails_closed(): void { + $driver = $this->create_driver(); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_left_ids ( + `ID` bigint(20) NOT NULL, + `label` varchar(20) NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_right_ids ( + `ID` bigint(20) NOT NULL, + `label` varchar(20) NOT NULL + )' + ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT * FROM wptests_left_ids, wptests_right_ids WHERE ID = 'abc'" + ); + + $this->assertSame( 'SELECT * FROM wptests_left_ids, wptests_right_ids WHERE "ID" = \'abc\'', $sql ); + $this->assertStringNotContainsString( 'SUBSTRING(CAST', $sql ); + } + + /** + * Tests MySQL SIGNED and UNSIGNED casts coerce text safely for PostgreSQL. + */ + public function test_signed_and_unsigned_casts_coerce_mysql_text_values_for_postgresql(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( 'CREATE TABLE wptests_postmeta (post_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + + $values = array( + array( 1, '' ), + array( 2, ' ' ), + array( 3, 'abc' ), + array( 4, '10abc' ), + array( 5, '-7xyz' ), + array( 6, '+8' ), + array( 7, '42' ), + array( 8, '+' ), + array( 9, '-' ), + array( 10, ' 15xyz' ), + ); + + foreach ( $values as $value ) { + $driver->query( + sprintf( + 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (%d, \'score\', %s)', + $value[0], + $driver->get_connection()->quote( $value[1] ) + ) + ); + } + + $select = 'SELECT post_id, meta_value, CAST(meta_value AS SIGNED) AS signed_value, CAST(meta_value AS UNSIGNED) AS unsigned_value FROM wptests_postmeta ORDER BY post_id'; + $rows = $driver->query( $select ); + + $this->assertSame( + array( + array( '', '0', '0' ), + array( ' ', '0', '0' ), + array( 'abc', '0', '0' ), + array( '10abc', '10', '10' ), + array( '-7xyz', '-7', '-7' ), + array( '+8', '8', '8' ), + array( '42', '42', '42' ), + array( '+', '0', '0' ), + array( '-', '0', '0' ), + array( ' 15xyz', '15', '15' ), + ), + array_map( + static function ( $row ): array { + return array( $row->meta_value, $row->signed_value, $row->unsigned_value ); + }, + $rows + ) + ); + + $meta_value_cast_sql = $this->get_expected_mysql_integer_cast_sql( 'meta_value' ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT post_id, meta_value, ' . $meta_value_cast_sql . ' AS signed_value, ' . $meta_value_cast_sql . ' AS unsigned_value FROM wptests_postmeta ORDER BY post_id', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $select = "SELECT post_id, meta_value FROM wptests_postmeta WHERE meta_key = 'score' AND CAST(meta_value AS SIGNED) > 0 ORDER BY CAST(meta_value AS UNSIGNED INTEGER) DESC, post_id ASC"; + $rows = $driver->query( $select ); + + $this->assertSame( + array( '42', ' 15xyz', '10abc', '+8' ), + array_map( + static function ( $row ): string { + return $row->meta_value; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => "SELECT post_id, meta_value FROM wptests_postmeta WHERE meta_key = 'score' AND " . $meta_value_cast_sql . ' > 0 ORDER BY ' . $meta_value_cast_sql . ' DESC, post_id ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests lowercase signed integer casts trigger PostgreSQL compatibility translation. + */ + public function test_lowercase_signed_integer_cast_triggers_postgresql_rewrite(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $rows = $driver->query( "SELECT cast('7' as signed integer) AS cast_value" ); + + $this->assertSame( '7', $rows[0]->cast_value ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT ' . $this->get_expected_mysql_integer_cast_sql( "'7'" ) . ' AS cast_value', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests RAND() and literal RAND(seed) are translated for PostgreSQL. + */ + public function test_rand_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_links (link_id INTEGER PRIMARY KEY)' ); + $driver->query( 'INSERT INTO wptests_links (link_id) VALUES (1)' ); + $driver->query( 'INSERT INTO wptests_links (link_id) VALUES (2)' ); + + $rows = $driver->query( 'SELECT link_id FROM wptests_links ORDER BY RAND(7) LIMIT 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT link_id FROM wptests_links ORDER BY CAST(0.90650219368422613 AS double precision) LIMIT 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( + "SELECT RAND(0) AS r0, + RAND(1) AS r1, + RAND(5) AS r5, + RAND(NULL) AS rnull, + RAND(3.9) AS rfloat, + RAND('5') AS rstring, + RAND('abc') AS rbadstring" + ); + $this->assertEqualsWithDelta( 0.15522042769493574, (float) $rows[0]->r0, 1e-12 ); + $this->assertEqualsWithDelta( 0.40540353712197724, (float) $rows[0]->r1, 1e-12 ); + $this->assertEqualsWithDelta( 0.40613597483014313, (float) $rows[0]->r5, 1e-12 ); + $this->assertEqualsWithDelta( 0.15522042769493574, (float) $rows[0]->rnull, 1e-12 ); + $this->assertEqualsWithDelta( 0.15595286540310166, (float) $rows[0]->rfloat, 1e-12 ); + $this->assertEqualsWithDelta( 0.40613597483014313, (float) $rows[0]->rstring, 1e-12 ); + $this->assertEqualsWithDelta( 0.15522042769493574, (float) $rows[0]->rbadstring, 1e-12 ); + $this->assertSame( + 'SELECT CAST(0.15522042769493574 AS double precision) AS r0, CAST(0.40540353712197724 AS double precision) AS r1, CAST(0.40613597483014313 AS double precision) AS r5, CAST(0.15522042769493574 AS double precision) AS rnull, CAST(0.15595286540310166 AS double precision) AS rfloat, CAST(0.40613597483014313 AS double precision) AS rstring, CAST(0.15522042769493574 AS double precision) AS rbadstring', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $driver->query( 'SELECT rand() AS random_value, RAND(link_id) AS conservative_seed FROM wptests_links LIMIT 1' ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT random() AS random_value, random() AS conservative_seed FROM wptests_links LIMIT 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests MySQL SELECT row-locking clauses are stripped like the SQLite backend. + */ + public function test_select_row_locking_clauses_are_supported_noops(): void { + $queries = array( + "SELECT value FROM wptests_locking WHERE name = 'test_lock' FOR UPDATE", + "SELECT value FROM wptests_locking WHERE name = 'test_lock' FOR SHARE", + "SELECT value FROM wptests_locking WHERE name = 'test_lock' LOCK IN SHARE MODE", + "SELECT value FROM wptests_locking WHERE name = 'test_lock' FOR UPDATE SKIP LOCKED", + "SELECT value FROM wptests_locking WHERE name = 'test_lock' FOR UPDATE NOWAIT", + "SELECT value FROM wptests_locking WHERE name = 'test_lock' FOR SHARE OF wptests_locking NOWAIT", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_locking (name VARCHAR(255), value VARCHAR(255))' ); + $driver->query( "INSERT INTO wptests_locking (name, value) VALUES ('test_lock', '123')" ); + + $rows = $driver->query( $query ); + + $this->assertSame( '123', $rows[0]->value, $query ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT value FROM wptests_locking WHERE name = \'test_lock\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries(), + $query + ); + } + } + + /** + * Tests MySQL-only expression names inside string literals are not rewritten. + */ + public function test_expression_rewrite_does_not_replace_string_literals(): void { + $driver = $this->create_driver(); + + $select = "SELECT 'FIELD(ID, 1)', 'CAST(meta_value AS SIGNED)', 'CAST(meta_value AS UNSIGNED)', 'RAND()' AS literal_value"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + "SELECT 'FIELD(ID, 1)', 'CAST(meta_value AS SIGNED)', 'CAST(meta_value AS UNSIGNED)', 'RAND()' AS literal_value", + $sql + ); + } + + /** + * Tests SELECT DISTINCT term ID queries hide ORDER BY expressions. + */ + public function test_distinct_term_id_order_by_name_preserves_visible_projection_with_limit(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Beta')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Alpha')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (10, 1, 'category')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (20, 2, 'category')" ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 20)' ); + + $select = "SELECT DISTINCT t.term_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 IN ('category') AND tr.object_id IN (1) + ORDER BY t.name ASC + LIMIT 10"; + $rows = $driver->query( $select ); + + $this->assertCount( 2, $rows ); + $this->assertSame( '2', $rows[0]->term_id ); + $this->assertSame( '1', $rows[1]->term_id ); + $this->assertSame( array( 'term_id' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "__wp_pg_distinct"."term_id" AS "term_id" FROM (SELECT t.term_id AS "term_id", MIN(t.name) AS "__wp_pg_order_0" 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 IN (\'category\') AND tr.object_id IN (1) GROUP BY t.term_id) AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" ASC LIMIT 10', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests grouped SELECT DISTINCT term ID queries hide ORDER BY expressions. + */ + public function test_grouped_distinct_term_id_order_by_name_preserves_visible_projection(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Beta')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Alpha')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (10, 1, 'category')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (20, 2, 'category')" ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (1, 20)' ); + + $select = "SELECT DISTINCT t.term_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 IN ('category') AND tr.object_id IN (1) + GROUP BY t.term_id + ORDER BY t.name ASC + LIMIT 10"; + $rows = $driver->query( $select ); + + $this->assertCount( 2, $rows ); + $this->assertSame( '2', $rows[0]->term_id ); + $this->assertSame( '1', $rows[1]->term_id ); + $this->assertSame( array( 'term_id' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "__wp_pg_distinct"."term_id" AS "term_id" FROM (SELECT DISTINCT t.term_id AS "term_id", MIN(t.name) AS "__wp_pg_order_0" 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 IN (\'category\') AND tr.object_id IN (1) GROUP BY t.term_id) AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" ASC LIMIT 10', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests grouped SELECT DISTINCT term query rows hide ORDER BY expressions. + */ + public function test_grouped_distinct_term_query_order_by_name_preserves_visible_projection(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL, description TEXT NOT NULL, parent INTEGER NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Beta')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Alpha')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent) VALUES (10, 1, 'wptests_tax', 'Beta description', 0)" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent) VALUES (20, 2, 'wptests_tax', 'Alpha description', 0)" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent) VALUES (30, 1, 'other_tax', 'Other description', 0)" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status) VALUES (100, 'post', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status) VALUES (101, 'post', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status) VALUES (102, 'post', 'draft')" ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (100, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (101, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (102, 10)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id) VALUES (100, 20)' ); + + $select = "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"; + $rows = $driver->query( $select ); + + $this->assertCount( 2, $rows ); + $this->assertSame( array( 'term_id', 'term_taxonomy_id', 'taxonomy', 'description', 'parent', 'count' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( '2', $rows[0]->term_id ); + $this->assertSame( '20', $rows[0]->term_taxonomy_id ); + $this->assertSame( '1', $rows[0]->count ); + $this->assertSame( '1', $rows[1]->term_id ); + $this->assertSame( '10', $rows[1]->term_taxonomy_id ); + $this->assertSame( '2', $rows[1]->count ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "__wp_pg_distinct"."term_id" AS "term_id", "__wp_pg_distinct"."term_taxonomy_id" AS "term_taxonomy_id", "__wp_pg_distinct"."taxonomy" AS "taxonomy", "__wp_pg_distinct"."description" AS "description", "__wp_pg_distinct"."parent" AS "parent", "__wp_pg_distinct"."count" AS "count" FROM (SELECT DISTINCT t.term_id AS "term_id", tt.term_taxonomy_id AS "term_taxonomy_id", tt.taxonomy AS "taxonomy", tt.description AS "description", tt.parent AS "parent", COUNT (p.post_type) AS "count", MIN(t.name) AS "__wp_pg_order_0" 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, tt.term_taxonomy_id, tt.taxonomy, tt.description, tt.parent) AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests WordPress term cache priming preserves MySQL shared-term row order. + */ + public function test_wordpress_term_cache_priming_orders_shared_terms_by_term_taxonomy_id(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Shared')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Single')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (20, 1, 'second_tax')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (10, 1, 'first_tax')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (30, 2, 'single_tax')" ); + + $rows = $driver->query( + 'SELECT t.*, tt.* FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id WHERE t.term_id IN (1,2)' + ); + + $this->assertSame( + array( '10', '20', '30' ), + array_map( + static function ( $row ): string { + return $row->term_taxonomy_id; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT t.*, tt.* FROM wptests_terms AS t INNER JOIN wptests_term_taxonomy AS tt ON t.term_id = tt.term_id WHERE t.term_id IN (1, 2) ORDER BY tt.term_taxonomy_id ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests SELECT DISTINCT term ID queries hide relationship order columns. + */ + public function test_distinct_term_id_order_by_term_order_preserves_visible_projection(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL, term_order INTEGER NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Beta')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Alpha')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (10, 1, 'category')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (20, 2, 'category')" ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id, term_order) VALUES (1, 10, 2)' ); + $driver->query( 'INSERT INTO wptests_term_relationships (object_id, term_taxonomy_id, term_order) VALUES (1, 20, 1)' ); + + $rows = $driver->query( + "SELECT DISTINCT t.term_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 IN ('category') AND tr.object_id IN (1) + ORDER BY tr.term_order ASC + LIMIT 100" + ); + + $this->assertCount( 2, $rows ); + $this->assertSame( '2', $rows[0]->term_id ); + $this->assertSame( '1', $rows[1]->term_id ); + $this->assertSame( array( 'term_id' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "__wp_pg_distinct"."term_id" AS "term_id" FROM (SELECT t.term_id AS "term_id", MIN(tr.term_order) AS "__wp_pg_order_0" 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 IN (\'category\') AND tr.object_id IN (1) GROUP BY t.term_id) AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" ASC LIMIT 100', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests parenthesized user ID DISTINCT queries keep user_login ordering hidden. + */ + public function test_distinct_parenthesized_user_id_order_by_hides_login_order_column(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, user_login TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (1, \'zeta\')' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (2, \'alpha\')' ); + + $rows = $driver->query( 'SELECT DISTINCT(wptests_users.ID) FROM wptests_users WHERE 1=1 ORDER BY user_login LIMIT 0, 50' ); + + $this->assertCount( 2, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( '1', $rows[1]->ID ); + $this->assertSame( array( 'ID' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "__wp_pg_distinct"."ID" AS "ID" FROM (SELECT (wptests_users."ID") AS "ID", MIN(user_login) AS "__wp_pg_order_0" FROM wptests_users WHERE 1 = 1 GROUP BY (wptests_users."ID")) AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" ASC LIMIT 50 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests unsupported DISTINCT SELECT modifiers do not enter the grouped rewrite. + */ + public function test_distinct_order_by_unsupported_select_modifier_fails_closed(): void { + $driver = $this->create_driver(); + $modifiers = array( + 'HIGH_PRIORITY', + 'SQL_BIG_RESULT', + 'SQL_BUFFER_RESULT', + 'SQL_CACHE', + 'SQL_NO_CACHE', + 'SQL_SMALL_RESULT', + 'STRAIGHT_JOIN', + ); + + foreach ( $modifiers as $modifier ) { + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_distinct_order_by_query', + sprintf( + 'SELECT DISTINCT %s t.term_id FROM wptests_terms AS t ORDER BY t.name ASC', + $modifier + ) + ); + + $this->assertNull( $sql, sprintf( '%s should fall through unchanged.', $modifier ) ); + } + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_distinct_order_by_query', + 'SELECT DISTINCT SQL_CALC_FOUND_ROWS HIGH_PRIORITY t.term_id FROM wptests_terms AS t ORDER BY t.name ASC' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests keyword-like DISTINCT projection aliases are matched in ORDER BY. + */ + public function test_distinct_order_by_keyword_projection_alias_preserves_projected_order(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY)' ); + $driver->query( 'INSERT INTO wptests_users ("ID") VALUES (2023)' ); + $driver->query( 'INSERT INTO wptests_users ("ID") VALUES (2024)' ); + + $rows = $driver->query( 'SELECT DISTINCT ID AS year FROM wptests_users ORDER BY year DESC' ); + + $this->assertCount( 2, $rows ); + $this->assertSame( '2024', $rows[0]->year ); + $this->assertSame( '2023', $rows[1]->year ); + $this->assertSame( array( 'year' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT DISTINCT "ID" AS year FROM wptests_users ORDER BY year DESC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests date archive aliases can satisfy DISTINCT ORDER BY references. + */ + public function test_distinct_date_archive_keyword_projection_aliases_match_order_by_items(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_distinct_order_by_query', + 'SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month + FROM wptests_posts + ORDER BY year DESC, month DESC' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests date archive DISTINCT queries order by hidden aggregate post dates. + */ + public function test_distinct_date_archive_order_by_uses_hidden_aggregate_sort_column(): void { + $driver = $this->create_driver(); + + $select = "SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month + FROM wptests_posts + WHERE post_type = 'foo' + AND post_status != 'auto-draft' AND post_status != 'trash' + ORDER BY post_date DESC"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_distinct_order_by_query', $select ); + + $year_sql = $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ); + $month_sql = $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'post_date' ); + $this->assertSame( + 'SELECT "__wp_pg_distinct"."year" AS "year", "__wp_pg_distinct"."month" AS "month" FROM (SELECT ' . $year_sql . ' AS "year", ' . $month_sql . ' AS "month", MAX(post_date) AS "__wp_pg_order_0" FROM wptests_posts WHERE post_type = \'foo\' AND post_status != \'auto-draft\' AND post_status != \'trash\' GROUP BY ' . $year_sql . ', ' . $month_sql . ') AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" DESC', + $sql + ); + + $outer_projection = substr( $sql, 0, strpos( $sql, ' FROM (' ) ); + $this->assertStringNotContainsString( '__wp_pg_order_0', $outer_projection ); + $this->assertStringNotContainsString( 'SELECT DISTINCT', $sql ); + } + + /** + * Tests SQL_CALC_FOUND_ROWS SELECT queries are translated for PostgreSQL. + */ + public function test_sql_calc_found_rows_select_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES (2, 'post', 'publish', '2024-01-02 00:00:00')" ); + + $select = "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1 = 1 AND ((wptests_posts.post_type = 'post' AND (wptests_posts.post_status = 'publish'))) + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 1"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID", COUNT(*) OVER() AS "__wp_pg_found_rows" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\'))) ORDER BY wptests_posts.post_date DESC, wptests_posts."ID" DESC LIMIT 1 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests leading-comment SQL_CALC_FOUND_ROWS SELECTs still use FOUND_ROWS accounting. + */ + public function test_leading_comment_sql_calc_found_rows_select_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status, post_date) VALUES (2, 'post', 'publish', '2024-01-01 00:00:00')" ); + + $select = "/* cache gate */ SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1 = 1 AND ((wptests_posts.post_type = 'post' AND (wptests_posts.post_status = 'publish'))) + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 1"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_posts."ID", COUNT(*) OVER() AS "__wp_pg_found_rows" FROM wptests_posts WHERE 1 = 1 AND ((wptests_posts.post_type = \'post\' AND (wptests_posts.post_status = \'publish\'))) ORDER BY wptests_posts.post_date DESC, wptests_posts."ID" DESC LIMIT 1 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests FOUND_ROWS returns the last SQL_CALC_FOUND_ROWS total count. + */ + public function test_found_rows_returns_last_sql_calc_found_rows_count(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status) VALUES (1, 'post', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status) VALUES (2, 'post', 'publish')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type, post_status) VALUES (3, 'post', 'publish')" ); + + $page_rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE wptests_posts.post_type = 'post' + ORDER BY wptests_posts.ID ASC + LIMIT 1, 1" + ); + $rows = $driver->query( 'SELECT FOUND_ROWS()' ); + + $this->assertCount( 1, $page_rows ); + $this->assertSame( '2', $page_rows[0]->ID ); + $this->assertSame( '3', $rows[0]->{'FOUND_ROWS()'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests non-SQL_CALC SELECT queries do not run FOUND_ROWS accounting. + */ + public function test_non_sql_calc_select_does_not_run_found_rows_accounting(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type) VALUES (1, 'post')" ); + $driver->query( "INSERT INTO wptests_posts (\"ID\", post_type) VALUES (2, 'post')" ); + + $rows = $driver->query( 'SELECT ID FROM wptests_posts ORDER BY ID ASC LIMIT 0, 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT "ID" FROM wptests_posts ORDER BY "ID" ASC LIMIT 1 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests DISTINCT SQL_CALC_FOUND_ROWS queries strip the modifier before PostgreSQL. + */ + public function test_distinct_sql_calc_found_rows_select_strips_modifier_and_orders_safely(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY, user_login TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_usermeta (user_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (1, \'zeta\')' ); + $driver->query( 'INSERT INTO wptests_users ("ID", user_login) VALUES (2, \'alpha\')' ); + $driver->query( 'INSERT INTO wptests_usermeta (user_id, meta_key, meta_value) VALUES (1, \'foo\', \'bar\')' ); + $driver->query( 'INSERT INTO wptests_usermeta (user_id, meta_key, meta_value) VALUES (1, \'foo\', \'baz\')' ); + $driver->query( 'INSERT INTO wptests_usermeta (user_id, meta_key, meta_value) VALUES (2, \'foo\', \'bar\')' ); + + $select = "SELECT DISTINCT SQL_CALC_FOUND_ROWS wptests_users.ID + FROM wptests_users INNER JOIN wptests_usermeta ON ( wptests_users.ID = wptests_usermeta.user_id ) + WHERE 1=1 AND wptests_usermeta.meta_key = 'foo' + ORDER BY user_login ASC + LIMIT 0, 1"; + $rows = $driver->query( $select ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( array( 'ID' ), array_keys( get_object_vars( $rows[0] ) ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $queries[0]['sql'] ); + $this->assertSame( + 'SELECT "__wp_pg_distinct"."ID" AS "ID" FROM (SELECT wptests_users."ID" AS "ID", MIN(user_login) AS "__wp_pg_order_0" FROM wptests_users INNER JOIN wptests_usermeta ON (wptests_users."ID" = wptests_usermeta.user_id) WHERE 1 = 1 AND wptests_usermeta.meta_key = \'foo\' GROUP BY wptests_users."ID") AS "__wp_pg_distinct" ORDER BY "__wp_pg_distinct"."__wp_pg_order_0" ASC LIMIT 1 OFFSET 0', + $queries[0]['sql'] + ); + $this->assertSame( + 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT DISTINCT wptests_users."ID" FROM wptests_users INNER JOIN wptests_usermeta ON (wptests_users."ID" = wptests_usermeta.user_id) WHERE 1 = 1 AND wptests_usermeta.meta_key = \'foo\') AS "__wp_pg_found_rows"', + $queries[1]['sql'] + ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests simple SQL_CALC_FOUND_ROWS counts use the paged result when possible. + */ + public function test_simple_sql_calc_found_rows_count_uses_window_count_for_non_empty_pages(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_postmeta (post_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (1, \'post\', \'publish\', \'2024-01-01 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (2, \'post\', \'publish\', \'2024-01-02 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (3, \'post\', \'draft\', \'2024-01-03 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (1, \'color\', \'blue\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (1, \'color\', \'green\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (2, \'color\', \'red\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (3, \'color\', \'red\')' ); + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE wptests_posts.post_status = 'publish' + AND wptests_postmeta.meta_key = 'color' + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 1" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->ID ); + $this->assertSame( array( 'ID' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( array( 'ID' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'COUNT(*) OVER() AS "__wp_pg_found_rows"', $queries[0]['sql'] ); + $this->assertStringContainsString( 'ORDER BY wptests_posts.post_date DESC', $queries[0]['sql'] ); + $this->assertStringContainsString( 'LIMIT 1 OFFSET 0', $queries[0]['sql'] ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '3', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests grouped associative SQL_CALC_FOUND_ROWS fetches use the count fallback. + */ + public function test_sql_calc_found_rows_fetch_group_assoc_uses_count_fallback_without_hidden_column(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT NOT NULL)' ); + $driver->query( "INSERT INTO t (id, v) VALUES (1, 'a')" ); + $driver->query( "INSERT INTO t (id, v) VALUES (2, 'b')" ); + $driver->query( "INSERT INTO t (id, v) VALUES (3, 'c')" ); + + $rows = $driver->query( + 'SELECT SQL_CALC_FOUND_ROWS id, v FROM t ORDER BY id ASC LIMIT 0, 2', + PDO::FETCH_GROUP | PDO::FETCH_ASSOC + ); + + $this->assertSame( array( 1, 2 ), array_keys( $rows ) ); + $this->assertSame( array( array( 'v' => 'a' ) ), $rows[1] ); + $this->assertSame( array( array( 'v' => 'b' ) ), $rows[2] ); + $this->assertArrayNotHasKey( '__wp_pg_found_rows', $rows[1][0] ); + $this->assertArrayNotHasKey( '__wp_pg_found_rows', $rows[2][0] ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertStringNotContainsString( 'COUNT(*) OVER() AS "__wp_pg_found_rows"', $queries[0]['sql'] ); + $this->assertSame( 'SELECT id, v FROM t ORDER BY id ASC LIMIT 2 OFFSET 0', $queries[0]['sql'] ); + $this->assertSame( 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM t', $queries[1]['sql'] ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '3', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests grouped object SQL_CALC_FOUND_ROWS fetches use the count fallback. + */ + public function test_sql_calc_found_rows_fetch_group_obj_uses_count_fallback_without_hidden_column(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT NOT NULL)' ); + $driver->query( "INSERT INTO t (id, v) VALUES (1, 'a')" ); + $driver->query( "INSERT INTO t (id, v) VALUES (2, 'b')" ); + $driver->query( "INSERT INTO t (id, v) VALUES (3, 'c')" ); + + $rows = $driver->query( + 'SELECT SQL_CALC_FOUND_ROWS id, v FROM t ORDER BY id ASC LIMIT 0, 2', + PDO::FETCH_GROUP | PDO::FETCH_OBJ + ); + + $this->assertSame( array( 1, 2 ), array_keys( $rows ) ); + $this->assertCount( 1, $rows[1] ); + $this->assertCount( 1, $rows[2] ); + $this->assertSame( array( 'v' ), array_keys( get_object_vars( $rows[1][0] ) ) ); + $this->assertSame( 'a', $rows[1][0]->v ); + $this->assertSame( array( 'v' ), array_keys( get_object_vars( $rows[2][0] ) ) ); + $this->assertSame( 'b', $rows[2][0]->v ); + $this->assertFalse( property_exists( $rows[1][0], '__wp_pg_found_rows' ) ); + $this->assertFalse( property_exists( $rows[2][0], '__wp_pg_found_rows' ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertStringNotContainsString( 'COUNT(*) OVER() AS "__wp_pg_found_rows"', $queries[0]['sql'] ); + $this->assertSame( 'SELECT id, v FROM t ORDER BY id ASC LIMIT 2 OFFSET 0', $queries[0]['sql'] ); + $this->assertSame( 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM t', $queries[1]['sql'] ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '3', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests empty SQL_CALC_FOUND_ROWS pages keep the direct count fallback. + */ + public function test_simple_sql_calc_found_rows_count_uses_direct_unordered_source_count_for_empty_pages(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_postmeta (post_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (1, \'post\', \'publish\', \'2024-01-01 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (2, \'post\', \'publish\', \'2024-01-02 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (3, \'post\', \'draft\', \'2024-01-03 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (1, \'color\', \'blue\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (1, \'color\', \'green\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (2, \'color\', \'red\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (3, \'color\', \'red\')' ); + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE wptests_posts.post_status = 'publish' + AND wptests_postmeta.meta_key = 'color' + ORDER BY wptests_posts.post_date DESC + LIMIT 10, 1" + ); + + $this->assertCount( 0, $rows ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertStringContainsString( 'COUNT(*) OVER() AS "__wp_pg_found_rows"', $queries[0]['sql'] ); + $this->assertSame( + 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM wptests_posts INNER JOIN wptests_postmeta ON (wptests_posts."ID" = wptests_postmeta.post_id) WHERE wptests_posts.post_status = \'publish\' AND wptests_postmeta.meta_key = \'color\'', + $queries[1]['sql'] + ); + $this->assertStringNotContainsString( 'ORDER BY', $queries[1]['sql'] ); + $this->assertStringNotContainsString( 'LIMIT', $queries[1]['sql'] ); + $this->assertStringNotContainsString( 'FROM (SELECT', $queries[1]['sql'] ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '3', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests aggregate SQL_CALC_FOUND_ROWS counts keep the cardinality-preserving wrapper. + */ + public function test_aggregate_sql_calc_found_rows_count_keeps_cardinality_preserving_wrapper(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE t (id INTEGER PRIMARY KEY)' ); + $driver->query( 'INSERT INTO t (id) VALUES (1)' ); + $driver->query( 'INSERT INTO t (id) VALUES (2)' ); + + $rows = $driver->query( 'SELECT SQL_CALC_FOUND_ROWS COUNT(*) AS c FROM t LIMIT 1, 1' ); + + $this->assertCount( 0, $rows ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( + 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT COUNT (*) AS c FROM t) AS "__wp_pg_found_rows"', + $queries[1]['sql'] + ); + $this->assertNotSame( + 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM t', + $queries[1]['sql'] + ); + $this->assertStringContainsString( 'FROM (SELECT', $queries[1]['sql'] ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests grouped SQL_CALC_FOUND_ROWS counts keep the cardinality-preserving wrapper. + */ + public function test_grouped_sql_calc_found_rows_count_keeps_cardinality_preserving_wrapper(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_type TEXT NOT NULL, post_status TEXT NOT NULL, post_date TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_postmeta (post_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (1, \'post\', \'publish\', \'2024-01-01 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_type, post_status, post_date) VALUES (2, \'post\', \'publish\', \'2024-01-02 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (1, \'color\', \'blue\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (1, \'color\', \'green\')' ); + $driver->query( 'INSERT INTO wptests_postmeta (post_id, meta_key, meta_value) VALUES (2, \'color\', \'red\')' ); + + $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE wptests_posts.post_status = 'publish' + AND wptests_postmeta.meta_key = 'color' + GROUP BY wptests_posts.ID + HAVING COUNT(*) >= 1 + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 1" + ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 2, $queries ); + $this->assertSame( + 'SELECT COUNT(*) AS "__wp_pg_found_rows" FROM (SELECT wptests_posts."ID" FROM wptests_posts INNER JOIN wptests_postmeta ON (wptests_posts."ID" = wptests_postmeta.post_id) WHERE wptests_posts.post_status = \'publish\' AND wptests_postmeta.meta_key = \'color\' GROUP BY wptests_posts."ID" HAVING COUNT (*) >= 1) AS "__wp_pg_found_rows"', + $queries[1]['sql'] + ); + $this->assertStringNotContainsString( 'ORDER BY', $queries[1]['sql'] ); + $this->assertStringNotContainsString( 'LIMIT', $queries[1]['sql'] ); + $this->assertStringContainsString( 'FROM (SELECT', $queries[1]['sql'] ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests SQL_CALC_FOUND_ROWS grouped postmeta queries aggregate sort expressions. + */ + public function test_sql_calc_grouped_postmeta_order_by_uses_aggregate_sort_expressions(): void { + $driver = $this->create_driver_with_postgresql_substring_function(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + `post_date` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (2, 'post', 'publish', '2024-01-02 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (3, 'post', 'publish', '2024-01-03 00:00:00')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'foo', 'b')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'foo', 'a')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'foo', 'a')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'bar', '5')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'bar', '2')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'bar', '9')" ); + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + INNER JOIN wptests_postmeta AS mt1 ON ( wptests_posts.ID = mt1.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'foo' + AND mt1.meta_key = 'bar' + GROUP BY wptests_posts.ID + ORDER BY CAST(wptests_postmeta.meta_value AS CHAR) ASC, CAST(mt1.meta_value AS UNSIGNED) DESC + LIMIT 0, 10" + ); + + $this->assertSame( + array( '3', '2', '1' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $sql ); + $this->assertStringContainsString( 'ORDER BY MIN(CAST(wptests_postmeta.meta_value AS text)) ASC', $sql ); + $this->assertStringContainsString( 'MAX(' . $this->get_expected_mysql_integer_cast_sql( 'mt1.meta_value' ) . ') DESC', $sql ); + + $numeric_rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'bar' + GROUP BY wptests_posts.ID + ORDER BY wptests_postmeta.meta_value+0 ASC + LIMIT 0, 10" + ); + + $this->assertSame( + array( '2', '1', '3' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $numeric_rows + ) + ); + $this->assertStringContainsString( + 'ORDER BY MIN(' . $this->get_expected_mysql_numeric_cast_sql( 'wptests_postmeta.meta_value' ) . ') ASC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $decimal_rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'bar' + GROUP BY wptests_posts.ID + ORDER BY CAST(wptests_postmeta.meta_value AS DECIMAL(10, 2)) DESC + LIMIT 0, 10" + ); + + $this->assertSame( + array( '3', '1', '2' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $decimal_rows + ) + ); + $this->assertStringContainsString( + 'ORDER BY MAX(CAST (wptests_postmeta.meta_value AS DECIMAL (10, 2))) DESC', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '3', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests grouped postmeta value-only queries preserve MySQL case-insensitive LIKE behavior. + */ + public function test_grouped_postmeta_value_like_without_key_uses_case_insensitive_mysql_collation_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + `post_date` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (2, 'post', 'publish', '2024-01-02 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (3, 'post', 'publish', '2024-01-03 00:00:00')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'city', 'Lorem')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'address', '123 Lorem St.')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'city', 'Lorem')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (3, 'city', 'Loren')" ); + + $rows = $driver->query( + "SELECT wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND ( ( wptests_postmeta.meta_value LIKE '%lorem%' ) ) + 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, 5" + ); + + $this->assertSame( + array( '2', '1' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $rows + ) + ); + $this->assertStringContainsString( + "LOWER(wptests_postmeta.meta_value) LIKE LOWER('%lorem%')", + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests numeric literals in predicate context use MySQL truthiness. + */ + public function test_numeric_literal_predicates_use_mysql_truthiness_without_changing_values_or_limits(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_date TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (1, \'2024-01-01 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (7, \'2024-01-07 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (9, \'2024-01-09 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (10, \'2024-01-10 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (11, \'2024-01-11 00:00:00\')' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_date) VALUES (12, \'2024-01-12 00:00:00\')' ); + + $rows = $driver->query( + 'SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + WHERE 1=1 AND 0 + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10' + ); + + $this->assertSame( array(), $rows ); + $this->assertStringContainsString( 'WHERE 1 = 1 AND (0 <> 0)', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $between_rows = $driver->query( 'SELECT ID FROM wptests_posts WHERE ID BETWEEN 9 AND 11 ORDER BY ID ASC' ); + $this->assertSame( + array( '9', '10', '11' ), + array_map( + static function ( $row ): string { + return $row->ID; + }, + $between_rows + ) + ); + $this->assertStringContainsString( 'WHERE "ID" BETWEEN 9 AND 11', $driver->get_last_postgresql_queries()[0]['sql'] ); + $this->assertStringNotContainsString( '(11 <> 0)', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $date_between_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT ID FROM wptests_posts WHERE DAYOFMONTH(post_date) BETWEEN 9 AND 11' + ); + $this->assertStringContainsString( ' BETWEEN 9 AND 11', $date_between_sql ); + $this->assertStringNotContainsString( '(11 <> 0)', $date_between_sql ); + + $in_rows = $driver->query( 'SELECT ID FROM wptests_posts WHERE ID IN (7) ORDER BY ID ASC' ); + $this->assertCount( 1, $in_rows ); + $this->assertSame( '7', $in_rows[0]->ID ); + $this->assertStringContainsString( 'WHERE "ID" IN (7)', $driver->get_last_postgresql_queries()[0]['sql'] ); + $this->assertStringNotContainsString( '(7 <> 0)', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $selected_zero = $driver->query( 'SELECT 0' ); + $this->assertSame( '0', $selected_zero[0]->{'0'} ); + $this->assertSame( 'SELECT 0', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $limit_zero = $driver->query( 'SELECT ID FROM wptests_posts ORDER BY ID LIMIT 0' ); + $this->assertSame( array(), $limit_zero ); + $this->assertSame( 'SELECT "ID" FROM wptests_posts ORDER BY "ID" LIMIT 0', $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests correlated subquery identifiers resolve through table metadata casing. + */ + public function test_correlated_subquery_post_id_identifier_uses_metadata_casing(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + `post_date` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (2, 'post', 'publish', '2024-01-02 00:00:00')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'target', 'abc')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'target', 'abc')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'blocked', '1')" ); + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND ( + NOT EXISTS ( + SELECT 1 FROM wptests_postmeta mt1 + WHERE mt1.post_ID = wptests_postmeta.post_ID + AND mt1.meta_key = 'blocked' + LIMIT 1 + ) + AND wptests_postmeta.meta_value = 'abc' + ) + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringContainsString( 'mt1.post_id = wptests_postmeta.post_id', $sql ); + $this->assertStringNotContainsString( 'post_ID', $sql ); + $this->assertStringContainsString( 'wptests_posts."ID"', $sql ); + } + + /** + * Tests DECIMAL casts use text only for LIKE predicates. + */ + public function test_decimal_cast_like_uses_text_without_changing_numeric_comparisons(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + `post_date` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (1, 'post', 'publish', '2024-01-01 00:00:00')" ); + $driver->query( "INSERT INTO wptests_posts (`ID`, `post_type`, `post_status`, `post_date`) VALUES (2, 'post', 'publish', '2024-01-02 00:00:00')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (1, 'decimal_value', '10.30')" ); + $driver->query( "INSERT INTO wptests_postmeta (`post_id`, `meta_key`, `meta_value`) VALUES (2, 'decimal_value', '10.40')" ); + + $rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'decimal_value' + AND CAST(wptests_postmeta.meta_value AS DECIMAL(10,2)) LIKE '%.3%' + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertStringContainsString( 'AS text) LIKE', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $numeric_rows = $driver->query( + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'decimal_value' + AND CAST(wptests_postmeta.meta_value AS DECIMAL(10,2)) > 10.35 + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + ); + + $this->assertCount( 1, $numeric_rows ); + $this->assertSame( '2', $numeric_rows[0]->ID ); + $this->assertStringNotContainsString( 'AS text) >', $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests FOUND_ROWS count queries preserve MySQL token adjacency before translation. + */ + public function test_found_rows_count_source_preserves_mysql_cast_and_regexp_tokens(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_posts ( + `ID` bigint(20) unsigned NOT NULL, + `post_type` varchar(20) NOT NULL DEFAULT "", + `post_status` varchar(20) NOT NULL DEFAULT "", + `post_date` datetime NOT NULL DEFAULT "0000-00-00 00:00:00", + PRIMARY KEY (`ID`) + )' + ); + $driver->query( + 'CREATE TABLE wptests_postmeta ( + `post_id` bigint(20) unsigned NOT NULL, + `meta_key` varchar(255) NOT NULL DEFAULT "", + `meta_value` longtext NOT NULL + )' + ); + + $unsigned_count_sql = $this->translate_driver_query_with_private_method( + $driver, + 'get_sql_calc_found_rows_count_query', + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'num_as_longtext' + AND CAST(wptests_postmeta.meta_value AS UNSIGNED) > '0' + GROUP BY wptests_posts.ID + ORDER BY CAST(wptests_postmeta.meta_value AS UNSIGNED) ASC + LIMIT 0, 10" + ); + + $this->assertStringContainsString( + $this->get_expected_mysql_integer_cast_sql( 'wptests_postmeta.meta_value' ) . " > '0'", + $unsigned_count_sql + ); + $this->assertStringNotContainsString( 'UNSIGNED', $unsigned_count_sql ); + $this->assertStringNotContainsString( 'ORDER BY', $unsigned_count_sql ); + $this->assertStringNotContainsString( 'LIMIT', $unsigned_count_sql ); + + $binary_count_sql = $this->translate_driver_query_with_private_method( + $driver, + 'get_sql_calc_found_rows_count_query', + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND CAST(wptests_postmeta.meta_key AS BINARY) REGEXP BINARY 'AAA_FOO_.*' + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + ); + + $this->assertStringContainsString( 'CAST(wptests_postmeta.meta_key AS text) ~', $binary_count_sql ); + $this->assertStringNotContainsString( 'BINARY', $binary_count_sql ); + $this->assertStringNotContainsString( 'REGEXP', $binary_count_sql ); + + $decimal_like_count_sql = $this->translate_driver_query_with_private_method( + $driver, + 'get_sql_calc_found_rows_count_query', + "SELECT SQL_CALC_FOUND_ROWS wptests_posts.ID + FROM wptests_posts + INNER JOIN wptests_postmeta ON ( wptests_posts.ID = wptests_postmeta.post_id ) + WHERE 1=1 + AND wptests_postmeta.meta_key = 'decimal_value' + AND CAST(wptests_postmeta.meta_value AS DECIMAL(10,2)) LIKE '%.3%' + GROUP BY wptests_posts.ID + ORDER BY wptests_posts.post_date DESC + LIMIT 0, 10" + ); + + $this->assertStringContainsString( 'AS text) LIKE', $decimal_like_count_sql ); + $this->assertStringNotContainsString( 'DECIMAL (10, 2)) LIKE', $decimal_like_count_sql ); + } + + /** + * Tests unsupported grouped DISTINCT ORDER BY shapes fail closed. + */ + public function test_distinct_grouped_order_by_unsupported_shapes_fail_closed(): void { + $driver = $this->create_driver(); + + $term_query = '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 %s + GROUP BY t.term_id ORDER BY %s'; + $unsupported_queries = array( + 'SELECT DISTINCT t.term_id, COUNT(*) AS term_tt_count FROM wptests_terms AS t GROUP BY t.term_id ORDER BY t.name ASC', + 'SELECT DISTINCT t.term_id FROM wptests_terms AS t GROUP BY t.term_id, t.slug ORDER BY t.name ASC', + 'SELECT DISTINCT t.term_id FROM wptests_terms AS t GROUP BY t.term_id ORDER BY COUNT(*) DESC', + sprintf( + $term_query, + "tt.taxonomy IN ('wptests_tax', 'category') AND (p.post_status = 'publish')", + 't.name ASC' + ), + sprintf( + $term_query, + "(p.post_status = 'publish')", + 't.name ASC' + ), + "SELECT DISTINCT t.term_id, tt.term_taxonomy_id, tt.taxonomy, tt.description, tt.parent, tt.count, 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_status = 'publish') + GROUP BY t.term_id ORDER BY t.name ASC", + sprintf( + $term_query, + "tt.taxonomy IN ('wptests_tax') AND (p.post_status = 'publish')", + 'p.post_date DESC' + ), + ); + + foreach ( $unsupported_queries as $query ) { + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $query + ); + + $this->assertNull( $sql ); + } + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_distinct_order_by_query', + 'SELECT DISTINCT wptests_users.ID FROM wptests_users WHERE wptests_users.ID IN (SELECT user_id FROM wptests_usermeta) ORDER BY user_login ASC' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests grouped HAVING predicates can reference aggregate projection aliases. + */ + public function test_grouped_having_aggregate_alias_is_translated_for_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_terms (term_id INTEGER PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (1, 'Parent')" ); + $driver->query( "INSERT INTO wptests_terms (term_id, name) VALUES (2, 'Single')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (10, 1, 'category')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (11, 1, 'post_tag')" ); + $driver->query( "INSERT INTO wptests_term_taxonomy (term_taxonomy_id, term_id, taxonomy) VALUES (12, 2, 'category')" ); + + $rows = $driver->query( + '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' + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', (string) $rows[0]->term_id ); + $this->assertSame( '2', (string) $rows[0]->term_tt_count ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertStringContainsString( 'GROUP BY t.term_id, tt.term_id', $sql ); + $this->assertStringContainsString( 'HAVING (count (*)) > 1', $sql ); + $this->assertStringNotContainsString( 'HAVING term_tt_count', $sql ); + } + + /** + * Tests grouped HAVING aliases can extend GROUP BY using safe inner-join equalities. + */ + public function test_grouped_having_inner_join_projection_extension_is_translated_for_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + 'SELECT tt.term_id, count(*) AS term_tt_count FROM wptests_term_taxonomy tt INNER JOIN wptests_terms t ON t.term_id = tt.term_id GROUP BY t.term_id HAVING term_tt_count > 1' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'GROUP BY t.term_id, tt.term_id', $sql ); + $this->assertStringContainsString( 'HAVING (count (*)) > 1', $sql ); + } + + /** + * Tests grouped HAVING identifiers that are not aliases fail closed. + */ + public function test_grouped_having_non_alias_identifier_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + 'SELECT term_id, COUNT(*) AS term_tt_count FROM wptests_term_taxonomy GROUP BY term_id HAVING missing_alias > 1' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests OR-scoped join equalities are not used for GROUP BY extensions. + */ + public function test_grouped_having_or_join_equality_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + 'SELECT a.id, b.id AS bid, COUNT(*) AS c FROM a LEFT JOIN b ON a.id = b.id OR b.flag = 1 GROUP BY a.id HAVING c > 0' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests nullable-side GROUP BY semantics from outer joins fail closed. + */ + public function test_grouped_having_outer_join_nullable_grouping_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + 'SELECT tt.term_id, COUNT(*) AS c FROM tt LEFT JOIN t ON t.term_id = tt.term_id GROUP BY t.term_id HAVING c > 1' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests nested predicate equalities are not used for GROUP BY extensions. + */ + public function test_grouped_having_nested_equality_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + 'SELECT a.id, b.id AS bid, COUNT(*) AS c FROM a, b WHERE (a.id = b.id) GROUP BY a.id HAVING c > 0' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests non-grouped non-aggregate projection aliases are not rewritten in HAVING. + */ + public function test_grouped_having_unsupported_projection_alias_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_grouped_having_alias_query', + "SELECT term_id, name AS term_name FROM wptests_terms GROUP BY term_id HAVING term_name = 'Parent'" + ); + + $this->assertNull( $sql ); + } + + /** + * Tests scalar COUNT queries drop irrelevant ORDER BY clauses. + */ + public function test_aggregate_count_order_by_is_dropped_for_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, comment_date_gmt TEXT NOT NULL, comment_approved TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date_gmt, comment_approved) VALUES (1, \'2024-01-03 00:00:00\', \'1\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date_gmt, comment_approved) VALUES (2, \'2024-01-01 00:00:00\', \'0\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date_gmt, comment_approved) VALUES (3, \'2024-01-02 00:00:00\', \'spam\')' ); + + $rows = $driver->query( + "SELECT COUNT(*) + FROM wptests_comments + WHERE comment_approved IN ('0', '1') + ORDER BY wptests_comments.comment_date_gmt ASC + LIMIT 0,3" + ); + + $this->assertSame( '2', array_values( get_object_vars( $rows[0] ) )[0] ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT COUNT (*) FROM wptests_comments WHERE comment_approved IN (\'0\', \'1\') LIMIT 3 OFFSET 0', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests WordPress role-count aggregates preserve ARRAY_N row shape. + */ + public function test_user_role_count_aggregate_projection_preserves_array_n_shape(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_users ("ID" INTEGER PRIMARY KEY)' ); + $driver->query( 'CREATE TABLE wptests_usermeta (user_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_users ("ID") VALUES (1)' ); + $driver->query( 'INSERT INTO wptests_users ("ID") VALUES (2)' ); + $driver->query( 'INSERT INTO wptests_users ("ID") VALUES (3)' ); + $driver->query( 'INSERT INTO wptests_usermeta (user_id, meta_key, meta_value) VALUES (1, \'wptests_capabilities\', \'a:1:{s:13:"administrator";b:1;}\')' ); + $driver->query( 'INSERT INTO wptests_usermeta (user_id, meta_key, meta_value) VALUES (2, \'wptests_capabilities\', \'a:1:{s:6:"editor";b:1;}\')' ); + $driver->query( 'INSERT INTO wptests_usermeta (user_id, meta_key, meta_value) VALUES (3, \'wptests_capabilities\', \'a:0:{}\')' ); + + $rows = $driver->query( + 'SELECT COUNT(NULLIF(`meta_value` LIKE \'%\"administrator\"%\', false)), + COUNT(NULLIF(`meta_value` LIKE \'%\"editor\"%\', false)), + COUNT(NULLIF(`meta_value` LIKE \'%\"author\"%\', false)), + COUNT(NULLIF(`meta_value` LIKE \'%\"contributor\"%\', false)), + COUNT(NULLIF(`meta_value` LIKE \'%\"subscriber\"%\', false)), + COUNT(NULLIF(`meta_value` = \'a:0:{}\', false)), + COUNT(*) + FROM wptests_usermeta + INNER JOIN wptests_users ON user_id = ID + WHERE meta_key = \'wptests_capabilities\'' + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( + array( '1', '1', '0', '0', '0', '1', '3' ), + array_values( get_object_vars( $rows[0] ) ) + ); + + $sql = $driver->get_last_postgresql_queries()[0]['sql']; + $this->assertSame( 7, substr_count( $sql, ' AS "' ) ); + $this->assertStringContainsString( 'COUNT (*) AS "COUNT (*)"', $sql ); + } + + /** + * Tests grouped date archive queries order by an aggregate post date. + */ + public function test_grouped_date_archive_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $select = "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"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $select + ); + + $year_sql = $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ); + $month_sql = $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'post_date' ); + $this->assertSame( + 'SELECT ' . $year_sql . ' AS "year", ' . $month_sql . ' AS "month", count ("ID") as posts FROM wptests_posts WHERE post_type = \'post\' AND post_status = \'publish\' GROUP BY ' . $year_sql . ', ' . $month_sql . ' ORDER BY MAX(post_date) DESC', + $sql + ); + $this->assertStringNotContainsString( 'post_date DESC', str_replace( 'MAX(post_date) DESC', '', $sql ) ); + } + + /** + * Tests yearly grouped archive queries order by an aggregate post date. + */ + public function test_grouped_year_archive_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $select = "SELECT YEAR(post_date) AS `year`, count(ID) as posts + FROM wptests_posts + WHERE post_type = 'post' AND post_status = 'publish' + GROUP BY YEAR(post_date) + ORDER BY post_date DESC"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $select + ); + + $year_sql = $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ); + $this->assertSame( + 'SELECT ' . $year_sql . ' AS "year", count ("ID") as posts FROM wptests_posts WHERE post_type = \'post\' AND post_status = \'publish\' GROUP BY ' . $year_sql . ' ORDER BY MAX(post_date) DESC', + $sql + ); + $this->assertStringNotContainsString( 'post_date DESC', str_replace( 'MAX(post_date) DESC', '', $sql ) ); + } + + /** + * Tests unsupported DISTINCT grouped archive queries fail closed. + */ + public function test_distinct_count_grouped_year_archive_order_by_fails_closed(): void { + $driver = $this->create_driver(); + + $select = "SELECT DISTINCT count(ID) AS posts + FROM wptests_posts + WHERE post_type = 'post' + GROUP BY YEAR(post_date) + ORDER BY post_date DESC"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $select + ); + + $this->assertNull( $sql ); + } + + /** + * Tests weekly grouped DISTINCT archive queries order by an aggregate post date. + */ + public function test_grouped_week_archive_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $select = "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"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $select + ); + + $week_sql = $this->get_expected_mysql_week_mode_one_sql( '"post_date"' ); + $year_sql = $this->get_expected_zero_date_safe_extract_sql( 'YEAR', '"post_date"' ); + $date_sql = $this->get_expected_mysql_date_format_sql( '%Y-%m-%d', 'MAX("post_date")' ); + $this->assertSame( + 'SELECT ' . $week_sql . ' AS "week", ' . $year_sql . ' AS "yr", ' . $date_sql . ' AS "yyyymmdd", count ("ID") AS "posts" FROM "wptests_posts" WHERE post_type = \'post\' AND post_status = \'publish\' GROUP BY ' . $week_sql . ', ' . $year_sql . ' ORDER BY MAX("post_date") DESC', + $sql + ); + $this->assertStringNotContainsString( 'SELECT DISTINCT', $sql ); + $this->assertStringNotContainsString( 'WEEK', $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests daily grouped archive queries order by an aggregate post date. + */ + public function test_grouped_day_archive_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $select = "SELECT YEAR(post_date) AS `year`, MONTH(post_date) AS `month`, DAYOFMONTH(post_date) AS `dayofmonth`, count(ID) as posts + FROM wptests_posts + WHERE post_type = 'post' AND post_status = 'publish' + GROUP BY YEAR(post_date), MONTH(post_date), DAYOFMONTH(post_date) + ORDER BY post_date DESC"; + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + $select + ); + + $year_sql = $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ); + $month_sql = $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'post_date' ); + $day_sql = $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ); + $this->assertSame( + 'SELECT ' . $year_sql . ' AS "year", ' . $month_sql . ' AS "month", ' . $day_sql . ' AS "dayofmonth", count ("ID") as posts FROM wptests_posts WHERE post_type = \'post\' AND post_status = \'publish\' GROUP BY ' . $year_sql . ', ' . $month_sql . ', ' . $day_sql . ' ORDER BY MAX(post_date) DESC', + $sql + ); + $this->assertStringNotContainsString( 'post_date DESC', str_replace( 'MAX(post_date) DESC', '', $sql ) ); + } + + /** + * Tests grouped comment ID queries order by aggregate meta values. + */ + public function test_grouped_comment_meta_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY)' ); + $driver->query( 'CREATE TABLE wptests_commentmeta (comment_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID") VALUES (1)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID") VALUES (2)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID") VALUES (3)' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (1, \'foo\', \'aaa\')' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (2, \'foo\', \'zzz\')' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (3, \'foo\', \'jjj\')' ); + + $rows = $driver->query( + "SELECT wptests_comments.comment_ID + FROM wptests_comments INNER JOIN wptests_commentmeta ON ( wptests_comments.comment_ID = wptests_commentmeta.comment_id ) + WHERE wptests_commentmeta.meta_key = 'foo' + GROUP BY wptests_comments.comment_ID + ORDER BY CAST(wptests_commentmeta.meta_value AS CHAR) DESC, wptests_comments.comment_ID DESC" + ); + + $this->assertSame( + array( '2', '3', '1' ), + array_map( + static function ( $row ): string { + return $row->comment_ID; + }, + $rows + ) + ); + $this->assertSame( array( 'comment_ID' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_comments."comment_ID" FROM wptests_comments INNER JOIN wptests_commentmeta ON (wptests_comments."comment_ID" = wptests_commentmeta.comment_id) WHERE wptests_commentmeta.meta_key = \'foo\' GROUP BY wptests_comments."comment_ID" ORDER BY MAX(CAST(wptests_commentmeta.meta_value AS text)) DESC, wptests_comments."comment_ID" DESC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests grouped comment ID queries aggregate comment date secondary ordering. + */ + public function test_grouped_comment_meta_secondary_order_by_uses_aggregate_sort_expression(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_comments ("comment_ID" INTEGER PRIMARY KEY, comment_date TEXT NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_commentmeta (comment_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date) VALUES (1, \'2015-01-28 03:00:00\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date) VALUES (2, \'2015-01-28 05:00:00\')' ); + $driver->query( 'INSERT INTO wptests_comments ("comment_ID", comment_date) VALUES (3, \'2015-01-28 03:00:00\')' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (1, \'foo\', \'jjj\')' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (2, \'foo\', \'zzz\')' ); + $driver->query( 'INSERT INTO wptests_commentmeta (comment_id, meta_key, meta_value) VALUES (3, \'foo\', \'aaa\')' ); + + $rows = $driver->query( + "SELECT wptests_comments.comment_ID + FROM wptests_comments INNER JOIN wptests_commentmeta ON ( wptests_comments.comment_ID = wptests_commentmeta.comment_id ) + WHERE wptests_commentmeta.meta_key = 'foo' + GROUP BY wptests_comments.comment_ID + ORDER BY wptests_comments.comment_date ASC, CAST(wptests_commentmeta.meta_value AS CHAR) ASC, wptests_comments.comment_ID ASC" + ); + + $this->assertSame( + array( '3', '1', '2' ), + array_map( + static function ( $row ): string { + return $row->comment_ID; + }, + $rows + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT wptests_comments."comment_ID" FROM wptests_comments INNER JOIN wptests_commentmeta ON (wptests_comments."comment_ID" = wptests_commentmeta.comment_id) WHERE wptests_commentmeta.meta_key = \'foo\' GROUP BY wptests_comments."comment_ID" ORDER BY MIN(wptests_comments.comment_date) ASC, MIN(CAST(wptests_commentmeta.meta_value AS text)) ASC, wptests_comments."comment_ID" ASC', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests normal non-aggregate SELECT ORDER BY shapes do not enter the strict rewrite. + */ + public function test_strict_order_by_rewrite_ignores_normal_select_order_by(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_strict_aggregate_grouped_order_by_query', + 'SELECT comment_ID FROM wptests_comments ORDER BY comment_ID DESC' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests MySQL date/time extraction functions are translated for PostgreSQL. + */ + public function test_mysql_date_time_extract_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = 'SELECT YEAR(post_date) AS y, MONTH(post_date) AS m, QUARTER(post_date) AS q, DAYOFYEAR(post_date) AS doy, DAYOFMONTH(post_date) AS d, DAY(post_date) AS day_value, HOUR(post_date) AS h, MINUTE(post_date) AS i, SECOND(post_date) AS s, EXTRACT(DAY FROM post_date) AS extracted_day, EXTRACT(QUARTER FROM post_date) AS extracted_quarter, EXTRACT(DAYOFYEAR FROM post_date) AS extracted_doy FROM wptests_posts WHERE ID = 1'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ) . ' AS y, ' . $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'post_date' ) . ' AS m, ' . $this->get_expected_zero_date_safe_extract_sql( 'QUARTER', 'post_date' ) . ' AS q, ' . $this->get_expected_zero_date_safe_extract_sql( 'DOY', 'post_date' ) . ' AS doy, ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ) . ' AS d, ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ) . ' AS day_value, ' . $this->get_expected_zero_date_safe_extract_sql( 'HOUR', 'post_date' ) . ' AS h, ' . $this->get_expected_zero_date_safe_extract_sql( 'MINUTE', 'post_date' ) . ' AS i, ' . $this->get_expected_zero_date_safe_extract_sql( 'SECOND', 'post_date' ) . ' AS s, ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ) . ' AS extracted_day, ' . $this->get_expected_zero_date_safe_extract_sql( 'QUARTER', 'post_date' ) . ' AS extracted_quarter, ' . $this->get_expected_zero_date_safe_extract_sql( 'DOY', 'post_date' ) . ' AS extracted_doy FROM wptests_posts WHERE "ID" = 1', + $sql + ); + } + + /** + * Tests generated date/time extraction SQL is safe for MySQL zero-date values. + */ + public function test_mysql_date_time_extract_functions_are_zero_date_safe_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = 'SELECT YEAR(post_date) AS y, MONTH(post_date) AS m, DAYOFMONTH(post_date) AS d, HOUR(post_date) AS h FROM wptests_posts WHERE post_date = \'0000-00-00 00:00:00\''; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertStringContainsString( "CASE WHEN CAST(post_date AS text) = '' THEN NULL WHEN CAST(post_date AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST(post_date AS text) FROM 1 FOR 4) = '0000'", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST(post_date AS text) FROM 6 FOR 2) = '00'", $sql ); + $this->assertStringContainsString( "SUBSTRING(CAST(post_date AS text) FROM 9 FOR 2) = '00'", $sql ); + $this->assertStringContainsString( 'THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 1 FOR 4) AS integer)', $sql ); + $this->assertStringContainsString( 'THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 6 FOR 2) AS integer)', $sql ); + $this->assertStringContainsString( 'THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 9 FOR 2) AS integer)', $sql ); + $this->assertStringContainsString( "THEN CASE WHEN CAST(post_date AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN CAST(SUBSTRING(CAST(post_date AS text) FROM 12 FOR 2) AS integer) ELSE 0 END", $sql ); + $this->assertStringNotContainsString( 'SELECT CAST(EXTRACT(YEAR FROM CAST(post_date AS timestamp)) AS integer) AS y', $sql ); + $this->assertStringContainsString( 'CAST(EXTRACT(YEAR FROM CAST(CASE WHEN CAST(post_date AS text)', $sql ); + $this->assertStringContainsString( 'THEN NULL ELSE CAST(post_date AS text) END AS timestamp)', $sql ); + } + + /** + * Tests stored empty temporal values are safe for MySQL-compatible date functions. + */ + public function test_stored_empty_temporal_values_are_safe_for_mysql_date_functions(): void { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wp_acid_dates ( + id int(11) NOT NULL, + note varchar(20) NOT NULL, + d date DEFAULT NULL, + dt datetime DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + $driver->get_connection()->query( + "INSERT INTO wp_acid_dates (id, note, d, dt) VALUES + (1, 'empty', '', ''), + (2, 'zero', '0000-00-00', '0000-00-00 00:00:00'), + (3, 'partial', '2020-00-15', '2020-00-15 13:04:05'), + (4, 'valid', '2024-05-06', '2024-05-06 07:08:09')" + ); + + $select = "SELECT note, d, dt, DATE_FORMAT(d, '%Y-%m-%d') AS formatted, YEAR(d) AS y, MONTH(d) AS m, DAYOFMONTH(d) AS day + FROM wp_acid_dates + ORDER BY id"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertNotNull( $sql ); + $d_text_sql = 'CAST(d AS text)'; + $empty_temporal_sql = $this->get_expected_empty_temporal_condition_sql( $d_text_sql ); + $zero_date_condition_sql = $this->get_expected_zero_date_condition_sql( $d_text_sql ); + + $this->assertStringContainsString( + sprintf( + "CASE WHEN %1\$s THEN NULL WHEN %2\$s THEN SUBSTRING(%3\$s FROM 1 FOR 10) ELSE TO_CHAR(%4\$s, 'YYYY-MM-DD') END AS formatted", + $empty_temporal_sql, + $zero_date_condition_sql, + $d_text_sql, + $this->get_expected_zero_date_safe_timestamp_sql( 'd' ) + ), + $sql + ); + $this->assertStringContainsString( $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'd' ) . ' AS y', $sql ); + $this->assertStringContainsString( $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'd' ) . ' AS m', $sql ); + $this->assertStringContainsString( $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'd' ) . ' AS day', $sql ); + $this->assertStringContainsString( + sprintf( 'CAST(CASE WHEN %1$s OR %2$s THEN NULL ELSE %3$s END AS timestamp)', $empty_temporal_sql, $zero_date_condition_sql, $d_text_sql ), + $sql + ); + $this->assertStringNotContainsString( + sprintf( 'CAST(CASE WHEN %1$s THEN NULL ELSE %2$s END AS timestamp)', $zero_date_condition_sql, $d_text_sql ), + $sql + ); + } + + /** + * Tests MySQL temporal expressions compare safely against text-backed datetime columns. + */ + public function test_mysql_temporal_expression_comparisons_against_text_backed_datetime_columns_use_text_ordering_for_postgresql(): void { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wp_acid_dates ( + id int(11) NOT NULL, + note varchar(20) NOT NULL, + d date DEFAULT NULL, + dt datetime DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + $driver->get_connection()->query( + "INSERT INTO wp_acid_dates (id, note, d, dt) VALUES + (1, 'empty', '', ''), + (2, 'zero', '0000-00-00', '0000-00-00 00:00:00'), + (3, 'partial', '2020-00-15', '2020-00-15 13:04:05'), + (4, 'old', '2001-01-01', '2001-01-01 00:00:00'), + (5, 'future', '2999-01-01', '2999-01-01 00:00:00')" + ); + + $now_sql = "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')"; + $date_sub_sql = $this->get_expected_date_arithmetic_sql( '-', $now_sql, '7', 'day' ); + $threshold_sql = $this->get_expected_temporal_expression_comparison_text_sql( $date_sub_sql, true ); + $datetime_text_sql = 'CAST(dt AS text)'; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT COUNT(*) AS old_rows FROM wp_acid_dates WHERE DATE_SUB(NOW(), INTERVAL 7 DAY) > dt' + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( $threshold_sql . ' > ' . $datetime_text_sql, $sql ); + $this->assertStringNotContainsString( ') > dt', $sql ); + $this->assertStringNotContainsString( 'CAST(dt AS timestamp)', $sql ); + + $reversed_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT COUNT(*) AS old_rows FROM wp_acid_dates WHERE dt < DATE_SUB(NOW(), INTERVAL 7 DAY)' + ); + + $this->assertNotNull( $reversed_sql ); + $this->assertStringContainsString( $datetime_text_sql . ' < ' . $threshold_sql, $reversed_sql ); + $this->assertStringNotContainsString( 'dt < (', $reversed_sql ); + $this->assertStringNotContainsString( 'CAST(dt AS timestamp)', $reversed_sql ); + } + + /** + * Tests literal MySQL zero-date extraction SQL guards the timestamp cast. + */ + public function test_mysql_date_time_extract_functions_guard_literal_zero_dates_for_postgresql(): void { + $driver = $this->create_driver(); + + $literals = array( + '0000-00-00 00:00:00', + '0000-00-00', + '2020-00-15 00:00:00', + '2020-01-00 00:00:00', + '2026-06-10 14:08:09', + ); + $extract_functions = array( + array( + 'name' => 'YEAR', + 'unit' => 'YEAR', + ), + array( + 'name' => 'MONTH', + 'unit' => 'MONTH', + ), + array( + 'name' => 'QUARTER', + 'unit' => 'QUARTER', + ), + array( + 'name' => 'DAYOFYEAR', + 'unit' => 'DOY', + ), + array( + 'name' => 'DAYOFMONTH', + 'unit' => 'DAY', + ), + array( + 'name' => 'DAY', + 'unit' => 'DAY', + ), + array( + 'name' => 'HOUR', + 'unit' => 'HOUR', + ), + array( + 'name' => 'MINUTE', + 'unit' => 'MINUTE', + ), + array( + 'name' => 'SECOND', + 'unit' => 'SECOND', + ), + ); + + foreach ( $literals as $literal ) { + $expression_sql = "'" . $literal . "'"; + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + foreach ( $extract_functions as $extract_function ) { + $function_sql = sprintf( '%s(%s)', $extract_function['name'], $expression_sql ); + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT ' . $function_sql . ' AS extracted_value' + ); + $expected_sql = 'SELECT ' . $this->get_expected_zero_date_safe_extract_sql( $extract_function['unit'], $expression_sql ) . ' AS extracted_value'; + + $this->assertSame( + $expected_sql, + $sql, + $function_sql + ); + $this->assertStringContainsString( + 'CAST(EXTRACT(' . $extract_function['unit'] . ' FROM CAST(CASE WHEN ' . $expression_text_sql, + $sql, + $function_sql + ); + $this->assertStringContainsString( + 'THEN NULL ELSE ' . $expression_text_sql . ' END AS timestamp)', + $sql, + $function_sql + ); + $this->assertStringNotContainsString( + 'CAST(EXTRACT(' . $extract_function['unit'] . ' FROM CAST(' . $expression_sql . ' AS timestamp))', + $sql, + $function_sql + ); + } + } + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT EXTRACT(DAY FROM '2020-01-00 00:00:00') AS extracted_value" + ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', "'2020-01-00 00:00:00'" ) . ' AS extracted_value', + $sql + ); + } + + /** + * Tests MySQL WEEK and weekday index functions are translated for PostgreSQL. + */ + public function test_mysql_week_and_weekday_index_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = 'SELECT WEEK(post_date, 1) AS week_num, DAYOFWEEK(post_date) AS day_of_week, WEEKDAY(post_date) AS weekday_value FROM wptests_posts WHERE WEEK(post_date, 1) = 24 AND DAYOFWEEK(post_date) = 1 AND WEEKDAY(post_date) = 6'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_week_mode_one_sql( 'post_date' ) . ' AS week_num, ' . $this->get_expected_mysql_weekday_index_sql( 'dayofweek', 'post_date' ) . ' AS day_of_week, ' . $this->get_expected_mysql_weekday_index_sql( 'weekday', 'post_date' ) . ' AS weekday_value FROM wptests_posts WHERE ' . $this->get_expected_mysql_week_mode_one_sql( 'post_date' ) . ' = 24 AND ' . $this->get_expected_mysql_weekday_index_sql( 'dayofweek', 'post_date' ) . ' = 1 AND ' . $this->get_expected_mysql_weekday_index_sql( 'weekday', 'post_date' ) . ' = 6', + $sql + ); + $this->assertStringNotContainsString( 'WEEK(', $sql ); + $this->assertStringNotContainsString( 'DAYOFWEEK', $sql ); + $this->assertStringNotContainsString( 'WEEKDAY', $sql ); + } + + /** + * Tests MySQL WEEK modes supported by the PostgreSQL backend are translated. + */ + public function test_mysql_week_supported_modes_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $cases = array( + 'WEEK(post_date)' => 0, + 'WEEK(post_date, 0)' => 0, + 'WEEK(post_date, 1)' => 1, + 'WEEK(post_date, 2)' => 2, + 'WEEK(post_date, 3)' => 3, + 'WEEK(post_date, 4)' => 4, + 'WEEK(post_date, 5)' => 5, + 'WEEK(post_date, 6)' => 6, + 'WEEK(post_date, 7)' => 7, + ); + + foreach ( $cases as $week_call => $mode ) { + $select = 'SELECT ' . $week_call . ' AS week_num FROM wptests_posts WHERE ' . $week_call . ' = 1'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + $week_sql = $this->get_expected_mysql_week_sql( 'post_date', $mode ); + + $this->assertSame( + 'SELECT ' . $week_sql . ' AS week_num FROM wptests_posts WHERE ' . $week_sql . ' = 1', + $sql, + $week_call + ); + $this->assertStringNotContainsString( 'WEEK(', $sql, $week_call ); + } + } + + /** + * Tests unsupported MySQL WEEK modes fail closed. + */ + public function test_mysql_week_unsupported_modes_fail_closed(): void { + $driver = $this->create_driver(); + $queries = array( + 'SELECT WEEK(post_date, 8) AS week_num', + 'SELECT WEEK(post_date, -1) AS week_num', + 'SELECT WEEK(post_date, default_week_format) AS week_num', + 'SELECT WEEK(post_date, 1 + 1) AS week_num', + 'SELECT WEEK(post_date, 0, 1) AS week_num', + ); + + foreach ( $queries as $query ) { + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $query + ); + } + } + + /** + * Tests unsupported MySQL WEEK modes are rejected before backend execution. + */ + public function test_mysql_week_unsupported_modes_fail_closed_before_backend_execution(): void { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( 'SELECT WEEK(post_date, 8) AS week_num' ); + $this->fail( 'Expected unsupported WEEK() mode to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL WEEK() mode.', $e->getMessage() ); + } + + $this->assertSame( 0, $connection->get_query_count() ); + } + + /** + * Tests lowercase MySQL date compatibility functions trigger translation. + */ + public function test_lowercase_mysql_date_compatibility_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT week(post_date, 1) AS week_num, dayofweek(post_date) AS day_of_week, weekday(post_date) AS weekday_value, date_format(post_date, '%Y-%m-%d') AS formatted_date FROM wptests_posts"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_week_mode_one_sql( 'post_date' ) . ' AS week_num, ' . $this->get_expected_mysql_weekday_index_sql( 'dayofweek', 'post_date' ) . ' AS day_of_week, ' . $this->get_expected_mysql_weekday_index_sql( 'weekday', 'post_date' ) . ' AS weekday_value, ' . $this->get_expected_mysql_date_format_sql( '%Y-%m-%d', 'post_date' ) . ' AS formatted_date FROM wptests_posts', + $sql + ); + } + + /** + * Tests supported MySQL DATE_FORMAT calls are translated for PostgreSQL. + */ + public function test_mysql_date_format_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_FORMAT(post_date, '%H.%i') AS hour_minute, DATE_FORMAT(post_date, '%Y-%m-%d') AS formatted_date FROM wptests_posts WHERE DATE_FORMAT(post_date, '%H.%i') >= 0.42"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_date_format_sql( '%H.%i', 'post_date' ) . ' AS hour_minute, ' . $this->get_expected_mysql_date_format_sql( '%Y-%m-%d', 'post_date' ) . ' AS formatted_date FROM wptests_posts WHERE ' . $this->get_expected_mysql_date_format_sql( '%H.%i', 'post_date' ) . ' >= 0.42', + $sql + ); + $this->assertStringContainsString( 'CAST(TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ) . ", 'HH24.MI') AS double precision)", $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ) . ", 'YYYY-MM-DD')", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests numeric MySQL DATE_FORMAT masks used by WordPress date queries are translated. + */ + public function test_mysql_date_format_numeric_masks_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_FORMAT(post_date, '%H.%i%s') AS hour_minute_second, DATE_FORMAT(post_date, '0.%i%s') AS minute_second_fraction FROM wptests_posts"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ) . ", 'HH24.MISS')", $sql ); + $this->assertStringContainsString( "'0.' || TO_CHAR(" . $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ) . ", 'MISS')", $sql ); + $this->assertStringContainsString( 'AS hour_minute_second', $sql ); + $this->assertStringContainsString( 'AS minute_second_fraction', $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests date compatibility function names inside string literals are not translated. + */ + public function test_mysql_date_compatibility_function_names_inside_literals_are_not_translated(): void { + $driver = $this->create_driver(); + + $select = "SELECT 'WEEK(post_date, 1)' AS literal_week, 'DAYOFWEEK(post_date)' AS literal_day, 'DATE_FORMAT(post_date, %H.%i)' AS literal_format"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + "SELECT 'WEEK(post_date, 1)' AS literal_week, 'DAYOFWEEK(post_date)' AS literal_day, 'DATE_FORMAT(post_date, %H.%i)' AS literal_format", + $sql + ); + $this->assertStringNotContainsString( 'DATE_TRUNC', $sql ); + $this->assertStringNotContainsString( 'EXTRACT(DOW', $sql ); + $this->assertStringNotContainsString( 'TO_CHAR', $sql ); + } + + /** + * Tests generated WEEK, weekday, and DATE_FORMAT SQL guards zero-date timestamp casts. + */ + public function test_mysql_date_compatibility_functions_guard_zero_date_timestamp_casts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT WEEK('0000-00-00 00:00:00', 1) AS week_num, DAYOFWEEK('2020-00-15 13:05:00') AS day_of_week, WEEKDAY('2020-01-00 13:05:00') AS weekday_value, DATE_FORMAT('0000-00-00 13:05:00', '%H.%i') AS hour_minute, DATE_FORMAT('2020-00-15 13:05:00', '%Y-%m-%d') AS formatted_date"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertStringContainsString( "CAST(CASE WHEN CAST('0000-00-00 00:00:00' AS text) = '' OR CAST('0000-00-00 00:00:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "CAST(CASE WHEN CAST('2020-00-15 13:05:00' AS text) = '' OR CAST('2020-00-15 13:05:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "CAST(CASE WHEN CAST('2020-01-00 13:05:00' AS text) = '' OR CAST('2020-01-00 13:05:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "THEN CAST(SUBSTRING(CAST('0000-00-00 13:05:00' AS text) FROM 12 FOR 2) || '.' || SUBSTRING(CAST('0000-00-00 13:05:00' AS text) FROM 15 FOR 2) AS double precision) ELSE 0 END", $sql ); + $this->assertStringContainsString( "THEN SUBSTRING(CAST('2020-00-15 13:05:00' AS text) FROM 1 FOR 10)", $sql ); + $this->assertStringNotContainsString( "CAST('0000-00-00 00:00:00' AS timestamp)", $sql ); + $this->assertStringNotContainsString( "CAST('2020-00-15 13:05:00' AS timestamp)", $sql ); + $this->assertStringNotContainsString( "CAST('2020-01-00 13:05:00' AS timestamp)", $sql ); + } + + /** + * Tests DATE_FORMAT derives numeric/time parts from zero-ish dates without timestamp casts. + */ + public function test_mysql_date_format_zero_date_numeric_and_time_specifiers_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + $expression_sql = "CAST('2006-06-00 13:04:05.123' AS text)"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT('2006-06-00 13:04:05.123', '%Y %y %m %c %d %e %D %H %k %h %I %l %i %s %S %T %r %p %f %% %q') AS formatted_date" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WHEN ' . $this->get_expected_zero_date_condition_sql( $expression_sql ) . ' THEN SUBSTRING(' . $expression_sql . ' FROM 1 FOR 4)', $sql ); + $this->assertStringContainsString( 'SUBSTRING(' . $expression_sql . ' FROM 6 FOR 2)', $sql ); + $this->assertStringContainsString( 'CAST(CAST(SUBSTRING(' . $expression_sql . ' FROM 6 FOR 2) AS integer) AS text)', $sql ); + $this->assertStringContainsString( 'SUBSTRING(' . $expression_sql . ' FROM 9 FOR 2)', $sql ); + $this->assertStringContainsString( "CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN SUBSTRING($expression_sql FROM 12 FOR 2) ELSE '00' END", $sql ); + $this->assertStringContainsString( 'LPAD(CAST(MOD(CAST(CASE WHEN ' . $expression_sql, $sql ); + $this->assertStringContainsString( "CASE WHEN CAST(CASE WHEN $expression_sql ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}' THEN SUBSTRING($expression_sql FROM 12 FOR 2) ELSE '00' END AS integer) < 12 THEN 'AM' ELSE 'PM' END", $sql ); + $this->assertStringContainsString( "LEFT(RPAD(SUBSTRING($expression_sql FROM '[.]([0-9]+)'), 6, '0'), 6)", $sql ); + $this->assertStringContainsString( "'%'", $sql ); + $this->assertStringContainsString( "|| 'q'", $sql ); + $this->assertStringNotContainsString( "CAST('2006-06-00 13:04:05.123' AS timestamp)", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests DATE_FORMAT keeps NULL semantics for zero-ish dates requiring a real calendar date. + */ + public function test_mysql_date_format_zero_date_calendar_specifiers_return_null_for_postgresql(): void { + $driver = $this->create_driver(); + $expression_sql = "CAST('2006-06-00' AS text)"; + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT('2006-06-00', '%Y %W') AS formatted_date" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( 'WHEN ' . $this->get_expected_zero_date_condition_sql( $expression_sql ) . ' THEN NULL ELSE', $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $this->get_expected_zero_date_safe_timestamp_sql( "'2006-06-00'" ) . ", 'FMDay')", $sql ); + $this->assertStringNotContainsString( "CAST('2006-06-00' AS timestamp)", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests broader MySQL DATE_FORMAT specifiers are translated for PostgreSQL. + */ + public function test_mysql_date_format_extended_specifiers_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT(post_date, '%a %b %c %D %d %e %f %H %h %I %i %j %k %l %M %m %p %r %S %s %T %U %u %V %v %W %w %X %x %Y %y %%') AS formatted_date" + ); + + $this->assertNotNull( $sql ); + $timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( 'post_date' ); + $formats = array( + 'Dy', + 'Mon', + 'FMMM', + 'DD', + 'FMDD', + 'US', + 'HH24', + 'HH12', + 'MI', + 'DDD', + 'FMHH24', + 'FMHH12', + 'FMMonth', + 'MM', + 'AM', + 'HH12:MI:SS AM', + 'SS', + 'HH24:MI:SS', + 'IW', + 'FMDay', + 'YYYY', + 'IYYY', + 'YY', + ); + foreach ( $formats as $format ) { + $this->assertStringContainsString( 'TO_CHAR(' . $timestamp_sql . ", '" . $format . "')", $sql, $format ); + } + + $this->assertStringContainsString( 'CAST(EXTRACT(DAY FROM ' . $timestamp_sql . ') AS integer)', $sql ); + $this->assertStringContainsString( 'CAST(CAST(EXTRACT(DOW FROM ' . $timestamp_sql . ') AS integer) AS text)', $sql ); + $this->assertStringContainsString( "'%'", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests DATE_FORMAT week specifiers follow MySQL week modes. + */ + public function test_mysql_date_format_week_specifiers_follow_mysql_week_modes(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT('2021-01-03', '%U %u %V %v %X %x') AS formatted_date" + ); + + $this->assertNotNull( $sql ); + $timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( "'2021-01-03'" ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_sunday_week_mode_zero_sql( $timestamp_sql ) ), + $sql + ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_week_mode_one_timestamp_sql( $timestamp_sql ) ), + $sql + ); + $this->assertStringContainsString( + $this->get_expected_mysql_zero_padded_week_sql( $this->get_expected_mysql_sunday_week_mode_two_sql( $timestamp_sql ) ), + $sql + ); + $this->assertSame( 1, substr_count( $sql, 'TO_CHAR(' . $timestamp_sql . ", 'IW')" ) ); + $this->assertStringContainsString( $this->get_expected_mysql_sunday_week_mode_two_year_sql( $timestamp_sql ), $sql ); + $this->assertStringContainsString( 'TO_CHAR(' . $timestamp_sql . ", 'IYYY')", $sql ); + $this->assertStringNotContainsString( 'TO_CHAR(' . $timestamp_sql . ", 'YYYY') || ' ' || TO_CHAR(" . $timestamp_sql . ", 'IW')", $sql ); + $this->assertStringNotContainsString( 'DATE_FORMAT', $sql ); + } + + /** + * Tests representative WordPress date archive queries do not reach PostgreSQL with raw MySQL functions. + */ + public function test_wordpress_date_query_extract_functions_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT post_id FROM wptests_postmeta, wptests_posts + WHERE ID = post_id + AND post_type = 'post' + AND meta_key = '_wp_old_slug' + AND meta_value = 'foo-bar' + AND YEAR(post_date) = 2026 + AND MONTH(post_date) = 6 + AND DAYOFMONTH(post_date) = 10"; + + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT post_id FROM wptests_postmeta, wptests_posts WHERE "ID" = post_id AND post_type = \'post\' AND meta_key = \'_wp_old_slug\' AND meta_value = \'foo-bar\' AND ' . $this->get_expected_zero_date_safe_extract_sql( 'YEAR', 'post_date' ) . ' = 2026 AND ' . $this->get_expected_zero_date_safe_extract_sql( 'MONTH', 'post_date' ) . ' = 6 AND ' . $this->get_expected_zero_date_safe_extract_sql( 'DAY', 'post_date' ) . ' = 10', + $sql + ); + } + + /** + * Tests WordPress DATE_ADD queries are translated for PostgreSQL. + */ + public function test_wordpress_date_add_queries_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD(comment_date_gmt, INTERVAL '0' SECOND) FROM wptests_comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'comment_date_gmt', "'0'", 'second' ) . " FROM wptests_comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1", + $sql + ); + $this->assertStringNotContainsString( 'DATE_ADD', $sql ); + } + + /** + * Tests DATE_SUB queries are detected even without another rewrite trigger. + */ + public function test_mysql_date_sub_queries_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $select = 'SELECT DATE_SUB(post_date_gmt, INTERVAL 1 DAY) AS older'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '-', 'post_date_gmt', '1', 'day' ) . ' AS older', + $sql + ); + $this->assertStringNotContainsString( 'DATE_SUB', $sql ); + } + + /** + * Tests ADDDATE and SUBDATE aliases share DATE_ADD/DATE_SUB interval translation. + */ + public function test_mysql_adddate_and_subdate_aliases_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT ADDDATE(post_date_gmt, INTERVAL 2 DAY) AS newer, SUBDATE(post_date_gmt, INTERVAL 3 HOUR) AS older' + ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '2', 'day' ) . ' AS newer, ' . $this->get_expected_date_arithmetic_sql( '-', 'post_date_gmt', '3', 'hour' ) . ' AS older', + $sql + ); + $this->assertStringNotContainsString( 'ADDDATE', $sql ); + $this->assertStringNotContainsString( 'SUBDATE', $sql ); + } + + /** + * Tests ADDDATE and SUBDATE support MySQL's numeric day alias form. + */ + public function test_mysql_adddate_and_subdate_numeric_day_aliases_are_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT ADDDATE(post_date_gmt, 2) AS newer, SUBDATE(post_date_gmt, 3) AS older' + ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '2', 'day' ) . ' AS newer, ' . $this->get_expected_date_arithmetic_sql( '-', 'post_date_gmt', '3', 'day' ) . ' AS older', + $sql + ); + $this->assertStringNotContainsString( 'ADDDATE', $sql ); + $this->assertStringNotContainsString( 'SUBDATE', $sql ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT ADDDATE(COALESCE(post_date_gmt, post_date), 1 + 1) AS shifted' + ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'COALESCE(post_date_gmt, post_date)', '1 + 1', 'day' ) . ' AS shifted', + $sql + ); + $this->assertStringNotContainsString( 'ADDDATE', $sql ); + } + + /** + * Tests DATE_ADD supports simple MySQL interval units for PostgreSQL. + */ + public function test_mysql_date_add_supports_simple_mysql_interval_units_for_postgresql(): void { + $driver = $this->create_driver(); + $units = array( + 'MICROSECOND' => 'microsecond', + 'SECOND' => 'second', + 'MINUTE' => 'minute', + 'HOUR' => 'hour', + 'DAY' => 'day', + 'WEEK' => 'week', + 'MONTH' => 'month', + 'QUARTER' => '3 months', + 'YEAR' => 'year', + 'SQL_TSI_MICROSECOND' => 'microsecond', + 'SQL_TSI_SECOND' => 'second', + 'SQL_TSI_MINUTE' => 'minute', + 'SQL_TSI_HOUR' => 'hour', + 'SQL_TSI_DAY' => 'day', + 'SQL_TSI_WEEK' => 'week', + 'SQL_TSI_MONTH' => 'month', + 'SQL_TSI_QUARTER' => '3 months', + 'SQL_TSI_YEAR' => 'year', + ); + + foreach ( $units as $mysql_unit => $postgresql_unit ) { + $select = 'SELECT DATE_ADD(post_date_gmt, INTERVAL 2 ' . $mysql_unit . ') AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '2', $postgresql_unit ) . ' AS shifted', + $sql, + $mysql_unit + ); + } + } + + /** + * Tests TIMESTAMPADD supports simple MySQL interval units for PostgreSQL. + */ + public function test_mysql_timestampadd_supports_simple_mysql_interval_units_for_postgresql(): void { + $driver = $this->create_driver(); + $units = array( + 'MICROSECOND' => 'microsecond', + 'SECOND' => 'second', + 'MINUTE' => 'minute', + 'HOUR' => 'hour', + 'DAY' => 'day', + 'WEEK' => 'week', + 'MONTH' => 'month', + 'QUARTER' => '3 months', + 'YEAR' => 'year', + 'SQL_TSI_MINUTE' => 'minute', + 'SQL_TSI_QUARTER' => '3 months', + ); + + foreach ( $units as $mysql_unit => $postgresql_unit ) { + $select = 'SELECT TIMESTAMPADD(' . $mysql_unit . ', 2, post_date_gmt) AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '2', $postgresql_unit ) . ' AS shifted', + $sql, + $mysql_unit + ); + $this->assertStringNotContainsString( 'TIMESTAMPADD', $sql, $mysql_unit ); + } + } + + /** + * Tests TIMESTAMPDIFF supports simple MySQL interval units for PostgreSQL. + */ + public function test_mysql_timestampdiff_supports_simple_mysql_interval_units_for_postgresql(): void { + $driver = $this->create_driver(); + $units = array( + 'MICROSECOND' => 'microsecond', + 'SECOND' => 'second', + 'MINUTE' => 'minute', + 'HOUR' => 'hour', + 'DAY' => 'day', + 'WEEK' => 'week', + 'MONTH' => 'month', + 'QUARTER' => 'quarter', + 'YEAR' => 'year', + 'SQL_TSI_SECOND' => 'second', + 'SQL_TSI_QUARTER' => 'quarter', + ); + + foreach ( $units as $mysql_unit => $postgresql_unit ) { + $select = "SELECT TIMESTAMPDIFF($mysql_unit, '2001-02-01 12:59:59.123456', post_date_gmt) AS diff"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_timestampdiff_sql( $postgresql_unit, "'2001-02-01 12:59:59.123456'", 'post_date_gmt' ) . ' AS diff', + $sql, + $mysql_unit + ); + $this->assertStringNotContainsString( 'TIMESTAMPDIFF', $sql, $mysql_unit ); + } + } + + /** + * Tests TIMESTAMPDIFF guards zero-date timestamp casts for PostgreSQL. + */ + public function test_mysql_timestampdiff_guards_zero_date_timestamp_casts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT TIMESTAMPDIFF(DAY, '0000-00-00 00:00:00', post_date_gmt) AS diff"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_mysql_timestampdiff_sql( 'day', "'0000-00-00 00:00:00'", 'post_date_gmt' ) . ' AS diff', + $sql + ); + $this->assertStringContainsString( "CAST(CASE WHEN CAST('0000-00-00 00:00:00' AS text) = '' OR CAST('0000-00-00 00:00:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "THEN NULL ELSE CAST('0000-00-00 00:00:00' AS text) END AS timestamp", $sql ); + $this->assertStringNotContainsString( "CAST('0000-00-00 00:00:00' AS timestamp)", $sql ); + } + + /** + * Tests TIMESTAMPADD supports composite MySQL interval literal units for PostgreSQL. + */ + public function test_mysql_timestampadd_supports_composite_interval_literals_for_postgresql(): void { + $driver = $this->create_driver(); + $cases = array( + array( 'SECOND_MICROSECOND', "'10.09'", array( array( '10', 'second' ), array( '090000', 'microsecond' ) ) ), + array( 'MINUTE_SECOND', "'1:02'", array( array( '1', 'minute' ), array( '02', 'second' ) ) ), + array( 'MINUTE_MICROSECOND', "'3:04.005006'", array( array( '3', 'minute' ), array( '04', 'second' ), array( '005006', 'microsecond' ) ) ), + array( 'HOUR_MINUTE', "'5:30'", array( array( '5', 'hour' ), array( '30', 'minute' ) ) ), + array( 'HOUR_SECOND', "'6:07:08'", array( array( '6', 'hour' ), array( '07', 'minute' ), array( '08', 'second' ) ) ), + array( 'HOUR_MICROSECOND', "'9:10:11.000012'", array( array( '9', 'hour' ), array( '10', 'minute' ), array( '11', 'second' ), array( '000012', 'microsecond' ) ) ), + array( 'DAY_HOUR', "'13 14'", array( array( '13', 'day' ), array( '14', 'hour' ) ) ), + array( 'DAY_MINUTE', "'15 16:17'", array( array( '15', 'day' ), array( '16', 'hour' ), array( '17', 'minute' ) ) ), + array( 'DAY_SECOND', "'18 19:20:21'", array( array( '18', 'day' ), array( '19', 'hour' ), array( '20', 'minute' ), array( '21', 'second' ) ) ), + array( 'DAY_MICROSECOND', "'22 23:24:25.123456'", array( array( '22', 'day' ), array( '23', 'hour' ), array( '24', 'minute' ), array( '25', 'second' ), array( '123456', 'microsecond' ) ) ), + array( 'YEAR_MONTH', "'2-03'", array( array( '2', 'year' ), array( '03', 'month' ) ) ), + array( 'HOUR_MINUTE', '-1.5', array( array( '-1', 'hour' ), array( '-5', 'minute' ) ) ), + ); + + foreach ( $cases as $case ) { + list( $mysql_unit, $value_sql, $components ) = $case; + + $select = 'SELECT TIMESTAMPADD(' . $mysql_unit . ', ' . $value_sql . ', post_date_gmt) AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( '+', 'post_date_gmt', $this->get_expected_mysql_composite_interval_sql( $components ) ) . ' AS shifted', + $sql, + $mysql_unit . ' ' . $value_sql + ); + $this->assertStringNotContainsString( 'TIMESTAMPADD', $sql, $mysql_unit . ' ' . $value_sql ); + } + } + + /** + * Tests fractional SECOND interval values preserve MySQL numeric coercion. + */ + public function test_mysql_date_arithmetic_preserves_fractional_second_intervals_for_postgresql(): void { + $driver = $this->create_driver(); + $cases = array( + array( + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 0.5 SECOND) AS shifted', + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '0.5', 'second' ) . ' AS shifted', + 'DATE_ADD', + ), + array( + "SELECT DATE_SUB(post_date_gmt, INTERVAL '0.25' SECOND) AS shifted", + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '-', 'post_date_gmt', "'0.25'", 'second' ) . ' AS shifted', + 'DATE_SUB', + ), + array( + 'SELECT TIMESTAMPADD(SECOND, 0.5, post_date_gmt) AS shifted', + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'post_date_gmt', '0.5', 'second' ) . ' AS shifted', + 'TIMESTAMPADD', + ), + ); + + foreach ( $cases as $case ) { + list( $select, $expected, $function_name ) = $case; + + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( $expected, $sql, $select ); + $this->assertStringContainsString( ' AS numeric)', $sql, $select ); + $this->assertStringNotContainsString( $function_name, $sql, $select ); + } + } + + /** + * Tests DATE_ADD translates full MySQL composite interval literals exactly. + */ + public function test_mysql_date_add_supports_composite_interval_literals_for_postgresql(): void { + $driver = $this->create_driver(); + $cases = array( + array( 'SECOND_MICROSECOND', "'10.09'", array( array( '10', 'second' ), array( '090000', 'microsecond' ) ) ), + array( 'MINUTE_SECOND', "'1:02'", array( array( '1', 'minute' ), array( '02', 'second' ) ) ), + array( 'MINUTE_MICROSECOND', "'3:04.005006'", array( array( '3', 'minute' ), array( '04', 'second' ), array( '005006', 'microsecond' ) ) ), + array( 'HOUR_MINUTE', "'5:30'", array( array( '5', 'hour' ), array( '30', 'minute' ) ) ), + array( 'HOUR_SECOND', "'6:07:08'", array( array( '6', 'hour' ), array( '07', 'minute' ), array( '08', 'second' ) ) ), + array( 'HOUR_MICROSECOND', "'9:10:11.000012'", array( array( '9', 'hour' ), array( '10', 'minute' ), array( '11', 'second' ), array( '000012', 'microsecond' ) ) ), + array( 'DAY_HOUR', "'13 14'", array( array( '13', 'day' ), array( '14', 'hour' ) ) ), + array( 'DAY_MINUTE', "'15 16:17'", array( array( '15', 'day' ), array( '16', 'hour' ), array( '17', 'minute' ) ) ), + array( 'DAY_SECOND', "'18 19:20:21'", array( array( '18', 'day' ), array( '19', 'hour' ), array( '20', 'minute' ), array( '21', 'second' ) ) ), + array( 'DAY_MICROSECOND', "'22 23:24:25.123456'", array( array( '22', 'day' ), array( '23', 'hour' ), array( '24', 'minute' ), array( '25', 'second' ), array( '123456', 'microsecond' ) ) ), + array( 'YEAR_MONTH', "'2-03'", array( array( '2', 'year' ), array( '03', 'month' ) ) ), + ); + + foreach ( $cases as $case ) { + list( $mysql_unit, $value_sql, $components ) = $case; + + $select = 'SELECT DATE_ADD(post_date_gmt, INTERVAL ' . $value_sql . ' ' . $mysql_unit . ') AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( '+', 'post_date_gmt', $this->get_expected_mysql_composite_interval_sql( $components ) ) . ' AS shifted', + $sql, + $mysql_unit + ); + $this->assertStringNotContainsString( 'DATE_ADD', $sql, $mysql_unit ); + } + } + + /** + * Tests DATE_SUB translates MySQL composite interval literals exactly. + */ + public function test_mysql_date_sub_supports_composite_interval_literals_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_SUB(post_date_gmt, INTERVAL '1 02:03:04.005006' DAY_MICROSECOND) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '-', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '1', 'day' ), + array( '02', 'hour' ), + array( '03', 'minute' ), + array( '04', 'second' ), + array( '005006', 'microsecond' ), + ) + ) + ) . ' AS shifted', + $sql + ); + $this->assertStringNotContainsString( 'DATE_SUB', $sql ); + } + + /** + * Tests negative composite interval signs apply to every component. + */ + public function test_mysql_date_add_negative_composite_interval_sign_applies_to_all_parts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD(post_date_gmt, INTERVAL '-5:30' HOUR_MINUTE) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '+', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '-5', 'hour' ), + array( '-30', 'minute' ), + ) + ) + ) . ' AS shifted', + $sql + ); + + $select = 'SELECT DATE_ADD(post_date_gmt, INTERVAL 1.5 HOUR_MINUTE) AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '+', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '1', 'hour' ), + array( '5', 'minute' ), + ) + ) + ) . ' AS shifted', + $sql + ); + } + + /** + * Tests DATE_ADD parsing handles nested expressions and lowercase interval syntax. + */ + public function test_mysql_date_add_handles_nested_and_lowercase_interval_arguments(): void { + $driver = $this->create_driver(); + + $select = 'SELECT DATE_ADD(COALESCE(post_date_gmt, post_date), interval (1 + 1) day) AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', 'COALESCE(post_date_gmt, post_date)', '(1 + 1)', 'day' ) . ' AS shifted', + $sql + ); + } + + /** + * Tests DATE_ADD supports WordPress upgrade HOUR_MINUTE interval values. + */ + public function test_mysql_date_add_supports_hour_minute_interval_values_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD(post_date_gmt, INTERVAL '5:30' HOUR_MINUTE) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '+', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '5', 'hour' ), + array( '30', 'minute' ), + ) + ) + ) . ' AS shifted', + $sql + ); + $this->assertStringContainsString( "INTERVAL '1 hour'", $sql ); + $this->assertStringContainsString( "INTERVAL '1 minute'", $sql ); + $this->assertStringNotContainsString( 'DATE_ADD', $sql ); + } + + /** + * Tests composite interval literals support MySQL's right-aligned short values. + */ + public function test_mysql_date_add_supports_short_composite_interval_literals_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = 'SELECT DATE_ADD(post_date_gmt, INTERVAL 2 HOUR_MINUTE) AS shifted'; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '+', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '2', 'minute' ), + ) + ) + ) . ' AS shifted', + $sql + ); + + $select = "SELECT DATE_SUB(post_date_gmt, INTERVAL '1:2' DAY_SECOND) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '-', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '1', 'minute' ), + array( '2', 'second' ), + ) + ) + ) . ' AS shifted', + $sql + ); + } + + /** + * Tests composite interval literals accept alternate MySQL delimiters. + */ + public function test_mysql_date_add_supports_alternate_composite_interval_delimiters_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD(post_date_gmt, INTERVAL '6/4' HOUR_MINUTE) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_with_interval_sql( + '+', + 'post_date_gmt', + $this->get_expected_mysql_composite_interval_sql( + array( + array( '6', 'hour' ), + array( '4', 'minute' ), + ) + ) + ) . ' AS shifted', + $sql + ); + } + + /** + * Tests DATE_ADD timestamp casts are guarded for MySQL zero-date values. + */ + public function test_mysql_date_add_guards_zero_date_timestamp_casts_for_postgresql(): void { + $driver = $this->create_driver(); + + $select = "SELECT DATE_ADD('0000-00-00 00:00:00', INTERVAL 1 DAY) AS shifted"; + $sql = $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $select ); + + $this->assertSame( + 'SELECT ' . $this->get_expected_date_arithmetic_sql( '+', "'0000-00-00 00:00:00'", '1', 'day' ) . ' AS shifted', + $sql + ); + $this->assertStringContainsString( "CAST(CASE WHEN CAST('0000-00-00 00:00:00' AS text) = '' OR CAST('0000-00-00 00:00:00' AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "THEN NULL ELSE CAST('0000-00-00 00:00:00' AS text) END AS timestamp", $sql ); + $this->assertStringNotContainsString( "CAST('0000-00-00 00:00:00' AS timestamp)", $sql ); + } + + /** + * Tests invalid DATE_ADD interval units and unsafe composite shapes fail closed. + */ + public function test_mysql_date_add_with_unsupported_interval_shape_fails_closed(): void { + $driver = $this->create_driver(); + $queries = array( + 'SELECT DATE_ADD(post_date_gmt, 1) AS shifted', + 'SELECT DATE_SUB(post_date_gmt, 1) AS shifted', + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 1 fortnight) AS shifted', + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 6/4 HOUR_MINUTE) AS shifted', + "SELECT DATE_ADD(post_date_gmt, INTERVAL '1:2:3' MINUTE_SECOND) AS shifted", + ); + + foreach ( $queries as $query ) { + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + $query + ), + $query + ); + } + } + + /** + * Tests invalid date arithmetic forms fail before backend execution. + */ + public function test_mysql_date_arithmetic_unsupported_shapes_fail_closed_before_backend_execution(): void { + $queries = array( + 'SELECT DATE_ADD(post_date_gmt, 1) AS shifted', + 'SELECT DATE_SUB(post_date_gmt, 1) AS shifted', + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 1 fortnight) AS shifted', + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 6/4 HOUR_MINUTE) AS shifted', + "SELECT DATE_ADD(post_date_gmt, INTERVAL '1:2:3' MINUTE_SECOND) AS shifted", + 'SELECT TIMESTAMPADD(FORTNIGHT, 1, post_date_gmt) AS shifted', + 'SELECT TIMESTAMPADD(DAY_SECOND, interval_value, post_date_gmt) AS shifted', + 'SELECT TIMESTAMPADD(DAY_SECOND, 6/4, post_date_gmt) AS shifted', + "SELECT TIMESTAMPADD(MINUTE_SECOND, '1:2:3', post_date_gmt) AS shifted", + 'SELECT TIMESTAMPADD(DAY, 1) AS shifted', + 'SELECT TIMESTAMPDIFF(FORTNIGHT, started_at, finished_at) AS diff', + 'SELECT TIMESTAMPDIFF(DAY_SECOND, started_at, finished_at) AS diff', + 'SELECT TIMESTAMPDIFF(DAY, started_at) AS diff', + 'SELECT TIMESTAMPDIFF(DAY, started_at, finished_at, extra_at) AS diff', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported date arithmetic statement to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL date arithmetic statement.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + } + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports constant scalar subquery assignments. + */ + public function test_options_upsert_scalar_subquery_assignment_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('siteurl', 'old', 'yes')" ); + + $upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.org', 'yes') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT 'http://example.net')"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.org\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = CAST((SELECT \'http://example.net\') AS text)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'http://example.net', $rows[0]->option_value ); + + $dual_tail_upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.org', 'yes') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT 'http://example.net/dual' FROM DUAL WHERE 1 = 1 ORDER BY 1 LIMIT 1)"; + + $this->assertSame( 1, $driver->query( $dual_tail_upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'http://example.org\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = CAST((SELECT \'http://example.net/dual\' WHERE 1 = 1 ORDER BY 1 LIMIT 1) AS text)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value FROM wptests_options WHERE option_name = 'siteurl'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'http://example.net/dual', $rows[0]->option_value ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports table-backed scalar subquery assignments. + */ + public function test_options_upsert_table_backed_scalar_subquery_assignment_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE wptests_upsert_source ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_upsert_source ( + id int(11) NOT NULL, + label varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('source_counts', '0', '0')" ); + $driver->query( "INSERT INTO wptests_upsert_source (id, label) VALUES (1, 'one'), (2, 'two'), (3, 'three')" ); + + $upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT COUNT(*) FROM `wptests_upsert_source` WHERE `id` > 1), + `autoload` = (SELECT `s`.`label` FROM `wptests_upsert_source` AS `s` WHERE `s`.`id` = 2)"; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'source_counts\', \'ignored\', \'ignored\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = CAST((SELECT COUNT(*) FROM "wptests_upsert_source" WHERE "id" > 1) AS text), "autoload" = CAST((SELECT "s"."label" FROM "wptests_upsert_source" AS "s" WHERE "s"."id" = 2) AS text)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'source_counts'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '2', $rows[0]->option_value ); + $this->assertSame( 'two', $rows[0]->autoload ); + + $count_literal_upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT COUNT(1) FROM `wptests_upsert_source` WHERE `id` > 0), + `autoload` = (SELECT COUNT(NULL) FROM `wptests_upsert_source`)"; + + $this->assertSame( 1, $driver->query( $count_literal_upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'source_counts\', \'ignored\', \'ignored\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = CAST((SELECT COUNT(1) FROM "wptests_upsert_source" WHERE "id" > 0) AS text), "autoload" = CAST((SELECT COUNT(NULL) FROM "wptests_upsert_source") AS text)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'source_counts'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '3', $rows[0]->option_value ); + $this->assertSame( '0', $rows[0]->autoload ); + + $ordered_upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT `label` FROM `wptests_upsert_source` ORDER BY `id` DESC LIMIT 1), + `autoload` = (SELECT `s`.`label` FROM `wptests_upsert_source` AS `s` WHERE `s`.`id` > 0 ORDER BY `s`.`id` ASC LIMIT 1, 1)"; + + $this->assertSame( 1, $driver->query( $ordered_upsert ) ); + $this->assertSame( + array( + array( + 'sql' => 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'source_counts\', \'ignored\', \'ignored\') ON CONFLICT ("option_name") DO UPDATE SET "option_value" = CAST((SELECT "label" FROM "wptests_upsert_source" ORDER BY "id" DESC LIMIT 1) AS text), "autoload" = CAST((SELECT "s"."label" FROM "wptests_upsert_source" AS "s" WHERE "s"."id" > 0 ORDER BY "s"."id" ASC LIMIT 1 OFFSET 1) AS text)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $rows = $driver->query( "SELECT option_value, autoload FROM wptests_options WHERE option_name = 'source_counts'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'three', $rows[0]->option_value ); + $this->assertSame( 'two', $rows[0]->autoload ); + } + + /** + * Tests ON DUPLICATE KEY UPDATE supports scalar subqueries inside expressions. + */ + public function test_options_upsert_scalar_subquery_inside_expression_is_translated_to_postgresql(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_upsert_subquery_expr ( + id INTEGER PRIMARY KEY, + counter INTEGER NOT NULL, + bonus INTEGER NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_upsert_subquery_expr ( + id int(11) NOT NULL, + counter int(11) NOT NULL DEFAULT 3, + bonus int(11) NOT NULL DEFAULT 2, + PRIMARY KEY (id) + )' + ); + $driver->query( + 'CREATE TABLE wptests_upsert_subquery_expr_source ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_upsert_subquery_expr_source ( + id int(11) NOT NULL, + label varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( 'INSERT INTO wptests_upsert_subquery_expr (id, counter, bonus) VALUES (1, 9, 5)' ); + $driver->query( "INSERT INTO wptests_upsert_subquery_expr_source (id, label) VALUES (1, 'one'), (2, 'two'), (3, 'three')" ); + + $upsert = 'INSERT INTO `wptests_upsert_subquery_expr` (`id`, `counter`, `bonus`) + VALUES (1, 4, 8) + ON DUPLICATE KEY UPDATE `counter` = (SELECT COUNT(*) FROM `wptests_upsert_subquery_expr_source` WHERE `id` > 1) + VALUES(`counter`) + DEFAULT(`bonus`)'; + + $this->assertSame( 1, $driver->query( $upsert ) ); + $this->assertSame( + 'INSERT INTO "wptests_upsert_subquery_expr" ("id", "counter", "bonus") VALUES (1, 4, 8) ON CONFLICT ("id") DO UPDATE SET "counter" = (SELECT COUNT(*) FROM "wptests_upsert_subquery_expr_source" WHERE "id" > 1) + excluded."counter" + \'2\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT counter, bonus FROM wptests_upsert_subquery_expr WHERE id = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '8', $rows[0]->counter ); + $this->assertSame( '5', $rows[0]->bonus ); + } + + /** + * Tests unsupported table-backed scalar subquery shapes fail closed. + */ + public function test_options_upsert_unsupported_table_backed_subquery_assignment_fails_closed(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + $driver->query( + 'CREATE TABLE wptests_upsert_source ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_upsert_source ( + id int(11) NOT NULL, + label varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value, autoload) VALUES ('source_counts', '0', '0')" ); + + $queries = array( + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT missing FROM `wptests_upsert_source` WHERE id > 0)", + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT label FROM `wptests_upsert_source` WHERE missing > 0)", + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT label FROM `wptests_upsert_source` ORDER BY missing LIMIT 1)", + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT COUNT(missing) FROM `wptests_upsert_source`)", + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT 'bad' FROM DUAL WHERE missing > 0)", + "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('source_counts', 'ignored', 'ignored') + ON DUPLICATE KEY UPDATE `option_value` = (SELECT 'bad' FROM DUAL ORDER BY missing LIMIT 1)", + ); + + foreach ( $queries as $query ) { + $this->assertNull( + $this->translate_driver_query_data_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $query + ), + $query + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported table-backed scalar subquery assignment to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ON DUPLICATE KEY UPDATE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests DESCRIBE for a missing table returns an empty catalog result. + */ + public function test_describe_missing_table_returns_empty_result(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( 'DESCRIBE wptests_missing' ); + + $this->assertSame( array(), $result ); + $this->assertSame( 'DESCRIBE wptests_missing', $driver->get_last_mysql_query() ); + $this->assertSame( 6, $driver->get_last_column_count() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'DESCRIBE', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_missing' ), $queries[0]['params'] ); + } + + /** + * Tests DESC returns MySQL-shaped column metadata for an existing table. + */ + public function test_desc_existing_table_returns_mysql_shaped_column_metadata(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( 'DESC `wptests_options`;' ); + + $this->assertCount( 4, $result ); + $this->assertSame( 'DESC `wptests_options`;', $driver->get_last_mysql_query() ); + + $this->assertSame( 'option_id', $result[0]->Field ); + $this->assertSame( 'bigint', $result[0]->Type ); + $this->assertSame( 'NO', $result[0]->Null ); + $this->assertSame( 'PRI', $result[0]->Key ); + $this->assertNull( $result[0]->Default ); + $this->assertSame( 'auto_increment', $result[0]->Extra ); + + $this->assertSame( 'option_name', $result[1]->Field ); + $this->assertSame( 'varchar(191)', $result[1]->Type ); + $this->assertSame( 'NO', $result[1]->Null ); + $this->assertSame( 'UNI', $result[1]->Key ); + $this->assertNull( $result[1]->Default ); + $this->assertSame( '', $result[1]->Extra ); + + $this->assertSame( 'option_value', $result[2]->Field ); + $this->assertSame( 'text', $result[2]->Type ); + $this->assertSame( '', $result[2]->Key ); + + $this->assertSame( 'autoload', $result[3]->Field ); + $this->assertSame( 'varchar(20)', $result[3]->Type ); + $this->assertSame( "'yes'::character varying", $result[3]->Default ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'DESC `wptests_options`', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + } + + /** + * Tests DESCRIBE/DESC accepts current database-qualified table references. + */ + public function test_describe_accepts_current_database_qualification_forms(): void { + $cases = array( + 'DESCRIBE wptests.wptests_options', + 'DESC public.wptests_options', + 'DESC `wptests`.`wptests_options`', + ); + + foreach ( $cases as $query ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( $query ); + + $this->assertCount( 4, $result, $query ); + $this->assertSame( 'option_id', $result[0]->Field, $query ); + $this->assertSame( 'autoload', $result[3]->Field, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'], $query ); + $this->assertStringNotContainsString( 'DESCRIBE', $queries[0]['sql'], $query ); + $this->assertStringNotContainsString( 'DESC', $queries[0]['sql'], $query ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'], $query ); + } + } + + /** + * Tests DESCRIBE accepts explicit main database targets under USE information_schema. + */ + public function test_describe_after_use_information_schema_accepts_main_database_qualified_targets(): void { + $cases = array( + 'DESCRIBE wptests.wptests_options', + 'DESC public.wptests_options', + ); + + foreach ( $cases as $query ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ), $query ); + + $result = $driver->query( $query ); + + $this->assertCount( 4, $result, $query ); + $this->assertSame( 'option_id', $result[0]->Field, $query ); + $this->assertSame( 'autoload', $result[3]->Field, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'], $query ); + $this->assertStringNotContainsString( 'DESCRIBE', $queries[0]['sql'], $query ); + $this->assertStringNotContainsString( 'DESC', $queries[0]['sql'], $query ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'], $query ); + } + } + + /** + * Tests unsupported DESCRIBE/DESC qualifiers fail before backend execution. + */ + public function test_describe_unsupported_qualification_does_not_reach_backend(): void { + $queries = array( + 'DESC other_db.wptests_options' => 'Unsupported DESCRIBE statement.', + 'DESC information_schema.wptests_options' => 'Unsupported information_schema query.', + 'DESC wptests.wptests_options.extra' => 'Unsupported DESCRIBE statement.', + ); + + foreach ( $queries as $query => $message ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported DESCRIBE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW FULL COLUMNS returns MySQL-shaped PostgreSQL catalog rows. + */ + public function test_show_full_columns_returns_mysql_shaped_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( 'SHOW FULL COLUMNS FROM `wptests_options`' ); + + $this->assertCount( 4, $result ); + $this->assertSame( 'SHOW FULL COLUMNS FROM `wptests_options`', $driver->get_last_mysql_query() ); + $this->assertSame( 9, $driver->get_last_column_count() ); + $this->assertSame( 'Field', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Collation', $driver->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Comment', $driver->get_last_column_meta()[8]['name'] ); + + $this->assertSame( 'option_id', $result[0]->Field ); + $this->assertSame( 'bigint', $result[0]->Type ); + $this->assertNull( $result[0]->Collation ); + $this->assertSame( 'PRI', $result[0]->Key ); + $this->assertSame( 'auto_increment', $result[0]->Extra ); + $this->assertSame( 'select,insert,update,references', $result[0]->Privileges ); + $this->assertSame( '', $result[0]->Comment ); + + $this->assertSame( 'option_name', $result[1]->Field ); + $this->assertSame( 'varchar(191)', $result[1]->Type ); + $this->assertSame( 'utf8mb4_unicode_ci', $result[1]->Collation ); + $this->assertSame( 'UNI', $result[1]->Key ); + + $this->assertSame( 'option_value', $result[2]->Field ); + $this->assertSame( 'text', $result[2]->Type ); + $this->assertSame( 'utf8mb4_unicode_ci', $result[2]->Collation ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW FULL COLUMNS', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW FIELDS returns the same MySQL-shaped rows as SHOW COLUMNS. + */ + public function test_show_fields_returns_same_catalog_rows_as_show_columns(): void { + $columns_driver = $this->create_driver(); + $fields_driver = $this->create_driver(); + $this->install_information_schema_fixture( $columns_driver ); + $this->install_information_schema_fixture( $fields_driver ); + + $columns = $columns_driver->query( 'SHOW COLUMNS FROM `wptests_options`' ); + $fields = $fields_driver->query( 'SHOW FIELDS FROM `wptests_options`' ); + + $this->assertEquals( $columns, $fields ); + $this->assertSame( 'SHOW FIELDS FROM `wptests_options`', $fields_driver->get_last_mysql_query() ); + $this->assertSame( $columns_driver->get_last_column_count(), $fields_driver->get_last_column_count() ); + $this->assertSame( $columns_driver->get_last_column_meta(), $fields_driver->get_last_column_meta() ); + + $queries = $fields_driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW FIELDS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW FULL/EXTENDED FIELDS use the SHOW COLUMNS parser. + */ + public function test_show_prefixed_fields_use_show_columns_parser(): void { + $full_driver = $this->create_driver(); + $this->install_information_schema_fixture( $full_driver ); + + $full = $full_driver->query( 'SHOW FULL FIELDS FROM `wptests_options`' ); + + $this->assertCount( 4, $full ); + $this->assertSame( 9, $full_driver->get_last_column_count() ); + $this->assertSame( 'Collation', $full_driver->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'utf8mb4_unicode_ci', $full[1]->Collation ); + $this->assertStringNotContainsString( + 'SHOW FULL FIELDS', + strtoupper( $full_driver->get_last_postgresql_queries()[0]['sql'] ) + ); + + $extended_driver = $this->create_driver(); + $this->install_information_schema_fixture( $extended_driver ); + + $extended = $extended_driver->query( 'SHOW EXTENDED FIELDS FROM `wptests_options`' ); + + $this->assertCount( 4, $extended ); + $this->assertSame( 6, $extended_driver->get_last_column_count() ); + $this->assertSame( 'Field', $extended_driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'option_id', $extended[0]->Field ); + $this->assertStringNotContainsString( + 'SHOW EXTENDED FIELDS', + strtoupper( $extended_driver->get_last_postgresql_queries()[0]['sql'] ) + ); + } + + /** + * Tests SHOW COLUMNS accepts MySQL table qualification forms. + */ + public function test_show_columns_accepts_table_qualification_forms(): void { + $cases = array( + 'SHOW COLUMNS IN wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW COLUMNS FROM public.wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW COLUMNS FROM wptests_options FROM public' => array( 'public', 'wptests_options' ), + 'SHOW COLUMNS IN wptests_options IN public' => array( 'public', 'wptests_options' ), + ); + + foreach ( $cases as $query => $params ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( $query ); + + $this->assertCount( 4, $result, $query ); + $this->assertSame( 'option_id', $result[0]->Field, $query ); + $this->assertSame( 'autoload', $result[3]->Field, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'], $query ); + $this->assertStringNotContainsString( 'SHOW COLUMNS', $queries[0]['sql'], $query ); + $this->assertSame( $params, $queries[0]['params'], $query ); + } + } + + /** + * Tests SHOW FIELDS accepts MySQL table qualification forms. + */ + public function test_show_fields_accepts_table_qualification_forms(): void { + $cases = array( + 'SHOW FIELDS IN wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW FIELDS FROM public.wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW FIELDS FROM wptests_options FROM public' => array( 'public', 'wptests_options' ), + 'SHOW FIELDS IN wptests_options IN public' => array( 'public', 'wptests_options' ), + ); + + foreach ( $cases as $query => $params ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( $query ); + + $this->assertCount( 4, $result, $query ); + $this->assertSame( 'option_id', $result[0]->Field, $query ); + $this->assertSame( 'autoload', $result[3]->Field, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringContainsString( 'information_schema.columns', $queries[0]['sql'], $query ); + $this->assertStringNotContainsString( 'SHOW FIELDS', strtoupper( $queries[0]['sql'] ), $query ); + $this->assertSame( $params, $queries[0]['params'], $query ); + } + } + + /** + * Tests SHOW COLUMNS LIKE filters catalog rows with bound parameters. + */ + public function test_show_columns_like_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( "SHOW COLUMNS FROM wptests_options LIKE 'option_%'" ); + + $this->assertCount( 3, $result ); + $this->assertSame( 'option_id', $result[0]->Field ); + $this->assertSame( 'option_name', $result[1]->Field ); + $this->assertSame( 'option_value', $result[2]->Field ); + $this->assertSame( 6, $driver->get_last_column_count() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'field_name LIKE ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW COLUMNS', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'option_%' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW FIELDS LIKE filters catalog rows with bound parameters. + */ + public function test_show_fields_like_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( "SHOW FIELDS FROM wptests_options LIKE 'option_%'" ); + + $this->assertCount( 3, $result ); + $this->assertSame( 'option_id', $result[0]->Field ); + $this->assertSame( 'option_name', $result[1]->Field ); + $this->assertSame( 'option_value', $result[2]->Field ); + $this->assertSame( 6, $driver->get_last_column_count() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'field_name LIKE ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW FIELDS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options', 'option_%' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW COLUMNS uses the same MySQL metadata rows as DESCRIBE. + */ + public function test_show_columns_uses_mysql_schema_metadata_like_describe(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_meta_columns ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + lookup_key varchar(191) CHARACTER SET latin1 NOT NULL DEFAULT '', + payload longtext COLLATE koi8r_general_ci NOT NULL, + PRIMARY KEY (id), + KEY lookup_key (lookup_key) + )" + ); + + $describe = $driver->query( 'DESC wptests_meta_columns' ); + $show = $driver->query( 'SHOW COLUMNS FROM wptests_meta_columns' ); + + $this->assertEquals( $describe, $show ); + $this->assertSame( 'id', $show[0]->Field ); + $this->assertSame( 'PRI', $show[0]->Key ); + $this->assertSame( 'auto_increment', $show[0]->Extra ); + $this->assertSame( 'lookup_key', $show[1]->Field ); + $this->assertSame( 'varchar(191)', $show[1]->Type ); + $this->assertSame( 'MUL', $show[1]->Key ); + $this->assertSame( 'payload', $show[2]->Field ); + $this->assertSame( 'longtext', $show[2]->Type ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( '__wp_postgresql_mysql_column_metadata', $queries[0]['sql'] ); + $this->assertStringContainsString( '__wp_postgresql_mysql_index_metadata', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW COLUMNS', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_meta_columns' ), $queries[0]['params'] ); + + $filtered = $driver->query( "SHOW COLUMNS FROM wptests_meta_columns LIKE 'lookup%'" ); + + $this->assertCount( 1, $filtered ); + $this->assertSame( 'lookup_key', $filtered[0]->Field ); + $this->assertSame( 'MUL', $filtered[0]->Key ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'field_name LIKE ?', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_meta_columns', 'lookup%' ), $queries[0]['params'] ); + + $full = $driver->query( 'SHOW FULL COLUMNS FROM wptests_meta_columns' ); + + $this->assertSame( 9, $driver->get_last_column_count() ); + $this->assertSame( 'id', $full[0]->Field ); + $this->assertNull( $full[0]->Collation ); + $this->assertSame( 'lookup_key', $full[1]->Field ); + $this->assertSame( 'latin1_swedish_ci', $full[1]->Collation ); + $this->assertSame( 'MUL', $full[1]->Key ); + $this->assertSame( 'payload', $full[2]->Field ); + $this->assertSame( 'koi8r_general_ci', $full[2]->Collation ); + } + + /** + * Tests DESCRIBE and SHOW COLUMNS preserve numeric precision and scale metadata. + */ + public function test_describe_preserves_numeric_precision_and_scale_from_mysql_metadata(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_numeric_meta ( + amount DECIMAL(10,2) NOT NULL, + ratio NUMERIC(12,6), + score FLOAT(10,3), + measure DOUBLE(8,4) + )' + ); + + $describe = $driver->query( 'DESC wptests_numeric_meta' ); + $show = $driver->query( 'SHOW COLUMNS FROM wptests_numeric_meta' ); + + $this->assertSame( 'decimal(10,2)', $describe[0]->Type ); + $this->assertSame( 'numeric(12,6)', $describe[1]->Type ); + $this->assertSame( 'float(10,3)', $describe[2]->Type ); + $this->assertSame( 'double(8,4)', $describe[3]->Type ); + $this->assertEquals( $describe, $show ); + } + + /** + * Tests CHANGE COLUMN without DEFAULT removes the backend and metadata defaults. + */ + public function test_change_column_without_default_drops_existing_default(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_defaults', 'BASE TABLE')" + ); + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.columns + (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, collation_name, is_nullable, column_default, is_identity) + VALUES + ('public', 'wptests_defaults', 'post_title', 1, 'character varying', 20, 'utf8mb4_unicode_ci', 'NO', '''stale''::character varying', 'NO')" + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_defaults ( + post_title varchar(20) NOT NULL DEFAULT 'stale' + )" + ); + + $driver->query( 'ALTER TABLE wptests_defaults CHANGE COLUMN post_title post_title text NOT NULL' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_defaults" ALTER COLUMN "post_title" TYPE text', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_defaults" ALTER COLUMN "post_title" SET NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_defaults" ALTER COLUMN "post_title" DROP DEFAULT', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $describe = $driver->query( 'DESC wptests_defaults' ); + $show = $driver->query( 'SHOW COLUMNS FROM wptests_defaults' ); + + $this->assertSame( 'post_title', $describe[0]->Field ); + $this->assertSame( 'text', $describe[0]->Type ); + $this->assertNull( $describe[0]->Default ); + $this->assertNull( $show[0]->Default ); + } + + /** + * Tests CHANGE COLUMN preserves existing identity DDL while updating MySQL metadata. + */ + public function test_change_column_auto_increment_integer_family_preserves_identity_ddl_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_identity', 'BASE TABLE')" + ); + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.columns + (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, collation_name, is_nullable, column_default, is_identity) + VALUES + ('public', 'wptests_identity', 'id', 1, 'bigint', NULL, NULL, 'NO', NULL, 'YES')" + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_identity ( + id bigint(20) NOT NULL AUTO_INCREMENT, + PRIMARY KEY (id) + )' + ); + + $driver->query( 'ALTER TABLE wptests_identity CHANGE COLUMN `id` id int(11) NOT NULL AUTO_INCREMENT' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_identity" ALTER COLUMN "id" SET NOT NULL', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $describe = $driver->query( 'DESC wptests_identity' ); + $show = $driver->query( 'SHOW COLUMNS FROM wptests_identity' ); + + $this->assertEquals( $describe, $show ); + $this->assertSame( 'id', $show[0]->Field ); + $this->assertSame( 'int(11)', $show[0]->Type ); + $this->assertSame( 'NO', $show[0]->Null ); + $this->assertNull( $show[0]->Default ); + $this->assertSame( 'auto_increment', $show[0]->Extra ); + } + + /** + * Tests CHANGE COLUMN adds PostgreSQL identity when AUTO_INCREMENT is introduced. + */ + public function test_change_column_auto_increment_adds_identity_for_plain_integer_column(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_identity_add', 'BASE TABLE')" + ); + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.columns + (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, collation_name, is_nullable, column_default, is_identity) + VALUES + ('public', 'wptests_identity_add', 'legacy_id', 1, 'integer', NULL, NULL, 'NO', NULL, 'NO')" + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_identity_add ( + legacy_id int(11) NOT NULL, + PRIMARY KEY (legacy_id) + )' + ); + + $driver->query( 'ALTER TABLE wptests_identity_add CHANGE COLUMN legacy_id id bigint(20) unsigned NOT NULL AUTO_INCREMENT' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_identity_add" RENAME COLUMN "legacy_id" TO "id"', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_identity_add" ALTER COLUMN "id" TYPE bigint', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_identity_add" ALTER COLUMN "id" SET NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_identity_add" ALTER COLUMN "id" DROP DEFAULT', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_identity_add" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_identity_add' ); + $this->assertSame( 'id', $columns[0]['column_name'] ); + $this->assertSame( 'bigint(20) unsigned', $columns[0]['column_type'] ); + $this->assertSame( 'NO', $columns[0]['is_nullable'] ); + $this->assertSame( 'auto_increment', $columns[0]['extra'] ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_identity_add' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $create_table ); + $this->assertStringContainsString( ' PRIMARY KEY (`id`)', $create_table ); + } + + /** + * Tests MODIFY COLUMN clauses update backend and metadata definitions. + */ + public function test_modify_column_updates_backend_and_metadata_definitions(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_actionscheduler_actions ( + scheduled_date_gmt datetime NOT NULL default '2026-01-01 00:00:00', + last_attempt_gmt datetime NOT NULL default '2026-01-01 00:00:00' + )" + ); + + $driver->query( + "ALTER TABLE wptests_actionscheduler_actions + MODIFY COLUMN scheduled_date_gmt datetime NULL default '0000-00-00 00:00:00', + MODIFY COLUMN last_attempt_gmt datetime NULL default '0000-00-00 00:00:00'" + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "scheduled_date_gmt" TYPE text', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "scheduled_date_gmt" DROP NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "scheduled_date_gmt" SET DEFAULT \'0000-00-00 00:00:00\'', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "last_attempt_gmt" TYPE text', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "last_attempt_gmt" DROP NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_actionscheduler_actions" ALTER COLUMN "last_attempt_gmt" SET DEFAULT \'0000-00-00 00:00:00\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $describe = $driver->query( 'DESC wptests_actionscheduler_actions' ); + + $this->assertSame( 'scheduled_date_gmt', $describe[0]->Field ); + $this->assertSame( 'datetime', $describe[0]->Type ); + $this->assertSame( 'YES', $describe[0]->Null ); + $this->assertSame( '0000-00-00 00:00:00', $describe[0]->Default ); + $this->assertSame( 'last_attempt_gmt', $describe[1]->Field ); + $this->assertSame( 'datetime', $describe[1]->Type ); + $this->assertSame( 'YES', $describe[1]->Null ); + $this->assertSame( '0000-00-00 00:00:00', $describe[1]->Default ); + } + + /** + * Tests CHANGE/MODIFY COLUMN inline key constraints update backend and metadata. + */ + public function test_alter_table_change_and_modify_inline_key_constraints_update_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_change_inline_keys ( + a int, + b int + )' + ); + + $driver->query( 'ALTER TABLE wptests_change_inline_keys CHANGE COLUMN a a INT PRIMARY KEY' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_change_inline_keys" ALTER COLUMN "a" TYPE integer', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_change_inline_keys" ALTER COLUMN "a" SET NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_change_inline_keys" ALTER COLUMN "a" DROP DEFAULT', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_change_inline_keys" ADD PRIMARY KEY ("a")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( 'ALTER TABLE wptests_change_inline_keys MODIFY COLUMN b INT UNIQUE' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_change_inline_keys" ALTER COLUMN "b" TYPE integer', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_change_inline_keys" ALTER COLUMN "b" DROP NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_change_inline_keys" ALTER COLUMN "b" DROP DEFAULT', + 'params' => array(), + ), + array( + 'sql' => 'CREATE UNIQUE INDEX "wptests_change_inline_keys__b" ON "wptests_change_inline_keys" ("b")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_change_inline_keys' ); + $this->assertSame( 'PRIMARY', $indexes[0]['key_name'] ); + $this->assertSame( 'a', $indexes[0]['column_name'] ); + $this->assertSame( '0', $indexes[0]['non_unique'] ); + $this->assertSame( 'b', $indexes[1]['key_name'] ); + $this->assertSame( 'b', $indexes[1]['column_name'] ); + $this->assertSame( '0', $indexes[1]['non_unique'] ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_change_inline_keys' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' PRIMARY KEY (`a`)', $create_table ); + $this->assertStringContainsString( ' UNIQUE KEY `b` (`b`)', $create_table ); + } + + /** + * Tests CHANGE/MODIFY COLUMN preserve MySQL JSON metadata parity. + */ + public function test_alter_table_change_and_modify_json_update_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_json_alter ( + payload longtext COLLATE koi8r_general_ci NOT NULL, + settings JSON DEFAULT NULL + )' + ); + + $driver->query( 'ALTER TABLE wptests_json_alter CHANGE COLUMN payload payload JSON DEFAULT NULL' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_json_alter" ALTER COLUMN "payload" TYPE text', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_json_alter" ALTER COLUMN "payload" DROP NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_json_alter" ALTER COLUMN "payload" SET DEFAULT NULL', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_json_alter' ); + $this->assertSame( 'json', $columns[0]['column_type'] ); + $this->assertNull( $columns[0]['character_set_name'] ); + $this->assertNull( $columns[0]['collation_name'] ); + $this->assertSame( 'YES', $columns[0]['is_nullable'] ); + $this->assertNull( $columns[0]['column_default'] ); + + $full = $driver->query( 'SHOW FULL COLUMNS FROM wptests_json_alter' ); + $this->assertSame( 'payload', $full[0]->Field ); + $this->assertSame( 'json', $full[0]->Type ); + $this->assertNull( $full[0]->Collation ); + $this->assertNull( $full[0]->Default ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_json_alter' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' `payload` json DEFAULT NULL', $create_table ); + + $driver->query( 'ALTER TABLE wptests_json_alter MODIFY COLUMN settings LONGTEXT COLLATE koi8r_general_ci NOT NULL' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_json_alter" ALTER COLUMN "settings" TYPE text', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_json_alter" ALTER COLUMN "settings" SET NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_json_alter" ALTER COLUMN "settings" DROP DEFAULT', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_json_alter' ); + $this->assertSame( 'longtext', $columns[1]['column_type'] ); + $this->assertSame( 'koi8r', $columns[1]['character_set_name'] ); + $this->assertSame( 'koi8r_general_ci', $columns[1]['collation_name'] ); + $this->assertSame( 'NO', $columns[1]['is_nullable'] ); + } + + /** + * Tests mixed ALTER TABLE batches add columns and indexes while ignoring MySQL placement. + */ + public function test_alter_table_add_batch_updates_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_plugin_alter ( + id int(11) NOT NULL, + status varchar(20) DEFAULT 'draft', + PRIMARY KEY (id) + )" + ); + + $driver->query( + 'ALTER TABLE wptests_plugin_alter + ADD flag tinyint(1) NOT NULL DEFAULT 0, + ADD COLUMN code varchar(20) DEFAULT "x" AFTER id, + ADD KEY flag_idx (flag DESC)' + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_plugin_alter" ADD COLUMN "flag" integer NOT NULL DEFAULT \'0\'', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_plugin_alter" ADD COLUMN "code" varchar(20) DEFAULT \'x\'', + 'params' => array(), + ), + array( + 'sql' => 'CREATE INDEX "wptests_plugin_alter__flag_idx" ON "wptests_plugin_alter" ("flag" DESC)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_alter' ); + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_plugin_alter' ); + + $this->assertSame( array( 'id', 'status', 'flag', 'code' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( array( 'PRIMARY', 'flag_idx' ), array_values( array_unique( array_column( $indexes, 'key_name' ) ) ) ); + $this->assertSame( 'D', $indexes[1]['collation'] ); + + $show_index = $driver->query( "SHOW INDEX FROM wptests_plugin_alter WHERE Key_name = 'flag_idx'" ); + $this->assertSame( 'D', $show_index[0]->Collation ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_plugin_alter' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' KEY `flag_idx` (`flag` DESC)', $create_table ); + } + + /** + * Tests ALTER TABLE HASH indexes are normalized to BTREE like the SQLite backend. + */ + public function test_alter_table_add_hash_index_is_normalized_to_btree(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_hash_index ( + id int NOT NULL, + value varchar(255) NOT NULL + )' + ); + + $this->assertSame( + 1, + $driver->query( 'ALTER TABLE wptests_alter_hash_index ADD KEY value_hash USING HASH (value)' ) + ); + + $this->assertSame( + array( + array( + 'sql' => 'CREATE INDEX "wptests_alter_hash_index__value_hash" ON "wptests_alter_hash_index" ("value")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_hash_index' ); + $this->assertSame( array( 'value_hash' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( array( 'BTREE' ), array_column( $indexes, 'index_type' ) ); + } + + /** + * Tests ALTER TABLE can drop and recreate the same column name in one batch. + */ + public function test_alter_table_drop_and_readd_same_column_updates_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_alter_readd_column ( + id int(11) NOT NULL, + slug varchar(20) DEFAULT 'old', + PRIMARY KEY (id) + )" + ); + + $driver->query( + "ALTER TABLE wptests_alter_readd_column + DROP COLUMN slug, + ADD COLUMN slug varchar(50) NOT NULL DEFAULT 'restored'" + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_readd_column" DROP COLUMN "slug"', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_readd_column" ADD COLUMN "slug" varchar(50) NOT NULL DEFAULT \'restored\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_alter_readd_column' ); + $this->assertSame( array( 'id', 'slug' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( 'varchar(50)', $columns[1]['column_type'] ); + $this->assertSame( 'NO', $columns[1]['is_nullable'] ); + $this->assertSame( 'restored', $columns[1]['column_default'] ); + } + + /** + * Tests ALTER TABLE can rename a column and then re-add the original name in one batch. + */ + public function test_alter_table_change_and_readd_original_column_name_updates_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_alter_change_readd_column ( + id int(11) NOT NULL, + slug varchar(20) DEFAULT 'old', + PRIMARY KEY (id) + )" + ); + + $driver->query( + "ALTER TABLE wptests_alter_change_readd_column + CHANGE COLUMN slug legacy_slug varchar(50) NOT NULL DEFAULT 'legacy', + ADD COLUMN slug varchar(20) DEFAULT NULL" + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_change_readd_column" RENAME COLUMN "slug" TO "legacy_slug"', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_change_readd_column" ALTER COLUMN "legacy_slug" TYPE varchar(50)', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_change_readd_column" ALTER COLUMN "legacy_slug" SET NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_change_readd_column" ALTER COLUMN "legacy_slug" SET DEFAULT \'legacy\'', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_change_readd_column" ADD COLUMN "slug" varchar(20) DEFAULT NULL', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_alter_change_readd_column' ); + $this->assertSame( array( 'id', 'legacy_slug', 'slug' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( 'varchar(50)', $columns[1]['column_type'] ); + $this->assertSame( 'NO', $columns[1]['is_nullable'] ); + $this->assertSame( 'legacy', $columns[1]['column_default'] ); + $this->assertSame( 'varchar(20)', $columns[2]['column_type'] ); + $this->assertNull( $columns[2]['column_default'] ); + } + + /** + * Tests ALTER TABLE can drop and recreate the same secondary or primary key name in one batch. + */ + public function test_alter_table_drop_and_readd_same_index_names_update_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_readd_index ( + id int(11) NOT NULL, + slug varchar(20) NOT NULL, + PRIMARY KEY (id), + KEY lookup (slug) + )' + ); + + $driver->query( + 'ALTER TABLE wptests_alter_readd_index + DROP INDEX lookup, + ADD INDEX lookup (id DESC)' + ); + + $this->assertSame( + array( + array( + 'sql' => 'DROP INDEX "wptests_alter_readd_index__lookup"', + 'params' => array(), + ), + array( + 'sql' => 'CREATE INDEX "wptests_alter_readd_index__lookup" ON "wptests_alter_readd_index" ("id" DESC)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $lookup_index = array_values( + array_filter( + $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_readd_index' ), + static function ( array $row ): bool { + return 'lookup' === $row['key_name']; + } + ) + ); + $this->assertSame( array( 'id' ), array_column( $lookup_index, 'column_name' ) ); + $this->assertSame( array( 'D' ), array_column( $lookup_index, 'collation' ) ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_readd_primary ( + id int(11) NOT NULL, + slug int(11) NOT NULL, + PRIMARY KEY (id) + )' + ); + + $driver->query( + 'ALTER TABLE wptests_alter_readd_primary + DROP PRIMARY KEY, + ADD PRIMARY KEY (slug)' + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_readd_primary" DROP CONSTRAINT "wptests_alter_readd_primary_pkey"', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_readd_primary" ADD PRIMARY KEY ("slug")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $primary_index = $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_readd_primary' ); + $this->assertSame( array( 'PRIMARY' ), array_values( array_unique( array_column( $primary_index, 'key_name' ) ) ) ); + $this->assertSame( array( 'slug' ), array_column( $primary_index, 'column_name' ) ); + } + + /** + * Tests same-name ALTER TABLE replacements still require DROP before ADD. + */ + public function test_alter_table_same_name_replacements_require_drop_before_add(): void { + $queries = array( + 'ALTER TABLE wptests_alter_readd_order ADD COLUMN slug varchar(50), DROP COLUMN slug' => "Duplicate column name 'slug'.", + 'ALTER TABLE wptests_alter_readd_order ADD INDEX lookup (id), DROP INDEX lookup' => "Duplicate key name 'lookup'.", + ); + + foreach ( $queries as $query => $message ) { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_readd_order ( + id int(11) NOT NULL, + slug varchar(20) DEFAULT NULL, + KEY lookup (slug) + )' + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected same-name ALTER replacement with ADD before DROP to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests ALTER TABLE ADD COLUMN accepts parenthesized column lists like the SQLite driver. + */ + public function test_alter_table_add_parenthesized_column_list_updates_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_parenthesized_alter ( + id int(11) NOT NULL, + PRIMARY KEY (id) + )' + ); + + $this->assertSame( + 1, + $driver->query( + "ALTER TABLE wptests_parenthesized_alter ADD COLUMN ( + title varchar(100) NOT NULL DEFAULT 'draft', + score int DEFAULT 0 + )" + ) + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_parenthesized_alter" ADD COLUMN "title" varchar(100) NOT NULL DEFAULT \'draft\'', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_parenthesized_alter" ADD COLUMN "score" integer DEFAULT \'0\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_parenthesized_alter' ); + $this->assertSame( array( 'id', 'title', 'score' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( 'NO', $columns[1]['is_nullable'] ); + $this->assertSame( 'draft', $columns[1]['column_default'] ); + $this->assertSame( '0', $columns[2]['column_default'] ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_parenthesized_alter' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' `title` varchar(100) NOT NULL DEFAULT \'draft\'', $create_table ); + $this->assertStringContainsString( ' `score` int DEFAULT \'0\'', $create_table ); + } + + /** + * Tests parenthesized ALTER TABLE ADD column lists ignore MySQL placement. + */ + public function test_alter_table_add_parenthesized_column_list_accepts_placement_suffixes(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_parenthesized_placement_alter ( + id int(11) NOT NULL, + PRIMARY KEY (id) + )' + ); + + $this->assertSame( + 1, + $driver->query( + "ALTER TABLE wptests_parenthesized_placement_alter ADD ( + title varchar(100) NOT NULL DEFAULT 'draft' FIRST, + score int DEFAULT 0 AFTER title + )" + ) + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_parenthesized_placement_alter" ADD COLUMN "title" varchar(100) NOT NULL DEFAULT \'draft\'', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_parenthesized_placement_alter" ADD COLUMN "score" integer DEFAULT \'0\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_parenthesized_placement_alter' ); + $this->assertSame( array( 'id', 'title', 'score' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( 'NO', $columns[1]['is_nullable'] ); + $this->assertSame( 'draft', $columns[1]['column_default'] ); + $this->assertSame( '0', $columns[2]['column_default'] ); + } + + /** + * Tests malformed placement in parenthesized ALTER TABLE ADD lists fails before backend execution. + */ + public function test_alter_table_add_parenthesized_column_list_malformed_placement_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_parenthesized_bad_placement_alter (id INTEGER)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_parenthesized_bad_placement_alter ( + id int NOT NULL + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_parenthesized_bad_placement_alter ADD (slug varchar(20) AFTER)' ); + $this->fail( 'Expected malformed parenthesized ALTER TABLE ADD COLUMN placement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertSame( array( 'id' ), array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_parenthesized_bad_placement_alter' ), 'column_name' ) ); + } + + /** + * Tests duplicate columns inside parenthesized ALTER TABLE ADD lists fail atomically. + */ + public function test_alter_table_add_parenthesized_duplicate_column_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_parenthesized_duplicate_alter (id INTEGER)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_parenthesized_duplicate_alter ( + id int NOT NULL + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_parenthesized_duplicate_alter ADD (slug varchar(20), slug varchar(30))' ); + $this->fail( 'Expected duplicate parenthesized ALTER TABLE ADD COLUMN batch to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Duplicate column name 'slug'.", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertSame( array( 'id' ), array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_parenthesized_duplicate_alter' ), 'column_name' ) ); + } + + /** + * Tests parenthesized ALTER TABLE ADD batches support index and constraint entries. + */ + public function test_alter_table_add_parenthesized_index_and_constraint_entries_update_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE wptests_parenthesized_parent (id int NOT NULL)' ); + $driver->query( 'CREATE TABLE wptests_parenthesized_supported_alter (id int NOT NULL, parent_id int NOT NULL, code int NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_parenthesized_supported_alter ( + id int NOT NULL, + parent_id int NOT NULL, + code int NOT NULL + )' + ); + + $driver->query( + 'ALTER TABLE wptests_parenthesized_supported_alter + ADD CHECK (code < 100), + ADD ( + slug varchar(50) NOT NULL DEFAULT "draft", + PRIMARY KEY (id), + KEY slug_idx (slug DESC), + UNIQUE KEY code_unique (code), + CHECK (code > 0), + CONSTRAINT child_parent FOREIGN KEY (parent_id) REFERENCES wptests_parenthesized_parent (id) ON DELETE CASCADE + )' + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_parenthesized_supported_alter" ADD CONSTRAINT "wptests_parenthesized_supported_alter_chk_1" CHECK (code < 100)', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_parenthesized_supported_alter" ADD COLUMN "slug" varchar(50) NOT NULL DEFAULT \'draft\'', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_parenthesized_supported_alter" ADD PRIMARY KEY ("id")', + 'params' => array(), + ), + array( + 'sql' => 'CREATE INDEX "wptests_parenthesized_supported_alter__slug_idx" ON "wptests_parenthesized_supported_alter" ("slug" DESC)', + 'params' => array(), + ), + array( + 'sql' => 'CREATE UNIQUE INDEX "wptests_parenthesized_supported_alter__code_unique" ON "wptests_parenthesized_supported_alter" ("code")', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_parenthesized_supported_alter" ADD CONSTRAINT "wptests_parenthesized_supported_alter_chk_2" CHECK (code > 0)', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_parenthesized_supported_alter" ADD CONSTRAINT "child_parent" FOREIGN KEY ("parent_id") REFERENCES "wptests_parenthesized_parent" ("id") ON DELETE CASCADE', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_parenthesized_supported_alter' ); + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_parenthesized_supported_alter' ); + + $this->assertSame( array( 'id', 'parent_id', 'code', 'slug' ), array_column( $columns, 'column_name' ) ); + $index_names = array_values( array_unique( array_column( $indexes, 'key_name' ) ) ); + sort( $index_names ); + $this->assertSame( array( 'PRIMARY', 'code_unique', 'slug_idx' ), $index_names ); + + $slug_index_rows = array_values( + array_filter( + $indexes, + static function ( array $index ): bool { + return 'slug_idx' === $index['key_name']; + } + ) + ); + $this->assertSame( 'D', $slug_index_rows[0]['collation'] ); + + $this->assertSame( + array( + array( + 'constraint_name' => 'wptests_parenthesized_supported_alter_chk_1', + 'check_clause' => 'code < 100', + 'enforced' => 'YES', + ), + array( + 'constraint_name' => 'wptests_parenthesized_supported_alter_chk_2', + 'check_clause' => 'code > 0', + 'enforced' => 'YES', + ), + ), + $this->get_mysql_check_metadata_rows( $driver, 'wptests_parenthesized_supported_alter' ) + ); + + $this->assertSame( + array( + array( + 'constraint_name' => 'child_parent', + 'seq_in_index' => '1', + 'column_name' => 'parent_id', + 'referenced_table_name' => 'wptests_parenthesized_parent', + 'referenced_column_name' => 'id', + 'update_rule' => 'NO ACTION', + 'delete_rule' => 'CASCADE', + ), + ), + $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_parenthesized_supported_alter' ) + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_parenthesized_supported_alter' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' PRIMARY KEY (`id`)', $create_table ); + $this->assertStringContainsString( ' KEY `slug_idx` (`slug` DESC)', $create_table ); + $this->assertStringContainsString( ' UNIQUE KEY `code_unique` (`code`)', $create_table ); + $this->assertStringContainsString( ' CONSTRAINT `wptests_parenthesized_supported_alter_chk_1` CHECK (code < 100)', $create_table ); + $this->assertStringContainsString( ' CONSTRAINT `wptests_parenthesized_supported_alter_chk_2` CHECK (code > 0)', $create_table ); + $this->assertStringContainsString( + ' CONSTRAINT `child_parent` FOREIGN KEY (`parent_id`) REFERENCES `wptests_parenthesized_parent` (`id`) ON DELETE CASCADE', + $create_table + ); + } + + /** + * Tests duplicate ALTER TABLE ADD COLUMN statements fail before backend execution. + */ + public function test_alter_table_duplicate_add_column_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_duplicate_alter_column (id INTEGER)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_duplicate_alter_column ( + id int NOT NULL + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_duplicate_alter_column ADD COLUMN id int' ); + $this->fail( 'Expected duplicate ALTER TABLE ADD COLUMN to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Duplicate column name 'id'.", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertSame( array( 'id' ), array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_duplicate_alter_column' ), 'column_name' ) ); + } + + /** + * Tests same-statement duplicate ALTER TABLE ADD COLUMN batches fail atomically. + */ + public function test_alter_table_duplicate_add_column_batch_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_duplicate_alter_column_batch (id INTEGER)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_duplicate_alter_column_batch ( + id int NOT NULL + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_duplicate_alter_column_batch ADD COLUMN slug varchar(20), ADD COLUMN slug varchar(30)' ); + $this->fail( 'Expected duplicate ALTER TABLE ADD COLUMN batch to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Duplicate column name 'slug'.", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertSame( array( 'id' ), array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_duplicate_alter_column_batch' ), 'column_name' ) ); + } + + /** + * Tests duplicate ALTER TABLE ADD INDEX statements fail before backend execution. + */ + public function test_alter_table_duplicate_add_index_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_duplicate_alter_index (id INTEGER)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_duplicate_alter_index ( + id int NOT NULL, + KEY idx (id) + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_duplicate_alter_index ADD KEY idx (id)' ); + $this->fail( 'Expected duplicate ALTER TABLE ADD INDEX to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Duplicate key name 'idx'.", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertSame( array( 'idx' ), array_values( array_unique( array_column( $this->get_mysql_index_metadata_rows( $driver, 'wptests_duplicate_alter_index' ), 'key_name' ) ) ) ); + } + + /** + * Tests same-statement duplicate ALTER TABLE ADD INDEX batches fail atomically. + */ + public function test_alter_table_duplicate_add_index_batch_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_duplicate_alter_index_batch (id INTEGER)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_duplicate_alter_index_batch ( + id int NOT NULL + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_duplicate_alter_index_batch ADD KEY idx (id), ADD UNIQUE idx (id)' ); + $this->fail( 'Expected duplicate ALTER TABLE ADD INDEX batch to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Duplicate key name 'idx'.", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_duplicate_alter_index_batch' ) ); + } + + /** + * Tests alias-only CREATE TABLE statements use the MySQL DDL translator. + */ + public function test_create_table_with_only_mysql_type_alias_markers_uses_translator(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_alias_create ( + flags BIT(10), + enabled BOOL, + amount DEC(10,2), + fixed_value FIXED(8,3), + real_value REAL + )' + ) + ); + + $this->assertSame( + array( + array( + 'sql' => "CREATE TABLE \"wptests_alias_create\" (\n \"flags\" integer,\n \"enabled\" integer,\n \"amount\" numeric(10,2),\n \"fixed_value\" numeric(8,3),\n \"real_value\" double precision\n)", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_alias_create' ); + $this->assertSame( + array( 'bit(10)', 'bool', 'dec(10,2)', 'fixed(8,3)', 'real' ), + array_column( $columns, 'column_type' ) + ); + } + + /** + * Tests CREATE TABLE stores MySQL JSON metadata while using text storage. + */ + public function test_create_table_json_uses_text_storage_with_mysql_metadata(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_json_create ( + id int NOT NULL, + payload JSON DEFAULT NULL + )' + ) + ); + + $this->assertSame( + array( + array( + 'sql' => "CREATE TABLE \"wptests_json_create\" (\n \"id\" integer NOT NULL,\n \"payload\" text DEFAULT NULL\n)", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_json_create' ); + $this->assertSame( 'json', $columns[1]['column_type'] ); + $this->assertNull( $columns[1]['character_set_name'] ); + $this->assertNull( $columns[1]['collation_name'] ); + + $this->install_information_schema_fixture( $driver ); + $describe = $driver->query( 'DESC wptests_json_create' ); + $this->assertSame( 'payload', $describe[1]->Field ); + $this->assertSame( 'json', $describe[1]->Type ); + $this->assertNull( $describe[1]->Default ); + + $full = $driver->query( 'SHOW FULL COLUMNS FROM wptests_json_create' ); + $this->assertSame( 'payload', $full[1]->Field ); + $this->assertSame( 'json', $full[1]->Type ); + $this->assertNull( $full[1]->Collation ); + $this->assertNull( $full[1]->Default ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_json_create' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' `payload` json DEFAULT NULL', $create_table ); + + $information_schema = $driver->query( + "SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_DEFAULT + FROM information_schema.columns + WHERE table_name = 'wptests_json_create' + AND column_name = 'payload'" + ); + + $this->assertCount( 1, $information_schema ); + $this->assertSame( 'payload', $information_schema[0]->COLUMN_NAME ); + $this->assertSame( 'json', $information_schema[0]->DATA_TYPE ); + $this->assertSame( 'json', $information_schema[0]->COLUMN_TYPE ); + $this->assertNull( $information_schema[0]->CHARACTER_SET_NAME ); + $this->assertNull( $information_schema[0]->COLLATION_NAME ); + $this->assertNull( $information_schema[0]->COLUMN_DEFAULT ); + } + + /** + * Tests CREATE TABLE preserves ON UPDATE CURRENT_TIMESTAMP metadata. + */ + public function test_create_table_on_update_current_timestamp_updates_mysql_metadata(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_on_update_create ( + id int NOT NULL, + updated timestamp NULL ON UPDATE CURRENT_TIMESTAMP + )' + ) + ); + + $this->assertSame( + array( + array( + 'sql' => "CREATE TABLE \"wptests_on_update_create\" (\n \"id\" integer NOT NULL,\n \"updated\" text\n)", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_on_update_create' ); + $this->assertSame( 'on update CURRENT_TIMESTAMP', $columns[1]['extra'] ); + + $this->install_information_schema_fixture( $driver ); + $describe = $driver->query( 'DESC wptests_on_update_create' ); + $this->assertSame( 'on update CURRENT_TIMESTAMP', $describe[1]->Extra ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_on_update_create' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' `updated` timestamp DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP', $create_table ); + } + + /** + * Tests ALTER TABLE preserves ON UPDATE CURRENT_TIMESTAMP metadata and pgsql trigger side effects. + */ + public function test_alter_table_on_update_current_timestamp_updates_mysql_metadata_and_postgresql_triggers(): void { + $connection = new class() extends WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection { + public function query( string $sql, array $params = array() ): PDOStatement { + if ( + 0 === strpos( $sql, 'CREATE OR REPLACE FUNCTION ' ) + || 0 === strpos( $sql, 'DROP TRIGGER IF EXISTS ' ) + || 0 === strpos( $sql, 'CREATE TRIGGER ' ) + || 0 === strpos( $sql, 'DROP FUNCTION IF EXISTS ' ) + ) { + return parent::query( 'SELECT 1 WHERE 0 = 1' ); + } + + return parent::query( $sql, $params ); + } + + public function get_driver_name(): string { + return 'pgsql'; + } + }; + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_on_update_alter ( + id int NOT NULL, + updated timestamp NULL + )' + ); + + $driver->query( 'ALTER TABLE wptests_on_update_alter ADD COLUMN touched timestamp NULL ON UPDATE CURRENT_TIMESTAMP' ); + + $this->assertSame( + 'ALTER TABLE "wptests_on_update_alter" ADD COLUMN "touched" text', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + $this->assertStringContainsString( 'CREATE OR REPLACE FUNCTION "__wp_pg_on_update_fn_', $driver->get_last_postgresql_queries()[1]['sql'] ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "__wp_pg_on_update_', $driver->get_last_postgresql_queries()[2]['sql'] ); + $this->assertStringContainsString( 'CREATE TRIGGER "__wp_pg_on_update_', $driver->get_last_postgresql_queries()[3]['sql'] ); + $this->assertStringContainsString( 'BEFORE UPDATE ON "wptests_on_update_alter"', $driver->get_last_postgresql_queries()[3]['sql'] ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_on_update_alter' ); + $this->assertSame( array( 'id', 'updated', 'touched' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( 'on update CURRENT_TIMESTAMP', $columns[2]['extra'] ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_on_update_alter' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' `touched` timestamp DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP', $create_table ); + + $driver->query( 'ALTER TABLE wptests_on_update_alter CHANGE COLUMN touched touched timestamp NULL' ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertSame( 'ALTER TABLE "wptests_on_update_alter" ALTER COLUMN "touched" TYPE text', $queries[0]['sql'] ); + $this->assertSame( 'ALTER TABLE "wptests_on_update_alter" ALTER COLUMN "touched" DROP NOT NULL', $queries[1]['sql'] ); + $this->assertSame( 'ALTER TABLE "wptests_on_update_alter" ALTER COLUMN "touched" DROP DEFAULT', $queries[2]['sql'] ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "__wp_pg_on_update_', $queries[3]['sql'] ); + $this->assertStringContainsString( 'DROP FUNCTION IF EXISTS "__wp_pg_on_update_fn_', $queries[4]['sql'] ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_on_update_alter' ); + $this->assertSame( '', $columns[2]['extra'] ); + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_on_update_alter' )[0]->{'Create Table'}; + $this->assertStringNotContainsString( ' `touched` timestamp DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP', $create_table ); + $this->assertStringContainsString( ' `touched` timestamp DEFAULT NULL', $create_table ); + } + + /** + * Tests generated timestamp defaults preserve MySQL metadata and SHOW CREATE shape. + */ + public function test_create_table_generated_timestamp_defaults_preserve_mysql_shape(): void { + $driver = $this->create_driver(); + $query = 'CREATE TABLE wptests_on_update_default ( + id int NOT NULL, + updated timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP + )'; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $statements = $translator->translate_schema( $query ); + + $this->assertCount( 1, $statements ); + $this->assertStringContainsString( + '"updated" text NOT NULL DEFAULT TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS\')', + $statements[0] + ); + + $metadata = $translator->extract_schema_metadata( $query, true ); + $this->assertSame( 'now()', $metadata[0]['columns'][1]['default'] ); + $this->assertSame( 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP', $metadata[0]['columns'][1]['extra'] ); + + $driver->store_mysql_schema_metadata( $query ); + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_on_update_default' )[0]->{'Create Table'}; + + $this->assertStringContainsString( ' `updated` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP', $create_table ); + + $fractional_query = 'CREATE TABLE wptests_fractional_timestamp_defaults ( + id int NOT NULL, + created timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated timestamp(3) NOT NULL DEFAULT (now(3)) ON UPDATE CURRENT_TIMESTAMP(3), + expires datetime(2) NOT NULL DEFAULT (DATE_ADD(NOW(2), INTERVAL 1 SECOND)) + )'; + + $statements = $translator->translate_schema( $fractional_query ); + + $this->assertCount( 1, $statements ); + $this->assertStringContainsString( + '"created" text NOT NULL DEFAULT LEFT(TO_CHAR(CURRENT_TIMESTAMP(6) AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS.US\'), 26)', + $statements[0] + ); + $this->assertStringContainsString( + '"updated" text NOT NULL DEFAULT LEFT(TO_CHAR(CURRENT_TIMESTAMP(3) AT TIME ZONE \'UTC\', \'YYYY-MM-DD HH24:MI:SS.US\'), 23)', + $statements[0] + ); + $this->assertStringContainsString( + '"expires" text NOT NULL DEFAULT (LEFT(TO_CHAR((CURRENT_TIMESTAMP(2) AT TIME ZONE \'UTC\' + (1 * INTERVAL \'1 second\')), \'YYYY-MM-DD HH24:MI:SS.US\'), 22))', + $statements[0] + ); + + $metadata = $translator->extract_schema_metadata( $fractional_query, true ); + $this->assertSame( 'CURRENT_TIMESTAMP(6)', $metadata[0]['columns'][1]['default'] ); + $this->assertSame( 'now(3)', $metadata[0]['columns'][2]['default'] ); + $this->assertSame( 'DATE_ADD(NOW(2), INTERVAL 1 SECOND)', $metadata[0]['columns'][3]['default'] ); + $this->assertSame( 'DEFAULT_GENERATED', $metadata[0]['columns'][1]['extra'] ); + $this->assertSame( 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP', $metadata[0]['columns'][2]['extra'] ); + $this->assertSame( 'DEFAULT_GENERATED', $metadata[0]['columns'][3]['extra'] ); + + $driver->store_mysql_schema_metadata( $fractional_query ); + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_fractional_timestamp_defaults' )[0]->{'Create Table'}; + + $this->assertStringContainsString( ' `created` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)', $create_table ); + $this->assertStringContainsString( ' `updated` timestamp(3) NOT NULL DEFAULT (now(3)) ON UPDATE CURRENT_TIMESTAMP', $create_table ); + $this->assertStringContainsString( ' `expires` datetime(2) NOT NULL DEFAULT (DATE_ADD(NOW(2), INTERVAL 1 SECOND))', $create_table ); + } + + /** + * Tests generated default expressions preserve SQLite-compatible MySQL metadata. + */ + public function test_create_table_generated_default_expressions_preserve_mysql_shape(): void { + $driver = $this->create_driver(); + $query = "CREATE TABLE wptests_generated_defaults ( + id int NOT NULL, + col1 int NOT NULL DEFAULT (1 + 2), + col2 datetime NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 1 YEAR)), + col3 varchar(255) NOT NULL DEFAULT (CONCAT('a', 'b')) + )"; + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $statements = $translator->translate_schema( $query ); + + $this->assertCount( 1, $statements ); + $this->assertStringContainsString( + '"col1" integer NOT NULL DEFAULT (1 + 2)', + $statements[0] + ); + $this->assertStringContainsString( + '"col2" text NOT NULL DEFAULT (TO_CHAR((CURRENT_TIMESTAMP AT TIME ZONE \'UTC\' + (1 * INTERVAL \'1 year\')), \'YYYY-MM-DD HH24:MI:SS\'))', + $statements[0] + ); + $this->assertStringContainsString( + '"col3" varchar(255) NOT NULL DEFAULT ((CAST(\'a\' AS text) || CAST(\'b\' AS text)))', + $statements[0] + ); + + $metadata = $translator->extract_schema_metadata( $query, true ); + $this->assertSame( '1 + 2', $metadata[0]['columns'][1]['default'] ); + $this->assertSame( 'DATE_ADD(NOW(), INTERVAL 1 YEAR)', $metadata[0]['columns'][2]['default'] ); + $this->assertSame( "CONCAT('a', 'b')", $metadata[0]['columns'][3]['default'] ); + $this->assertSame( 'DEFAULT_GENERATED', $metadata[0]['columns'][1]['extra'] ); + $this->assertSame( 'DEFAULT_GENERATED', $metadata[0]['columns'][2]['extra'] ); + $this->assertSame( 'DEFAULT_GENERATED', $metadata[0]['columns'][3]['extra'] ); + + $driver->store_mysql_schema_metadata( $query ); + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_generated_defaults' )[0]->{'Create Table'}; + + $this->assertStringContainsString( ' `col1` int NOT NULL DEFAULT (1 + 2)', $create_table ); + $this->assertStringContainsString( ' `col2` datetime NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 1 YEAR))', $create_table ); + $this->assertStringContainsString( " `col3` varchar(255) NOT NULL DEFAULT (CONCAT('a', 'b'))", $create_table ); + } + + /** + * Tests ALTER ADD COLUMN generated default expressions use the CREATE TABLE fragment translator. + */ + public function test_alter_table_add_generated_default_expressions_updates_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_generated_default_alter ( + id int NOT NULL + )' + ); + + $driver->query( + "ALTER TABLE wptests_generated_default_alter + ADD COLUMN col1 int NOT NULL DEFAULT (1 + 2), + ADD COLUMN col2 datetime NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 1 YEAR)), + ADD COLUMN col3 varchar(255) NOT NULL DEFAULT (CONCAT('a', 'b'))" + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_generated_default_alter" ADD COLUMN "col1" integer NOT NULL DEFAULT (1 + 2)', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_generated_default_alter" ADD COLUMN "col2" text NOT NULL DEFAULT (TO_CHAR((CURRENT_TIMESTAMP AT TIME ZONE \'UTC\' + (1 * INTERVAL \'1 year\')), \'YYYY-MM-DD HH24:MI:SS\'))', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_generated_default_alter" ADD COLUMN "col3" varchar(255) NOT NULL DEFAULT ((CAST(\'a\' AS text) || CAST(\'b\' AS text)))', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_generated_default_alter' ); + $this->assertSame( array( 'id', 'col1', 'col2', 'col3' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( '1 + 2', $columns[1]['column_default'] ); + $this->assertSame( 'DATE_ADD(NOW(), INTERVAL 1 YEAR)', $columns[2]['column_default'] ); + $this->assertSame( "CONCAT('a', 'b')", $columns[3]['column_default'] ); + $this->assertSame( 'DEFAULT_GENERATED', $columns[1]['extra'] ); + $this->assertSame( 'DEFAULT_GENERATED', $columns[2]['extra'] ); + $this->assertSame( 'DEFAULT_GENERATED', $columns[3]['extra'] ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_generated_default_alter' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' `col1` int NOT NULL DEFAULT (1 + 2)', $create_table ); + $this->assertStringContainsString( ' `col2` datetime NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 1 YEAR))', $create_table ); + $this->assertStringContainsString( " `col3` varchar(255) NOT NULL DEFAULT (CONCAT('a', 'b'))", $create_table ); + } + + /** + * Tests PostgreSQL trigger DDL for ON UPDATE CURRENT_TIMESTAMP columns. + */ + public function test_on_update_current_timestamp_trigger_statements_use_postgresql_row_trigger(): void { + $connection = new class( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ) extends WP_PostgreSQL_Connection { + public function get_driver_name(): string { + return 'pgsql'; + } + }; + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $get_create_statements = Closure::bind( + function (): array { + return $this->get_postgresql_on_update_current_timestamp_create_statements( 'public', 'wptests_triggered', 'updated' ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + $get_drop_statements = Closure::bind( + function (): array { + return $this->get_postgresql_on_update_current_timestamp_drop_statements( 'public', 'wptests_triggered', 'updated' ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + $create_statements = $get_create_statements(); + $this->assertCount( 3, $create_statements ); + $this->assertStringContainsString( 'CREATE OR REPLACE FUNCTION ', $create_statements[0] ); + $this->assertStringContainsString( '__wp_pg_on_update_fn_', $create_statements[0] ); + $this->assertStringContainsString( 'NEW."updated" IS NOT DISTINCT FROM OLD."updated"', $create_statements[0] ); + $this->assertStringContainsString( 'to_jsonb(NEW) - \'updated\' IS DISTINCT FROM to_jsonb(OLD) - \'updated\'', $create_statements[0] ); + $this->assertStringContainsString( "TO_CHAR(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')", $create_statements[0] ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "__wp_pg_on_update_', $create_statements[1] ); + $this->assertStringContainsString( 'CREATE TRIGGER "__wp_pg_on_update_', $create_statements[2] ); + $this->assertStringContainsString( 'BEFORE UPDATE ON "wptests_triggered"', $create_statements[2] ); + + $drop_statements = $get_drop_statements(); + $this->assertCount( 2, $drop_statements ); + $this->assertStringContainsString( 'DROP TRIGGER IF EXISTS "__wp_pg_on_update_', $drop_statements[0] ); + $this->assertStringContainsString( 'DROP FUNCTION IF EXISTS ', $drop_statements[1] ); + $this->assertStringContainsString( '__wp_pg_on_update_fn_', $drop_statements[1] ); + } + + /** + * Tests CREATE TABLE routes LONG-prefixed MySQL aliases through the DDL translator. + */ + public function test_create_table_long_aliases_use_sqlite_compatible_metadata(): void { + $driver = $this->create_driver(); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_long_alias_create ( + notes LONG VARCHAR, + raw_data LONG VARBINARY + )' + ) + ); + + $this->assertSame( + array( + array( + 'sql' => "CREATE TABLE \"wptests_long_alias_create\" (\n \"notes\" text,\n \"raw_data\" bytea\n)", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_long_alias_create' ); + $this->assertSame( array( 'mediumtext', 'mediumblob' ), array_column( $columns, 'column_type' ) ); + $this->assertSame( 'utf8mb4', $columns[0]['character_set_name'] ); + $this->assertSame( 'utf8mb4_unicode_ci', $columns[0]['collation_name'] ); + $this->assertNull( $columns[1]['character_set_name'] ); + $this->assertNull( $columns[1]['collation_name'] ); + } + + /** + * Tests ALTER TABLE ADD accepts MySQL data type aliases. + */ + public function test_alter_table_add_accepts_mysql_data_type_aliases(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alias_alter ( + id int(11) NOT NULL, + PRIMARY KEY (id) + )' + ); + + $driver->query( + 'ALTER TABLE wptests_alias_alter + ADD flags BIT(10), + ADD enabled BOOL NOT NULL DEFAULT 0, + ADD toggled BOOLEAN, + ADD amount DEC(10,2), + ADD fixed_value FIXED(8,3), + ADD real_value REAL, + ADD payload JSON DEFAULT NULL, + ADD notes LONG VARCHAR, + ADD raw_data LONG VARBINARY' + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "flags" integer', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "enabled" integer NOT NULL DEFAULT \'0\'', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "toggled" integer', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "amount" numeric(10,2)', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "fixed_value" numeric(8,3)', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "real_value" double precision', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "payload" text DEFAULT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "notes" text', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alias_alter" ADD COLUMN "raw_data" bytea', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_alias_alter' ); + $this->assertSame( + array( 'int(11)', 'bit(10)', 'bool', 'boolean', 'dec(10,2)', 'fixed(8,3)', 'real', 'json', 'mediumtext', 'mediumblob' ), + array_column( $columns, 'column_type' ) + ); + $this->assertNull( $columns[7]['character_set_name'] ); + $this->assertNull( $columns[7]['collation_name'] ); + $this->assertSame( 'utf8mb4', $columns[8]['character_set_name'] ); + $this->assertSame( 'utf8mb4_unicode_ci', $columns[8]['collation_name'] ); + $this->assertNull( $columns[9]['character_set_name'] ); + $this->assertNull( $columns[9]['collation_name'] ); + } + + /** + * Tests ALTER TABLE accepts current database-qualified targets and updates metadata. + */ + public function test_alter_table_accepts_current_database_qualified_targets_and_updates_metadata(): void { + $driver = $this->create_driver( 'wp' ); + + $driver->query( 'CREATE TABLE wp.t (id INT PRIMARY KEY)' ); + + $driver->query( 'ALTER TABLE wp.t ADD COLUMN name VARCHAR(255)' ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "t" ADD COLUMN "name" varchar(255)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( "ALTER TABLE `wp`.`t` ADD COLUMN `slug` VARCHAR(191) DEFAULT 'x'" ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "t" ADD COLUMN "slug" varchar(191) DEFAULT \'x\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 't' ); + + $this->assertSame( array( 'id', 'name', 'slug' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( 'varchar(255)', $columns[1]['column_type'] ); + $this->assertSame( 'varchar(191)', $columns[2]['column_type'] ); + $this->assertSame( 'x', $columns[2]['column_default'] ); + } + + /** + * Tests ALTER TABLE resolves existing column references case-insensitively. + */ + public function test_alter_table_resolves_existing_column_names_case_insensitively(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_case_alter ( + value int DEFAULT 1, + parent_id int, + obsolete int, + PRIMARY KEY (value) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_case_parent ( + id int NOT NULL, + PRIMARY KEY (id) + )' + ); + + $driver->query( 'ALTER TABLE wptests_case_alter CHANGE COLUMN VaLuE renamed_value bigint NOT NULL DEFAULT 2' ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_case_alter" RENAME COLUMN "value" TO "renamed_value"', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_case_alter" ALTER COLUMN "renamed_value" TYPE bigint', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_case_alter" ALTER COLUMN "renamed_value" SET NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_case_alter" ALTER COLUMN "renamed_value" SET DEFAULT \'2\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( 'ALTER TABLE wptests_case_alter ALTER COLUMN ReNaMeD_Value DROP DEFAULT' ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_case_alter" ALTER COLUMN "renamed_value" DROP DEFAULT', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( 'ALTER TABLE wptests_case_alter ADD INDEX mixed_case_idx (ReNaMeD_Value DESC)' ); + $this->assertSame( + array( + array( + 'sql' => 'CREATE INDEX "wptests_case_alter__mixed_case_idx" ON "wptests_case_alter" ("renamed_value" DESC)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( 'ALTER TABLE wptests_case_alter ADD CONSTRAINT parent_fk FOREIGN KEY (PaReNt_Id) REFERENCES wptests_case_parent (ID)' ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_case_alter" ADD CONSTRAINT "parent_fk" FOREIGN KEY ("parent_id") REFERENCES "wptests_case_parent" ("id")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( 'ALTER TABLE wptests_case_alter RENAME COLUMN ReNaMeD_Value TO final_value' ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_case_alter" RENAME COLUMN "renamed_value" TO "final_value"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( 'ALTER TABLE wptests_case_alter DROP COLUMN ObSoLeTe' ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_case_alter" DROP COLUMN "obsolete"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_case_alter' ); + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_case_alter' ); + $foreign_keys = $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_case_alter' ); + + $this->assertSame( array( 'final_value', 'parent_id' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( array( 'PRIMARY', 'mixed_case_idx' ), array_values( array_unique( array_column( $indexes, 'key_name' ) ) ) ); + $this->assertSame( array( 'final_value', 'final_value' ), array_column( $indexes, 'column_name' ) ); + $this->assertSame( array( 'parent_id' ), array_column( $foreign_keys, 'column_name' ) ); + $this->assertSame( array( 'id' ), array_column( $foreign_keys, 'referenced_column_name' ) ); + } + + /** + * Tests unsupported CREATE TABLE column attributes fail before backend execution. + */ + public function test_create_table_unsupported_column_attributes_fail_closed_before_backend_execution(): void { + $queries = array( + 'CREATE TABLE wptests_bad_column_attribute (base int, generated_value int GENERATED ALWAYS AS (base + 1) STORED)', + 'CREATE TABLE wptests_bad_column_attribute (base int, generated_value int GENERATED ALWAYS AS (base + 1) VIRTUAL)', + 'CREATE TABLE wptests_bad_column_attribute (id int INVISIBLE)', + 'CREATE TABLE wptests_bad_column_attribute (id int VISIBLE)', + 'CREATE TABLE wptests_bad_column_attribute (id int COLUMN_FORMAT FIXED)', + 'CREATE TABLE wptests_bad_column_attribute (id int STORAGE DISK)', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported CREATE TABLE column attribute to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported CREATE TABLE column attribute.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests unsupported ALTER TABLE column attributes fail before backend execution. + */ + public function test_alter_table_unsupported_column_attributes_fail_closed_before_backend_execution(): void { + $queries = array( + 'ALTER TABLE wptests_bad_column_attribute ADD COLUMN generated_value int GENERATED ALWAYS AS (base + 1) STORED', + 'ALTER TABLE wptests_bad_column_attribute ADD COLUMN hidden_value int INVISIBLE', + 'ALTER TABLE wptests_bad_column_attribute ADD COLUMN stored_value int COLUMN_FORMAT FIXED', + 'ALTER TABLE wptests_bad_column_attribute ADD COLUMN disk_value int STORAGE DISK', + 'ALTER TABLE wptests_bad_column_attribute MODIFY COLUMN hidden_value int INVISIBLE', + 'ALTER TABLE wptests_bad_column_attribute CHANGE COLUMN hidden_value hidden_value int INVISIBLE', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER TABLE column attribute to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests ALTER TABLE rejects non-current schema-qualified targets before backend execution. + */ + public function test_alter_table_rejects_non_current_schema_qualified_targets(): void { + $queries = array( + 'ALTER TABLE other_db.t ADD COLUMN name VARCHAR(255)' => 'Unsupported ALTER TABLE statement.', + 'ALTER TABLE information_schema.tables ADD COLUMN name VARCHAR(255)' => 'Unsupported information_schema query.', + ); + + foreach ( $queries as $query => $message ) { + $driver = $this->create_driver( 'wp' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER TABLE target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + $driver = $this->create_driver( 'wp' ); + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + try { + $driver->query( 'ALTER TABLE tables ADD COLUMN name VARCHAR(255)' ); + $this->fail( 'Expected information_schema ALTER TABLE target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests CREATE TABLE inline constraints update PostgreSQL and MySQL-facing metadata. + */ + public function test_create_table_inline_constraints_update_postgresql_and_show_create_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_inline_parent (id int(11) PRIMARY KEY) DEFAULT CHARACTER SET utf8mb4' ); + $driver->query( + 'CREATE TABLE wptests_inline_child ( + id int(11) PRIMARY KEY, + slug varchar(100) UNIQUE, + score int CHECK (score > 0), + parent_id int(11) REFERENCES wptests_inline_parent(id) ON DELETE CASCADE ON UPDATE SET NULL + ) DEFAULT CHARACTER SET utf8mb4' + ); + $this->assertStringContainsString( + 'CONSTRAINT "wptests_inline_child_chk_1" CHECK (score > 0)', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( + array( + array( + 'key_name' => 'PRIMARY', + 'seq_in_index' => '1', + 'column_name' => 'id', + 'non_unique' => '0', + 'index_type' => 'BTREE', + 'collation' => 'A', + 'sub_part' => null, + 'nullable' => '', + ), + array( + 'key_name' => 'slug', + 'seq_in_index' => '1', + 'column_name' => 'slug', + 'non_unique' => '0', + 'index_type' => 'BTREE', + 'collation' => 'A', + 'sub_part' => null, + 'nullable' => 'YES', + ), + ), + $this->get_mysql_index_metadata_rows( $driver, 'wptests_inline_child' ) + ); + + $this->assertSame( + array( + array( + 'constraint_name' => 'wptests_inline_child_ibfk_1', + 'seq_in_index' => '1', + 'column_name' => 'parent_id', + 'referenced_table_name' => 'wptests_inline_parent', + 'referenced_column_name' => 'id', + 'update_rule' => 'SET NULL', + 'delete_rule' => 'CASCADE', + ), + ), + $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_inline_child' ) + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_inline_child' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' PRIMARY KEY (`id`)', $create_table ); + $this->assertStringContainsString( ' UNIQUE KEY `slug` (`slug`)', $create_table ); + $this->assertStringContainsString( + ' CONSTRAINT `wptests_inline_child_ibfk_1` FOREIGN KEY (`parent_id`) REFERENCES `wptests_inline_parent` (`id`) ON DELETE CASCADE ON UPDATE SET NULL', + $create_table + ); + } + + /** + * Tests ALTER TABLE ADD COLUMN inline constraints update PostgreSQL and MySQL-facing metadata. + */ + public function test_alter_table_add_column_inline_constraints_update_postgresql_and_show_create_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_inline_child ( + existing_parent_id int(11) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $driver->query( + 'ALTER TABLE wptests_alter_inline_child + ADD FOREIGN KEY (existing_parent_id) REFERENCES wptests_inline_parent(id), + ADD COLUMN id int(11) PRIMARY KEY, + ADD COLUMN slug varchar(100) UNIQUE, + ADD COLUMN parent_id int(11) REFERENCES wptests_inline_parent(id) ON DELETE CASCADE ON UPDATE SET NULL' + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_inline_child" ADD CONSTRAINT "wptests_alter_inline_child_ibfk_1" FOREIGN KEY ("existing_parent_id") REFERENCES "wptests_inline_parent" ("id")', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_inline_child" ADD COLUMN "id" integer PRIMARY KEY', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_inline_child" ADD COLUMN "slug" varchar(100) UNIQUE', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_inline_child" ADD COLUMN "parent_id" integer CONSTRAINT "wptests_alter_inline_child_ibfk_2" REFERENCES "wptests_inline_parent" ("id") ON DELETE CASCADE ON UPDATE SET NULL', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( + array( + array( + 'key_name' => 'PRIMARY', + 'seq_in_index' => '1', + 'column_name' => 'id', + 'non_unique' => '0', + 'index_type' => 'BTREE', + 'collation' => 'A', + 'sub_part' => null, + 'nullable' => '', + ), + array( + 'key_name' => 'slug', + 'seq_in_index' => '1', + 'column_name' => 'slug', + 'non_unique' => '0', + 'index_type' => 'BTREE', + 'collation' => 'A', + 'sub_part' => null, + 'nullable' => 'YES', + ), + ), + $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_inline_child' ) + ); + + $this->assertSame( + array( + array( + 'constraint_name' => 'wptests_alter_inline_child_ibfk_1', + 'seq_in_index' => '1', + 'column_name' => 'existing_parent_id', + 'referenced_table_name' => 'wptests_inline_parent', + 'referenced_column_name' => 'id', + 'update_rule' => 'NO ACTION', + 'delete_rule' => 'NO ACTION', + ), + array( + 'constraint_name' => 'wptests_alter_inline_child_ibfk_2', + 'seq_in_index' => '1', + 'column_name' => 'parent_id', + 'referenced_table_name' => 'wptests_inline_parent', + 'referenced_column_name' => 'id', + 'update_rule' => 'SET NULL', + 'delete_rule' => 'CASCADE', + ), + ), + $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_alter_inline_child' ) + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_alter_inline_child' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' PRIMARY KEY (`id`)', $create_table ); + $this->assertStringContainsString( ' UNIQUE KEY `slug` (`slug`)', $create_table ); + $this->assertStringContainsString( + ' CONSTRAINT `wptests_alter_inline_child_ibfk_2` FOREIGN KEY (`parent_id`) REFERENCES `wptests_inline_parent` (`id`) ON DELETE CASCADE ON UPDATE SET NULL', + $create_table + ); + } + + /** + * Tests ALTER TABLE ADD COLUMN inline CHECK constraints are translated. + */ + public function test_alter_table_add_column_supports_inline_check_constraint(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_alter_inline_check (id int(11))' ); + + $driver->query( 'ALTER TABLE wptests_alter_inline_check ADD COLUMN score int CHECK (score > 0)' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_inline_check" ADD COLUMN "score" integer CONSTRAINT "wptests_alter_inline_check_chk_1" CHECK (score > 0)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_alter_inline_check' ); + $this->assertSame( array( 'id', 'score' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( 'int(11)', $columns[0]['column_type'] ); + $this->assertSame( 'int', $columns[1]['column_type'] ); + } + + /** + * Tests ALTER TABLE ADD COLUMN NOT ENFORCED CHECK constraints are metadata-only. + */ + public function test_alter_table_add_column_supports_not_enforced_inline_check_constraint(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_alter_inline_check (id int(11))' ); + + $driver->query( 'ALTER TABLE wptests_alter_inline_check ADD COLUMN score int CHECK (score > 0) NOT ENFORCED' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_inline_check" ADD COLUMN "score" integer', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( + array( + array( + 'constraint_name' => 'wptests_alter_inline_check_chk_1', + 'check_clause' => 'score > 0', + 'enforced' => 'NO', + ), + ), + $this->get_mysql_check_metadata_rows( $driver, 'wptests_alter_inline_check' ) + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_alter_inline_check' )[0]->{'Create Table'}; + $this->assertStringContainsString( + ' CONSTRAINT `wptests_alter_inline_check_chk_1` CHECK (score > 0) /*!80016 NOT ENFORCED */', + $create_table + ); + } + + /** + * Tests ALTER TABLE ADD FOREIGN KEY forms update PostgreSQL and SHOW CREATE metadata. + */ + public function test_alter_table_add_foreign_key_updates_postgresql_and_show_create_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_fk_parent (id int NOT NULL, code int NOT NULL, PRIMARY KEY (id, code))' ); + $driver->query( 'CREATE TABLE wptests_fk_child (id int, code int)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_fk_child (id int, code int)' ); + + $driver->query( 'ALTER TABLE wptests_fk_child ADD FOREIGN KEY (id) REFERENCES wptests_fk_parent (id)' ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_fk_child" ADD CONSTRAINT "wptests_fk_child_ibfk_1" FOREIGN KEY ("id") REFERENCES "wptests_fk_parent" ("id")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( + 'ALTER TABLE wptests_fk_child + ADD CONSTRAINT fk_child_parent FOREIGN KEY (id, code) + REFERENCES wptests_fk_parent (id, code) + ON DELETE CASCADE ON UPDATE SET NULL' + ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_fk_child" ADD CONSTRAINT "fk_child_parent" FOREIGN KEY ("id", "code") REFERENCES "wptests_fk_parent" ("id", "code") ON DELETE CASCADE ON UPDATE SET NULL', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( + array( + array( + 'constraint_name' => 'fk_child_parent', + 'seq_in_index' => '1', + 'column_name' => 'id', + 'referenced_table_name' => 'wptests_fk_parent', + 'referenced_column_name' => 'id', + 'update_rule' => 'SET NULL', + 'delete_rule' => 'CASCADE', + ), + array( + 'constraint_name' => 'fk_child_parent', + 'seq_in_index' => '2', + 'column_name' => 'code', + 'referenced_table_name' => 'wptests_fk_parent', + 'referenced_column_name' => 'code', + 'update_rule' => 'SET NULL', + 'delete_rule' => 'CASCADE', + ), + array( + 'constraint_name' => 'wptests_fk_child_ibfk_1', + 'seq_in_index' => '1', + 'column_name' => 'id', + 'referenced_table_name' => 'wptests_fk_parent', + 'referenced_column_name' => 'id', + 'update_rule' => 'NO ACTION', + 'delete_rule' => 'NO ACTION', + ), + ), + $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_fk_child' ) + ); + + $show_create = $driver->query( 'SHOW CREATE TABLE wptests_fk_child' ); + $this->assertStringContainsString( + ' CONSTRAINT `fk_child_parent` FOREIGN KEY (`id`, `code`) REFERENCES `wptests_fk_parent` (`id`, `code`) ON DELETE CASCADE ON UPDATE SET NULL', + $show_create[0]->{'Create Table'} + ); + $this->assertStringContainsString( + ' CONSTRAINT `wptests_fk_child_ibfk_1` FOREIGN KEY (`id`) REFERENCES `wptests_fk_parent` (`id`)', + $show_create[0]->{'Create Table'} + ); + $this->assertStringContainsString( + WP_PostgreSQL_Driver::MYSQL_FOREIGN_KEY_METADATA_TABLE, + $driver->get_last_postgresql_queries()[2]['sql'] + ); + } + + /** + * Tests ALTER TABLE DROP FOREIGN KEY updates PostgreSQL and SHOW CREATE metadata. + */ + public function test_alter_table_drop_foreign_key_updates_postgresql_and_show_create_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->query( 'CREATE TABLE wptests_fk_drop_parent (id int NOT NULL, PRIMARY KEY (id))' ); + $driver->query( 'CREATE TABLE wptests_fk_drop_child (id int)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_fk_drop_child (id int)' ); + $driver->query( 'ALTER TABLE wptests_fk_drop_child ADD FOREIGN KEY (id) REFERENCES wptests_fk_drop_parent (id)' ); + $driver->query( 'ALTER TABLE wptests_fk_drop_child ADD CONSTRAINT fk_drop_parent FOREIGN KEY (id) REFERENCES wptests_fk_drop_parent (id) ON DELETE CASCADE' ); + + $driver->query( 'ALTER TABLE wptests_fk_drop_child DROP FOREIGN KEY wptests_fk_drop_child_ibfk_1' ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_fk_drop_child" DROP CONSTRAINT "wptests_fk_drop_child_ibfk_1"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( + array( 'fk_drop_parent' ), + array_values( array_unique( array_column( $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_fk_drop_child' ), 'constraint_name' ) ) ) + ); + + $show_create = $driver->query( 'SHOW CREATE TABLE wptests_fk_drop_child' ); + $this->assertStringContainsString( 'CONSTRAINT `fk_drop_parent` FOREIGN KEY', $show_create[0]->{'Create Table'} ); + $this->assertStringNotContainsString( 'wptests_fk_drop_child_ibfk_1', $show_create[0]->{'Create Table'} ); + + $driver->query( 'ALTER TABLE wptests_fk_drop_child DROP FOREIGN KEY fk_drop_parent' ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_fk_drop_child" DROP CONSTRAINT "fk_drop_parent"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( array(), $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_fk_drop_child' ) ); + + $show_create = $driver->query( 'SHOW CREATE TABLE wptests_fk_drop_child' ); + $this->assertStringNotContainsString( 'FOREIGN KEY', $show_create[0]->{'Create Table'} ); + } + + /** + * Tests ALTER TABLE DROP FOREIGN KEY fails before backend execution when metadata has no matching constraint. + */ + public function test_alter_table_drop_foreign_key_fails_for_missing_constraint_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_fk_drop_missing_child ( + id int NOT NULL + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_fk_drop_missing_child DROP FOREIGN KEY missing_fk' ); + $this->fail( 'Expected unsupported ALTER TABLE exception.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + } + + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests ALTER TABLE ADD FULLTEXT/SPATIAL indexes update metadata without PostgreSQL index DDL. + */ + public function test_alter_table_add_fulltext_and_spatial_indexes_are_metadata_only(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_alter_search_geo ( + id int NOT NULL, + body longtext NOT NULL, + shape geometry NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_search_geo ( + id int NOT NULL, + body longtext NOT NULL, + shape geometry NOT NULL + )' + ); + + $this->assertSame( + 0, + $driver->query( + 'ALTER TABLE wptests_alter_search_geo + ADD FULLTEXT KEY body_fulltext (body), + ADD SPATIAL INDEX shape_spatial (shape)' + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_search_geo' ); + $this->assertSame( array( 'body_fulltext', 'shape_spatial' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( array( 'FULLTEXT', 'SPATIAL' ), array_column( $indexes, 'index_type' ) ); + $this->assertNull( $indexes[0]['sub_part'] ); + $this->assertSame( '32', $indexes[1]['sub_part'] ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_alter_search_geo' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' SPATIAL KEY `shape_spatial` (`shape`(32))', $create_table ); + $this->assertStringContainsString( ' FULLTEXT KEY `body_fulltext` (`body`)', $create_table ); + } + + /** + * Tests ALTER TABLE metadata-only index options are accepted like SQLite. + */ + public function test_alter_table_add_metadata_only_index_options_update_show_create_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_alter_search_geo_options ( + id int NOT NULL, + body longtext NOT NULL, + shape geometry NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_search_geo_options ( + id int NOT NULL, + body longtext NOT NULL, + shape geometry NOT NULL + )' + ); + + $this->assertSame( + 0, + $driver->query( + 'ALTER TABLE wptests_alter_search_geo_options + ADD FULLTEXT KEY body_fulltext (body) COMMENT "Search docs" INVISIBLE, + ADD FULLTEXT KEY parser_fulltext (body) WITH PARSER ngram, + ADD SPATIAL INDEX shape_spatial (shape) COMMENT "Shape lookup" KEY_BLOCK_SIZE=8' + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_search_geo_options' ); + $this->assertSame( array( 'body_fulltext', 'parser_fulltext', 'shape_spatial' ), array_column( $indexes, 'key_name' ) ); + $this->assertSame( array( 'FULLTEXT', 'FULLTEXT', 'SPATIAL' ), array_column( $indexes, 'index_type' ) ); + $this->assertSame( '32', $indexes[2]['sub_part'] ); + + $comments = $driver->get_connection()->query( + sprintf( + 'SELECT key_name, index_comment + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY index_ordinal', + $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_INDEX_METADATA_TABLE ) + ), + array( 'public', 'wptests_alter_search_geo_options' ) + )->fetchAll( PDO::FETCH_KEY_PAIR ); + $this->assertSame( + array( + 'body_fulltext' => 'Search docs', + 'parser_fulltext' => '', + 'shape_spatial' => 'Shape lookup', + ), + $comments + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_alter_search_geo_options' )[0]->{'Create Table'}; + $this->assertStringContainsString( " SPATIAL KEY `shape_spatial` (`shape`(32)) COMMENT 'Shape lookup'", $create_table ); + $this->assertStringContainsString( " FULLTEXT KEY `body_fulltext` (`body`) COMMENT 'Search docs'", $create_table ); + $this->assertStringContainsString( ' FULLTEXT KEY `parser_fulltext` (`body`)', $create_table ); + $this->assertStringNotContainsString( 'WITH PARSER', $create_table ); + } + + /** + * Tests standalone ALTER TABLE online-DDL options are compatibility no-ops. + */ + public function test_alter_table_online_ddl_options_are_standalone_noops(): void { + $queries = array( + 'ALTER TABLE wptests_alter_online_options ALGORITHM=DEFAULT', + 'ALTER TABLE wptests_alter_online_options ALGORITHM COPY', + 'ALTER TABLE wptests_alter_online_options LOCK=NONE', + 'ALTER TABLE wptests_alter_online_options LOCK SHARED', + 'ALTER TABLE wptests_alter_online_options LOCK=EXCLUSIVE', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_alter_online_options (id int NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_online_options ( + id int NOT NULL + )' + ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array( 'id' ), array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_alter_online_options' ), 'column_name' ), $query ); + } + } + + /** + * Tests ALTER TABLE online-DDL options are no-ops inside supported batches. + */ + public function test_alter_table_online_ddl_options_are_noops_in_supported_batches(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_alter_online_batch (id int NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_online_batch ( + id int NOT NULL + )' + ); + + $driver->query( + 'ALTER TABLE wptests_alter_online_batch + ALGORITHM=INPLACE, + ADD COLUMN slug varchar(20) NOT NULL DEFAULT "", + LOCK=NONE' + ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_online_batch" ADD COLUMN "slug" varchar(20) NOT NULL DEFAULT \'\'', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_alter_online_batch' ); + $this->assertSame( array( 'id', 'slug' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( '', $columns[1]['column_default'] ); + } + + /** + * Tests unsupported ALTER TABLE online-DDL option values fail closed. + */ + public function test_alter_table_online_ddl_options_fail_closed_for_unsupported_values(): void { + $queries = array( + 'ALTER TABLE wptests_alter_online_invalid ALGORITHM=INSTANT', + 'ALTER TABLE wptests_alter_online_invalid LOCK=UNKNOWN', + 'ALTER TABLE wptests_alter_online_invalid ADD COLUMN slug varchar(20), ALGORITHM=INSTANT', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_alter_online_invalid (id int NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_online_invalid ( + id int NOT NULL + )' + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER TABLE online-DDL option to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array( 'id' ), array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_alter_online_invalid' ), 'column_name' ), $query ); + } + } + } + + /** + * Tests ALTER TABLE online-DDL options remain unsupported for FULLTEXT/SPATIAL batches. + */ + public function test_alter_table_online_ddl_options_exclude_fulltext_and_spatial_batches(): void { + $queries = array( + 'ALTER TABLE wptests_alter_online_metadata_index ADD FULLTEXT KEY body_fulltext (body), ALGORITHM=INPLACE', + 'ALTER TABLE wptests_alter_online_metadata_index ADD SPATIAL INDEX shape_spatial (shape), LOCK=NONE', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wptests_alter_online_metadata_index ( + id int NOT NULL, + body longtext NOT NULL, + shape geometry NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_online_metadata_index ( + id int NOT NULL, + body longtext NOT NULL, + shape geometry NOT NULL + )' + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected ALTER TABLE online-DDL option with FULLTEXT/SPATIAL to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $this->get_mysql_index_metadata_rows( $driver, 'wptests_alter_online_metadata_index' ), $query ); + } + } + } + + /** + * Tests ALTER TABLE DROP COLUMN removes column and dependent index metadata. + */ + public function test_alter_table_drop_column_updates_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_plugin_drop ( + id int(11) NOT NULL, + status varchar(20) DEFAULT 'draft', + obsolete varchar(20) DEFAULT NULL, + PRIMARY KEY (id), + KEY obsolete_idx (obsolete) + )" + ); + + $driver->query( 'ALTER TABLE wptests_plugin_drop DROP obsolete' ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_plugin_drop" DROP COLUMN "obsolete"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_drop' ); + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_plugin_drop' ); + + $this->assertSame( array( 'id', 'status' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( array( 'PRIMARY' ), array_values( array_unique( array_column( $indexes, 'key_name' ) ) ) ); + } + + /** + * Tests ALTER TABLE DROP COLUMN accepts MySQL RESTRICT/CASCADE suffixes as no-ops. + */ + public function test_alter_table_drop_column_suffixes_are_supported_noops(): void { + foreach ( array( 'RESTRICT', 'CASCADE' ) as $suffix ) { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_plugin_drop ( + id int(11) NOT NULL, + status varchar(20) DEFAULT 'draft', + obsolete varchar(20) DEFAULT NULL, + PRIMARY KEY (id), + KEY obsolete_idx (obsolete) + )" + ); + + $driver->query( 'ALTER TABLE wptests_plugin_drop DROP COLUMN obsolete ' . $suffix ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_plugin_drop" DROP COLUMN "obsolete"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries(), + $suffix + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_drop' ); + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_plugin_drop' ); + + $this->assertSame( array( 'id', 'status' ), array_column( $columns, 'column_name' ), $suffix ); + $this->assertSame( array( 'PRIMARY' ), array_values( array_unique( array_column( $indexes, 'key_name' ) ) ), $suffix ); + } + } + + /** + * Tests ALTER TABLE DROP COLUMN preserves surviving composite secondary index parts. + */ + public function test_alter_table_drop_column_preserves_composite_secondary_index_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_plugin_drop_composite ( + id int(11) NOT NULL, + first_key varchar(20) NOT NULL, + obsolete varchar(20) DEFAULT NULL, + last_key varchar(20) NOT NULL, + PRIMARY KEY (id), + KEY combo_idx (first_key, obsolete, last_key), + KEY obsolete_idx (obsolete) + )' + ); + + $driver->query( 'ALTER TABLE wptests_plugin_drop_composite DROP COLUMN obsolete' ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_plugin_drop_composite' ); + $composite_index = array_values( + array_filter( + $indexes, + static function ( $row ): bool { + return 'combo_idx' === $row['key_name']; + } + ) + ); + + $this->assertSame( array( 'PRIMARY', 'combo_idx' ), array_values( array_unique( array_column( $indexes, 'key_name' ) ) ) ); + $this->assertSame( array( 'first_key', 'last_key' ), array_column( $composite_index, 'column_name' ) ); + $this->assertSame( array( '1', '2' ), array_map( 'strval', array_column( $composite_index, 'seq_in_index' ) ) ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_plugin_drop_composite' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' KEY `combo_idx` (`first_key`, `last_key`)', $create_table ); + $this->assertStringNotContainsString( '`obsolete`', $create_table ); + $this->assertStringNotContainsString( 'obsolete_idx', $create_table ); + + $show_index = $driver->query( "SHOW INDEX FROM wptests_plugin_drop_composite WHERE Key_name = 'combo_idx'" ); + $this->assertCount( 2, $show_index ); + $this->assertSame( 'first_key', $show_index[0]->Column_name ); + $this->assertSame( '1', $show_index[0]->Seq_in_index ); + $this->assertSame( 'last_key', $show_index[1]->Column_name ); + $this->assertSame( '2', $show_index[1]->Seq_in_index ); + } + + /** + * Tests ALTER TABLE RENAME COLUMN updates backend and MySQL metadata. + */ + public function test_alter_table_rename_column_updates_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_column_parent ( + id int(11) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_column ( + id int(11) NOT NULL, + old_parent int(11) NOT NULL, + status varchar(20) DEFAULT "draft", + PRIMARY KEY (id), + KEY old_parent_idx (old_parent) + )' + ); + $driver->query( 'ALTER TABLE wptests_rename_column ADD CONSTRAINT fk_old_parent FOREIGN KEY (old_parent) REFERENCES wptests_rename_column_parent (id)' ); + + $driver->query( 'ALTER TABLE wptests_rename_column RENAME COLUMN `old_parent` TO `parent_id`' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_rename_column" RENAME COLUMN "old_parent" TO "parent_id"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_column' ); + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_rename_column' ); + $foreign_keys = $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_rename_column' ); + $renamed_key = array_values( + array_filter( + $indexes, + static function ( array $index ): bool { + return 'old_parent_idx' === $index['key_name']; + } + ) + ); + + $this->assertSame( array( 'id', 'parent_id', 'status' ), array_column( $columns, 'column_name' ) ); + $this->assertSame( array( 'parent_id' ), array_column( $renamed_key, 'column_name' ) ); + $this->assertSame( array( 'parent_id' ), array_values( array_unique( array_column( $foreign_keys, 'column_name' ) ) ) ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_rename_column' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' `parent_id` int(11) NOT NULL,', $create_table ); + $this->assertStringContainsString( ' KEY `old_parent_idx` (`parent_id`)', $create_table ); + $this->assertStringContainsString( ' CONSTRAINT `fk_old_parent` FOREIGN KEY (`parent_id`) REFERENCES `wptests_rename_column_parent` (`id`)', $create_table ); + $this->assertStringNotContainsString( '`old_parent`', $create_table ); + } + + /** + * Tests ALTER TABLE RENAME COLUMN updates foreign keys that reference the renamed column. + */ + public function test_alter_table_rename_referenced_column_updates_foreign_key_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_ref_parent ( + id int(11) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_ref_child ( + id int(11) NOT NULL, + parent_id int(11) NOT NULL, + PRIMARY KEY (id), + KEY parent_id (parent_id) + )' + ); + $driver->query( 'ALTER TABLE wptests_rename_ref_child ADD CONSTRAINT fk_rename_ref_parent FOREIGN KEY (parent_id) REFERENCES wptests_rename_ref_parent (id)' ); + + $driver->query( 'ALTER TABLE wptests_rename_ref_parent RENAME COLUMN id TO parent_pk' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_rename_ref_parent" RENAME COLUMN "id" TO "parent_pk"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $foreign_keys = $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_rename_ref_child' ); + $this->assertSame( array( 'parent_pk' ), array_values( array_unique( array_column( $foreign_keys, 'referenced_column_name' ) ) ) ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_rename_ref_child' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' CONSTRAINT `fk_rename_ref_parent` FOREIGN KEY (`parent_id`) REFERENCES `wptests_rename_ref_parent` (`parent_pk`)', $create_table ); + $this->assertStringNotContainsString( 'REFERENCES `wptests_rename_ref_parent` (`id`)', $create_table ); + } + + /** + * Tests ALTER TABLE RENAME INDEX updates backend and MySQL metadata. + */ + public function test_alter_table_rename_index_updates_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_rename_index ( + id int(11) NOT NULL, + slug varchar(191) NOT NULL DEFAULT '', + status varchar(20) DEFAULT 'draft', + PRIMARY KEY (id), + KEY old_slug_idx (slug) + )" + ); + + $driver->query( 'ALTER TABLE wptests_rename_index RENAME INDEX `old_slug_idx` TO `new_slug_idx`' ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER INDEX "wptests_rename_index__old_slug_idx" RENAME TO "wptests_rename_index__new_slug_idx"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_rename_index' ); + $this->assertSame( array( 'PRIMARY', 'new_slug_idx' ), array_values( array_unique( array_column( $indexes, 'key_name' ) ) ) ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_rename_index' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' KEY `new_slug_idx` (`slug`)', $create_table ); + $this->assertStringNotContainsString( 'old_slug_idx', $create_table ); + } + + /** + * Tests ALTER TABLE RENAME INDEX validates stored MySQL metadata before backend execution. + */ + public function test_alter_table_rename_index_fails_closed_for_missing_or_duplicate_metadata(): void { + $queries = array( + 'ALTER TABLE wptests_rename_index_guard RENAME INDEX missing_idx TO new_slug_idx', + 'ALTER TABLE wptests_rename_index_guard RENAME INDEX old_slug_idx TO existing_slug_idx', + 'ALTER TABLE wptests_rename_index_guard RENAME INDEX PRIMARY TO renamed_primary', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_rename_index_guard ( + id int(11) NOT NULL, + slug varchar(191) NOT NULL DEFAULT '', + status varchar(20) DEFAULT 'draft', + PRIMARY KEY (id), + KEY old_slug_idx (slug), + KEY existing_slug_idx (status) + )" + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER TABLE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests ALTER TABLE RENAME KEY accepts KEY syntax and metadata-only index types. + */ + public function test_alter_table_rename_key_accepts_metadata_only_indexes(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_metadata_index ( + id int(11) NOT NULL, + body text NOT NULL, + PRIMARY KEY (id), + FULLTEXT KEY old_body_idx (body) + )' + ); + + $driver->query( 'ALTER TABLE wptests_rename_metadata_index RENAME KEY old_body_idx TO new_body_idx' ); + + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $indexes = $this->get_mysql_index_metadata_rows( $driver, 'wptests_rename_metadata_index' ); + $renamed_key = array_values( + array_filter( + $indexes, + static function ( array $index ): bool { + return 'new_body_idx' === $index['key_name']; + } + ) + ); + $this->assertSame( array( 'new_body_idx' ), array_column( $renamed_key, 'key_name' ) ); + $this->assertSame( array( 'FULLTEXT' ), array_column( $renamed_key, 'index_type' ) ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_rename_metadata_index' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' FULLTEXT KEY `new_body_idx` (`body`)', $create_table ); + $this->assertStringNotContainsString( 'old_body_idx', $create_table ); + } + + /** + * Tests ALTER TABLE RENAME TO updates backend and MySQL metadata. + */ + public function test_alter_table_rename_to_updates_backend_and_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_rename_table_old (id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_table_old ( + id int(11) NOT NULL, + name varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( "INSERT INTO wptests_rename_table_old (id, name) VALUES (1, 'before')" ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_rename_table_old RENAME TO wptests_rename_table_new' ) ); + + $rows = $driver->query( 'SELECT id, name FROM wptests_rename_table_new' ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'before', $rows[0]->name ); + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_table_old' ) ); + $this->assertSame( array( 'id', 'name' ), array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_table_new' ), 'column_name' ) ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_rename_table_new' )[0]->{'Create Table'}; + $this->assertStringStartsWith( "CREATE TABLE `wptests_rename_table_new` (\n", $create_table ); + $this->assertStringNotContainsString( 'wptests_rename_table_old', $create_table ); + } + + /** + * Tests ALTER TABLE RENAME TO accepts current database and public-qualified targets. + */ + public function test_alter_table_rename_to_accepts_current_database_qualified_targets(): void { + $driver = $this->create_driver( 'wp' ); + + $driver->query( 'CREATE TABLE wptests_rename_table_qualified_old (id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_table_qualified_old ( + id int(11) NOT NULL, + name varchar(20) NOT NULL, + PRIMARY KEY (id) + )' + ); + + $this->assertSame( + 0, + $driver->query( 'ALTER TABLE wptests_rename_table_qualified_old RENAME TO wp.wptests_rename_table_qualified_new' ) + ); + + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertContains( 'ALTER TABLE "wptests_rename_table_qualified_old" RENAME TO "wptests_rename_table_qualified_new"', $sql ); + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_table_qualified_old' ) ); + $this->assertSame( + array( 'id', 'name' ), + array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_table_qualified_new' ), 'column_name' ) + ); + + $driver->query( 'CREATE TABLE wptests_rename_table_public_old (id INTEGER NOT NULL PRIMARY KEY)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_table_public_old ( + id int(11) NOT NULL, + PRIMARY KEY (id) + )' + ); + + $this->assertSame( + 0, + $driver->query( 'ALTER TABLE wptests_rename_table_public_old RENAME TO public.wptests_rename_table_public_new' ) + ); + + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertContains( 'ALTER TABLE "wptests_rename_table_public_old" RENAME TO "wptests_rename_table_public_new"', $sql ); + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_table_public_old' ) ); + $this->assertSame( + array( 'id' ), + array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_table_public_new' ), 'column_name' ) + ); + } + + /** + * Tests RENAME TABLE updates table, index, and foreign-key metadata. + */ + public function test_rename_table_updates_indexes_and_foreign_key_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_parent ( + id int(11) NOT NULL, + slug varchar(20) NOT NULL, + PRIMARY KEY (id), + KEY slug_idx (slug) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_child ( + id int(11) NOT NULL, + parent_id int(11) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( + 'ALTER TABLE wptests_rename_child + ADD CONSTRAINT parent_fk FOREIGN KEY (parent_id) REFERENCES wptests_rename_parent (id)' + ); + $driver->query( 'CREATE INDEX standalone_slug ON wptests_rename_parent (slug)' ); + + $this->assertSame( 0, $driver->query( 'RENAME TABLE wptests_rename_parent TO wptests_renamed_parent' ) ); + + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertContains( 'ALTER TABLE "wptests_rename_parent" RENAME TO "wptests_renamed_parent"', $sql ); + $this->assertContains( 'ALTER INDEX "wptests_rename_parent__slug_idx" RENAME TO "wptests_renamed_parent__slug_idx"', $sql ); + $this->assertContains( 'ALTER INDEX "wptests_rename_parent__standalone_slug" RENAME TO "wptests_renamed_parent__standalone_slug"', $sql ); + + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_parent' ) ); + $this->assertSame( array( 'id', 'slug' ), array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_renamed_parent' ), 'column_name' ) ); + $this->assertSame( array( 'PRIMARY', 'slug_idx', 'standalone_slug' ), array_column( $this->get_mysql_index_metadata_rows( $driver, 'wptests_renamed_parent' ), 'key_name' ) ); + + $child_foreign_keys = $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_rename_child' ); + $this->assertSame( array( 'wptests_renamed_parent' ), array_values( array_unique( array_column( $child_foreign_keys, 'referenced_table_name' ) ) ) ); + } + + /** + * Tests multi-pair RENAME TABLE applies table, index, and FK metadata left-to-right. + */ + public function test_multi_pair_rename_table_updates_indexes_and_foreign_key_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_swap_left ( + id int(11) NOT NULL, + left_value varchar(20) NOT NULL, + PRIMARY KEY (id), + KEY left_value_idx (left_value) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_swap_right ( + id int(11) NOT NULL, + right_value varchar(20) NOT NULL, + PRIMARY KEY (id), + KEY right_value_idx (right_value) + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_rename_swap_child ( + id int(11) NOT NULL, + parent_id int(11) NOT NULL, + PRIMARY KEY (id) + )' + ); + $driver->query( + 'ALTER TABLE wptests_rename_swap_child + ADD CONSTRAINT swap_parent_fk FOREIGN KEY (parent_id) REFERENCES wptests_rename_swap_left (id)' + ); + + $this->assertSame( + 0, + $driver->query( + 'RENAME TABLE + wptests_rename_swap_left TO wptests_rename_swap_tmp, + wptests_rename_swap_right TO wptests_rename_swap_left, + wptests_rename_swap_tmp TO wptests_rename_swap_right' + ) + ); + + $sql = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertSame( + array( + 'ALTER TABLE "wptests_rename_swap_left" RENAME TO "wptests_rename_swap_tmp"', + 'ALTER INDEX "wptests_rename_swap_left__left_value_idx" RENAME TO "wptests_rename_swap_tmp__left_value_idx"', + 'ALTER TABLE "wptests_rename_swap_right" RENAME TO "wptests_rename_swap_left"', + 'ALTER INDEX "wptests_rename_swap_right__right_value_idx" RENAME TO "wptests_rename_swap_left__right_value_idx"', + 'ALTER TABLE "wptests_rename_swap_tmp" RENAME TO "wptests_rename_swap_right"', + 'ALTER INDEX "wptests_rename_swap_tmp__left_value_idx" RENAME TO "wptests_rename_swap_right__left_value_idx"', + ), + $sql + ); + + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_swap_tmp' ) ); + $this->assertSame( + array( 'id', 'right_value' ), + array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_swap_left' ), 'column_name' ) + ); + $this->assertSame( + array( 'id', 'left_value' ), + array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_swap_right' ), 'column_name' ) + ); + $this->assertSame( + array( 'PRIMARY', 'right_value_idx' ), + array_column( $this->get_mysql_index_metadata_rows( $driver, 'wptests_rename_swap_left' ), 'key_name' ) + ); + $this->assertSame( + array( 'PRIMARY', 'left_value_idx' ), + array_column( $this->get_mysql_index_metadata_rows( $driver, 'wptests_rename_swap_right' ), 'key_name' ) + ); + + $child_foreign_keys = $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_rename_swap_child' ); + $this->assertSame( array( 'wptests_rename_swap_right' ), array_values( array_unique( array_column( $child_foreign_keys, 'referenced_table_name' ) ) ) ); + } + + /** + * Tests ALTER TABLE RENAME AS and bare RENAME forms are accepted. + */ + public function test_alter_table_rename_as_and_bare_rename_forms_update_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_rename_as_old (id INTEGER NOT NULL PRIMARY KEY)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_rename_as_old (id int(11) NOT NULL, PRIMARY KEY (id))' ); + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_rename_as_old RENAME AS wptests_rename_as_new' ) ); + $this->assertSame( array( 'id' ), array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_as_new' ), 'column_name' ) ); + + $driver->query( 'CREATE TABLE wptests_rename_bare_old (id INTEGER NOT NULL PRIMARY KEY)' ); + $driver->store_mysql_schema_metadata( 'CREATE TABLE wptests_rename_bare_old (id int(11) NOT NULL, PRIMARY KEY (id))' ); + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_rename_bare_old RENAME wptests_rename_bare_new' ) ); + $this->assertSame( array( 'id' ), array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_rename_bare_new' ), 'column_name' ) ); + } + + /** + * Tests unsupported RENAME TABLE variants fail before backend execution. + */ + public function test_unsupported_rename_table_variants_do_not_reach_backend(): void { + $queries = array( + 'RENAME TABLE wptests_old_name TO other_db.wptests_new_name', + 'RENAME TABLE wptests_old_name TO wptests_new_name, wptests_old_two TO other_db.wptests_new_two', + 'RENAME TABLE information_schema.tables TO wptests_tables', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported RENAME TABLE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertContains( + $e->getMessage(), + array( 'Unsupported RENAME TABLE statement.', 'Unsupported information_schema query.' ), + $query + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests ALTER COLUMN DROP DEFAULT updates backend and MySQL metadata. + */ + public function test_alter_table_drop_default_updates_backend_and_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_plugin_defaults ( + id int(11) NOT NULL, + status varchar(20) DEFAULT 'draft', + PRIMARY KEY (id) + )" + ); + + $driver->query( 'ALTER TABLE wptests_plugin_defaults ALTER COLUMN status DROP DEFAULT' ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_plugin_defaults" ALTER COLUMN "status" DROP DEFAULT', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns = $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_defaults' ); + + $this->assertNull( $columns[1]['column_default'] ); + } + + /** + * Tests MySQL table-option ALTER clauses are supported no-ops. + */ + public function test_alter_table_storage_options_are_supported_noops(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_plugin_options ( + id int(11) NOT NULL, + status varchar(20) DEFAULT 'draft', + PRIMARY KEY (id) + )" + ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_options' ); + + $this->assertSame( + 0, + $driver->query( + 'ALTER TABLE wptests_plugin_options + ENGINE=InnoDB, + DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci, + DEFAULT COLLATE=utf8mb4_unicode_ci, + ROW_FORMAT=DYNAMIC, + KEY_BLOCK_SIZE=8, + MAX_ROWS=10, + MIN_ROWS=1, + AVG_ROW_LENGTH=100, + CHECKSUM=1, + DELAY_KEY_WRITE=1, + PACK_KEYS=1, + STATS_PERSISTENT=0, + STATS_AUTO_RECALC=1, + STATS_SAMPLE_PAGES=10, + COMPRESSION="zlib", + ENCRYPTION="Y", + DATA DIRECTORY "/tmp", + INDEX DIRECTORY "/tmp", + CONNECTION="mysql://x", + PASSWORD="x", + INSERT_METHOD=NO, + TABLESPACE ts, + SECONDARY_ENGINE=mock, + AUTOEXTEND_SIZE=4M, + UNION=(t1,t2), + ENGINE_ATTRIBUTE="{}", + SECONDARY_ENGINE_ATTRIBUTE="{}"' + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_options' ) ); + } + + /** + * Tests MySQL table-option ALTER clauses accept optional-equals forms as no-ops. + */ + public function test_alter_table_storage_options_accept_optional_equals_forms_as_noops(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_plugin_option_spacing ( + id int(11) NOT NULL, + status varchar(20) DEFAULT 'draft', + PRIMARY KEY (id) + )" + ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_option_spacing' ); + $queries = array( + 'ALTER TABLE wptests_plugin_option_spacing ENGINE InnoDB', + 'ALTER TABLE wptests_plugin_option_spacing ROW_FORMAT DYNAMIC', + 'ALTER TABLE wptests_plugin_option_spacing DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'ALTER TABLE wptests_plugin_option_spacing DEFAULT CHAR SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'ALTER TABLE wptests_plugin_option_spacing CHAR SET utf8mb4', + 'ALTER TABLE wptests_plugin_option_spacing DEFAULT CHARSET utf8mb4', + 'ALTER TABLE wptests_plugin_option_spacing COLLATE utf8mb4_unicode_ci', + 'ALTER TABLE wptests_plugin_option_spacing CONVERT TO CHAR SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'ALTER TABLE wptests_plugin_option_spacing STATS_PERSISTENT DEFAULT', + 'ALTER TABLE wptests_plugin_option_spacing ENGINE_ATTRIBUTE "{}"', + ); + + foreach ( $queries as $query ) { + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_option_spacing' ) ); + + $this->assertSame( + 1, + $driver->query( + 'ALTER TABLE wptests_plugin_option_spacing + ENGINE InnoDB, + ADD COLUMN note varchar(20), + DEFAULT CHAR SET utf8mb4 COLLATE utf8mb4_unicode_ci' + ) + ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_plugin_option_spacing" ADD COLUMN "note" varchar(20)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + $this->assertSame( + array( 'id', 'status', 'note' ), + array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_option_spacing' ), 'column_name' ) + ); + } + + /** + * Tests ALTER TABLE comment clauses update MySQL-facing metadata. + */ + public function test_alter_table_comment_clauses_update_introspection_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_alter_comment_metadata ( + id int(11) NOT NULL COMMENT 'Old id', + label varchar(20) DEFAULT NULL COMMENT 'Old label', + PRIMARY KEY (id), + KEY label_lookup (label) COMMENT 'Old index' + ) COMMENT='Old table'" + ); + + $driver->query( + "ALTER TABLE wptests_alter_comment_metadata + COMMENT = 'New table', + CHANGE COLUMN id id int(11) NOT NULL COMMENT 'New id', + ADD COLUMN note varchar(100) DEFAULT NULL COMMENT 'Note column', + ADD KEY note_lookup (note) COMMENT 'Note lookup'" + ); + + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_alter_comment_metadata" ALTER COLUMN "id" TYPE integer', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_comment_metadata" ALTER COLUMN "id" SET NOT NULL', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_comment_metadata" ALTER COLUMN "id" DROP DEFAULT', + 'params' => array(), + ), + array( + 'sql' => 'ALTER TABLE "wptests_alter_comment_metadata" ADD COLUMN "note" varchar(100) DEFAULT NULL', + 'params' => array(), + ), + array( + 'sql' => 'CREATE INDEX "wptests_alter_comment_metadata__note_lookup" ON "wptests_alter_comment_metadata" ("note")', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $column_comments = $driver->get_connection()->query( + sprintf( + 'SELECT column_name, column_comment + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY ordinal_position', + $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( 'public', 'wptests_alter_comment_metadata' ) + )->fetchAll( PDO::FETCH_KEY_PAIR ); + $this->assertSame( 'New id', $column_comments['id'] ); + $this->assertSame( 'Note column', $column_comments['note'] ); + + $index_comments = $driver->get_connection()->query( + sprintf( + 'SELECT key_name, index_comment + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY index_ordinal, seq_in_index', + $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_INDEX_METADATA_TABLE ) + ), + array( 'public', 'wptests_alter_comment_metadata' ) + )->fetchAll( PDO::FETCH_KEY_PAIR ); + $this->assertSame( 'Note lookup', $index_comments['note_lookup'] ); + + $table_comment = $driver->get_connection()->query( + sprintf( + 'SELECT table_comment + FROM %s + WHERE table_schema = ? AND table_name = ?', + $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_TABLE_METADATA_TABLE ) + ), + array( 'public', 'wptests_alter_comment_metadata' ) + )->fetchColumn(); + $this->assertSame( 'New table', $table_comment ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_alter_comment_metadata' )[0]->{'Create Table'}; + $this->assertStringContainsString( " `id` int(11) NOT NULL COMMENT 'New id'", $create_table ); + $this->assertStringContainsString( " `note` varchar(100) DEFAULT NULL COMMENT 'Note column'", $create_table ); + $this->assertStringContainsString( " KEY `note_lookup` (`note`) COMMENT 'Note lookup'", $create_table ); + $this->assertStringEndsWith( "COMMENT='New table'", $create_table ); + } + + /** + * Tests unsupported ALTER TABLE comment option values fail before backend execution. + */ + public function test_alter_table_comment_option_requires_string_literal(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_alter_bad_comment (id INTEGER)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_bad_comment ( + id int(11) DEFAULT NULL + )' + ); + + try { + $driver->query( 'ALTER TABLE wptests_alter_bad_comment COMMENT = 123' ); + $this->fail( 'Expected unsupported ALTER TABLE comment option to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests MySQL key-maintenance ALTER clauses are supported no-ops. + */ + public function test_alter_table_key_maintenance_clauses_are_supported_noops(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_plugin_keys ( + id int(11) NOT NULL, + status varchar(20) DEFAULT 'draft', + PRIMARY KEY (id), + KEY status (status) + )" + ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_keys' ); + $create_before = $driver->query( 'SHOW CREATE TABLE wptests_plugin_keys' ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_plugin_keys DISABLE KEYS, ENABLE KEYS' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_keys' ) ); + + $create_after = $driver->query( 'SHOW CREATE TABLE wptests_plugin_keys' ); + $this->assertSame( $create_before[0]->{'Create Table'}, $create_after[0]->{'Create Table'} ); + + $this->assertSame( 1, $driver->query( 'ALTER TABLE wptests_plugin_keys DISABLE KEYS, ADD COLUMN note varchar(20), ENABLE KEYS' ) ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_plugin_keys" ADD COLUMN "note" varchar(20)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $columns_after = $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_keys' ); + $this->assertCount( 3, $columns_after ); + $this->assertSame( 'note', $columns_after[2]['column_name'] ); + } + + /** + * Tests MySQL ALTER TABLE ORDER BY clauses are supported schema no-ops. + */ + public function test_alter_table_order_by_clauses_are_supported_noops(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_plugin_order_by ( + id int(11) NOT NULL, + status varchar(20) DEFAULT 'draft', + PRIMARY KEY (id) + )" + ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_order_by' ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_plugin_order_by ORDER BY id DESC' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_order_by' ) ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_plugin_order_by ORDER BY wptests_plugin_order_by.id, status ASC' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_order_by' ) ); + + $this->assertSame( 1, $driver->query( 'ALTER TABLE wptests_plugin_order_by ADD COLUMN note varchar(20), ORDER BY id DESC, status ASC' ) ); + $this->assertSame( + array( + array( + 'sql' => 'ALTER TABLE "wptests_plugin_order_by" ADD COLUMN "note" varchar(20)', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( + array( 'id', 'status', 'note' ), + array_column( $this->get_mysql_column_metadata_rows( $driver, 'wptests_plugin_order_by' ), 'column_name' ) + ); + } + + /** + * Tests malformed ALTER TABLE ORDER BY clauses fail before backend execution. + */ + public function test_alter_table_order_by_clauses_fail_closed_for_unsupported_forms(): void { + $queries = array( + 'ALTER TABLE wptests_bad_order_by ORDER BY', + 'ALTER TABLE wptests_bad_order_by ORDER BY id,', + 'ALTER TABLE wptests_bad_order_by ORDER BY other_table.id', + 'ALTER TABLE wptests_bad_order_by ORDER BY RAND()', + 'ALTER TABLE wptests_bad_order_by ORDER BY id, ADD COLUMN note varchar(20)', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_bad_order_by (id INTEGER, status TEXT)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_bad_order_by ( + id int(11) DEFAULT NULL, + status varchar(20) DEFAULT NULL + )' + ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER TABLE ORDER BY clause to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests ALTER TABLE AUTO_INCREMENT adjusts the SQLite-backed test sequence. + */ + public function test_alter_table_auto_increment_updates_sqlite_sequence(): void { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wptests_alter_auto_increment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_auto_increment ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_alter_auto_increment AUTO_INCREMENT = 50' ) ); + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_alter_auto_increment (name) VALUES ('first')" ) ); + + $rows = $driver->query( 'SELECT id, name FROM wptests_alter_auto_increment' ); + $this->assertCount( 1, $rows ); + $this->assertSame( '50', $rows[0]->id ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_alter_auto_increment AUTO_INCREMENT = 1' ) ); + $this->assertSame( 1, $driver->query( "INSERT INTO wptests_alter_auto_increment (name) VALUES ('second')" ) ); + + $rows = $driver->query( 'SELECT id, name FROM wptests_alter_auto_increment ORDER BY id' ); + $this->assertSame( '50', $rows[0]->id ); + $this->assertSame( '51', $rows[1]->id ); + + $driver->query( 'CREATE TABLE wptests_alter_auto_increment_plain (id INTEGER, name TEXT)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_alter_auto_increment_plain ( + id int(11) DEFAULT NULL, + name varchar(191) DEFAULT NULL + )' + ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_alter_auto_increment_plain AUTO_INCREMENT = 500' ) ); + } + + /** + * Tests ALTER TABLE AUTO_INCREMENT emits guarded PostgreSQL sequence repair. + */ + public function test_alter_table_auto_increment_uses_postgresql_identity_metadata(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection( + $this->get_dml_identity_metadata_fixture( 'wptests_pg_alter_auto_increment', 'id', 'wptests_pg_alter_auto_increment_id_seq' ) + ); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $driver->query( + 'CREATE TABLE wptests_pg_alter_auto_increment ( + id INTEGER PRIMARY KEY, + name TEXT + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_pg_alter_auto_increment ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_pg_alter_auto_increment AUTO_INCREMENT = 200' ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( array(), $queries[0]['params'] ); + $this->assertStringContainsString( 'pg_catalog.setval(CAST(', $queries[0]['sql'] ); + $this->assertStringContainsString( 'wptests_pg_alter_auto_increment_id_seq', $queries[0]['sql'] ); + $this->assertStringContainsString( 'GREATEST(COALESCE(MAX("id"), 0), 199)', $queries[0]['sql'] ); + $this->assertStringContainsString( 'table_state.max_identity_value > 0', $queries[0]['sql'] ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + + $this->assertSame( 0, $driver->query( 'ALTER TABLE wptests_pg_alter_auto_increment AUTO_INCREMENT 300' ) ); + $this->assertStringContainsString( 'GREATEST(COALESCE(MAX("id"), 0), 299)', $driver->get_last_postgresql_queries()[0]['sql'] ); + $this->assertSame( 2, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests SHOW COLUMNS WHERE exact filters catalog rows with bound parameters. + */ + public function test_show_columns_where_exact_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( "SHOW COLUMNS FROM wptests_options WHERE Field = 'option_name'" ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'option_name', $result[0]->Field ); + $this->assertSame( 'varchar(191)', $result[0]->Type ); + $this->assertSame( 6, $driver->get_last_column_count() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'field_name = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW COLUMNS', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'option_name' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW FIELDS WHERE exact filters use the SHOW COLUMNS parser. + */ + public function test_show_fields_where_exact_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( "SHOW FIELDS FROM wptests_options WHERE Type = 'varchar(191)'" ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'option_name', $result[0]->Field ); + $this->assertSame( 'varchar(191)', $result[0]->Type ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'column_type = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW FIELDS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options', 'varchar(191)' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW FULL COLUMNS WHERE exact filters full catalog rows. + */ + public function test_show_full_columns_where_exact_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( "SHOW FULL COLUMNS FROM wptests_options WHERE Field = 'option_name'" ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'option_name', $result[0]->Field ); + $this->assertSame( 'utf8mb4_unicode_ci', $result[0]->Collation ); + $this->assertSame( 9, $driver->get_last_column_count() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'field_name = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW FULL COLUMNS', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'option_name' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW COLUMNS/FIELDS WHERE LIKE filters catalog rows with bound parameters. + */ + public function test_show_columns_where_like_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( "SHOW COLUMNS FROM wptests_options WHERE Field LIKE 'option_%'" ); + + $this->assertSame( + array( 'option_id', 'option_name', 'option_value' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'field_name LIKE ?', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'option_%' ), $queries[0]['params'] ); + + $result = $driver->query( "SHOW FIELDS FROM wptests_options WHERE Type LIKE 'varchar%'" ); + + $this->assertSame( + array( 'option_name', 'autoload' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'column_type LIKE ?', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'varchar%' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW COLUMNS/FIELDS WHERE filters support AND-combined predicates. + */ + public function test_show_columns_where_and_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( "SHOW COLUMNS FROM wptests_options WHERE Field LIKE 'option_%' AND Type = 'varchar(191)'" ); + + $this->assertSame( + array( 'option_name' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( "field_name LIKE ? ESCAPE '\\'", $queries[0]['sql'] ); + $this->assertStringContainsString( 'column_type = ?', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'option_%', 'varchar(191)' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW COLUMNS/FIELDS WHERE expression filters catalog rows after fetching. + */ + public function test_show_columns_where_expression_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( "SHOW COLUMNS FROM wptests_options WHERE Field <> 'option_name'" ); + + $this->assertSame( + array( 'option_id', 'option_value', 'autoload' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + $this->assertStringNotContainsString( 'field_name <>', $queries[0]['sql'] ); + + $result = $driver->query( "SHOW FIELDS FROM wptests_options WHERE Field = 'option_name' OR Type = 'text'" ); + + $this->assertSame( + array( 'option_name', 'option_value' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + $this->assertSame( array( 'public', 'wptests_options' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $result = $driver->query( "SHOW COLUMNS FROM wptests_options WHERE Field IN ('option_id', 'autoload')" ); + + $this->assertSame( + array( 'option_id', 'autoload' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + $this->assertSame( array( 'public', 'wptests_options' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $result = $driver->query( "SHOW COLUMNS FROM wptests_options WHERE Field BETWEEN 'option_id' AND 'option_value'" ); + + $this->assertSame( + array( 'option_id', 'option_name', 'option_value' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + $this->assertSame( array( 'public', 'wptests_options' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $result = $driver->query( "SHOW COLUMNS FROM wptests_options WHERE BINARY Field = 'OPTION_ID'" ); + $this->assertSame( array(), $result ); + $this->assertSame( array( 'public', 'wptests_options' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $this->assertSame( array(), $driver->query( 'SHOW COLUMNS FROM wptests_options WHERE NOT 1' ) ); + $this->assertSame( array( 'public', 'wptests_options' ), $driver->get_last_postgresql_queries()[0]['params'] ); + } + + /** + * Tests SHOW COLUMNS/FIELDS WHERE expression filters support runtime string functions. + */ + public function test_show_columns_where_runtime_functions_filter_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( 'SHOW COLUMNS FROM wptests_options WHERE LENGTH(Field) = 8' ); + + $this->assertSame( + array( 'autoload' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + $this->assertStringNotContainsString( 'SHOW COLUMNS', strtoupper( $queries[0]['sql'] ) ); + + $result = $driver->query( 'SHOW FIELDS FROM wptests_options WHERE CHAR_LENGTH(Field) >= 11' ); + + $this->assertSame( + array( 'option_name', 'option_value' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + $this->assertStringNotContainsString( 'SHOW FIELDS', strtoupper( $queries[0]['sql'] ) ); + + $result = $driver->query( 'SHOW COLUMNS FROM wptests_options WHERE LENGTH(Field) * 2 = 16' ); + + $this->assertSame( + array( 'autoload' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + $this->assertStringNotContainsString( 'LENGTH(Field) * 2', $queries[0]['sql'] ); + + $result = $driver->query( 'SHOW COLUMNS FROM wptests_options WHERE MOD(LENGTH(Field), 2) = 0' ); + + $this->assertSame( + array( 'option_value', 'autoload' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + $this->assertStringNotContainsString( 'MOD(LENGTH(Field), 2)', $queries[0]['sql'] ); + + $result = $driver->query( 'SHOW COLUMNS FROM wptests_options WHERE MOD(Field, 2) = 0' ); + + $this->assertSame( + array( 'option_id', 'option_name', 'option_value', 'autoload' ), + array_map( + static function ( $row ): string { + return $row->Field; + }, + $result + ) + ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + $this->assertStringNotContainsString( 'MOD(Field, 2)', $queries[0]['sql'] ); + } + + /** + * Tests unsupported SHOW COLUMNS/FIELDS WHERE forms do not reach the backend. + */ + public function test_show_columns_where_unsupported_forms_do_not_reach_backend(): void { + $queries = array( + 'SHOW COLUMNS FROM wptests_options WHERE Field = option_name', + 'SHOW COLUMNS FROM wptests_options WHERE Field LIKE option_%', + "SHOW COLUMNS FROM wptests_options WHERE Unknown = 'option_name'", + "SHOW FIELDS FROM wptests_options WHERE Privileges = 'select,insert,update,references'", + "SHOW COLUMNS FROM wptests_options LIKE 'option_%' WHERE Field = 'option_name'", + "SHOW FIELDS FROM wptests_options LIKE 'option_%' WHERE Field = 'option_name'", + "SHOW FULL COLUMNS FROM wptests_options LIKE 'option_%' WHERE Field = 'option_name'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW COLUMNS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW COLUMNS statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW TABLES returns MySQL-shaped catalog rows. + */ + public function test_show_tables_returns_mysql_shaped_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( "SHOW TABLES LIKE 'wptests_%'" ); + + $this->assertCount( 3, $tables ); + $this->assertSame( 'wptests_options', $tables[0]->Tables_in_wptests ); + $this->assertSame( 'wptests_posts', $tables[1]->Tables_in_wptests ); + $this->assertSame( 'wptests_view', $tables[2]->Tables_in_wptests ); + $this->assertSame( "SHOW TABLES LIKE 'wptests_%'", $driver->get_last_mysql_query() ); + $this->assertSame( 'Tables_in_wptests', $driver->get_last_column_meta()[0]['name'] ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'information_schema.tables', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW TABLES', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_%' ), $queries[0]['params'] ); + + $full_tables = $driver->query( "SHOW FULL TABLES LIKE 'wptests_%'" ); + + $this->assertCount( 3, $full_tables ); + $this->assertSame( 'wptests_options', $full_tables[0]->Tables_in_wptests ); + $this->assertSame( 'BASE TABLE', $full_tables[0]->Table_type ); + $this->assertSame( 'wptests_view', $full_tables[2]->Tables_in_wptests ); + $this->assertSame( 'VIEW', $full_tables[2]->Table_type ); + $this->assertSame( 'Tables_in_wptests', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Table_type', $driver->get_last_column_meta()[1]['name'] ); + } + + /** + * Tests SHOW TABLES accepts current database qualification forms. + */ + public function test_show_tables_accepts_current_database_qualification_forms(): void { + $cases = array( + 'SHOW TABLES FROM wptests' => array( 'Tables_in_wptests', 3, array( 'public' ) ), + "SHOW TABLES IN `wptests` LIKE 'wptests_%'" => array( 'Tables_in_wptests', 3, array( 'public', 'wptests_%' ) ), + "SHOW FULL TABLES FROM wptests LIKE 'wptests_%'" => array( 'Tables_in_wptests', 3, array( 'public', 'wptests_%' ) ), + ); + + foreach ( $cases as $query => $expected ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( $query ); + + $this->assertCount( $expected[1], $tables, $query ); + $this->assertSame( $expected[0], $driver->get_last_column_meta()[0]['name'], $query ); + $this->assertSame( 'wptests_options', $tables[0]->{$expected[0]}, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringNotContainsString( 'SHOW TABLES', $queries[0]['sql'], $query ); + $this->assertSame( $expected[2], $queries[0]['params'], $query ); + } + } + + /** + * Tests SHOW TABLES WHERE exact filters catalog rows with bound parameters. + */ + public function test_show_tables_where_exact_filters_catalog_rows(): void { + $cases = array( + "SHOW TABLES WHERE Tables_in_wptests = 'wptests_options'" => array( + 'Tables_in_wptests', + 1, + array( 'public', 'wptests_options' ), + ), + "SHOW TABLES FROM wptests WHERE Tables_in_wptests = 'wptests_options'" => array( + 'Tables_in_wptests', + 1, + array( 'public', 'wptests_options' ), + ), + "SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'" => array( + 'Tables_in_wptests', + 2, + array( 'public', 'BASE TABLE' ), + ), + "SHOW FULL TABLES FROM wptests WHERE Tables_in_wptests = 'wptests_options'" => array( + 'Tables_in_wptests', + 1, + array( 'public', 'wptests_options' ), + ), + ); + + foreach ( $cases as $query => $expected ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( $query ); + + $this->assertCount( $expected[1], $tables, $query ); + $this->assertSame( $expected[0], $driver->get_last_column_meta()[0]['name'], $query ); + $this->assertSame( 'wptests_options', $tables[0]->{$expected[0]}, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringNotContainsString( 'SHOW TABLES', $queries[0]['sql'], $query ); + $this->assertSame( $expected[2], $queries[0]['params'], $query ); + } + } + + /** + * Tests SHOW TABLES WHERE LIKE filters catalog rows with bound parameters. + */ + public function test_show_tables_where_like_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( "SHOW TABLES WHERE Tables_in_wptests LIKE 'wptests_%'" ); + + $this->assertSame( + array( 'wptests_options', 'wptests_posts', 'wptests_view' ), + array_map( + static function ( $row ): string { + return $row->Tables_in_wptests; + }, + $tables + ) + ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'table_name LIKE ?', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_%' ), $queries[0]['params'] ); + + $full_tables = $driver->query( "SHOW FULL TABLES WHERE Table_type LIKE 'BASE%'" ); + + $this->assertSame( + array( 'wptests_options', 'wptests_posts' ), + array_map( + static function ( $row ): string { + return $row->Tables_in_wptests; + }, + $full_tables + ) + ); + $this->assertSame( array( 'BASE TABLE', 'BASE TABLE' ), array( $full_tables[0]->Table_type, $full_tables[1]->Table_type ) ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( "CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'BASE TABLE' END LIKE ?", $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'BASE%' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW FULL TABLES WHERE filters support AND-combined predicates. + */ + public function test_show_full_tables_where_and_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( "SHOW FULL TABLES WHERE Tables_in_wptests LIKE 'wptests_%' AND Table_type = 'BASE TABLE'" ); + + $this->assertSame( + array( 'wptests_options', 'wptests_posts' ), + array_map( + static function ( $row ): string { + return $row->Tables_in_wptests; + }, + $tables + ) + ); + $this->assertSame( array( 'BASE TABLE', 'BASE TABLE' ), array( $tables[0]->Table_type, $tables[1]->Table_type ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( "table_name LIKE ? ESCAPE '\\'", $queries[0]['sql'] ); + $this->assertStringContainsString( "CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'BASE TABLE' END = ?", $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_%', 'BASE TABLE' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW TABLES WHERE expression filters catalog rows after fetching. + */ + public function test_show_full_tables_where_expression_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( "SHOW FULL TABLES WHERE Table_type <> 'VIEW'" ); + + $this->assertSame( + array( 'wptests_options', 'wptests_posts' ), + array_map( + static function ( $row ): string { + return $row->Tables_in_wptests; + }, + $tables + ) + ); + $this->assertSame( array( 'BASE TABLE', 'BASE TABLE' ), array( $tables[0]->Table_type, $tables[1]->Table_type ) ); + $this->assertSame( array( 'public' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $tables = $driver->query( "SHOW FULL TABLES WHERE LEFT(Tables_in_wptests, 8) = 'wptests_' AND Table_type = 'VIEW'" ); + + $this->assertSame( array( 'wptests_view' ), array( $tables[0]->Tables_in_wptests ) ); + $this->assertSame( 'VIEW', $tables[0]->Table_type ); + $this->assertSame( array( 'public' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $this->assertSame( array(), $driver->query( 'SHOW TABLES WHERE FALSE' ) ); + $this->assertSame( array( 'public' ), $driver->get_last_postgresql_queries()[0]['params'] ); + } + + /** + * Tests unsupported SHOW TABLES database qualifiers fail before backend execution. + */ + public function test_show_tables_unsupported_database_qualification_does_not_reach_backend(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'SHOW TABLES FROM other_db' ); + $this->fail( 'Expected unsupported SHOW TABLES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW TABLES statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests unsupported SHOW TABLES WHERE forms do not reach the backend. + */ + public function test_show_tables_where_unsupported_forms_do_not_reach_backend(): void { + $queries = array( + "SHOW TABLES WHERE Table_type = 'BASE TABLE'", + 'SHOW TABLES WHERE Tables_in_wptests LIKE wptests_%', + 'SHOW TABLES WHERE Tables_in_wptests = wptests_options', + "SHOW TABLES WHERE Unknown = 'wptests_options'", + "SHOW TABLES WHERE Tables_in_wptests = 'wptests_options' AND Table_type = 'BASE TABLE'", + "SHOW TABLES WHERE Tables_in_wptests LIKE 'wptests_%' AND Table_type = 'BASE TABLE'", + "SHOW TABLES LIKE 'wptests_%' WHERE Tables_in_wptests = 'wptests_options'", + "SHOW FULL TABLES LIKE 'wptests_%' WHERE Table_type = 'BASE TABLE'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW TABLES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW TABLES statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW TABLES hides internal PostgreSQL metadata tables. + */ + public function test_show_tables_hides_internal_postgresql_metadata_tables(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', '__wp_postgresql_mysql_column_metadata', 'BASE TABLE'), + ('public', '__wp_postgresql_mysql_index_metadata', 'BASE TABLE'), + ('public', '__wp_postgresql_mysql_charset_metadata', 'BASE TABLE')" + ); + + $raw_catalog_tables = $driver->get_connection()->query( + "SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ( + '__wp_postgresql_mysql_column_metadata', + '__wp_postgresql_mysql_index_metadata', + '__wp_postgresql_mysql_charset_metadata' + ) + ORDER BY table_name" + )->fetchAll( PDO::FETCH_COLUMN ); + + $this->assertSame( + array( + '__wp_postgresql_mysql_charset_metadata', + '__wp_postgresql_mysql_column_metadata', + '__wp_postgresql_mysql_index_metadata', + ), + $raw_catalog_tables + ); + + $tables = $driver->query( 'SHOW TABLES' ); + $names = array_map( + function ( $row ) { + return $row->Tables_in_wptests; + }, + $tables + ); + + $this->assertSame( + array( 'wptests_options', 'wptests_posts', 'wptests_view' ), + $names + ); + } + + /** + * Tests SHOW TABLE STATUS returns MySQL-shaped catalog rows. + */ + public function test_show_table_status_returns_mysql_shaped_catalog_rows(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( 'SHOW TABLE STATUS' ); + + $this->assertCount( 2, $tables ); + $this->assertSame( 'wptests_options', $tables[0]->Name ); + $this->assertSame( 'InnoDB', $tables[0]->Engine ); + $this->assertSame( '10', $tables[0]->Version ); + $this->assertSame( 'Dynamic', $tables[0]->Row_format ); + $this->assertSame( '0', $tables[0]->Rows ); + $this->assertSame( '0', $tables[0]->Avg_row_length ); + $this->assertSame( '0', $tables[0]->Data_length ); + $this->assertSame( '0', $tables[0]->Max_data_length ); + $this->assertSame( '0', $tables[0]->Index_length ); + $this->assertSame( '0', $tables[0]->Data_free ); + $this->assertSame( '1', $tables[0]->Auto_increment ); + $this->assertRegExp( '/\A\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d\z/', $tables[0]->Create_time ); + $this->assertNull( $tables[0]->Update_time ); + $this->assertNull( $tables[0]->Check_time ); + $this->assertSame( $driver->get_collation(), $tables[0]->Collation ); + $this->assertNull( $tables[0]->Checksum ); + $this->assertSame( '', $tables[0]->Create_options ); + $this->assertSame( '', $tables[0]->Comment ); + $this->assertSame( 'wptests_posts', $tables[1]->Name ); + $this->assertNull( $tables[1]->Auto_increment ); + $this->assertSame( + $this->get_show_table_status_column_names(), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + + foreach ( $driver->get_last_postgresql_queries() as $query ) { + $this->assertStringNotContainsString( 'SHOW TABLE STATUS', $query['sql'] ); + } + $this->assertStringContainsString( 'information_schema.tables', $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests SHOW TABLE STATUS accepts current database qualification forms. + */ + public function test_show_table_status_accepts_current_database_qualification_forms(): void { + $cases = array( + 'SHOW TABLE STATUS FROM wptests' => 'public', + 'SHOW TABLE STATUS IN `wptests`' => 'public', + 'SHOW TABLE STATUS FROM public' => 'public', + 'SHOW TABLE STATUS IN `public`' => 'public', + ); + + foreach ( $cases as $query => $expected_schema ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( $query ); + + $this->assertSame( array( 'wptests_options', 'wptests_posts' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( $expected_schema, $driver->get_last_postgresql_queries()[0]['params'][0], $query ); + foreach ( $driver->get_last_postgresql_queries() as $postgresql_query ) { + $this->assertStringNotContainsString( 'SHOW TABLE STATUS', $postgresql_query['sql'], $query ); + } + } + } + + /** + * Tests SHOW TABLE STATUS LIKE filters rows and hides internal metadata tables. + */ + public function test_show_table_status_like_filters_and_hides_internal_metadata_tables(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', '__wp_postgresql_mysql_column_metadata', 'BASE TABLE'), + ('public', '__wp_postgresql_mysql_index_metadata', 'BASE TABLE'), + ('public', '__wp_postgresql_mysql_charset_metadata', 'BASE TABLE'), + ('public', 'other_visible', 'BASE TABLE')" + ); + + $tables = $driver->query( "SHOW TABLE STATUS LIKE 'wptests_%'" ); + + $this->assertSame( + array( 'wptests_options', 'wptests_posts' ), + array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) + ); + + $internal_tables = $driver->query( "SHOW TABLE STATUS LIKE '__wp_postgresql_mysql_%'" ); + + $this->assertSame( array(), $internal_tables ); + } + + /** + * Tests SHOW TABLE STATUS excludes temporary tables and views. + */ + public function test_show_table_status_excludes_temporary_tables_and_views(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $driver->get_connection()->get_pdo()->exec( 'CREATE TEMPORARY TABLE wptests_temp (id INTEGER)' ); + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_temp', 'LOCAL TEMPORARY')" + ); + + $tables = $driver->query( 'SHOW TABLE STATUS' ); + $names = array_map( array( $this, 'get_show_table_status_row_name' ), $tables ); + + $this->assertSame( array( 'wptests_options', 'wptests_posts' ), $names ); + $this->assertNotContains( 'wptests_view', $names ); + $this->assertNotContains( 'wptests_temp', $names ); + } + + /** + * Tests SHOW TABLE STATUS supports the scoped Auto_increment WHERE filters. + */ + public function test_show_table_status_where_filters_by_auto_increment(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_show_table_status_auto_increment_fixture( $driver ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE `Auto_increment` > 3' ); + + $this->assertSame( array( 'wptests_posts' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) ); + $this->assertSame( '6', $tables[0]->Auto_increment ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE Auto_increment IS NULL' ); + + $this->assertSame( array( 'wptests_plain' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) ); + $this->assertNull( $tables[0]->Auto_increment ); + } + + /** + * Tests SHOW TABLE STATUS WHERE filters match materialized output columns. + */ + public function test_show_table_status_where_exact_filters_output_columns(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_show_table_status_auto_increment_fixture( $driver ); + + $tables = $driver->query( "SHOW TABLE STATUS WHERE Name = 'wptests_options'" ); + + $this->assertSame( array( 'wptests_options' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE Version = 10' ); + + $this->assertSame( + array( 'wptests_options', 'wptests_plain', 'wptests_posts' ), + array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) + ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE Auto_increment = 6' ); + + $this->assertSame( array( 'wptests_posts' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) ); + $this->assertSame( '6', $tables[0]->Auto_increment ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE Auto_increment >= 1' ); + + $this->assertSame( array( 'wptests_options', 'wptests_posts' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) ); + + $tables = $driver->query( "SHOW TABLE STATUS WHERE Name = 'wptests_posts' OR Auto_increment IS NULL" ); + + $this->assertSame( array( 'wptests_plain', 'wptests_posts' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) ); + + $tables = $driver->query( "SHOW TABLE STATUS WHERE SUBSTR(Name, 9, 7) = 'options'" ); + + $this->assertSame( array( 'wptests_options' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) ); + + $tables = $driver->query( "SHOW TABLE STATUS WHERE Name LIKE 'wptests_%'" ); + + $this->assertSame( + array( 'wptests_options', 'wptests_plain', 'wptests_posts' ), + array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) + ); + + $tables = $driver->query( "SHOW TABLE STATUS WHERE Name LIKE 'wptests_%' AND Engine = 'InnoDB'" ); + + $this->assertSame( + array( 'wptests_options', 'wptests_plain', 'wptests_posts' ), + array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) + ); + } + + /** + * Tests SHOW TABLE STATUS WHERE supports arithmetic over numeric output columns. + */ + public function test_show_table_status_where_arithmetic_expression_filters_output_columns(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_show_table_status_auto_increment_fixture( $driver ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE Data_length + Index_length >= 0' ); + + $this->assertSame( + array( 'wptests_options', 'wptests_plain', 'wptests_posts' ), + array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) + ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE Data_length + Index_length > 0' ); + + $this->assertSame( array(), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE Auto_increment - 1 >= 5' ); + + $this->assertSame( array( 'wptests_posts' ), array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE Version * 2 / 4 = 5' ); + + $this->assertSame( + array( 'wptests_options', 'wptests_plain', 'wptests_posts' ), + array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) + ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE Auto_increment % 5 = 1' ); + + $this->assertSame( + array( 'wptests_options', 'wptests_posts' ), + array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) + ); + + $tables = $driver->query( 'SHOW TABLE STATUS WHERE Version MOD 4 = 2' ); + + $this->assertSame( + array( 'wptests_options', 'wptests_plain', 'wptests_posts' ), + array_map( array( $this, 'get_show_table_status_row_name' ), $tables ) + ); + } + + /** + * Tests unsupported SHOW TABLE STATUS WHERE clauses fail before backend execution. + */ + public function test_unsupported_show_table_status_where_clause_does_not_reach_backend(): void { + $unsupported_queries = array( + 'SHOW TABLE STATUS WHERE Name LIKE wptests_%', + 'SHOW TABLE STATUS WHERE Name = wptests_options', + 'SHOW TABLE STATUS FROM other_db', + ); + + foreach ( $unsupported_queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW TABLE STATUS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW TABLE STATUS statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW CREATE TABLE returns MySQL-shaped metadata rows. + */ + public function test_show_create_table_returns_mysql_shaped_metadata_result(): void { + $driver = $this->create_driver(); + $this->install_show_create_table_fixture( $driver, 'wptests_show_create' ); + + $tables = $driver->query( 'SHOW CREATE TABLE wptests_show_create' ); + + $this->assertCount( 1, $tables ); + $this->assertSame( 'wptests_show_create', $tables[0]->Table ); + $this->assertSame( array( 'Table', 'Create Table' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $create_table = $tables[0]->{'Create Table'}; + $this->assertStringStartsWith( "CREATE TABLE `wptests_show_create` (\n", $create_table ); + $this->assertStringContainsString( ' `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $create_table ); + $this->assertStringContainsString( " `title` varchar(191) NOT NULL DEFAULT ''", $create_table ); + $this->assertStringContainsString( ' `description` text DEFAULT NULL', $create_table ); + $this->assertStringContainsString( " `status` varchar(20) NOT NULL DEFAULT 'draft'", $create_table ); + $this->assertStringContainsString( ' PRIMARY KEY (`id`)', $create_table ); + $this->assertStringContainsString( ' UNIQUE KEY `title` (`title`)', $create_table ); + $this->assertStringContainsString( ' KEY `status` (`status`)', $create_table ); + $this->assertStringContainsString( ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci', $create_table ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 5, $queries ); + $this->assertStringContainsString( WP_PostgreSQL_Driver::MYSQL_COLUMN_METADATA_TABLE, $queries[0]['sql'] ); + $this->assertStringContainsString( WP_PostgreSQL_Driver::MYSQL_INDEX_METADATA_TABLE, $queries[1]['sql'] ); + $this->assertStringContainsString( WP_PostgreSQL_Driver::MYSQL_FOREIGN_KEY_METADATA_TABLE, $queries[2]['sql'] ); + $this->assertStringContainsString( WP_PostgreSQL_Driver::MYSQL_CHECK_METADATA_TABLE, $queries[3]['sql'] ); + $this->assertStringContainsString( WP_PostgreSQL_Driver::MYSQL_TABLE_METADATA_TABLE, $queries[4]['sql'] ); + foreach ( $queries as $query ) { + $this->assertStringNotContainsString( 'SHOW CREATE TABLE', $query['sql'] ); + } + + $assoc = $driver->query( 'SHOW CREATE TABLE wptests_show_create', PDO::FETCH_ASSOC ); + $this->assertSame( 'wptests_show_create', $assoc[0]['Table'] ); + $this->assertSame( $create_table, $assoc[0]['Create Table'] ); + + $num = $driver->query( 'SHOW CREATE TABLE wptests_show_create', PDO::FETCH_NUM ); + $this->assertSame( array( 'wptests_show_create', $create_table ), $num[0] ); + } + + /** + * Tests SHOW CREATE TABLE includes CHECK constraints from PostgreSQL catalogs. + */ + public function test_show_create_table_includes_check_constraints(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_posts ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_status varchar(20) NOT NULL DEFAULT 'publish', + PRIMARY KEY (ID) + )" + ); + + $tables = $driver->query( 'SHOW CREATE TABLE wptests_posts' ); + $create_table = $tables[0]->{'Create Table'}; + + $this->assertStringContainsString( + ' CONSTRAINT `wptests_posts_status_chk` CHECK (post_status IS NOT NULL)', + $create_table + ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 6, $queries ); + $this->assertStringContainsString( WP_PostgreSQL_Driver::MYSQL_CHECK_METADATA_TABLE, $queries[3]['sql'] ); + $this->assertStringContainsString( 'information_schema.check_constraints', $queries[4]['sql'] ); + } + + /** + * Tests NOT ENFORCED CHECK constraints are metadata-only and visible to MySQL introspection. + */ + public function test_create_table_not_enforced_check_constraints_are_metadata_only(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( + 'CREATE TABLE wptests_not_enforced_check ( + id int CHECK (id > 0) NOT ENFORCED, + score int CHECK (score > 0) ENFORCED, + CONSTRAINT score_ceiling CHECK (score < 100) NOT ENFORCED + )' + ); + + $this->assertSame( + array( + array( + 'sql' => "CREATE TABLE \"wptests_not_enforced_check\" (\n \"id\" integer,\n \"score\" integer CONSTRAINT \"wptests_not_enforced_check_chk_2\" CHECK (score > 0)\n)", + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->assertSame( + array( + array( + 'constraint_name' => 'wptests_not_enforced_check_chk_1', + 'check_clause' => 'id > 0', + 'enforced' => 'NO', + ), + array( + 'constraint_name' => 'wptests_not_enforced_check_chk_2', + 'check_clause' => 'score > 0', + 'enforced' => 'YES', + ), + array( + 'constraint_name' => 'score_ceiling', + 'check_clause' => 'score < 100', + 'enforced' => 'NO', + ), + ), + $this->get_mysql_check_metadata_rows( $driver, 'wptests_not_enforced_check' ) + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_not_enforced_check' )[0]->{'Create Table'}; + $this->assertStringContainsString( ' CONSTRAINT `wptests_not_enforced_check_chk_1` CHECK (id > 0) /*!80016 NOT ENFORCED */', $create_table ); + $this->assertStringContainsString( ' CONSTRAINT `wptests_not_enforced_check_chk_2` CHECK (score > 0)', $create_table ); + $this->assertStringContainsString( ' CONSTRAINT `score_ceiling` CHECK (score < 100) /*!80016 NOT ENFORCED */', $create_table ); + + $table_constraints = $driver->query( + "SELECT CONSTRAINT_NAME, ENFORCED + FROM information_schema.table_constraints + WHERE table_name = 'wptests_not_enforced_check' + ORDER BY CONSTRAINT_NAME" + ); + $this->assertSame( + array( + array( 'score_ceiling', 'NO' ), + array( 'wptests_not_enforced_check_chk_1', 'NO' ), + array( 'wptests_not_enforced_check_chk_2', 'YES' ), + ), + array_map( + static function ( $row ): array { + return array( $row->CONSTRAINT_NAME, $row->ENFORCED ); + }, + $table_constraints + ) + ); + + $check_constraints = $driver->query( + "SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_name = 'score_ceiling'" + ); + $this->assertSame( 'score < 100', $check_constraints[0]->CHECK_CLAUSE ); + } + + /** + * Tests MySQL comments round-trip through PostgreSQL-backed introspection. + */ + public function test_mysql_comment_metadata_round_trips_through_introspection(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( + "CREATE TABLE wptests_comment_metadata ( + id int NOT NULL COMMENT 'Identifier', + label varchar(50) DEFAULT NULL COMMENT \"Display label\", + KEY label_lookup (label) COMMENT 'Lookup index' + ) COMMENT='Table note'" + ); + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_comment_metadata', 'BASE TABLE')" + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE wptests_comment_metadata' )[0]->{'Create Table'}; + + $this->assertStringContainsString( " `id` int NOT NULL COMMENT 'Identifier'", $create_table ); + $this->assertStringContainsString( " `label` varchar(50) DEFAULT NULL COMMENT 'Display label'", $create_table ); + $this->assertStringContainsString( " KEY `label_lookup` (`label`) COMMENT 'Lookup index'", $create_table ); + $this->assertStringEndsWith( "COMMENT='Table note'", $create_table ); + + $columns = $driver->query( 'SHOW FULL COLUMNS FROM wptests_comment_metadata' ); + $column_comments = array(); + foreach ( $columns as $column ) { + $column_comments[ $column->Field ] = $column->Comment; + } + $this->assertSame( 'Identifier', $column_comments['id'] ); + $this->assertSame( 'Display label', $column_comments['label'] ); + + $filtered_columns = $driver->query( "SHOW FULL COLUMNS FROM wptests_comment_metadata WHERE Comment = 'Display label'" ); + $this->assertCount( 1, $filtered_columns ); + $this->assertSame( 'label', $filtered_columns[0]->Field ); + + $indexes = $driver->query( "SHOW INDEX FROM wptests_comment_metadata WHERE Index_comment = 'Lookup index'" ); + $this->assertCount( 1, $indexes ); + $this->assertSame( 'label_lookup', $indexes[0]->Key_name ); + $this->assertSame( 'Lookup index', $indexes[0]->Index_comment ); + + $status = $driver->query( "SHOW TABLE STATUS LIKE 'wptests_comment_metadata'" ); + $this->assertCount( 1, $status ); + $this->assertSame( 'Table note', $status[0]->Comment ); + + $tables = $driver->query( + "SELECT TABLE_COMMENT + FROM information_schema.tables + WHERE table_name = 'wptests_comment_metadata'" + ); + $this->assertCount( 1, $tables ); + $this->assertSame( 'Table note', $tables[0]->TABLE_COMMENT ); + + $information_schema_columns = $driver->query( + "SELECT COLUMN_NAME, COLUMN_COMMENT + FROM information_schema.columns + WHERE table_name = 'wptests_comment_metadata' + ORDER BY ORDINAL_POSITION" + ); + $this->assertSame( + array( + array( 'id', 'Identifier' ), + array( 'label', 'Display label' ), + ), + array_map( + static function ( $row ): array { + return array( $row->COLUMN_NAME, $row->COLUMN_COMMENT ); + }, + $information_schema_columns + ) + ); + + $statistics = $driver->query( + "SELECT INDEX_NAME, INDEX_COMMENT + FROM information_schema.statistics + WHERE table_name = 'wptests_comment_metadata'" + ); + $this->assertSame( array( 'label_lookup', 'Lookup index' ), array( $statistics[0]->INDEX_NAME, $statistics[0]->INDEX_COMMENT ) ); + } + + /** + * Tests SHOW CREATE TABLE accepts backtick and main database qualifications. + */ + public function test_show_create_table_accepts_backtick_and_main_database_qualification_forms(): void { + $queries = array( + 'SHOW CREATE TABLE `wptests_show_create_forms`', + 'SHOW CREATE TABLE wptests.wptests_show_create_forms', + 'SHOW CREATE TABLE `wptests`.`wptests_show_create_forms`', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $this->install_show_create_table_fixture( $driver, 'wptests_show_create_forms' ); + + $tables = $driver->query( $query ); + + $this->assertCount( 1, $tables, $query ); + $this->assertSame( 'wptests_show_create_forms', $tables[0]->Table, $query ); + $this->assertStringStartsWith( "CREATE TABLE `wptests_show_create_forms` (\n", $tables[0]->{'Create Table'}, $query ); + foreach ( $driver->get_last_postgresql_queries() as $postgresql_query ) { + $this->assertStringNotContainsString( 'SHOW CREATE TABLE', $postgresql_query['sql'], $query ); + } + } + } + + /** + * Tests SHOW CREATE TABLE for a missing table returns an empty metadata result. + */ + public function test_show_create_table_missing_table_returns_empty_result(): void { + $driver = $this->create_driver(); + + $tables = $driver->query( 'SHOW CREATE TABLE wptests_missing' ); + + $this->assertSame( array(), $tables ); + $this->assertSame( array( 'Table', 'Create Table' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( WP_PostgreSQL_Driver::MYSQL_COLUMN_METADATA_TABLE, $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW CREATE TABLE', $queries[0]['sql'] ); + } + + /** + * Tests SHOW CREATE TABLE information_schema targets return computed metadata. + */ + public function test_show_create_table_information_schema_target_returns_computed_metadata(): void { + $driver = $this->create_driver(); + + $tables = $driver->query( 'SHOW CREATE TABLE information_schema.tables' ); + + $this->assertCount( 1, $tables ); + $this->assertSame( 'tables', $tables[0]->Table ); + $this->assertStringStartsWith( "CREATE TEMPORARY TABLE `tables` (\n", $tables[0]->{'Create Table'} ); + $this->assertStringContainsString( ' `TABLE_SCHEMA` varchar(512) DEFAULT NULL', $tables[0]->{'Create Table'} ); + $this->assertStringContainsString( ' `TABLE_NAME` varchar(512) DEFAULT NULL', $tables[0]->{'Create Table'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW GRANTS returns a static MySQL-shaped grants row. + */ + public function test_show_grants_returns_static_mysql_shaped_row(): void { + $driver = $this->create_driver(); + + $grants = $driver->query( 'SHOW GRANTS' ); + + $this->assertEquals( $this->get_show_grants_expected_result(), $grants ); + $this->assertSame( 'SHOW GRANTS', $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 1, $driver->get_last_column_count() ); + $this->assertSame( + array( + array( + 'name' => 'Grants for root@%', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Grants for root@%', + 'mysqli:db' => 'wptests', + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 4096, + 'precision' => 0, + 'native_type' => 'string', + ), + ), + $driver->get_last_column_meta() + ); + + $assoc = $driver->query( 'SHOW GRANTS', PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'Grants for root@%' => $this->get_show_grants_expected_value(), + ), + ), + $assoc + ); + + $num = $driver->query( 'SHOW GRANTS', PDO::FETCH_NUM ); + $this->assertSame( array( array( $this->get_show_grants_expected_value() ) ), $num ); + } + + /** + * Tests supported SHOW GRANTS FOR/USING forms return the static grants row. + */ + public function test_show_grants_for_and_using_forms_return_static_row(): void { + $queries = array( + 'SHOW GRANTS FOR current_user();', + 'SHOW GRANTS FOR CURRENT_USER', + 'sHoW gRaNtS FoR CuRrEnT_UsEr()', + 'SHOW GRANTS FOR root', + "SHOW GRANTS FOR 'root'@'localhost'", + 'SHOW GRANTS USING role1', + 'SHOW GRANTS FOR CURRENT_USER() USING role1', + 'SHOW GRANTS FOR u@h USING r1,r2', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + $grants = $driver->query( $query ); + + $this->assertEquals( $this->get_show_grants_expected_result(), $grants, $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array( 'Grants for root@%' ), array_column( $driver->get_last_column_meta(), 'name' ), $query ); + } + } + + /** + * Tests SHOW GRANTS updates FOUND_ROWS() accounting. + */ + public function test_show_grants_sets_found_rows_to_one(): void { + $driver = $this->create_driver(); + + $driver->query( 'SHOW GRANTS' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW GRANTS is not table-scoped under USE information_schema. + */ + public function test_show_grants_after_use_information_schema_returns_static_row(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $grants = $driver->query( 'SHOW GRANTS' ); + + $this->assertEquals( $this->get_show_grants_expected_result(), $grants ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 'information_schema', $driver->get_last_column_meta()[0]['mysqli:db'] ); + } + + /** + * Tests malformed SHOW GRANTS syntax fails before backend execution. + */ + public function test_malformed_show_grants_syntax_fails_closed(): void { + $queries = array( + 'SHOW GRANTS FOR', + 'SHOW GRANTS USING', + 'SHOW GRANTS FOR CURRENT_USER(1)', + 'SHOW GRANTS FOR root USING', + "SHOW GRANTS FOR root USING role1 WHERE User = 'root'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW GRANTS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW GRANTS statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW STATUS returns bounded MySQL-shaped status rows. + */ + public function test_show_status_returns_bounded_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SHOW STATUS' ); + + $status = array(); + foreach ( $rows as $row ) { + $status[ $row->Variable_name ] = $row->Value; + } + + $this->assertSame( '0', $status['Uptime'] ); + $this->assertSame( '1', $status['Threads_connected'] ); + $this->assertSame( '0', $status['Questions'] ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array( 'Variable_name', 'Value' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $threads = $driver->query( "SHOW GLOBAL STATUS LIKE 'Threads_%'" ); + $this->assertSame( + array( 'Threads_cached', 'Threads_connected', 'Threads_created', 'Threads_running' ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $threads + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $bare_threads = $driver->query( "SHOW STATUS LIKE 'Threads_%'" ); + $this->assertSame( + array( 'Threads_cached', 'Threads_connected', 'Threads_created', 'Threads_running' ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $bare_threads + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $uptime = $driver->query( "SHOW SESSION STATUS WHERE Variable_name = 'Uptime'" ); + $this->assertCount( 1, $uptime ); + $this->assertSame( 'Uptime', $uptime[0]->Variable_name ); + $this->assertSame( '0', $uptime[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $handlers = $driver->query( "SHOW STATUS WHERE Variable_name LIKE 'Handler_read_%'" ); + $this->assertSame( + array( + 'Handler_read_first', + 'Handler_read_key', + 'Handler_read_next', + 'Handler_read_prev', + 'Handler_read_rnd', + 'Handler_read_rnd_next', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $handlers + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $zero_values = $driver->query( "SHOW STATUS WHERE Value = '0'" ); + $zero_names = array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $zero_values + ); + $this->assertContains( 'Uptime', $zero_names ); + $this->assertContains( 'Questions', $zero_names ); + $this->assertNotContains( 'Threads_connected', $zero_names ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $running_threads = $driver->query( "SHOW STATUS WHERE Variable_name LIKE 'Threads_%' AND Value = '1'" ); + $this->assertSame( + array( + 'Threads_connected', + 'Threads_created', + 'Threads_running', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $running_threads + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $non_zero_values = $driver->query( "SHOW STATUS WHERE Value <> '0'" ); + $this->assertSame( + array( + 'Connections', + 'Threads_connected', + 'Threads_created', + 'Threads_running', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $non_zero_values + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $thread_or_one_values = $driver->query( "SHOW STATUS WHERE Variable_name LIKE 'Threads_%' OR Value = '1'" ); + $this->assertSame( + array( + 'Connections', + 'Threads_cached', + 'Threads_connected', + 'Threads_created', + 'Threads_running', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $thread_or_one_values + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $case_insensitive_threads = $driver->query( "SHOW STATUS WHERE Variable_name LIKE 'threads_%'" ); + $this->assertSame( + array( + 'Threads_cached', + 'Threads_connected', + 'Threads_created', + 'Threads_running', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $case_insensitive_threads + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $binary_threads = $driver->query( "SHOW STATUS WHERE BINARY Variable_name LIKE 'threads_%'" ); + $this->assertSame( array(), $binary_threads ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $escaped_threads = $driver->query( "SHOW STATUS WHERE Variable_name LIKE 'Threads!_%' ESCAPE '!'" ); + $this->assertSame( + array( + 'Threads_cached', + 'Threads_connected', + 'Threads_created', + 'Threads_running', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $escaped_threads + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $selected_values = $driver->query( "SHOW STATUS WHERE Variable_name IN ('Uptime', 'Threads_running')" ); + $this->assertSame( + array( + 'Threads_running', + 'Uptime', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $selected_values + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $unknown_not_in_values = $driver->query( "SHOW STATUS WHERE Value NOT IN ('0', NULL)" ); + $this->assertSame( array(), $unknown_not_in_values ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $truthy_status = $driver->query( 'SHOW STATUS WHERE NOT 0' ); + $this->assertNotCount( 0, $truthy_status ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( (string) count( $truthy_status ), $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests unsupported SHOW STATUS clauses fail before backend execution. + */ + public function test_unsupported_show_status_clauses_fail_closed(): void { + $queries = array( + "SHOW STATUS WHERE Unknown = '0'", + 'SHOW STATUS LIMIT 1', + 'SHOW STATUS LIKE Threads_%', + "SHOW STATUS WHERE Variable_name LIKE 'Threads!!%' ESCAPE '!!'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW STATUS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW STATUS statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW WARNINGS/ERRORS expose empty MySQL-shaped diagnostics. + */ + public function test_show_warnings_and_errors_return_empty_mysql_shaped_diagnostics(): void { + $driver = $this->create_driver(); + + $warnings = $driver->query( 'SHOW WARNINGS' ); + $this->assertSame( array(), $warnings ); + $this->assertSame( array( 'Level', 'Code', 'Message' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $found_rows[0]->{'FOUND_ROWS()'} ); + + $errors = $driver->query( 'SHOW ERRORS LIMIT 0, 10', PDO::FETCH_ASSOC ); + $this->assertSame( array(), $errors ); + $this->assertSame( array( 'Level', 'Code', 'Message' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $limited_warnings = $driver->query( 'SHOW WARNINGS LIMIT 1 OFFSET 0' ); + $this->assertSame( array(), $limited_warnings ); + $this->assertSame( array( 'Level', 'Code', 'Message' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $warnings_count = $driver->query( 'SHOW COUNT(*) WARNINGS' ); + $this->assertSame( '0', $warnings_count[0]->{'@@session.warning_count'} ); + $this->assertSame( array( '@@session.warning_count' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $errors_count = $driver->query( 'SHOW COUNT(*) ERRORS', PDO::FETCH_NUM ); + $this->assertSame( array( array( '0' ) ), $errors_count ); + $this->assertSame( array( '@@session.error_count' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported SHOW WARNINGS/ERRORS clauses fail before backend execution. + */ + public function test_unsupported_show_warnings_and_errors_clauses_fail_closed(): void { + $queries = array( + "SHOW WARNINGS WHERE Level = 'Warning'" => 'Unsupported SHOW WARNINGS statement.', + 'SHOW WARNINGS LIMIT bad' => 'Unsupported SHOW WARNINGS statement.', + 'SHOW WARNINGS LIMIT 1, bad' => 'Unsupported SHOW WARNINGS statement.', + 'SHOW COUNT(*) WARNINGS LIMIT 1' => 'Unsupported SHOW WARNINGS statement.', + "SHOW ERRORS LIKE 'error%'" => 'Unsupported SHOW ERRORS statement.', + 'SHOW ERRORS LIMIT bad' => 'Unsupported SHOW ERRORS statement.', + 'SHOW COUNT(*) ERRORS LIMIT 1' => 'Unsupported SHOW ERRORS statement.', + ); + + foreach ( $queries as $query => $message ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW diagnostics statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW PROCESSLIST returns a bounded current-session row. + */ + public function test_show_processlist_returns_bounded_current_session_row(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SHOW PROCESSLIST' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->Id ); + $this->assertSame( 'root', $rows[0]->User ); + $this->assertSame( 'localhost', $rows[0]->Host ); + $this->assertSame( 'wptests', $rows[0]->db ); + $this->assertSame( 'Query', $rows[0]->Command ); + $this->assertSame( '0', $rows[0]->Time ); + $this->assertSame( '', $rows[0]->State ); + $this->assertSame( 'SHOW PROCESSLIST', $rows[0]->Info ); + $this->assertSame( array( 'Id', 'User', 'Host', 'db', 'Command', 'Time', 'State', 'Info' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $full = $driver->query( 'SHOW FULL PROCESSLIST', PDO::FETCH_ASSOC ); + + $this->assertSame( 'information_schema', $full[0]['db'] ); + $this->assertSame( 'SHOW FULL PROCESSLIST', $full[0]['Info'] ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests SHOW PROCESSLIST supports MySQL-style WHERE and LIMIT clauses. + */ + public function test_show_processlist_where_and_limit_clauses_filter_current_session_row(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SHOW PROCESSLIST WHERE Command = 'Query' AND Time + 1 = 1 LIMIT 1" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'root', $rows[0]->User ); + $this->assertSame( 'Query', $rows[0]->Command ); + $this->assertSame( array( 'Id', 'User', 'Host', 'db', 'Command', 'Time', 'State', 'Info' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $matching_info = $driver->query( "SHOW PROCESSLIST WHERE Info LIKE 'SHOW PROCESSLIST WHERE Info%'" ); + $this->assertCount( 1, $matching_info ); + $this->assertStringStartsWith( 'SHOW PROCESSLIST WHERE Info', $matching_info[0]->Info ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $case_sensitive_command = $driver->query( "SHOW PROCESSLIST WHERE BINARY Command = 'query'" ); + $this->assertSame( array(), $case_sensitive_command ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $limited_assoc = $driver->query( 'SHOW PROCESSLIST LIMIT 0, 1', PDO::FETCH_ASSOC ); + $this->assertCount( 1, $limited_assoc ); + $this->assertSame( 'root', $limited_assoc[0]['User'] ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $offset_past_current_row = $driver->query( 'SHOW PROCESSLIST LIMIT 1 OFFSET 1' ); + $this->assertSame( array(), $offset_past_current_row ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported SHOW PROCESSLIST clauses fail before backend execution. + */ + public function test_unsupported_show_processlist_clauses_fail_closed(): void { + $queries = array( + 'SHOW GLOBAL PROCESSLIST', + "SHOW PROCESSLIST WHERE Unknown = 'Query'", + 'SHOW PROCESSLIST LIMIT bad', + "SHOW PROCESSLIST WHERE Command = 'Query' ORDER BY Id", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW PROCESSLIST statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW PROCESSLIST statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW CHARACTER SET returns MySQL-shaped static character set rows. + */ + public function test_show_character_set_returns_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SHOW CHARACTER SET' ); + + $this->assertEquals( + array( + (object) array( + 'Charset' => 'binary', + 'Description' => 'Binary pseudo charset', + 'Default collation' => 'binary', + 'Maxlen' => '1', + ), + (object) array( + 'Charset' => 'utf8', + 'Description' => 'UTF-8 Unicode', + 'Default collation' => 'utf8_general_ci', + 'Maxlen' => '3', + ), + (object) array( + 'Charset' => 'utf8mb4', + 'Description' => 'UTF-8 Unicode', + 'Default collation' => 'utf8mb4_0900_ai_ci', + 'Maxlen' => '4', + ), + ), + $rows + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( + array( 'Charset', 'Description', 'Default collation', 'Maxlen' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + + $charset_rows = $driver->query( 'SHOW CHARSET' ); + $this->assertEquals( $rows, $charset_rows ); + + $like_rows = $driver->query( "SHOW CHARACTER SET LIKE 'utf8%'" ); + $this->assertSame( + array( 'utf8', 'utf8mb4' ), + array_map( + static function ( $row ): string { + return $row->Charset; + }, + $like_rows + ) + ); + + $where_rows = $driver->query( "SHOW CHARACTER SET WHERE Charset = 'utf8mb4'" ); + $this->assertSame( array( 'utf8mb4' ), array( $where_rows[0]->Charset ) ); + + $collation_rows = $driver->query( "SHOW CHARACTER SET WHERE `Default collation` = 'binary'" ); + $this->assertSame( array( 'binary' ), array( $collation_rows[0]->Charset ) ); + + $where_expression_rows = $driver->query( "SHOW CHARACTER SET WHERE Charset <> 'binary' AND Maxlen >= 4" ); + $this->assertSame( array( 'utf8mb4' ), array( $where_expression_rows[0]->Charset ) ); + + $literal_left_rows = $driver->query( "SHOW CHARACTER SET WHERE 'Charset' = 'utf8mb4'" ); + $this->assertSame( array(), $literal_left_rows ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW COLLATION returns MySQL-shaped static collation rows. + */ + public function test_show_collation_returns_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SHOW COLLATION' ); + + $this->assertSame( + array( + 'binary', + 'utf8_bin', + 'utf8_general_ci', + 'utf8_unicode_ci', + 'utf8mb4_bin', + 'utf8mb4_unicode_ci', + 'utf8mb4_0900_ai_ci', + ), + array_map( + static function ( $row ) { + return $row->Collation; + }, + $rows + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( + array( 'Collation', 'Charset', 'Id', 'Default', 'Compiled', 'Sortlen', 'Pad_attribute' ), + array_column( $driver->get_last_column_meta(), 'name' ) + ); + + $like_rows = $driver->query( "SHOW COLLATION LIKE 'utf8%'" ); + $this->assertCount( 6, $like_rows ); + $this->assertSame( 'utf8_bin', $like_rows[0]->Collation ); + $this->assertSame( 'utf8mb4_0900_ai_ci', $like_rows[5]->Collation ); + + $where_rows = $driver->query( "SHOW COLLATION WHERE Collation = 'utf8_bin'" ); + $this->assertSame( array( 'utf8_bin' ), array( $where_rows[0]->Collation ) ); + + $case_insensitive_charset_rows = $driver->query( "SHOW COLLATION WHERE Charset = 'UTF8'" ); + $this->assertSame( + array( 'utf8_bin', 'utf8_general_ci', 'utf8_unicode_ci' ), + array_map( + static function ( $row ): string { + return $row->Collation; + }, + $case_insensitive_charset_rows + ) + ); + + $binary_charset_rows = $driver->query( "SHOW COLLATION WHERE BINARY Charset = 'UTF8'" ); + $this->assertSame( array(), $binary_charset_rows ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $where_expression_rows = $driver->query( "SHOW COLLATION WHERE Collation LIKE 'utf8%' AND Charset = 'utf8'" ); + $this->assertSame( + array( 'utf8_bin', 'utf8_general_ci', 'utf8_unicode_ci' ), + array_map( + static function ( $row ): string { + return $row->Collation; + }, + $where_expression_rows + ) + ); + + $not_equal_rows = $driver->query( "SHOW COLLATION WHERE Collation <> 'binary' AND Charset = 'utf8mb4'" ); + $this->assertSame( + array( 'utf8mb4_bin', 'utf8mb4_unicode_ci', 'utf8mb4_0900_ai_ci' ), + array_map( + static function ( $row ): string { + return $row->Collation; + }, + $not_equal_rows + ) + ); + + $literal_left_rows = $driver->query( "SHOW COLLATION WHERE 'Collation' = 'utf8_bin'" ); + $this->assertSame( array(), $literal_left_rows ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW DATABASES and SHOW SCHEMAS return MySQL-shaped database rows. + */ + public function test_show_databases_and_schemas_return_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $databases = $driver->query( 'SHOW DATABASES' ); + + $this->assertEquals( + array( + (object) array( 'Database' => 'information_schema' ), + (object) array( 'Database' => 'wptests' ), + ), + $databases + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array( 'Database' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $like_rows = $driver->query( 'SHOW DATABASES LIKE "w%"' ); + $this->assertEquals( array( (object) array( 'Database' => 'wptests' ) ), $like_rows ); + + $where_rows = $driver->query( 'SHOW DATABASES WHERE `Database` = "information_schema"' ); + $this->assertEquals( array( (object) array( 'Database' => 'information_schema' ) ), $where_rows ); + + $where_rows = $driver->query( "SHOW DATABASES WHERE Database = 'information_schema'" ); + $this->assertEquals( array( (object) array( 'Database' => 'information_schema' ) ), $where_rows ); + + $where_like_rows = $driver->query( "SHOW DATABASES WHERE Database LIKE 'info%'" ); + $this->assertEquals( array( (object) array( 'Database' => 'information_schema' ) ), $where_like_rows ); + + $where_or_rows = $driver->query( "SHOW DATABASES WHERE Database = 'wptests' OR Database = 'information_schema'" ); + $this->assertEquals( $databases, $where_or_rows ); + + $this->assertSame( array(), $driver->query( "SHOW DATABASES WHERE 'Database' = 'information_schema'" ) ); + $this->assertSame( array(), $driver->query( 'SHOW DATABASES WHERE "Database" = \'information_schema\'' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $schemas = $driver->query( 'SHOW SCHEMAS' ); + $this->assertEquals( $databases, $schemas ); + } + + /** + * Tests SQLite-compatible FOUND_ROWS() accounting for static SHOW/admin rows. + */ + public function test_static_show_and_table_administration_update_found_rows_accounting(): void { + $driver = $this->create_driver(); + + $driver->query( 'SHOW PROCESSLIST' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( 'SHOW VARIABLES' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( 'SHOW COLLATION' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '7', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( "SHOW COLLATION WHERE Collation LIKE 'utf8mb4%'" ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '3', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( 'SHOW CHARACTER SET' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '3', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( 'SHOW DATABASES' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( "SHOW DATABASES WHERE Database = 'missing'" ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $found_rows[0]->{'FOUND_ROWS()'} ); + + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'SHOW COLUMNS FROM wptests_options' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '4', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( 'SHOW PROCESSLIST' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( 'SHOW COLUMNS FROM wptests_options' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '4', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( "SHOW TABLES LIKE 'wptests_%'" ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '3', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( "SHOW TABLE STATUS WHERE Name = 'wptests_options'" ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( 'SHOW CREATE TABLE wptests_missing' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( 'CREATE TABLE found_rows_admin (id INTEGER)' ); + $driver->query( 'ANALYZE TABLE found_rows_admin' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $found_rows[0]->{'FOUND_ROWS()'} ); + + $driver->query( 'OPTIMIZE TABLE found_rows_missing' ); + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '2', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests SHOW CREATE DATABASE/SCHEMA return MySQL-shaped static metadata. + */ + public function test_show_create_database_returns_mysql_shaped_metadata(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SHOW CREATE DATABASE `wptests`' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'wptests', $rows[0]->Database ); + $this->assertSame( + 'CREATE DATABASE `wptests` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + $rows[0]->{'Create Database'} + ); + $this->assertSame( array( 'Database', 'Create Database' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $rows = $driver->query( 'SHOW CREATE SCHEMA IF NOT EXISTS wptests', PDO::FETCH_ASSOC ); + $this->assertSame( + array( + array( + 'Database' => 'wptests', + 'Create Database' => 'CREATE DATABASE IF NOT EXISTS `wptests` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + ), + ), + $rows + ); + + $this->assertSame( array(), $driver->query( 'SHOW CREATE DATABASE missing_database' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests malformed SHOW CREATE DATABASE clauses fail before backend execution. + */ + public function test_unsupported_show_create_database_clauses_fail_closed(): void { + $queries = array( + 'SHOW CREATE DATABASE', + 'SHOW CREATE DATABASE wptests LIKE "wp%"', + 'SHOW CREATE DATABASE IF EXISTS wptests', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW CREATE DATABASE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW CREATE DATABASE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW ENGINES returns MySQL-shaped static storage engine rows. + */ + public function test_show_engines_returns_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SHOW ENGINES' ); + + $this->assertSame( + array( 'InnoDB', 'MEMORY', 'MyISAM' ), + array_map( + static function ( $row ): string { + return $row->Engine; + }, + $rows + ) + ); + $this->assertSame( 'DEFAULT', $rows[0]->Support ); + $this->assertSame( 'YES', $rows[0]->Transactions ); + $this->assertSame( array( 'Engine', 'Support', 'Comment', 'Transactions', 'XA', 'Savepoints' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $default_rows = $driver->query( "SHOW STORAGE ENGINES WHERE Support = 'DEFAULT'" ); + $this->assertSame( array( 'InnoDB' ), array( $default_rows[0]->Engine ) ); + + $like_rows = $driver->query( "SHOW ENGINES LIKE 'M%'" ); + $this->assertSame( + array( 'MEMORY', 'MyISAM' ), + array_map( + static function ( $row ): string { + return $row->Engine; + }, + $like_rows + ) + ); + + $transaction_rows = $driver->query( "SHOW ENGINES WHERE Transactions = 'YES' AND Savepoints = 'YES'" ); + $this->assertSame( array( 'InnoDB' ), array( $transaction_rows[0]->Engine ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported SHOW ENGINES clauses fail before backend execution. + */ + public function test_unsupported_show_engines_clauses_fail_closed(): void { + $queries = array( + 'SHOW ENGINES LIMIT 1', + 'SHOW ENGINES LIKE Engine', + "SHOW ENGINES WHERE Unknown = 'x'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW ENGINES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW ENGINES statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW PLUGINS returns an empty MySQL-shaped plugin metadata result. + */ + public function test_show_plugins_returns_empty_mysql_shaped_rows(): void { + $driver = $this->create_driver(); + + $driver->query( 'SHOW PROCESSLIST' ); + $stale_found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '1', $stale_found_rows[0]->{'FOUND_ROWS()'} ); + + $rows = $driver->query( 'SHOW PLUGINS' ); + + $this->assertSame( array(), $rows ); + $this->assertSame( array( 'Name', 'Status', 'Type', 'Library', 'License' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $like_rows = $driver->query( "SHOW PLUGINS LIKE 'auth_%'" ); + $this->assertSame( array(), $like_rows ); + $this->assertSame( array( 'Name', 'Status', 'Type', 'Library', 'License' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $where_rows = $driver->query( "SHOW PLUGINS WHERE Type = 'STORAGE ENGINE' AND Status = 'ACTIVE'" ); + $this->assertSame( array(), $where_rows ); + $this->assertSame( array( 'Name', 'Status', 'Type', 'Library', 'License' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $assoc_rows = $driver->query( "SHOW PLUGINS WHERE BINARY Name = 'InnoDB'", PDO::FETCH_ASSOC ); + $this->assertSame( array(), $assoc_rows ); + $this->assertSame( array( 'Name', 'Status', 'Type', 'Library', 'License' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $found_rows = $driver->query( 'SELECT FOUND_ROWS()' ); + $this->assertSame( '0', $found_rows[0]->{'FOUND_ROWS()'} ); + } + + /** + * Tests unsupported SHOW PLUGINS clauses fail before backend execution. + */ + public function test_unsupported_show_plugins_clauses_fail_closed(): void { + $queries = array( + 'SHOW PLUGINS LIMIT 1', + 'SHOW PLUGINS LIKE Name', + "SHOW PLUGINS WHERE Unknown = 'x'", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW PLUGINS statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW PLUGINS statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests USE accepts main database identifiers without backend execution. + */ + public function test_use_statement_accepts_main_database_identifiers_without_backend_execution(): void { + $queries = array( + 'USE wptests', + 'USE `wptests`', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + + $database = $driver->query( 'SELECT DATABASE()' ); + + $this->assertSame( 'wptests', $database[0]->{'DATABASE()'}, $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( 'DATABASE()', $driver->get_last_column_meta()[0]['name'], $query ); + } + } + + /** + * Tests USE information_schema changes current database state without backend execution. + */ + public function test_use_statement_accepts_information_schema_without_backend_execution(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + + $database = $driver->query( 'SELECT DATABASE()' ); + + $this->assertSame( 'information_schema', $database[0]->{'DATABASE()'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $tables = $driver->query( 'SHOW TABLES' ); + + $this->assertSame( array(), $tables ); + $this->assertSame( 'Tables_in_information_schema', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $full_tables = $driver->query( 'SHOW FULL TABLES' ); + + $this->assertSame( array(), $full_tables ); + $this->assertSame( array( 'Tables_in_information_schema', 'Table_type' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $qualified_tables = $driver->query( 'SHOW TABLES FROM information_schema' ); + + $this->assertSame( array(), $qualified_tables ); + $this->assertSame( 'Tables_in_information_schema', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $table_status = $driver->query( 'SHOW TABLE STATUS' ); + + $this->assertSame( array(), $table_status ); + $this->assertSame( $this->get_show_table_status_column_names(), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $qualified_table_status = $driver->query( 'SHOW TABLE STATUS FROM information_schema' ); + + $this->assertSame( array(), $qualified_table_status ); + $this->assertSame( $this->get_show_table_status_column_names(), array_column( $driver->get_last_column_meta(), 'name' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $databases = $driver->query( 'SHOW DATABASES' ); + + $this->assertEquals( + array( + (object) array( 'Database' => 'information_schema' ), + (object) array( 'Database' => 'wptests' ), + ), + $databases + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported USE database names fail before backend execution. + */ + public function test_use_statement_rejects_unsupported_database_before_backend_execution(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'USE other_db' ); + $this->fail( 'Expected unsupported USE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported USE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $database = $driver->query( 'SELECT DATABASE()' ); + + $this->assertSame( 'wptests', $database[0]->{'DATABASE()'} ); + } + + /** + * Tests USE can switch back from information_schema to the main database. + */ + public function test_use_statement_switches_back_to_main_database(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $this->assertSame( 0, $driver->query( 'USE `wptests`' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $database = $driver->query( 'SELECT DATABASE()' ); + + $this->assertSame( 'wptests', $database[0]->{'DATABASE()'} ); + + $tables = $driver->query( 'SHOW TABLES' ); + + $this->assertCount( 3, $tables ); + $this->assertSame( 'Tables_in_wptests', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'wptests_options', $tables[0]->Tables_in_wptests ); + } + + /** + * Tests USE information_schema routes supported direct table reads. + */ + public function test_use_statement_information_schema_table_reads_route_supported_relations(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $columns = $driver->query( + "SELECT t.table_name AS table_name, c.column_name AS column_name + FROM tables AS t, columns AS c + WHERE c.table_schema = t.table_schema + AND c.table_name = t.table_name + AND t.table_name = 'wptests_options' + ORDER BY c.ordinal_position" + ); + + $this->assertSame( + array( + array( 'wptests_options', 'option_id' ), + array( 'wptests_options', 'option_name' ), + array( 'wptests_options', 'option_value' ), + array( 'wptests_options', 'autoload' ), + ), + array_map( + static function ( $row ): array { + return array( $row->table_name, $row->column_name ); + }, + $columns + ) + ); + + $sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'AS "t"', $sql ); + $this->assertStringContainsString( 'AS "c"', $sql ); + $this->assertStringContainsString( '"c"."TABLE_SCHEMA" = "t"."TABLE_SCHEMA"', $sql ); + + $checks = $driver->query( + "SELECT tc.constraint_name AS constraint_name, cc.check_clause AS check_clause + FROM table_constraints AS tc + JOIN check_constraints AS cc + ON cc.constraint_schema = tc.constraint_schema + AND cc.constraint_name = tc.constraint_name + WHERE tc.table_name = 'wptests_posts'" + ); + + $this->assertEquals( + array( + (object) array( + 'constraint_name' => 'wptests_posts_status_chk', + 'check_clause' => 'post_status IS NOT NULL', + ), + ), + $checks + ); + } + + /** + * Tests USE information_schema routes multi-source catalog joins. + */ + public function test_use_statement_information_schema_routes_multi_source_catalog_joins(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $rows = $driver->query( + "SELECT t.table_name AS table_name, c.column_name AS column_name, s.index_name AS index_name + FROM tables AS t + JOIN columns AS c USING (table_schema, table_name) + LEFT JOIN statistics AS s + ON s.table_schema = c.table_schema + AND s.table_name = c.table_name + AND s.column_name = c.column_name + WHERE t.table_name = 'wptests_options' + ORDER BY c.ordinal_position, s.index_name" + ); + + $this->assertSame( + array( + array( 'wptests_options', 'option_id', 'PRIMARY' ), + array( 'wptests_options', 'option_name', 'option_name' ), + array( 'wptests_options', 'option_value', null ), + array( 'wptests_options', 'autoload', 'autoload' ), + ), + array_map( + static function ( $row ): array { + return array( $row->table_name, $row->column_name, $row->index_name ); + }, + $rows + ) + ); + + $sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'AS "t"', $sql ); + $this->assertStringContainsString( 'AS "c"', $sql ); + $this->assertStringContainsString( 'AS "s"', $sql ); + $this->assertStringContainsString( 'LEFT JOIN', $sql ); + } + + /** + * Tests USE information_schema routes supported CTE reads. + */ + public function test_use_statement_information_schema_cte_selects_route_supported_relations(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $rows = $driver->query( + "WITH + cols AS ( + SELECT column_name + FROM columns + WHERE table_name = 'wptests_options' + ), + indexes AS ( + SELECT DISTINCT index_name + FROM statistics + WHERE table_name = 'wptests_options' + ) + SELECT CONCAT(column_name, ' (column)') AS name + FROM cols + UNION ALL + SELECT CONCAT(index_name, ' (index)') AS name + FROM indexes + ORDER BY name" + ); + + $this->assertSame( + array( + 'PRIMARY (index)', + 'autoload (column)', + 'autoload (index)', + 'option_id (column)', + 'option_name (column)', + 'option_name (index)', + 'option_value (column)', + ), + array_column( $rows, 'name' ) + ); + + $sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'WITH "cols" ("column_name") AS', $sql ); + $this->assertStringContainsString( '"indexes" ("index_name") AS', $sql ); + $this->assertStringContainsString( 'AS "columns"', $sql ); + $this->assertStringContainsString( 'AS "statistics"', $sql ); + $this->assertStringContainsString( 'UNION ALL', $sql ); + } + + /** + * Tests USE information_schema routes final CTE SELECT reads from supported relations. + */ + public function test_use_statement_information_schema_cte_final_select_routes_supported_relations(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $rows = $driver->query( + "WITH cols AS ( + SELECT DISTINCT table_name + FROM columns + WHERE table_name = 'wptests_options' + ) + SELECT t.table_name AS table_name, t.table_type AS table_type + FROM cols + JOIN tables AS t ON t.table_name = cols.table_name + ORDER BY t.table_name" + ); + + $this->assertEquals( + array( + (object) array( + 'table_name' => 'wptests_options', + 'table_type' => 'BASE TABLE', + ), + ), + $rows + ); + + $sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'WITH "cols" ("table_name") AS', $sql ); + $this->assertStringContainsString( '"cols" AS "cols"', $sql ); + $this->assertStringContainsString( 'AS "t"', $sql ); + $this->assertStringContainsString( 'USING ("TABLE_NAME")', $sql ); + } + + /** + * Tests explicit information_schema CTE final SELECT reads route supported relations. + */ + public function test_explicit_information_schema_cte_final_select_routes_supported_relations(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + + $rows = $driver->query( + "WITH cols AS ( + SELECT DISTINCT table_name + FROM information_schema.columns + WHERE table_name = 'wptests_options' + ) + SELECT t.table_name AS table_name + FROM cols + JOIN information_schema.tables AS t ON t.table_name = cols.table_name" + ); + + $this->assertEquals( + array( + (object) array( + 'table_name' => 'wptests_options', + ), + ), + $rows + ); + + $sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'WITH "cols" ("table_name") AS', $sql ); + $this->assertStringContainsString( '"cols" AS "cols"', $sql ); + $this->assertStringContainsString( 'AS "t"', $sql ); + $this->assertStringNotContainsString( 'information_schema.tables AS t', $sql ); + } + + /** + * Tests unsupported information_schema CTE SELECT shapes fail closed. + */ + public function test_information_schema_cte_selects_fail_closed_for_recursive_and_unsupported_final_sources(): void { + $queries = array( + "WITH RECURSIVE cols AS ( + SELECT column_name FROM columns WHERE table_name = 'wptests_options' + ) + SELECT * FROM cols", + "WITH cols AS ( + SELECT column_name FROM columns WHERE table_name = 'wptests_options' + ) + SELECT * FROM unsupported_relation", + "WITH cols AS ( + SELECT column_name FROM columns WHERE table_name = 'wptests_options' + ) + SELECT * FROM information_schema.unsupported_relation", + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported information_schema CTE SELECT to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unsupported USE information_schema table reads fail closed. + */ + public function test_use_statement_information_schema_unsupported_table_reads_fail_closed(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_options (option_id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $queries = array( + 'SELECT * FROM wptests_options', + 'WITH q AS (SELECT * FROM wptests_options) SELECT * FROM q', + 'EXPLAIN SELECT * FROM wptests_options', + ); + + foreach ( $queries as $query ) { + try { + $driver->query( $query ); + $this->fail( 'Expected information_schema table read to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests USE information_schema routes table metadata handlers. + */ + public function test_use_statement_information_schema_table_metadata_handlers_route_supported_relations(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $columns = $driver->query( "SHOW COLUMNS FROM tables WHERE Field = 'TABLE_NAME'" ); + + $this->assertEquals( + array( + (object) array( + 'Field' => 'TABLE_NAME', + 'Type' => 'varchar(512)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + ), + $columns + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $full_fields = $driver->query( "SHOW FULL FIELDS FROM columns WHERE Field = 'COLUMN_NAME'" ); + + $this->assertCount( 1, $full_fields ); + $this->assertSame( 'COLUMN_NAME', $full_fields[0]->Field ); + $this->assertSame( 'varchar(512)', $full_fields[0]->Type ); + $this->assertNull( $full_fields[0]->Collation ); + $this->assertSame( 'select', $full_fields[0]->Privileges ); + + $index_driver = $this->create_show_index_driver(); + + $this->assertSame( 0, $index_driver->query( 'USE information_schema' ) ); + + $indexes = $index_driver->query( 'SHOW INDEX FROM tables' ); + + $this->assertSame( array(), $indexes ); + $this->assertSame( + array( + 'Table', + 'Non_unique', + 'Key_name', + 'Seq_in_index', + 'Column_name', + 'Collation', + 'Cardinality', + 'Sub_part', + 'Packed', + 'Null', + 'Index_type', + 'Comment', + 'Index_comment', + 'Visible', + 'Expression', + ), + array_column( $index_driver->get_last_column_meta(), 'name' ) + ); + $this->assertSame( array(), $index_driver->get_last_postgresql_queries() ); + + $show_create_driver = $this->create_driver(); + + $this->assertSame( 0, $show_create_driver->query( 'USE information_schema' ) ); + + $show_create = $show_create_driver->query( 'SHOW CREATE TABLE tables' ); + + $this->assertCount( 1, $show_create ); + $this->assertSame( 'tables', $show_create[0]->Table ); + $this->assertStringStartsWith( "CREATE TEMPORARY TABLE `tables` (\n", $show_create[0]->{'Create Table'} ); + $this->assertStringContainsString( ' `TABLE_NAME` varchar(512) DEFAULT NULL', $show_create[0]->{'Create Table'} ); + $this->assertSame( array(), $show_create_driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW-backed information_schema relations are available to direct metadata queries. + */ + public function test_direct_information_schema_static_relations_expose_show_backed_metadata(): void { + $driver = $this->create_driver(); + + $engines = $driver->query( + "SELECT ENGINE, SUPPORT, TRANSACTIONS + FROM information_schema.engines + WHERE ENGINE = 'InnoDB'" + ); + + $this->assertEquals( + array( + (object) array( + 'ENGINE' => 'InnoDB', + 'SUPPORT' => 'DEFAULT', + 'TRANSACTIONS' => 'YES', + ), + ), + $engines + ); + + $variables = $driver->query( + "SELECT VARIABLE_NAME, VARIABLE_VALUE + FROM information_schema.session_variables + WHERE VARIABLE_NAME = 'sql_mode'" + ); + + $this->assertCount( 1, $variables ); + $this->assertSame( 'sql_mode', $variables[0]->VARIABLE_NAME ); + $this->assertSame( $driver->get_sql_mode(), $variables[0]->VARIABLE_VALUE ); + + $global_variables = $driver->query( + "SELECT VARIABLE_NAME + FROM information_schema.global_variables + WHERE VARIABLE_NAME LIKE 'character_set_%' + ORDER BY VARIABLE_NAME" + ); + + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_database', + 'character_set_results', + 'character_set_server', + ), + array_column( $global_variables, 'VARIABLE_NAME' ) + ); + + $this->assertSame( 0, $driver->query( "SET SESSION time_zone = '+02:00'" ) ); + $this->assertSame( 0, $driver->query( "SET GLOBAL time_zone = '+03:00'" ) ); + + $session_time_zone = $driver->query( + "SELECT VARIABLE_VALUE + FROM information_schema.session_variables + WHERE VARIABLE_NAME = 'time_zone'" + ); + $global_time_zone = $driver->query( + "SELECT VARIABLE_VALUE + FROM information_schema.global_variables + WHERE VARIABLE_NAME = 'time_zone'" + ); + + $this->assertCount( 1, $session_time_zone ); + $this->assertCount( 1, $global_time_zone ); + $this->assertSame( '+02:00', $session_time_zone[0]->VARIABLE_VALUE ); + $this->assertSame( '+03:00', $global_time_zone[0]->VARIABLE_VALUE ); + + $status = $driver->query( + "SELECT VARIABLE_NAME, VARIABLE_VALUE + FROM information_schema.session_status + WHERE VARIABLE_NAME = 'Threads_running'" + ); + + $this->assertEquals( + array( + (object) array( + 'VARIABLE_NAME' => 'Threads_running', + 'VARIABLE_VALUE' => '1', + ), + ), + $status + ); + + $processlist = $driver->query( 'SELECT USER, DB, COMMAND, INFO FROM information_schema.processlist' ); + + $this->assertCount( 1, $processlist ); + $this->assertSame( 'root', $processlist[0]->USER ); + $this->assertSame( 'wptests', $processlist[0]->DB ); + $this->assertSame( 'Query', $processlist[0]->COMMAND ); + $this->assertStringContainsString( 'information_schema.processlist', $processlist[0]->INFO ); + + $driver->query( 'USE information_schema' ); + $engine_columns = $driver->query( "SHOW COLUMNS FROM engines WHERE Field = 'ENGINE'" ); + + $this->assertEquals( + array( + (object) array( + 'Field' => 'ENGINE', + 'Type' => 'varchar(512)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => null, + 'Extra' => '', + ), + ), + $engine_columns + ); + } + + /** + * Tests information_schema.PLUGINS is exposed as an empty plugin metadata relation. + */ + public function test_direct_information_schema_plugins_relation_is_empty_and_queryable(): void { + $driver = $this->create_driver(); + + $plugins = $driver->query( + 'SELECT PLUGIN_NAME, PLUGIN_STATUS + FROM information_schema.plugins' + ); + + $this->assertSame( array(), $plugins ); + $this->assertSame( array( 'PLUGIN_NAME', 'PLUGIN_STATUS' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $filtered = $driver->query( + "SELECT PLUGIN_NAME + FROM information_schema.plugins + WHERE PLUGIN_TYPE = 'STORAGE ENGINE' + AND LOAD_OPTION = 'ON'" + ); + + $this->assertSame( array(), $filtered ); + + $count = $driver->query( + "SELECT COUNT(*) AS plugin_count + FROM information_schema.plugins + WHERE PLUGIN_NAME = 'InnoDB'" + ); + + $this->assertCount( 1, $count ); + $this->assertSame( '0', $count[0]->plugin_count ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $routed = $driver->query( + "SELECT PLUGIN_NAME + FROM plugins + WHERE PLUGIN_STATUS = 'ACTIVE'" + ); + + $this->assertSame( array(), $routed ); + + $columns = $driver->query( "SHOW COLUMNS FROM plugins LIKE 'PLUGIN_%'" ); + $this->assertSame( + array( + 'PLUGIN_NAME', + 'PLUGIN_VERSION', + 'PLUGIN_STATUS', + 'PLUGIN_TYPE', + 'PLUGIN_TYPE_VERSION', + 'PLUGIN_LIBRARY', + 'PLUGIN_LIBRARY_VERSION', + 'PLUGIN_AUTHOR', + 'PLUGIN_DESCRIPTION', + 'PLUGIN_LICENSE', + ), + array_column( $columns, 'Field' ) + ); + } + + /** + * Tests privilege/security information_schema relations are empty and queryable. + */ + public function test_direct_information_schema_privilege_security_relations_are_empty_and_queryable(): void { + $relations = array( + 'user_privileges' => array( 'GRANTEE', 'TABLE_CATALOG', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + 'schema_privileges' => array( 'GRANTEE', 'TABLE_CATALOG', 'TABLE_SCHEMA', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + 'table_privileges' => array( 'GRANTEE', 'TABLE_CATALOG', 'TABLE_SCHEMA', 'TABLE_NAME', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + 'column_privileges' => array( 'GRANTEE', 'TABLE_CATALOG', 'TABLE_SCHEMA', 'TABLE_NAME', 'COLUMN_NAME', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + 'applicable_roles' => array( 'USER', 'HOST', 'GRANTEE', 'GRANTEE_HOST', 'ROLE_NAME', 'ROLE_HOST', 'IS_GRANTABLE', 'IS_DEFAULT', 'IS_MANDATORY' ), + 'administrable_role_authorizations' => array( 'USER', 'HOST', 'GRANTEE', 'GRANTEE_HOST', 'ROLE_NAME', 'ROLE_HOST', 'IS_GRANTABLE', 'IS_DEFAULT', 'IS_MANDATORY' ), + 'enabled_roles' => array( 'ROLE_NAME', 'ROLE_HOST', 'IS_DEFAULT', 'IS_MANDATORY' ), + 'role_table_grants' => array( 'GRANTOR', 'GRANTOR_HOST', 'GRANTEE', 'GRANTEE_HOST', 'TABLE_CATALOG', 'TABLE_SCHEMA', 'TABLE_NAME', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + 'role_column_grants' => array( 'GRANTOR', 'GRANTOR_HOST', 'GRANTEE', 'GRANTEE_HOST', 'TABLE_CATALOG', 'TABLE_SCHEMA', 'TABLE_NAME', 'COLUMN_NAME', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + 'role_routine_grants' => array( 'GRANTOR', 'GRANTOR_HOST', 'GRANTEE', 'GRANTEE_HOST', 'SPECIFIC_CATALOG', 'SPECIFIC_SCHEMA', 'SPECIFIC_NAME', 'ROUTINE_CATALOG', 'ROUTINE_SCHEMA', 'ROUTINE_NAME', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + ); + + foreach ( $relations as $relation => $columns ) { + $driver = $this->create_driver(); + $rows = $driver->query( "SELECT * FROM information_schema.$relation" ); + + $this->assertSame( array(), $rows, $relation ); + $this->assertSame( $columns, array_column( $driver->get_last_column_meta(), 'name' ), $relation ); + } + + $driver = $this->create_driver(); + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $roles = $driver->query( "SELECT ROLE_NAME FROM enabled_roles WHERE IS_DEFAULT = 'YES'" ); + $this->assertSame( array(), $roles ); + $this->assertSame( array( 'ROLE_NAME' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $columns = $driver->query( "SHOW COLUMNS FROM enabled_roles LIKE 'IS_%'" ); + $this->assertSame( array( 'IS_DEFAULT', 'IS_MANDATORY' ), array_column( $columns, 'Field' ) ); + + $grant_columns = $driver->query( "SHOW COLUMNS FROM role_table_grants LIKE '%GRANT%'" ); + $this->assertSame( + array( 'GRANTOR', 'GRANTOR_HOST', 'GRANTEE', 'GRANTEE_HOST', 'IS_GRANTABLE' ), + array_column( $grant_columns, 'Field' ) + ); + + $describe = $driver->query( 'DESCRIBE role_routine_grants' ); + $this->assertSame( + array( + 'GRANTOR', + 'GRANTOR_HOST', + 'GRANTEE', + 'GRANTEE_HOST', + 'SPECIFIC_CATALOG', + 'SPECIFIC_SCHEMA', + 'SPECIFIC_NAME', + 'ROUTINE_CATALOG', + 'ROUTINE_SCHEMA', + 'ROUTINE_NAME', + 'PRIVILEGE_TYPE', + 'IS_GRANTABLE', + ), + array_column( $describe, 'Field' ) + ); + + $qualified_describe = $driver->query( 'DESC information_schema.role_table_grants' ); + $this->assertSame( + array( 'GRANTOR', 'GRANTOR_HOST', 'GRANTEE', 'GRANTEE_HOST', 'TABLE_CATALOG', 'TABLE_SCHEMA', 'TABLE_NAME', 'PRIVILEGE_TYPE', 'IS_GRANTABLE' ), + array_column( $qualified_describe, 'Field' ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $joined = $driver->query( + 'SELECT p.GRANTEE, t.TABLE_NAME + FROM information_schema.role_table_grants AS p + JOIN information_schema.tables AS t + ON p.TABLE_SCHEMA = t.TABLE_SCHEMA + AND p.TABLE_NAME = t.TABLE_NAME' + ); + + $this->assertSame( array(), $joined ); + $this->assertSame( array( 'GRANTEE', 'TABLE_NAME' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $role_join = $driver->query( + 'SELECT c.GRANTEE, r.ROUTINE_NAME + FROM information_schema.role_column_grants AS c + LEFT JOIN information_schema.role_routine_grants AS r + ON r.GRANTEE = c.GRANTEE' + ); + + $this->assertSame( array(), $role_join ); + $this->assertSame( array( 'GRANTEE', 'ROUTINE_NAME' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + try { + $driver->query( 'SELECT * FROM information_schema.role_database_grants' ); + $this->fail( 'Expected unsupported role grant relation to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests plugin-facing empty information_schema metadata relations are queryable. + */ + public function test_direct_information_schema_empty_plugin_metadata_relations_are_queryable(): void { + $driver = $this->create_driver(); + + $views = $driver->query( + 'SELECT COUNT(*) AS view_count + FROM information_schema.views + WHERE table_schema = DATABASE()' + ); + + $this->assertSame( '0', $views[0]->view_count ); + + $trigger_columns = $driver->query( "SHOW COLUMNS FROM information_schema.triggers LIKE 'TRIGGER_%'" ); + $this->assertSame( + array( 'TRIGGER_CATALOG', 'TRIGGER_SCHEMA', 'TRIGGER_NAME' ), + array_column( $trigger_columns, 'Field' ) + ); + + $routines = $driver->query( + "SELECT ROUTINE_NAME + FROM information_schema.routines + WHERE ROUTINE_SCHEMA = DATABASE() + AND ROUTINE_TYPE IN ('FUNCTION', 'PROCEDURE')" + ); + + $this->assertSame( array(), $routines ); + + $parameters = $driver->query( + 'SELECT COUNT(*) AS parameter_count + FROM information_schema.parameters + WHERE specific_schema = DATABASE()' + ); + + $this->assertCount( 1, $parameters ); + $this->assertSame( '0', $parameters[0]->parameter_count ); + + $parameter_rows = $driver->query( + "SELECT DTD_IDENTIFIER + FROM INFORMATION_SCHEMA.PARAMETERS + WHERE SPECIFIC_NAME = 'f1'" + ); + + $this->assertSame( array(), $parameter_rows ); + $this->assertSame( array( 'DTD_IDENTIFIER' ), array_column( $driver->get_last_column_meta(), 'name' ) ); + + $parameter_columns = $driver->query( "SHOW COLUMNS FROM information_schema.parameters LIKE 'PARAMETER_%'" ); + $this->assertSame( + array( 'PARAMETER_MODE', 'PARAMETER_NAME' ), + array_column( $parameter_columns, 'Field' ) + ); + + $show_create = $driver->query( 'SHOW CREATE TABLE INFORMATION_SCHEMA.PARAMETERS' ); + + $this->assertCount( 1, $show_create ); + $this->assertSame( 'PARAMETERS', $show_create[0]->Table ); + $this->assertStringContainsString( ' `SPECIFIC_NAME` varchar(512) DEFAULT NULL', $show_create[0]->{'Create Table'} ); + $this->assertStringContainsString( ' `DTD_IDENTIFIER` varchar(512) DEFAULT NULL', $show_create[0]->{'Create Table'} ); + + $routine_parameters = $driver->query( + 'SELECT r.routine_name, p.parameter_name + FROM information_schema.routines AS r + LEFT JOIN information_schema.parameters AS p + ON p.specific_schema = r.routine_schema + AND p.specific_name = r.specific_name + WHERE r.routine_schema = DATABASE()' + ); + + $this->assertSame( array(), $routine_parameters ); + } + + /** + * Tests SHOW COLUMNS/INDEX database clauses override qualified table prefixes. + */ + public function test_information_schema_show_metadata_database_clause_overrides_qualified_table_prefix(): void { + $columns_driver = $this->create_driver(); + $this->install_information_schema_fixture( $columns_driver ); + + $columns = $columns_driver->query( 'SHOW COLUMNS FROM information_schema.wptests_options FROM wptests' ); + + $this->assertCount( 4, $columns ); + $this->assertSame( 'option_id', $columns[0]->Field ); + $this->assertSame( array( 'public', 'wptests_options' ), $columns_driver->get_last_postgresql_queries()[0]['params'] ); + + $index_driver = $this->create_show_index_driver(); + $indexes = $index_driver->query( 'SHOW INDEXES FROM other_db.wptests_options FROM wptests' ); + + $this->assertCount( 3, $indexes ); + $this->assertSame( 'PRIMARY', $indexes[0]->Key_name ); + $this->assertSame( array( 'public', 'wptests_options' ), $index_driver->get_last_postgresql_queries()[0]['params'] ); + } + + /** + * Tests information_schema table administration handlers fail closed until routing is implemented. + */ + public function test_use_statement_information_schema_table_administration_fails_closed(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_options (option_id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + try { + $driver->query( 'CHECK TABLE wptests_options' ); + $this->fail( 'Expected information_schema table administration to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests main database-qualified application table writes still route after USE information_schema. + */ + public function test_use_statement_information_schema_allows_main_database_qualified_writes(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $this->assertSame( 0, $driver->query( 'CREATE TABLE wptests.use_info_main_write (id INTEGER PRIMARY KEY, value TEXT)' ) ); + + $this->assertSame( 1, $driver->query( "INSERT INTO wptests.use_info_main_write (id, value) VALUES (1, 'inserted')" ) ); + $this->assertSame( 1, $driver->query( "REPLACE INTO wptests.use_info_main_write (id, value) VALUES (2, 'replaced')" ) ); + $this->assertSame( 1, $driver->query( "UPDATE wptests.use_info_main_write SET value = 'updated' WHERE id = 1" ) ); + $this->assertSame( 1, $driver->query( 'DELETE FROM wptests.use_info_main_write WHERE id = 2' ) ); + + $this->assertSame( 1, $driver->query( 'ALTER TABLE wptests.use_info_main_write ADD COLUMN extra VARCHAR(20)' ) ); + $this->assertSame( 0, $driver->query( 'CREATE INDEX idx_use_info_main_write_value ON wptests.use_info_main_write (value)' ) ); + + $check = $driver->query( 'CHECK TABLE wptests.use_info_main_write' ); + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.use_info_main_write', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $check + ); + + $this->assertSame( 0, $driver->query( 'LOCK TABLES wptests.use_info_main_write READ' ) ); + $this->assertSame( 0, $driver->query( 'UNLOCK TABLES' ) ); + $this->assertSame( 0, $driver->query( 'DROP INDEX idx_use_info_main_write_value ON wptests.use_info_main_write' ) ); + + $driver->query( 'USE wptests' ); + $rows = $driver->query( 'SELECT id, value, extra FROM use_info_main_write' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'updated', + 'extra' => null, + ), + ), + $rows + ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $this->assertSame( 0, $driver->query( 'TRUNCATE TABLE wptests.use_info_main_write' ) ); + + $driver->query( 'USE wptests' ); + $this->assertSame( array(), $driver->query( 'SELECT * FROM use_info_main_write' ) ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + $this->assertSame( 0, $driver->query( 'DROP TABLE wptests.use_info_main_write' ) ); + } + + /** + * Tests simple main database-qualified SELECT reads still route after USE information_schema. + */ + public function test_use_statement_information_schema_allows_simple_main_database_qualified_selects(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE use_info_main_read (id INTEGER PRIMARY KEY, label TEXT)' ); + $driver->query( 'CREATE TABLE use_info_main_read_two (id INTEGER PRIMARY KEY, label TEXT)' ); + $driver->query( "INSERT INTO use_info_main_read (id, label) VALUES (1, 'one'), (2, 'two')" ); + $driver->query( "INSERT INTO use_info_main_read_two (id, label) VALUES (1, 'first'), (2, 'second')" ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $rows = $driver->query( + 'SELECT label + FROM wptests.use_info_main_read + WHERE id = 1 + ORDER BY label + LIMIT 1' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'one', + ), + ), + $rows + ); + $this->assertSame( + 'SELECT label FROM use_info_main_read WHERE id = 1 ORDER BY label LIMIT 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( + 'SELECT r.label + FROM wptests.use_info_main_read AS r + WHERE r.id = 2 + ORDER BY r.label + LIMIT 1' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'two', + ), + ), + $rows + ); + $this->assertSame( + 'SELECT r.label FROM use_info_main_read AS "r" WHERE r.id = 2 ORDER BY r.label LIMIT 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( + 'SELECT r.label + FROM wptests.use_info_main_read r + WHERE r.id = 1 + ORDER BY r.label + LIMIT 1' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'one', + ), + ), + $rows + ); + $this->assertSame( + 'SELECT r.label FROM use_info_main_read AS "r" WHERE r.id = 1 ORDER BY r.label LIMIT 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( + 'SELECT r.label, r2.label AS two_label + FROM wptests.use_info_main_read AS r + JOIN wptests.use_info_main_read_two AS r2 ON r2.id = r.id + WHERE r.id = 2 + ORDER BY r2.label' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'two', + 'two_label' => 'second', + ), + ), + $rows + ); + $this->assertSame( + 'SELECT r.label, r2.label AS two_label FROM use_info_main_read AS r JOIN use_info_main_read_two AS r2 ON r2.id = r.id WHERE r.id = 2 ORDER BY r2.label', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests nested main database-qualified SELECT reads still route after USE information_schema. + */ + public function test_use_statement_information_schema_allows_nested_main_database_qualified_selects(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE use_info_main_read (id INTEGER PRIMARY KEY, label TEXT)' ); + $driver->query( 'CREATE TABLE use_info_main_read_two (id INTEGER PRIMARY KEY, label TEXT)' ); + $driver->query( "INSERT INTO use_info_main_read (id, label) VALUES (1, 'one'), (2, 'two')" ); + $driver->query( "INSERT INTO use_info_main_read_two (id, label) VALUES (1, 'first'), (2, 'second')" ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $derived_rows = $driver->query( + 'SELECT r.label + FROM ( + SELECT label + FROM wptests.use_info_main_read + WHERE id = 1 + ) AS r' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'one', + ), + ), + $derived_rows + ); + $derived_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'SELECT label FROM use_info_main_read WHERE id = 1', $derived_sql ); + $this->assertStringNotContainsString( 'wptests.use_info_main_read', $derived_sql ); + + $scalar_rows = $driver->query( + 'SELECT ( + SELECT label + FROM public.use_info_main_read + WHERE id = 2 + ) AS label' + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'two', + ), + ), + $scalar_rows + ); + $scalar_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'SELECT label FROM use_info_main_read WHERE id = 2', $scalar_sql ); + $this->assertStringNotContainsString( 'public.use_info_main_read', $scalar_sql ); + + $predicate_rows = $driver->query( + "SELECT label + FROM wptests.use_info_main_read + WHERE id IN ( + SELECT id + FROM wptests.use_info_main_read_two + WHERE label = 'second' + )" + ); + + $this->assertEquals( + array( + (object) array( + 'label' => 'two', + ), + ), + $predicate_rows + ); + $predicate_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'SELECT id FROM use_info_main_read_two WHERE label = \'second\'', $predicate_sql ); + $this->assertStringNotContainsString( 'wptests.use_info_main_read_two', $predicate_sql ); + } + + /** + * Tests nested unqualified application SELECT reads fail closed under USE information_schema. + */ + public function test_use_statement_information_schema_rejects_unqualified_nested_application_selects(): void { + $queries = array( + 'SELECT label FROM (SELECT label FROM use_info_main_read) AS r', + 'SELECT (SELECT label FROM use_info_main_read) AS label', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE use_info_main_read (id INTEGER PRIMARY KEY, label TEXT)' ); + $this->assertSame( 0, $driver->query( 'USE information_schema' ), $query ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unqualified nested application SELECT under USE information_schema to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests writes after USE information_schema fail before backend execution. + */ + public function test_use_statement_information_schema_writes_fail_closed(): void { + $queries = array( + 'INSERT INTO tables (table_name) VALUES (\'t\')', + 'REPLACE INTO tables (table_name) VALUES (\'t\')', + 'UPDATE tables SET table_name = \'new_t\' WHERE table_name = \'t\'', + 'DELETE FROM tables WHERE table_name = \'t\'', + 'TRUNCATE tables', + 'CREATE TABLE new_table (id INT)', + 'ALTER TABLE tables ADD COLUMN new_column INT', + 'DROP TABLE tables', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE tables (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ), $query ); + + try { + $driver->query( $query ); + $this->fail( 'Expected information_schema write to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests table administration statements return MySQL-shaped success rows. + */ + public function test_table_administration_statements_return_mysql_shaped_success_rows(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_one (id INTEGER)' ); + $driver->query( 'CREATE TABLE administration_two (id INTEGER)' ); + + $cases = array( + 'ANALYZE TABLE administration_one' => 'analyze', + 'ANALYZE TABLES administration_one' => 'analyze', + 'CHECK TABLE `administration_one`' => 'check', + 'CHECK TABLES `administration_one`' => 'check', + 'OPTIMIZE TABLE administration_one' => 'optimize', + 'OPTIMIZE TABLES administration_one' => 'optimize', + 'REPAIR TABLE administration_one' => 'repair', + 'REPAIR TABLES administration_one' => 'repair', + ); + + foreach ( $cases as $query => $operation ) { + $rows = $driver->query( $query ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_one', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $rows, + $query + ); + $this->assertSame( + array( 'Table', 'Op', 'Msg_type', 'Msg_text' ), + array_column( $driver->get_last_column_meta(), 'name' ), + $query + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + + $qualified_rows = $driver->query( 'CHECK TABLE `wptests`.`administration_two`' ); + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_two', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $qualified_rows + ); + } + + /** + * Tests WP-CLI/admin metadata commands honor ANSI_QUOTES table identifiers. + */ + public function test_wp_cli_admin_metadata_commands_honor_ansi_quotes_table_identifiers(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE wptests_options (option_id INTEGER PRIMARY KEY, option_name TEXT, option_value TEXT, autoload TEXT)' ); + $this->install_information_schema_fixture( $driver ); + $driver->set_sql_mode( 'ANSI_QUOTES' ); + + $this->assertSame( 0, $driver->query( 'USE "wptests"' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $columns = $driver->query( 'DESCRIBE "wptests_options"' ); + $this->assertCount( 4, $columns ); + $this->assertSame( 'option_id', $columns[0]->Field ); + $this->assertSame( 'autoload', $columns[3]->Field ); + + $check = $driver->query( 'CHECK TABLE "wptests_options"' ); + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.wptests_options', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $check + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $index_driver = $this->create_show_index_driver(); + $index_driver->set_sql_mode( 'ANSI_QUOTES' ); + + $indexes = $index_driver->query( 'SHOW INDEX FROM "wptests_options"' ); + $this->assertCount( 3, $indexes ); + $this->assertSame( 'PRIMARY', $indexes[0]->Key_name ); + $this->assertSame( 'autoload', $indexes[2]->Key_name ); + } + + /** + * Tests table administration statements treat PostgreSQL temporary tables as existing. + */ + public function test_table_administration_statements_treat_postgresql_temporary_table_as_existing(): void { + $connection = $this->create_table_administration_catalog_fixture_connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $cases = array( + 'ANALYZE TABLE administration_temp' => 'analyze', + 'CHECK TABLE administration_temp' => 'check', + 'OPTIMIZE TABLE administration_temp' => 'optimize', + 'REPAIR TABLE administration_temp' => 'repair', + ); + + foreach ( $cases as $query => $operation ) { + $rows = $driver->query( $query ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_temp', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $rows, + $query + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + + $catalog_queries = $connection->get_table_administration_catalog_queries(); + $this->assertCount( count( $cases ), $catalog_queries ); + + foreach ( $catalog_queries as $catalog_query ) { + $this->assertStringContainsString( 'FROM pg_catalog.pg_class c', $catalog_query['sql'] ); + $this->assertSame( array( 'pg_temp_7', 'administration_temp' ), $catalog_query['params'] ); + } + } + + /** + * Tests table administration statements preserve multiple-table order. + */ + public function test_table_administration_multiple_tables_preserve_order(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_first (id INTEGER)' ); + $driver->query( 'CREATE TABLE administration_second (id INTEGER)' ); + + $rows = $driver->query( 'CHECK TABLE administration_second, administration_first' ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_second', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + (object) array( + 'Table' => 'wptests.administration_first', + 'Op' => 'check', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $rows + ); + } + + /** + * Tests table administration statements return MySQL-shaped missing-table errors. + */ + public function test_table_administration_missing_table_returns_error_and_failed_status(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'OPTIMIZE TABLE administration_missing' ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'optimize', + 'Msg_type' => 'Error', + 'Msg_text' => "Table 'administration_missing' doesn't exist", + ), + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'optimize', + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $rows + ); + } + + /** + * Tests table administration statements preserve mixed existing and missing table order. + */ + public function test_table_administration_mixed_existing_and_missing_tables_preserve_order(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_existing (id INTEGER)' ); + + $rows = $driver->query( 'REPAIR TABLE administration_existing, administration_missing' ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_existing', + 'Op' => 'repair', + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'repair', + 'Msg_type' => 'Error', + 'Msg_text' => "Table 'administration_missing' doesn't exist", + ), + (object) array( + 'Table' => 'wptests.administration_missing', + 'Op' => 'repair', + 'Msg_type' => 'status', + 'Msg_text' => 'Operation failed', + ), + ), + $rows + ); + } + + /** + * Tests supported MySQL table administration modifiers are accepted as compatibility no-ops. + */ + public function test_table_administration_accepts_supported_mysql_option_clauses(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_existing (id INTEGER)' ); + + $cases = array( + 'ANALYZE LOCAL TABLE administration_existing' => 'analyze', + 'ANALYZE NO_WRITE_TO_BINLOG TABLE administration_existing' => 'analyze', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id WITH 10 BUCKETS' => 'analyze', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id USING DATA \'{\"buckets\": []}\'' => 'analyze', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id WITH 10 BUCKETS USING DATA \'{\"buckets\": []}\'' => 'analyze', + 'ANALYZE TABLE administration_existing DROP HISTOGRAM ON `id`' => 'analyze', + 'CHECK TABLE administration_existing FOR UPGRADE' => 'check', + 'CHECK TABLE administration_existing QUICK FAST MEDIUM EXTENDED CHANGED' => 'check', + 'OPTIMIZE LOCAL TABLE administration_existing' => 'optimize', + 'OPTIMIZE NO_WRITE_TO_BINLOG TABLE administration_existing' => 'optimize', + 'REPAIR LOCAL TABLE administration_existing QUICK EXTENDED USE_FRM' => 'repair', + 'REPAIR NO_WRITE_TO_BINLOG TABLE administration_existing USE_FRM' => 'repair', + ); + + foreach ( $cases as $query => $operation ) { + $rows = $driver->query( $query ); + + $this->assertEquals( + array( + (object) array( + 'Table' => 'wptests.administration_existing', + 'Op' => $operation, + 'Msg_type' => 'status', + 'Msg_text' => 'OK', + ), + ), + $rows, + $query + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests unsupported table administration clauses fail before reaching the backend. + */ + public function test_table_administration_unsupported_clauses_fail_closed(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_existing (id INTEGER)' ); + + $queries = array( + 'CHECK TABLE administration_existing UNKNOWN_OPTION', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id WITH ten BUCKETS', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id USING DATA', + 'ANALYZE TABLE administration_existing UPDATE HISTOGRAM ON id WITH 10 BUCKETS USING \'{}\'', + ); + + foreach ( $queries as $query ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported table administration clause to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported table administration statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unimplemented MySQL SHOW and administration statements fail before backend execution. + */ + public function test_unimplemented_mysql_show_and_administration_statements_fail_closed(): void { + $cases = array( + 'SHOW TRIGGERS' => 'Unsupported SHOW statement.', + 'SHOW OPEN TABLES' => 'Unsupported SHOW statement.', + 'SHOW ENGINE InnoDB STATUS' => 'Unsupported SHOW statement.', + 'CHECKSUM TABLE administration_existing' => 'Unsupported CHECKSUM TABLE statement.', + 'FLUSH TABLES WITH READ LOCK' => 'Unsupported FLUSH statement.', + 'KILL 1' => 'Unsupported KILL statement.', + 'CACHE INDEX administration_existing IN `default`' => 'Unsupported CACHE INDEX statement.', + 'LOAD INDEX INTO CACHE administration_existing' => 'Unsupported LOAD statement.', + 'BINLOG "unsupported-binlog-event"' => 'Unsupported BINLOG statement.', + 'SHUTDOWN' => 'Unsupported SHUTDOWN statement.', + 'GRANT SELECT ON *.* TO plugin_user' => 'Unsupported GRANT statement.', + 'REVOKE SELECT ON *.* FROM plugin_user' => 'Unsupported REVOKE statement.', + 'ALTER USER plugin_user IDENTIFIED BY "secret"' => 'Unsupported ALTER USER statement.', + 'RESET PERSIST' => 'Unsupported RESET statement.', + 'PURGE BINARY LOGS BEFORE "2024-01-01"' => 'Unsupported PURGE statement.', + 'INSTALL PLUGIN plugin_name SONAME "plugin.so"' => 'Unsupported INSTALL statement.', + 'UNINSTALL PLUGIN plugin_name' => 'Unsupported UNINSTALL statement.', + 'ANALYZE FORMAT = TREE SELECT 1' => 'Unsupported table administration statement.', + ); + + foreach ( $cases as $query => $message ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE administration_existing (id INTEGER)' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported MySQL administration statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests account/plugin/log administration statements do not reach the backend fallback. + */ + public function test_unsupported_mysql_account_and_plugin_administration_statements_do_not_reach_backend(): void { + $cases = array( + 'GRANT SELECT ON *.* TO plugin_user' => 'Unsupported GRANT statement.', + 'REVOKE SELECT ON *.* FROM plugin_user' => 'Unsupported REVOKE statement.', + 'ALTER USER plugin_user IDENTIFIED BY "secret"' => 'Unsupported ALTER USER statement.', + 'RESET PERSIST' => 'Unsupported RESET statement.', + 'PURGE BINARY LOGS BEFORE "2024-01-01"' => 'Unsupported PURGE statement.', + 'INSTALL PLUGIN plugin_name SONAME "plugin.so"' => 'Unsupported INSTALL statement.', + 'UNINSTALL PLUGIN plugin_name' => 'Unsupported UNINSTALL statement.', + ); + + foreach ( $cases as $query => $message ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported MySQL administration statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $message, $e->getMessage(), $query ); + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests information_schema table administration targets fail closed. + */ + public function test_table_administration_information_schema_target_fails_closed(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'CHECK TABLE `information_schema`.`tables`' ); + $this->fail( 'Expected information_schema table administration target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported table administration statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests Site Health's information_schema.TABLES query returns rows for existing catalog tables only. + */ + public function test_information_schema_tables_site_health_query_returns_mysql_shape_with_single_quoted_aliases(): void { + $driver = $this->create_driver( 'wordpress_develop_tests' ); + $this->install_information_schema_fixture( $driver ); + $this->install_site_health_table_count_fixture( $driver ); + + $query = "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', SUM(data_length + index_length) as 'bytes' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wordpress_develop_tests' + AND TABLE_NAME IN ('wptests_options','wptests_missing','wptests_posts') + GROUP BY TABLE_NAME;"; + $rows = $driver->query( $query ); + + usort( + $rows, + static function ( $left, $right ): int { + return strcmp( $left->table, $right->table ); + } + ); + + $this->assertCount( 2, $rows ); + $this->assertSame( array( 'table', 'rows', 'bytes' ), array_keys( get_object_vars( $rows[0] ) ) ); + $this->assertSame( + array( + array( 'wptests_options', '2', '0' ), + array( 'wptests_posts', '1', '0' ), + ), + array_map( + static function ( $row ): array { + return array( $row->table, $row->rows, $row->bytes ); + }, + $rows + ) + ); + + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'AS "table"' ); + $this->assertStringContainsString( 'AS "table"', $sql ); + $this->assertStringContainsString( 'AS "rows"', $sql ); + $this->assertStringContainsString( 'AS "bytes"', $sql ); + $this->assertStringNotContainsString( "AS 'table'", $sql ); + $this->assertStringNotContainsString( "AS 'rows'", $sql ); + $this->assertStringNotContainsString( "AS 'bytes'", $sql ); + $this->assertStringContainsString( '"information_schema"."tables"', $sql ); + $this->assertStringContainsString( "\"TABLE_SCHEMA\" = 'wordpress_develop_tests'", $sql ); + $this->assertStringNotContainsString( '"wordpress_develop_tests"', $sql ); + $this->assertStringNotContainsString( 'FROM "wptests_missing"', $sql ); + $this->assertStringNotContainsString( 'FROM "public"."wptests_missing"', $sql ); + } + + /** + * Tests single-quoted aliases do not turn catalog predicate literals into identifiers. + */ + public function test_information_schema_tables_site_health_single_quoted_alias_preserves_predicate_string_literals(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_site_health_table_count_fixture( $driver ); + + $query = "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', SUM(data_length + index_length) AS 'bytes' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wptests' AND TABLE_NAME = 'wptests_options' + GROUP BY TABLE_NAME"; + $rows = $driver->query( $query ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'wptests_options', $rows[0]->table ); + $this->assertSame( '2', $rows[0]->rows ); + $this->assertSame( '0', $rows[0]->bytes ); + + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'AS "table"' ); + $this->assertStringContainsString( 'AS "table"', $sql ); + $this->assertStringContainsString( "\"TABLE_SCHEMA\" = 'wptests'", $sql ); + $this->assertStringContainsString( "TABLE_NAME = 'wptests_options'", $sql ); + $this->assertStringNotContainsString( '"wptests"', $sql ); + } + + /** + * Tests unsupported information_schema.TABLES shapes do not enter the Site Health translator. + */ + public function test_information_schema_tables_site_health_unsupported_shapes_fail_closed(): void { + $driver = $this->create_driver(); + $queries = array( + "SELECT COUNT(*) AS 'rows' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wptests' AND TABLE_NAME IN ('wptests_options') + GROUP BY TABLE_NAME", + "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', SUM(data_length + index_length) AS 'bytes' + FROM information_schema.TABLES + WHERE TABLE_ROWS > 0 + GROUP BY TABLE_NAME", + "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', SUM(data_length + index_length) AS 'bytes' + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'wptests' AND TABLE_NAME IN ('wptests_options') + GROUP BY TABLE_NAME + ORDER BY TABLE_NAME", + ); + + foreach ( $queries as $query ) { + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_information_schema_tables_site_health_query', + $query + ), + $query + ); + } + } + + /** + * Tests direct information_schema.TABLES SELECTs return MySQL-shaped rows. + */ + public function test_direct_information_schema_tables_selects_return_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + + $count = $driver->query( "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'wptests'" ); + + $this->assertSame( '3', $count[0]->{'COUNT(*)'} ); + $this->assertSame( 'COUNT(*)', $driver->get_last_column_meta()[0]['name'] ); + + $current_schema_tables = $driver->query( + "SELECT table_name FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'wptests_options'" + ); + + $this->assertCount( 1, $current_schema_tables ); + $this->assertSame( 'wptests_options', $current_schema_tables[0]->TABLE_NAME ); + + $tables = $driver->query( + "SELECT table_name AS name, ENGINE AS engine, CAST(data_length / 1024 / 1024 AS UNSIGNED) AS data + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_NAME = 'wptests_options' + ORDER BY name ASC" + ); + + $this->assertCount( 1, $tables ); + $this->assertSame( 'wptests_options', $tables[0]->name ); + $this->assertSame( 'InnoDB', $tables[0]->engine ); + $this->assertSame( '0', $tables[0]->data ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $current_database_tables = $driver->query( "SELECT ENGINE FROM tables WHERE TABLE_NAME = 'wptests_options'" ); + + $this->assertCount( 1, $current_database_tables ); + $this->assertSame( 'InnoDB', $current_database_tables[0]->ENGINE ); + $this->assertNotEmpty( + array_filter( + $driver->get_last_postgresql_queries(), + static function ( array $query ): bool { + return false !== strpos( $query['sql'], 'AS "tables"' ); + } + ) + ); + } + + /** + * Tests direct information_schema.TABLES AUTO_INCREMENT values match SHOW TABLE STATUS metadata. + */ + public function test_direct_information_schema_tables_exposes_auto_increment_values(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_show_table_status_auto_increment_fixture( $driver ); + $pdo = $driver->get_connection()->get_pdo(); + + $pdo->exec( 'CREATE TABLE analytics_posts (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $pdo->exec( "INSERT INTO analytics_posts (value) VALUES ('a'), ('b')" ); + $pdo->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('analytics', 'analytics_posts', 'BASE TABLE')" + ); + $pdo->exec( + "INSERT INTO information_schema.columns + (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, collation_name, is_nullable, column_default, is_identity) + VALUES + ('analytics', 'analytics_posts', 'id', 1, 'bigint', NULL, NULL, 'NO', NULL, 'YES')" + ); + + $tables = $driver->query( + "SELECT TABLE_NAME, AUTO_INCREMENT + FROM information_schema.tables + WHERE table_schema = 'wptests' + AND table_name IN ('wptests_options', 'wptests_plain', 'wptests_posts') + ORDER BY table_name" + ); + + $this->assertSame( + array( + array( 'wptests_options', '1' ), + array( 'wptests_plain', null ), + array( 'wptests_posts', '6' ), + ), + array_map( + static function ( $row ): array { + return array( $row->TABLE_NAME, $row->AUTO_INCREMENT ); + }, + $tables + ) + ); + + $high_auto_increment = $driver->query( + "SELECT TABLE_NAME + FROM information_schema.tables + WHERE table_schema = 'wptests' + AND table_type = 'BASE TABLE' + AND auto_increment > 3" + ); + + $this->assertSame( array( 'wptests_posts' ), array_column( $high_auto_increment, 'TABLE_NAME' ) ); + + $without_auto_increment = $driver->query( + "SELECT TABLE_NAME + FROM information_schema.tables + WHERE table_schema = 'wptests' + AND table_type = 'BASE TABLE' + AND auto_increment IS NULL" + ); + + $this->assertSame( array( 'wptests_plain' ), array_column( $without_auto_increment, 'TABLE_NAME' ) ); + + $schema_qualified_auto_increment = $driver->query( + "SELECT TABLE_SCHEMA, TABLE_NAME, AUTO_INCREMENT + FROM information_schema.tables + WHERE table_schema = 'analytics' + AND table_name = 'analytics_posts'" + ); + + $this->assertSame( 'analytics', $schema_qualified_auto_increment[0]->TABLE_SCHEMA ); + $this->assertSame( 'analytics_posts', $schema_qualified_auto_increment[0]->TABLE_NAME ); + $this->assertSame( '3', $schema_qualified_auto_increment[0]->AUTO_INCREMENT ); + } + + /** + * Tests direct information_schema.TABLES BINARY predicates use exact matching. + */ + public function test_direct_information_schema_tables_binary_predicates_use_exact_matching(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $exact = $driver->query( + "SELECT table_name + FROM information_schema.tables + WHERE table_name = BINARY 'wptests_options'" + ); + + $this->assertCount( 1, $exact ); + $this->assertSame( 'wptests_options', $exact[0]->TABLE_NAME ); + $sql = $this->get_logged_postgresql_sql_containing( + $driver->get_last_postgresql_queries(), + '"TABLE_NAME" = \'wptests_options\'' + ); + $this->assertStringContainsString( '"TABLE_NAME" = \'wptests_options\'', $sql ); + $this->assertStringNotContainsString( 'BINARY', strtoupper( $sql ) ); + + $case_mismatch = $driver->query( + "SELECT table_name + FROM information_schema.tables + WHERE table_name = BINARY 'WPTESTS_OPTIONS'" + ); + + $this->assertSame( array(), $case_mismatch ); + $sql = $this->get_logged_postgresql_sql_containing( + $driver->get_last_postgresql_queries(), + '"TABLE_NAME" = \'WPTESTS_OPTIONS\'' + ); + $this->assertStringNotContainsString( 'BINARY', strtoupper( $sql ) ); + } + + /** + * Tests direct information_schema schema predicates accept DATABASE() and SCHEMA(). + */ + public function test_direct_information_schema_current_database_function_predicates_return_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + + $cases = array( + 'SELECT schema_name AS name FROM information_schema.schemata WHERE schema_name = DATABASE()' => 'wptests', + "SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'wptests_options'" => 'wptests_options', + "SELECT column_name AS name FROM information_schema.columns AS c WHERE SCHEMA() = c.table_schema AND c.table_name = 'wptests_options' AND c.column_name = 'option_name'" => 'option_name', + "SELECT index_name AS name FROM information_schema.statistics WHERE index_schema = SCHEMA() AND table_name = 'wptests_options' AND index_name = 'option_name'" => 'option_name', + "SELECT constraint_name AS name FROM information_schema.table_constraints WHERE constraint_schema = DATABASE() AND table_name = 'wptests_options' AND constraint_name = 'PRIMARY'" => 'PRIMARY', + "SELECT referenced_table_schema AS name FROM information_schema.key_column_usage WHERE referenced_table_schema = SCHEMA() AND table_name = 'wptests_posts'" => 'wptests', + "SELECT unique_constraint_schema AS name FROM information_schema.referential_constraints WHERE unique_constraint_schema = DATABASE() AND table_name = 'wptests_posts'" => 'wptests', + "SELECT constraint_schema AS name FROM information_schema.check_constraints WHERE constraint_schema = SCHEMA() AND constraint_name = 'wptests_posts_status_chk'" => 'wptests', + ); + + foreach ( $cases as $query => $expected_name ) { + $rows = $driver->query( $query ); + + $this->assertCount( 1, $rows, $query ); + $this->assertSame( $expected_name, $rows[0]->name, $query ); + + $sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertSame( 0, preg_match( '/\b(?:DATABASE|SCHEMA)\s*\(/i', $sql ), $query ); + $this->assertStringContainsString( "'wptests'", $sql, $query ); + } + + try { + $driver->query( "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE('wptests')" ); + $this->fail( 'Expected unsupported information_schema query.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests direct information_schema projections accept DATABASE() and SCHEMA(). + */ + public function test_direct_information_schema_current_database_function_projections_return_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $explicit = $driver->query( + "SELECT DATABASE() AS current_database, SCHEMA() AS current_schema, table_name AS table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'wptests_options'" + ); + + $this->assertCount( 1, $explicit ); + $this->assertSame( 'wptests', $explicit[0]->current_database ); + $this->assertSame( 'wptests', $explicit[0]->current_schema ); + $this->assertSame( 'wptests_options', $explicit[0]->table_name ); + + $sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertSame( 0, preg_match( '/\b(?:DATABASE|SCHEMA)\s*\(/i', $sql ) ); + $this->assertStringContainsString( "'wptests' AS current_database", $sql ); + $this->assertStringContainsString( "'wptests' AS current_schema", $sql ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $selected_schema = $driver->query( + 'SELECT DATABASE() AS current_database, SCHEMA() AS current_schema, schema_name AS schema_name + FROM schemata + WHERE schema_name = DATABASE()' + ); + + $this->assertCount( 1, $selected_schema ); + $this->assertSame( 'information_schema', $selected_schema[0]->current_database ); + $this->assertSame( 'information_schema', $selected_schema[0]->current_schema ); + $this->assertSame( 'information_schema', $selected_schema[0]->schema_name ); + + try { + $driver->query( "SELECT DATABASE('wptests') AS current_database FROM tables" ); + $this->fail( 'Expected unsupported information_schema query.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests direct information_schema.SCHEMATA SELECTs return MySQL-shaped rows. + */ + public function test_direct_information_schema_schemata_selects_return_mysql_shape(): void { + $driver = $this->create_driver( 'wp_test_new' ); + + $schemata = $driver->query( 'SELECT schema_name FROM information_schema.schemata ORDER BY schema_name' ); + + $this->assertEquals( + array( + (object) array( 'SCHEMA_NAME' => 'information_schema' ), + (object) array( 'SCHEMA_NAME' => 'wp_test_new' ), + ), + $schemata + ); + + $aliased = $driver->query( 'SELECT s.schema_name FROM information_schema.schemata AS s WHERE s.schema_name = \'wp_test_new\'' ); + + $this->assertEquals( array( (object) array( 'SCHEMA_NAME' => 'wp_test_new' ) ), $aliased ); + + $star = $driver->query( 'SELECT * FROM information_schema.schemata WHERE schema_name = \'information_schema\'' ); + + $this->assertCount( 1, $star ); + $this->assertSame( 'def', $star[0]->CATALOG_NAME ); + $this->assertSame( 'information_schema', $star[0]->SCHEMA_NAME ); + $this->assertSame( 'utf8mb4', $star[0]->DEFAULT_CHARACTER_SET_NAME ); + $this->assertSame( 'utf8mb4_unicode_ci', $star[0]->DEFAULT_COLLATION_NAME ); + $this->assertNull( $star[0]->SQL_PATH ); + $this->assertSame( 'NO', $star[0]->DEFAULT_ENCRYPTION ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $current_database_schemata = $driver->query( 'SELECT schema_name FROM schemata WHERE schema_name = \'wp_test_new\'' ); + + $this->assertEquals( array( (object) array( 'SCHEMA_NAME' => 'wp_test_new' ) ), $current_database_schemata ); + } + + /** + * Tests direct information_schema charset and collation SELECTs return MySQL-shaped rows. + */ + public function test_direct_information_schema_character_sets_and_collations_selects_return_mysql_shape(): void { + $driver = $this->create_driver(); + + $character_sets = $driver->query( 'SELECT * FROM INFORMATION_SCHEMA.CHARACTER_SETS ORDER BY CHARACTER_SET_NAME' ); + + $this->assertEquals( + array( + (object) array( + 'CHARACTER_SET_NAME' => 'binary', + 'DEFAULT_COLLATE_NAME' => 'binary', + 'DESCRIPTION' => 'Binary pseudo charset', + 'MAXLEN' => '1', + ), + (object) array( + 'CHARACTER_SET_NAME' => 'utf8', + 'DEFAULT_COLLATE_NAME' => 'utf8_general_ci', + 'DESCRIPTION' => 'UTF-8 Unicode', + 'MAXLEN' => '3', + ), + (object) array( + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'DEFAULT_COLLATE_NAME' => 'utf8mb4_0900_ai_ci', + 'DESCRIPTION' => 'UTF-8 Unicode', + 'MAXLEN' => '4', + ), + ), + $character_sets + ); + + $collations = $driver->query( 'SELECT * FROM INFORMATION_SCHEMA.COLLATIONS ORDER BY COLLATION_NAME' ); + + $this->assertEquals( + array( + (object) array( + 'COLLATION_NAME' => 'binary', + 'CHARACTER_SET_NAME' => 'binary', + 'ID' => '63', + 'IS_DEFAULT' => 'Yes', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'NO PAD', + ), + (object) array( + 'COLLATION_NAME' => 'utf8_bin', + 'CHARACTER_SET_NAME' => 'utf8', + 'ID' => '83', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + (object) array( + 'COLLATION_NAME' => 'utf8_general_ci', + 'CHARACTER_SET_NAME' => 'utf8', + 'ID' => '33', + 'IS_DEFAULT' => 'Yes', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + (object) array( + 'COLLATION_NAME' => 'utf8_unicode_ci', + 'CHARACTER_SET_NAME' => 'utf8', + 'ID' => '192', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '8', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + (object) array( + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'ID' => '255', + 'IS_DEFAULT' => 'Yes', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '0', + 'PAD_ATTRIBUTE' => 'NO PAD', + ), + (object) array( + 'COLLATION_NAME' => 'utf8mb4_bin', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'ID' => '46', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '1', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + (object) array( + 'COLLATION_NAME' => 'utf8mb4_unicode_ci', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'ID' => '224', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '8', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), + ), + $collations + ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $defaults = $driver->query( + "SELECT cs.character_set_name, c.collation_name + FROM character_sets AS cs + JOIN collations AS c ON c.character_set_name = cs.character_set_name + WHERE c.is_default = 'Yes' + ORDER BY c.collation_name" + ); + + $this->assertEquals( + array( + (object) array( + 'CHARACTER_SET_NAME' => 'binary', + 'COLLATION_NAME' => 'binary', + ), + (object) array( + 'CHARACTER_SET_NAME' => 'utf8', + 'COLLATION_NAME' => 'utf8_general_ci', + ), + (object) array( + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + ), + ), + $defaults + ); + } + + /** + * Tests direct information_schema aliases and star projections return MySQL-shaped rows. + */ + public function test_direct_information_schema_alias_star_and_count_selects_return_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $tables = $driver->query( "SELECT t.* FROM information_schema.tables AS t WHERE t.table_name = 'wptests_options'" ); + + $this->assertCount( 1, $tables ); + $this->assertSame( 'def', $tables[0]->TABLE_CATALOG ); + $this->assertSame( 'wptests', $tables[0]->TABLE_SCHEMA ); + $this->assertSame( 'wptests_options', $tables[0]->TABLE_NAME ); + $this->assertSame( 'BASE TABLE', $tables[0]->TABLE_TYPE ); + $this->assertSame( 'InnoDB', $tables[0]->ENGINE ); + $this->assertSame( 'utf8mb4_unicode_ci', $tables[0]->TABLE_COLLATION ); + + $count = $driver->query( "SELECT COUNT(*) FROM information_schema.columns AS c WHERE c.table_name = 'wptests_options'" ); + + $this->assertSame( '4', $count[0]->{'COUNT(*)'} ); + $this->assertSame( 'COUNT(*)', $driver->get_last_column_meta()[0]['name'] ); + $this->assertStringContainsString( 'AS "c"', $driver->get_last_postgresql_queries()[0]['sql'] ); + } + + /** + * Tests direct information_schema joins rewrite every emulated relation source. + */ + public function test_direct_information_schema_tables_columns_join_selects_return_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + + $columns = $driver->query( + "SELECT t.table_name AS table_name, c.column_name AS column_name, c.ordinal_position AS ordinal_position + FROM information_schema.tables AS t + JOIN information_schema.columns AS c + ON c.table_schema = t.table_schema + AND c.table_name = t.table_name + WHERE t.table_schema = 'wptests' + AND t.table_name = 'wptests_options' + ORDER BY c.ordinal_position" + ); + + $this->assertSame( + array( + array( 'wptests_options', 'option_id', '1' ), + array( 'wptests_options', 'option_name', '2' ), + array( 'wptests_options', 'option_value', '3' ), + array( 'wptests_options', 'autoload', '4' ), + ), + array_map( + static function ( $row ): array { + return array( $row->table_name, $row->column_name, $row->ordinal_position ); + }, + $columns + ) + ); + + $sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'AS "t"', $sql ); + $this->assertStringContainsString( 'AS "c"', $sql ); + $this->assertStringContainsString( 'USING ("TABLE_SCHEMA", "TABLE_NAME")', $sql ); + } + + /** + * Tests direct information_schema JOIN ... USING rewrites merged MySQL columns. + */ + public function test_direct_information_schema_join_using_selects_return_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + + $columns = $driver->query( + "SELECT table_name AS table_name, c.column_name AS column_name, c.ordinal_position AS ordinal_position + FROM information_schema.tables AS t + JOIN information_schema.columns AS c USING (table_schema, table_name) + WHERE table_name = 'wptests_options' + ORDER BY c.ordinal_position" + ); + + $this->assertSame( + array( + array( 'wptests_options', 'option_id', '1' ), + array( 'wptests_options', 'option_name', '2' ), + array( 'wptests_options', 'option_value', '3' ), + array( 'wptests_options', 'autoload', '4' ), + ), + array_map( + static function ( $row ): array { + return array( $row->table_name, $row->column_name, $row->ordinal_position ); + }, + $columns + ) + ); + + $sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'USING ("TABLE_SCHEMA", "TABLE_NAME")', $sql ); + $this->assertStringContainsString( 'WHERE "TABLE_NAME" = \'wptests_options\'', $sql ); + + $key_usage = $driver->query( + "SELECT constraint_name AS constraint_name, kcu.column_name AS column_name + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + USING (constraint_schema, constraint_name, table_schema, table_name) + WHERE table_name = 'wptests_options' + AND constraint_name = 'PRIMARY'" + ); + + $this->assertSame( 'PRIMARY', $key_usage[0]->constraint_name ); + $this->assertSame( 'option_id', $key_usage[0]->column_name ); + + $sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'USING ("CONSTRAINT_SCHEMA", "CONSTRAINT_NAME", "TABLE_SCHEMA", "TABLE_NAME")', $sql ); + $this->assertStringContainsString( 'WHERE "TABLE_NAME" = \'wptests_options\'', $sql ); + $this->assertStringContainsString( '"CONSTRAINT_NAME" = \'PRIMARY\'', $sql ); + } + + /** + * Tests direct information_schema UNION SELECTs route each branch through the MySQL-shaped relations. + */ + public function test_direct_information_schema_union_selects_return_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $rows = $driver->query( + "SELECT table_name AS object_name + FROM information_schema.tables + WHERE table_name = 'wptests_options' + UNION ALL + SELECT schema_name AS object_name + FROM information_schema.schemata + WHERE schema_name = 'wptests'" + ); + + $values = array_map( + static function ( $row ): string { + return $row->object_name; + }, + $rows + ); + sort( $values ); + + $this->assertSame( array( 'wptests', 'wptests_options' ), $values ); + + $sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'UNION ALL SELECT', $sql ); + $this->assertStringContainsString( 'AS "tables"', $sql ); + $this->assertStringContainsString( 'AS "schemata"', $sql ); + + $distinct = $driver->query( + "SELECT table_name AS object_name + FROM information_schema.tables + WHERE table_name = 'wptests_options' + UNION DISTINCT + SELECT table_name AS object_name + FROM information_schema.tables + WHERE table_name = 'wptests_options'" + ); + + $this->assertCount( 1, $distinct ); + $this->assertSame( 'wptests_options', $distinct[0]->object_name ); + $this->assertStringContainsString( + 'UNION SELECT', + $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'UNION SELECT' ) + ); + + $ordered = $driver->query( + "SELECT table_name AS object_name + FROM information_schema.tables + WHERE table_name = 'wptests_options' + UNION ALL + SELECT schema_name AS object_name + FROM information_schema.schemata + WHERE schema_name = 'wptests' + ORDER BY object_name DESC + LIMIT 1" + ); + + $this->assertSame( array( 'wptests_options' ), array_column( $ordered, 'object_name' ) ); + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'ORDER BY "object_name" DESC LIMIT 1' ); + $this->assertStringContainsString( 'UNION ALL SELECT', $sql ); + $this->assertStringContainsString( 'ORDER BY "object_name" DESC LIMIT 1', $sql ); + } + + /** + * Tests USE information_schema routes unqualified UNION SELECT sources. + */ + public function test_use_statement_information_schema_union_selects_route_supported_relations(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $rows = $driver->query( + "SELECT table_name AS object_name + FROM tables + WHERE table_name = 'wptests_options' + UNION ALL + SELECT schema_name AS object_name + FROM schemata + WHERE schema_name = 'wptests'" + ); + + $values = array_map( + static function ( $row ): string { + return $row->object_name; + }, + $rows + ); + sort( $values ); + + $this->assertSame( array( 'wptests', 'wptests_options' ), $values ); + $this->assertStringContainsString( + 'UNION ALL SELECT', + $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'UNION ALL SELECT' ) + ); + + $limited = $driver->query( + "SELECT table_name AS object_name + FROM tables + WHERE table_name = 'wptests_options' + UNION ALL + SELECT schema_name AS object_name + FROM schemata + WHERE schema_name = 'wptests' + ORDER BY 1 + LIMIT 1, 1" + ); + + $this->assertSame( array( 'wptests_options' ), array_column( $limited, 'object_name' ) ); + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'ORDER BY 1 LIMIT 1 OFFSET 1' ); + $this->assertStringContainsString( 'UNION ALL SELECT', $sql ); + $this->assertStringContainsString( 'ORDER BY 1 LIMIT 1 OFFSET 1', $sql ); + } + + /** + * Tests direct information_schema derived SELECT sources return MySQL-shaped rows. + */ + public function test_direct_information_schema_derived_selects_return_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $aliased = $driver->query( + "SELECT d.table_name AS table_name + FROM ( + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'wptests' + ) AS d + WHERE d.table_name = 'wptests_options'" + ); + + $this->assertCount( 1, $aliased ); + $this->assertSame( 'wptests_options', $aliased[0]->table_name ); + + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'AS "d"' ); + $this->assertStringContainsString( 'AS "d"', $sql ); + $this->assertStringContainsString( '"d"."TABLE_NAME" = \'wptests_options\'', $sql ); + + $unaliased = $driver->query( + "SELECT table_name + FROM ( + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'wptests' + ) + WHERE table_name = 'wptests_options'" + ); + + $this->assertCount( 1, $unaliased ); + $this->assertSame( 'wptests_options', $unaliased[0]->TABLE_NAME ); + $this->assertStringContainsString( + 'AS "derived"', + $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'AS "derived"' ) + ); + + $union = $driver->query( + "SELECT d.object_name AS object_name + FROM ( + SELECT table_name AS object_name + FROM information_schema.tables + WHERE table_name = 'wptests_options' + UNION ALL + SELECT schema_name AS object_name + FROM information_schema.schemata + WHERE schema_name = 'wptests' + ) AS d + WHERE d.object_name LIKE 'wptests%' + ORDER BY d.object_name" + ); + + $this->assertSame( array( 'wptests', 'wptests_options' ), array_column( $union, 'object_name' ) ); + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'UNION ALL SELECT' ); + $this->assertStringContainsString( 'UNION ALL SELECT', $sql ); + $this->assertStringContainsString( 'AS "d"', $sql ); + $this->assertStringContainsString( '"d"."object_name" LIKE \'wptests%\'', $sql ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $unqualified_union = $driver->query( + "SELECT object_name + FROM ( + SELECT table_name AS object_name + FROM tables + WHERE table_name = 'wptests_options' + UNION ALL + SELECT schema_name AS object_name + FROM schemata + WHERE schema_name = 'wptests' + ) AS d + WHERE object_name LIKE 'wptests%' + ORDER BY object_name" + ); + + $this->assertSame( array( 'wptests', 'wptests_options' ), array_column( $unqualified_union, 'object_name' ) ); + + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $projected_database_join = $driver->query( + "SELECT d.current_database AS current_database, t.table_name AS table_name + FROM ( + SELECT DATABASE() AS current_database + FROM information_schema.schemata + WHERE schema_name = DATABASE() + ) AS d + JOIN information_schema.tables AS t ON t.table_schema = d.current_database + WHERE t.table_name = 'wptests_options'" + ); + + $this->assertSame( array( 'wptests_options' ), array_column( $projected_database_join, 'table_name' ) ); + $this->assertSame( 'wptests', $projected_database_join[0]->current_database ); + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'current_database' ); + $this->assertSame( 0, preg_match( '/\b(?:DATABASE|SCHEMA)\s*\(/i', $sql ) ); + $this->assertStringContainsString( "'wptests' AS current_database", $sql ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $use_information_schema_join = $driver->query( + 'SELECT d.current_schema AS current_schema, s.schema_name AS schema_name + FROM ( + SELECT SCHEMA() AS current_schema + FROM schemata + WHERE schema_name = DATABASE() + ) AS d + JOIN schemata AS s ON s.schema_name = d.current_schema' + ); + + $this->assertCount( 1, $use_information_schema_join ); + $this->assertSame( 'information_schema', $use_information_schema_join[0]->current_schema ); + $this->assertSame( 'information_schema', $use_information_schema_join[0]->schema_name ); + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'current_schema' ); + $this->assertSame( 0, preg_match( '/\b(?:DATABASE|SCHEMA)\s*\(/i', $sql ) ); + $this->assertStringContainsString( "'information_schema' AS current_schema", $sql ); + + try { + $driver->query( + 'SELECT d.object_name + FROM ( + SELECT table_name AS object_name, table_schema + FROM tables + UNION ALL + SELECT schema_name AS object_name + FROM schemata + ) AS d' + ); + $this->fail( 'Expected unsupported mismatched derived information_schema UNION to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertStringNotContainsString( + 'SELECT d.object_name', + implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ) + ); + } + } + + /** + * Tests direct information_schema scalar subqueries route without a top-level FROM. + */ + public function test_direct_information_schema_scalar_subqueries_route_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $rows = $driver->query( + "SELECT + (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()) AS table_count, + (SELECT table_name FROM information_schema.tables WHERE table_name = 'wptests_options') AS option_table" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '3', $rows[0]->table_count ); + $this->assertSame( 'wptests_options', $rows[0]->option_table ); + + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'AS "tables"' ); + $this->assertStringContainsString( 'AS "tables"', $sql ); + $this->assertStringNotContainsString( 'information_schema.tables', $sql ); + $this->assertSame( 0, preg_match( '/\bDATABASE\s*\(/i', $sql ) ); + + $projected = $driver->query( + "SELECT t.table_name, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'wptests_options') AS column_count + FROM information_schema.tables AS t + WHERE t.table_name = 'wptests_options'" + ); + + $this->assertCount( 1, $projected ); + $this->assertSame( 'wptests_options', $projected[0]->TABLE_NAME ); + $this->assertSame( '4', $projected[0]->column_count ); + + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'AS "columns"' ); + $this->assertStringContainsString( 'AS "columns"', $sql ); + $this->assertStringContainsString( 'AS "t"', $sql ); + } + + /** + * Tests USE information_schema routes nested SELECT reads and rejects unsupported sources. + */ + public function test_use_statement_information_schema_nested_selects_route_or_fail_closed(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $rows = $driver->query( + "SELECT + (SELECT table_name FROM tables WHERE table_name = 'wptests_options') AS option_table" + ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'wptests_options', $rows[0]->option_table ); + $this->assertStringContainsString( + 'AS "tables"', + $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'AS "tables"' ) + ); + + $dual = $driver->query( 'SELECT 1 AS output FROM DUAL' ); + $this->assertSame( '1', $dual[0]->output ); + $this->assertSame( 'SELECT 1 AS output', $this->get_last_single_postgresql_sql( $driver ) ); + + try { + $driver->query( 'SELECT (SELECT COUNT(*) FROM unsupported_relation) AS relation_count' ); + $this->fail( 'Expected unsupported nested information_schema relation to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests INSERT ... SELECT can source supported direct information_schema relations. + */ + public function test_insert_select_from_information_schema_routes_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + 'CREATE TABLE wptests_information_schema_insert ( + id INTEGER NOT NULL, + value TEXT NOT NULL + )' + ); + + $insert = "INSERT INTO wptests_information_schema_insert (id, value) + SELECT 1, table_name + FROM information_schema.tables + WHERE table_schema = 'wptests' + AND table_name = 'wptests_options'"; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $sql = $this->get_logged_postgresql_sql_containing( $queries, 'INSERT INTO wptests_information_schema_insert' ); + $this->assertStringContainsString( 'INSERT INTO wptests_information_schema_insert', $sql ); + $this->assertStringContainsString( '"TABLE_NAME"', $sql ); + $this->assertStringContainsString( 'AS "tables"', $sql ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_information_schema_insert' ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'wptests_options', $rows[0]->value ); + } + + /** + * Tests INSERT ... SELECT routes supported multi-source information_schema joins. + */ + public function test_insert_select_from_information_schema_join_routes_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + 'CREATE TABLE wptests_information_schema_join_insert ( + id INTEGER NOT NULL, + value TEXT NOT NULL + )' + ); + + $insert = "INSERT INTO wptests_information_schema_join_insert (id, value) + SELECT 2, it.table_name + FROM information_schema.schemata AS s + JOIN information_schema.tables AS it ON s.schema_name = it.table_schema + WHERE s.schema_name = 'wptests' + AND it.table_name = 'wptests_options'"; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $sql = $this->get_logged_postgresql_sql_containing( $queries, 'INSERT INTO wptests_information_schema_join_insert' ); + $this->assertStringContainsString( 'AS "s"', $sql ); + $this->assertStringContainsString( 'AS "it"', $sql ); + $this->assertStringContainsString( '"s"."SCHEMA_NAME" = "it"."TABLE_SCHEMA"', $sql ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_information_schema_join_insert' ); + $this->assertSame( '2', $rows[0]->id ); + $this->assertSame( 'wptests_options', $rows[0]->value ); + } + + /** + * Tests INSERT ... SELECT routes supported information_schema JOIN ... USING. + */ + public function test_insert_select_from_information_schema_join_using_routes_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + + $driver->get_connection()->get_pdo()->exec( + 'CREATE TABLE wptests_information_schema_using_insert ( + id INTEGER NOT NULL, + value TEXT NOT NULL + )' + ); + + $insert = "INSERT INTO wptests_information_schema_using_insert (id, value) + SELECT c.ordinal_position, table_name + FROM information_schema.tables AS t + JOIN information_schema.columns AS c USING (table_schema, table_name) + WHERE table_name = 'wptests_options' + AND c.column_name = 'option_name'"; + + $this->assertSame( 1, $driver->query( $insert ) ); + + $queries = $driver->get_last_postgresql_queries(); + $sql = $this->get_logged_postgresql_sql_containing( $queries, 'INSERT INTO wptests_information_schema_using_insert' ); + $this->assertStringContainsString( 'USING ("TABLE_SCHEMA", "TABLE_NAME")', $sql ); + $this->assertStringContainsString( '"c"."ORDINAL_POSITION"', $sql ); + $this->assertStringContainsString( '"TABLE_NAME"', $sql ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_information_schema_using_insert' ); + $this->assertSame( '2', $rows[0]->id ); + $this->assertSame( 'wptests_options', $rows[0]->value ); + } + + /** + * Tests INSERT ... SELECT routes supported nested information_schema SELECTs. + */ + public function test_insert_select_from_nested_information_schema_routes_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + 'CREATE TABLE wptests_information_schema_nested_insert ( + id INTEGER NOT NULL, + value TEXT NOT NULL + )' + ); + + $this->assertSame( + 1, + $driver->query( + 'INSERT INTO wptests_information_schema_nested_insert (id, value) + SELECT 3, table_name + FROM ( + SELECT table_name + FROM information_schema.tables + WHERE table_name = "wptests_options" + )' + ) + ); + + $queries = $driver->get_last_postgresql_queries(); + $sql = $this->get_logged_postgresql_sql_containing( $queries, 'INSERT INTO wptests_information_schema_nested_insert' ); + $this->assertStringContainsString( 'AS "derived"', $sql ); + $this->assertStringContainsString( '"TABLE_NAME"', $sql ); + + $this->assertSame( + 1, + $driver->query( + 'INSERT INTO wptests_information_schema_nested_insert (id, value) + SELECT 4, table_name + FROM information_schema.tables + WHERE table_name IN ( + SELECT table_name FROM information_schema.tables + ) + AND table_name = "wptests_options"' + ) + ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_information_schema_nested_insert ORDER BY id' ); + $this->assertSame( + array( + array( '3', 'wptests_options' ), + array( '4', 'wptests_options' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->value ); + }, + $rows + ) + ); + } + + /** + * Tests INSERT ... SELECT can source no-table SELECTs with information_schema scalar subqueries. + */ + public function test_insert_select_from_information_schema_scalar_subquery_routes_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->get_connection()->get_pdo()->exec( + 'CREATE TABLE wptests_information_schema_scalar_insert ( + id INTEGER NOT NULL, + value TEXT NOT NULL + )' + ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO wptests_information_schema_scalar_insert (id, value) + SELECT 5, ( + SELECT table_name + FROM information_schema.tables + WHERE table_name = 'wptests_options' + )" + ) + ); + + $sql = $this->get_logged_postgresql_sql_containing( + $driver->get_last_postgresql_queries(), + 'INSERT INTO wptests_information_schema_scalar_insert' + ); + $this->assertStringContainsString( 'AS "tables"', $sql ); + $this->assertStringNotContainsString( 'information_schema.tables', $sql ); + + $rows = $driver->query( 'SELECT id, value FROM wptests_information_schema_scalar_insert' ); + $this->assertSame( '5', $rows[0]->id ); + $this->assertSame( 'wptests_options', $rows[0]->value ); + } + + /** + * Tests simple DML can read supported information_schema subqueries in predicates. + */ + public function test_simple_dml_information_schema_subquery_predicates_route_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $driver->query( + 'CREATE TABLE wptests_options ( + option_name TEXT NOT NULL, + option_value TEXT NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_options (option_name, option_value) + VALUES ('wptests_options', 'before'), ('other', 'before')" + ); + + $this->assertSame( + 1, + $driver->query( + "UPDATE wptests_options + SET option_value = 'updated' + WHERE option_name IN ( + SELECT table_name FROM information_schema.tables WHERE table_name = 'wptests_options' + )" + ) + ); + + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'UPDATE "wptests_options"' ); + $this->assertStringContainsString( 'AS "tables"', $sql ); + $this->assertStringNotContainsString( 'information_schema.tables', $sql ); + + $rows = $driver->query( 'SELECT option_name, option_value FROM wptests_options ORDER BY option_name' ); + $this->assertSame( + array( + array( + 'option_name' => 'other', + 'option_value' => 'before', + ), + array( + 'option_name' => 'wptests_options', + 'option_value' => 'updated', + ), + ), + array_map( + static function ( $row ): array { + return array( + 'option_name' => $row->option_name, + 'option_value' => $row->option_value, + ); + }, + $rows + ) + ); + + $this->assertSame( + 1, + $driver->query( + "DELETE FROM wptests_options + WHERE option_name IN ( + SELECT table_name FROM information_schema.tables WHERE table_name = 'wptests_options' + )" + ) + ); + + $sql = $this->get_logged_postgresql_sql_containing( $driver->get_last_postgresql_queries(), 'DELETE FROM "wptests_options"' ); + $this->assertStringContainsString( 'AS "tables"', $sql ); + $this->assertStringNotContainsString( 'information_schema.tables', $sql ); + + $rows = $driver->query( 'SELECT option_name, option_value FROM wptests_options' ); + $this->assertSame( 1, count( $rows ) ); + $this->assertSame( 'other', $rows[0]->option_name ); + $this->assertSame( 'before', $rows[0]->option_value ); + } + + /** + * Tests unsupported simple DML subquery predicates fail before backend execution. + */ + public function test_simple_dml_subquery_predicates_fail_closed(): void { + $queries = array( + 'UPDATE wptests_options SET option_value = "updated" WHERE option_name IN ( + SELECT option_name FROM wptests_options + )' => 'Unsupported UPDATE statement.', + 'DELETE FROM wptests_options WHERE option_name IN ( + SELECT option_name FROM wptests_options + )' => 'Unsupported DELETE statement.', + ); + + foreach ( $queries as $query => $expected_message ) { + $driver = $this->create_driver(); + $driver->query( + 'CREATE TABLE wptests_options ( + option_name TEXT NOT NULL, + option_value TEXT NOT NULL + )' + ); + $driver->query( "INSERT INTO wptests_options (option_name, option_value) VALUES ('wptests_options', 'before')" ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported simple DML subquery to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests direct information_schema SELECTs can join one application table source. + */ + public function test_direct_information_schema_mixed_application_table_join_returns_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( + 'CREATE TABLE wptests_schema_names ( + id INTEGER NOT NULL, + db_name TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_schema_names ( + id int(11) NOT NULL, + db_name varchar(191) NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_schema_names (id, db_name) + VALUES (1, 'other'), (2, 'wptests')" + ); + + $result = $driver->query( + "SELECT sub.id, sub.table_schema, sub.table_name, sub.column_name + FROM ( + SELECT * + FROM information_schema.columns AS c + JOIN wptests_schema_names AS app + ON app.db_name = CONCAT(COALESCE(c.table_schema, 'default'), '') + JOIN information_schema.schemata AS s + ON s.schema_name = c.table_schema + WHERE c.table_name = 'wptests_schema_names' + ) AS sub + ORDER BY ordinal_position" + ); + + $this->assertSame( + array( + array( '2', 'wptests', 'wptests_schema_names', 'id' ), + array( '2', 'wptests', 'wptests_schema_names', 'db_name' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->TABLE_SCHEMA, $row->TABLE_NAME, $row->COLUMN_NAME ); + }, + $result + ) + ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( '"wptests_schema_names" AS "app"', $sql ); + $this->assertStringContainsString( '"app"."db_name" = CONCAT', $sql ); + $this->assertStringContainsString( 'COALESCE ( "c"."TABLE_SCHEMA" , \'default\')', $sql ); + } + + /** + * Tests direct information_schema joins accept main database-qualified application tables. + */ + public function test_direct_information_schema_mixed_join_accepts_main_database_qualified_application_table(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( + 'CREATE TABLE wptests_schema_names ( + id INTEGER NOT NULL, + db_name TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_schema_names ( + id int(11) NOT NULL, + db_name varchar(191) NOT NULL + )' + ); + $driver->query( + "INSERT INTO wptests_schema_names (id, db_name) + VALUES (1, 'other'), (2, 'wptests')" + ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + $result = $driver->query( + "SELECT app.id, c.table_schema, c.table_name, c.column_name + FROM columns AS c + JOIN wptests.wptests_schema_names AS app + ON app.db_name = c.table_schema + WHERE c.table_name = 'wptests_schema_names' + ORDER BY c.ordinal_position" + ); + + $this->assertSame( + array( + array( '2', 'wptests', 'wptests_schema_names', 'id' ), + array( '2', 'wptests', 'wptests_schema_names', 'db_name' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->TABLE_SCHEMA, $row->TABLE_NAME, $row->COLUMN_NAME ); + }, + $result + ) + ); + + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( '"wptests_schema_names" AS "app"', $sql ); + $this->assertStringContainsString( '"app"."db_name" = "c"."TABLE_SCHEMA"', $sql ); + $this->assertStringNotContainsString( 'wptests.wptests_schema_names', $sql ); + + $public_result = $driver->query( + "SELECT app.id, c.table_schema, c.table_name, c.column_name + FROM columns AS c + JOIN public.wptests_schema_names AS app + ON app.db_name = c.table_schema + WHERE c.table_name = 'wptests_schema_names' + ORDER BY c.ordinal_position" + ); + + $this->assertSame( + array( + array( '2', 'wptests', 'wptests_schema_names', 'id' ), + array( '2', 'wptests', 'wptests_schema_names', 'db_name' ), + ), + array_map( + static function ( $row ): array { + return array( $row->id, $row->TABLE_SCHEMA, $row->TABLE_NAME, $row->COLUMN_NAME ); + }, + $public_result + ) + ); + + $public_sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( '"wptests_schema_names" AS "app"', $public_sql ); + $this->assertStringContainsString( '"app"."db_name" = "c"."TABLE_SCHEMA"', $public_sql ); + $this->assertStringNotContainsString( 'public.wptests_schema_names', $public_sql ); + } + + /** + * Tests direct information_schema predicates can use application-table subqueries. + */ + public function test_direct_information_schema_predicate_accepts_application_table_subquery(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE wptests_options (option_name TEXT NOT NULL)' ); + $driver->query( "INSERT INTO wptests_options (option_name) VALUES ('wptests_options')" ); + + $result = $driver->query( + 'SELECT t.table_name + FROM information_schema.tables AS t + WHERE t.table_name IN ( + SELECT option_name FROM wptests_options + )' + ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'wptests_options', $result[0]->TABLE_NAME ); + + $sql = $this->get_logged_postgresql_sql_containing( + $driver->get_last_postgresql_queries(), + 'SELECT option_name FROM wptests_options' + ); + $this->assertStringContainsString( 'FROM (SELECT', $sql ); + $this->assertStringContainsString( 'SELECT option_name FROM wptests_options', $sql ); + } + + /** + * Tests direct information_schema JOIN ON equality chains can merge shared columns. + */ + public function test_direct_information_schema_join_on_same_name_columns_routes_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $result = $driver->query( + "SELECT table_name, c.column_name + FROM information_schema.tables AS t + JOIN information_schema.columns AS c + ON c.table_schema = t.table_schema + AND c.table_name = t.table_name + WHERE table_name = 'wptests_options' + AND c.column_name = 'option_name'" + ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'wptests_options', $result[0]->TABLE_NAME ); + $this->assertSame( 'option_name', $result[0]->COLUMN_NAME ); + + $sql = $this->get_logged_postgresql_sql_containing( + $driver->get_last_postgresql_queries(), + 'USING ("TABLE_SCHEMA", "TABLE_NAME")' + ); + $this->assertStringContainsString( 'USING ("TABLE_SCHEMA", "TABLE_NAME")', $sql ); + } + + /** + * Tests unsupported mixed information_schema joins fail before backend execution. + */ + public function test_direct_information_schema_mixed_join_shape_fails_closed(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + foreach ( + array( + 'SELECT table_name + FROM information_schema.tables AS t + JOIN information_schema.columns AS c USING (engine)', + 'SELECT table_name + FROM information_schema.tables AS t + JOIN information_schema.columns AS c USING (column_name)', + 'SELECT table_name + FROM information_schema.tables AS t + JOIN information_schema.columns AS c USING (table_schema + table_name)', + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported information_schema query.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + } + + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests direct information_schema column/index/constraint SELECTs use MySQL metadata. + */ + public function test_direct_information_schema_columns_statistics_and_key_constraints_selects_return_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + $this->install_direct_information_schema_options_metadata( $driver ); + + $columns = $driver->query( + "SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, COLUMN_KEY, EXTRA + FROM information_schema.columns + WHERE table_name = 'wptests_options' + ORDER BY ordinal_position" + ); + + $this->assertSame( + array( + array( 'option_id', 'bigint', 'bigint(20) unsigned', 'PRI', 'auto_increment' ), + array( 'option_name', 'varchar', 'varchar(191)', 'UNI', '' ), + array( 'option_value', 'longtext', 'longtext', '', '' ), + array( 'autoload', 'varchar', 'varchar(20)', 'MUL', '' ), + ), + array_map( + static function ( $row ): array { + return array( $row->COLUMN_NAME, $row->DATA_TYPE, $row->COLUMN_TYPE, $row->COLUMN_KEY, $row->EXTRA ); + }, + $columns + ) + ); + + $statistics = $driver->query( + "SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE, SEQ_IN_INDEX + FROM information_schema.statistics + WHERE table_name = 'wptests_options' + ORDER BY INDEX_NAME, SEQ_IN_INDEX" + ); + + $statistics_by_name = array(); + foreach ( $statistics as $row ) { + $statistics_by_name[ $row->INDEX_NAME ] = array( $row->COLUMN_NAME, $row->NON_UNIQUE, $row->SEQ_IN_INDEX ); + } + ksort( $statistics_by_name ); + + $this->assertSame( + array( + 'PRIMARY' => array( 'option_id', '0', '1' ), + 'autoload' => array( 'autoload', '1', '1' ), + 'option_name' => array( 'option_name', '0', '1' ), + ), + $statistics_by_name + ); + + $current_schema_statistics = $driver->query( + "SELECT INDEX_NAME + FROM information_schema.statistics + WHERE table_schema = SCHEMA() + AND table_name = 'wptests_options' + AND index_name = 'option_name'" + ); + + $this->assertCount( 1, $current_schema_statistics ); + $this->assertSame( 'option_name', $current_schema_statistics[0]->INDEX_NAME ); + + $constraints = $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE + FROM information_schema.table_constraints + WHERE table_name = 'wptests_options' + ORDER BY CONSTRAINT_NAME" + ); + + $this->assertSame( + array( + array( 'PRIMARY', 'PRIMARY KEY' ), + array( 'option_name', 'UNIQUE' ), + ), + array_map( + static function ( $row ): array { + return array( $row->CONSTRAINT_NAME, $row->CONSTRAINT_TYPE ); + }, + $constraints + ) + ); + + $key_usage = $driver->query( + "SELECT CONSTRAINT_NAME, COLUMN_NAME, ORDINAL_POSITION + FROM information_schema.key_column_usage + WHERE table_name = 'wptests_options' + ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION" + ); + + $this->assertSame( + array( + array( 'PRIMARY', 'option_id', '1' ), + array( 'option_name', 'option_name', '1' ), + ), + array_map( + static function ( $row ): array { + return array( $row->CONSTRAINT_NAME, $row->COLUMN_NAME, $row->ORDINAL_POSITION ); + }, + $key_usage + ) + ); + } + + /** + * Tests direct FK and CHECK information_schema SELECTs return MySQL-shaped rows. + */ + public function test_direct_information_schema_foreign_and_check_constraints_selects_return_mysql_shape(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $constraints = $driver->query( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE + FROM information_schema.table_constraints + WHERE table_name = 'wptests_posts' + ORDER BY CONSTRAINT_NAME" + ); + + $this->assertSame( + array( + array( 'wptests_posts_author_fk', 'FOREIGN KEY' ), + array( 'wptests_posts_status_chk', 'CHECK' ), + ), + array_map( + static function ( $row ): array { + return array( $row->CONSTRAINT_NAME, $row->CONSTRAINT_TYPE ); + }, + $constraints + ) + ); + + $key_usage = $driver->query( + "SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.key_column_usage + WHERE table_name = 'wptests_posts' + ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION" + ); + + $this->assertSame( + array( + array( 'wptests_posts_author_fk', 'post_author', 'wptests_options', 'option_id' ), + ), + array_map( + static function ( $row ): array { + return array( $row->CONSTRAINT_NAME, $row->COLUMN_NAME, $row->REFERENCED_TABLE_NAME, $row->REFERENCED_COLUMN_NAME ); + }, + $key_usage + ) + ); + + $referential = $driver->query( + "SELECT CONSTRAINT_NAME, DELETE_RULE, REFERENCED_TABLE_NAME + FROM information_schema.referential_constraints + WHERE table_name = 'wptests_posts'" + ); + + $this->assertSame( 'wptests_posts_author_fk', $referential[0]->CONSTRAINT_NAME ); + $this->assertSame( 'CASCADE', $referential[0]->DELETE_RULE ); + $this->assertSame( 'wptests_options', $referential[0]->REFERENCED_TABLE_NAME ); + + $checks = $driver->query( + "SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.check_constraints + WHERE constraint_name = 'wptests_posts_status_chk'" + ); + + $this->assertSame( 'wptests_posts_status_chk', $checks[0]->CONSTRAINT_NAME ); + $this->assertSame( 'post_status IS NOT NULL', $checks[0]->CHECK_CLAUSE ); + } + + /** + * Tests CREATE TABLE table-level foreign keys populate MySQL-facing metadata. + */ + public function test_create_table_table_level_foreign_keys_populate_information_schema(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE wptests_fk_parent ( + id INTEGER NOT NULL, + site_id INTEGER NOT NULL, + PRIMARY KEY (id, site_id) + )' + ); + + $this->assertSame( + 0, + $driver->query( + 'CREATE TABLE wptests_fk_child ( + id INTEGER NOT NULL PRIMARY KEY, + parent_id INTEGER NOT NULL, + parent_site_id INTEGER NOT NULL, + CONSTRAINT parent_fk FOREIGN KEY parent_lookup (parent_id, parent_site_id) + REFERENCES wptests_fk_parent (id, site_id) + ON DELETE CASCADE + ON UPDATE RESTRICT + )' + ) + ); + + $this->assertStringContainsString( + 'CONSTRAINT "parent_fk" FOREIGN KEY ("parent_id", "parent_site_id") REFERENCES "wptests_fk_parent" ("id", "site_id") ON DELETE CASCADE ON UPDATE RESTRICT', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $foreign_keys = $this->get_mysql_foreign_key_metadata_rows( $driver, 'wptests_fk_child' ); + $this->assertSame( + array( + array( 'parent_fk', 'parent_id', 'wptests_fk_parent', 'id', '1' ), + array( 'parent_fk', 'parent_site_id', 'wptests_fk_parent', 'site_id', '2' ), + ), + array_map( + static function ( $row ): array { + return array( + $row['constraint_name'], + $row['column_name'], + $row['referenced_table_name'], + $row['referenced_column_name'], + (string) $row['seq_in_index'], + ); + }, + $foreign_keys + ) + ); + + $this->assertSame( array( 'RESTRICT' ), array_values( array_unique( array_column( $foreign_keys, 'update_rule' ) ) ) ); + $this->assertSame( array( 'CASCADE' ), array_values( array_unique( array_column( $foreign_keys, 'delete_rule' ) ) ) ); + + $show_create = $driver->query( 'SHOW CREATE TABLE wptests_fk_child' )[0]->{'Create Table'}; + $this->assertStringContainsString( + 'CONSTRAINT `parent_fk` FOREIGN KEY (`parent_id`, `parent_site_id`) REFERENCES `wptests_fk_parent` (`id`, `site_id`) ON DELETE CASCADE ON UPDATE RESTRICT', + $show_create + ); + } + + /** + * Tests SHOW INDEX returns MySQL-shaped PostgreSQL catalog rows. + */ + public function test_show_index_returns_mysql_shaped_catalog_rows(): void { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( 'SHOW INDEX FROM `wptests_options`;' ); + + $this->assertCount( 3, $indexes ); + $this->assertSame( 'SHOW INDEX FROM `wptests_options`;', $driver->get_last_mysql_query() ); + $this->assertSame( 15, $driver->get_last_column_count() ); + $this->assertSame( 'Table', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Key_name', $driver->get_last_column_meta()[2]['name'] ); + + $this->assertCount( 15, get_object_vars( $indexes[0] ) ); + $this->assertSame( 'wptests_options', $indexes[0]->Table ); + $this->assertSame( '0', $indexes[0]->Non_unique ); + $this->assertSame( 'PRIMARY', $indexes[0]->Key_name ); + $this->assertSame( '1', $indexes[0]->Seq_in_index ); + $this->assertSame( 'option_id', $indexes[0]->Column_name ); + $this->assertSame( 'A', $indexes[0]->Collation ); + $this->assertSame( '0', $indexes[0]->Cardinality ); + $this->assertNull( $indexes[0]->Sub_part ); + $this->assertNull( $indexes[0]->Packed ); + $this->assertSame( '', $indexes[0]->Null ); + $this->assertSame( 'BTREE', $indexes[0]->Index_type ); + $this->assertSame( '', $indexes[0]->Comment ); + $this->assertSame( '', $indexes[0]->Index_comment ); + $this->assertSame( 'YES', $indexes[0]->Visible ); + $this->assertNull( $indexes[0]->Expression ); + + $this->assertSame( 'option_name', $indexes[1]->Key_name ); + $this->assertSame( 'option_name', $indexes[1]->Column_name ); + $this->assertSame( '0', $indexes[1]->Non_unique ); + $this->assertSame( 'autoload', $indexes[2]->Key_name ); + $this->assertSame( 'autoload', $indexes[2]->Column_name ); + $this->assertSame( '1', $indexes[2]->Non_unique ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'pg_catalog.pg_index', $queries[0]['sql'] ); + $this->assertStringContainsString( 'pg_catalog.unnest(i.indkey)', $queries[0]['sql'] ); + $this->assertStringContainsString( 'show_index_rows', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW INDEX', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW KEYS returns the same MySQL-shaped rows as SHOW INDEX. + */ + public function test_show_keys_returns_same_catalog_rows_as_show_index(): void { + $index_driver = $this->create_show_index_driver(); + $keys_driver = $this->create_show_index_driver(); + + $indexes = $index_driver->query( 'SHOW INDEX FROM `wptests_options`;' ); + $keys = $keys_driver->query( 'SHOW KEYS FROM `wptests_options`;' ); + + $this->assertEquals( $indexes, $keys ); + $this->assertSame( 'SHOW KEYS FROM `wptests_options`;', $keys_driver->get_last_mysql_query() ); + $this->assertSame( 15, $keys_driver->get_last_column_count() ); + $this->assertSame( 'Table', $keys_driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Key_name', $keys_driver->get_last_column_meta()[2]['name'] ); + + $queries = $keys_driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'pg_catalog.pg_index', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW KEYS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW INDEX reports MySQL FULLTEXT and SPATIAL metadata rows. + */ + public function test_show_index_reports_fulltext_and_spatial_metadata_rows(): void { + $driver = $this->create_show_index_driver(); + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO show_index_fixture + (table_schema, table_name, sort_position, non_unique, key_name, seq_in_index, column_name, collation, cardinality, sub_part, packed, nullable, index_type, comment, index_comment, visible, expression) + VALUES + ('public', 'wptests_search_geo', 1, '1', 'shape_spatial', '1', 'shape', 'A', '0', '32', NULL, '', 'SPATIAL', '', '', 'YES', NULL), + ('public', 'wptests_search_geo', 2, '1', 'body_fulltext', '1', 'body', NULL, '0', NULL, NULL, '', 'FULLTEXT', '', '', 'YES', NULL)" + ); + + $indexes = $driver->query( 'SHOW INDEX FROM wptests_search_geo' ); + + $this->assertCount( 2, $indexes ); + $this->assertSame( 'shape_spatial', $indexes[0]->Key_name ); + $this->assertSame( 'SPATIAL', $indexes[0]->Index_type ); + $this->assertSame( 'A', $indexes[0]->Collation ); + $this->assertSame( '32', $indexes[0]->Sub_part ); + $this->assertSame( 'body_fulltext', $indexes[1]->Key_name ); + $this->assertSame( 'FULLTEXT', $indexes[1]->Index_type ); + $this->assertNull( $indexes[1]->Collation ); + $this->assertNull( $indexes[1]->Sub_part ); + $this->assertStringContainsString( + 'CASE WHEN im.index_type = \'FULLTEXT\' THEN NULL ELSE COALESCE(im."collation", \'A\') END AS "Collation"', + $driver->get_last_postgresql_queries()[0]['sql'] + ); + } + + /** + * Tests SHOW INDEX WHERE filters match FULLTEXT/SPATIAL metadata output columns. + */ + public function test_show_index_where_filters_fulltext_and_spatial_metadata_rows(): void { + $driver = $this->create_show_index_driver(); + $driver->get_connection()->get_pdo()->exec( + "INSERT INTO show_index_fixture + (table_schema, table_name, sort_position, non_unique, key_name, seq_in_index, column_name, collation, cardinality, sub_part, packed, nullable, index_type, comment, index_comment, visible, expression) + VALUES + ('public', 'wptests_search_geo', 1, '1', 'shape_spatial', '1', 'shape', 'A', '0', '32', NULL, '', 'SPATIAL', '', '', 'YES', NULL), + ('public', 'wptests_search_geo', 2, '1', 'body_fulltext', '1', 'body', NULL, '0', NULL, NULL, '', 'FULLTEXT', '', '', 'YES', NULL)" + ); + + $fulltext = $driver->query( "SHOW INDEX FROM wptests_search_geo WHERE Index_type = 'FULLTEXT'" ); + + $this->assertCount( 1, $fulltext ); + $this->assertSame( 'body_fulltext', $fulltext[0]->Key_name ); + $this->assertSame( 'FULLTEXT', $fulltext[0]->Index_type ); + $this->assertNull( $fulltext[0]->Sub_part ); + $this->assertSame( array( 'public', 'wptests_search_geo', 'FULLTEXT' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $spatial = $driver->query( 'SHOW INDEX FROM wptests_search_geo WHERE Sub_part = 32' ); + + $this->assertCount( 1, $spatial ); + $this->assertSame( 'shape_spatial', $spatial[0]->Key_name ); + $this->assertSame( 'SPATIAL', $spatial[0]->Index_type ); + $this->assertSame( '32', $spatial[0]->Sub_part ); + $this->assertSame( array( 'public', 'wptests_search_geo', '32' ), $driver->get_last_postgresql_queries()[0]['params'] ); + } + + /** + * Tests SHOW INDEXES WHERE Key_name filters on normalized MySQL index names. + */ + public function test_show_indexes_where_key_name_filters_catalog_rows(): void { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( "SHOW INDEXES FROM wptests_options WHERE Key_name = 'autoload'" ); + + $this->assertCount( 1, $indexes ); + $this->assertSame( 'autoload', $indexes[0]->Key_name ); + $this->assertSame( 'autoload', $indexes[0]->Column_name ); + $this->assertSame( '1', $indexes[0]->Non_unique ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WHERE "Key_name" = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW INDEXES', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'autoload' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW KEYS WHERE Key_name uses the SHOW INDEX key-name filter. + */ + public function test_show_keys_where_key_name_filters_catalog_rows(): void { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( "SHOW KEYS FROM wptests_options WHERE Key_name = 'autoload'" ); + + $this->assertCount( 1, $indexes ); + $this->assertSame( 'autoload', $indexes[0]->Key_name ); + $this->assertSame( 'autoload', $indexes[0]->Column_name ); + $this->assertSame( '1', $indexes[0]->Non_unique ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WHERE "Key_name" = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW KEYS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options', 'autoload' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW INDEX WHERE exact filters support additional output columns. + */ + public function test_show_index_where_exact_filters_additional_output_columns(): void { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( "SHOW INDEX FROM wptests_options WHERE Column_name = 'option_name'" ); + + $this->assertCount( 1, $indexes ); + $this->assertSame( 'option_name', $indexes[0]->Key_name ); + $this->assertSame( 'option_name', $indexes[0]->Column_name ); + $this->assertSame( '0', $indexes[0]->Non_unique ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WHERE "Column_name" = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW INDEX', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_options', 'option_name' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW KEYS WHERE exact filters support additional output columns. + */ + public function test_show_keys_where_exact_filters_additional_output_columns(): void { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( 'SHOW KEYS FROM wptests_options WHERE Non_unique = 0' ); + + $this->assertCount( 2, $indexes ); + $this->assertSame( 'PRIMARY', $indexes[0]->Key_name ); + $this->assertSame( 'option_name', $indexes[1]->Key_name ); + $this->assertSame( array( '0', '0' ), array( $indexes[0]->Non_unique, $indexes[1]->Non_unique ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WHERE "Non_unique" = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW KEYS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_options', '0' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW INDEX-family WHERE LIKE filters support allowed output columns. + */ + public function test_show_index_family_where_like_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_index_like ( + 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) + )" + ); + + $indexes = $driver->query( "SHOW INDEX FROM wptests_index_like WHERE Key_name LIKE 'auto%'" ); + + $this->assertCount( 1, $indexes ); + $this->assertSame( 'autoload', $indexes[0]->Key_name ); + $this->assertSame( 'autoload', $indexes[0]->Column_name ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WHERE "Key_name" LIKE ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW INDEX', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_index_like', 'auto%' ), $queries[0]['params'] ); + + $indexes = $driver->query( "SHOW KEYS FROM wptests_index_like WHERE Column_name LIKE 'option_%'" ); + + $this->assertSame( array( 'PRIMARY', 'option_name' ), array( $indexes[0]->Key_name, $indexes[1]->Key_name ) ); + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WHERE "Column_name" LIKE ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW KEYS', strtoupper( $queries[0]['sql'] ) ); + $this->assertSame( array( 'public', 'wptests_index_like', 'option_%' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW INDEX-family WHERE filters support simple AND combinations. + */ + public function test_show_index_family_where_and_filters_catalog_rows(): void { + $driver = $this->create_driver(); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_index_and ( + 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) + )" + ); + + $indexes = $driver->query( "SHOW INDEX FROM wptests_index_and WHERE Key_name LIKE 'auto%' AND Non_unique = 1" ); + + $this->assertCount( 1, $indexes ); + $this->assertSame( 'autoload', $indexes[0]->Key_name ); + $this->assertSame( 'autoload', $indexes[0]->Column_name ); + $this->assertSame( '1', $indexes[0]->Non_unique ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertStringContainsString( 'WHERE "Key_name" LIKE ? ESCAPE \'\\\' AND "Non_unique" = ?', $queries[0]['sql'] ); + $this->assertStringNotContainsString( 'SHOW INDEX', $queries[0]['sql'] ); + $this->assertSame( array( 'public', 'wptests_index_and', 'auto%', '1' ), $queries[0]['params'] ); + } + + /** + * Tests SHOW INDEX-family WHERE expression filters catalog rows after fetching. + */ + public function test_show_index_family_where_expression_filters_catalog_rows(): void { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( 'SHOW KEYS FROM wptests_options WHERE Non_unique > 0' ); + + $this->assertCount( 1, $indexes ); + $this->assertSame( 'autoload', $indexes[0]->Key_name ); + $this->assertSame( '1', $indexes[0]->Non_unique ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( array( 'public', 'wptests_options' ), $queries[0]['params'] ); + $this->assertStringNotContainsString( 'WHERE "Non_unique"', $queries[0]['sql'] ); + + $indexes = $driver->query( "SHOW INDEX FROM wptests_options WHERE Key_name = 'PRIMARY' OR Non_unique = 1" ); + + $this->assertSame( array( 'PRIMARY', 'autoload' ), array( $indexes[0]->Key_name, $indexes[1]->Key_name ) ); + $this->assertSame( array( '0', '1' ), array( $indexes[0]->Non_unique, $indexes[1]->Non_unique ) ); + $this->assertSame( array( 'public', 'wptests_options' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $indexes = $driver->query( "SHOW INDEX FROM wptests_options WHERE Key_name NOT IN ('PRIMARY')" ); + + $this->assertSame( array( 'option_name', 'autoload' ), array( $indexes[0]->Key_name, $indexes[1]->Key_name ) ); + $this->assertSame( array( 'public', 'wptests_options' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $indexes = $driver->query( 'SHOW INDEX FROM wptests_options WHERE Seq_in_index * 2 = 2' ); + + $this->assertSame( array( 'PRIMARY', 'option_name', 'autoload' ), array_column( $indexes, 'Key_name' ) ); + $this->assertSame( array( 'public', 'wptests_options' ), $driver->get_last_postgresql_queries()[0]['params'] ); + + $indexes = $driver->query( 'SHOW INDEX FROM wptests_options WHERE Seq_in_index % 2 = 1' ); + + $this->assertSame( array( 'PRIMARY', 'option_name', 'autoload' ), array_column( $indexes, 'Key_name' ) ); + $this->assertSame( array( 'public', 'wptests_options' ), $driver->get_last_postgresql_queries()[0]['params'] ); + } + + /** + * Tests SHOW INDEX-family statements accept current database qualification forms. + */ + public function test_show_index_accepts_current_database_qualification_forms(): void { + $cases = array( + 'SHOW INDEX FROM wptests.wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW KEYS IN wptests_options' => array( 'public', 'wptests_options' ), + 'SHOW INDEXES FROM wptests_options FROM wptests' => array( 'public', 'wptests_options' ), + "SHOW KEYS FROM wptests_options IN `wptests` WHERE Key_name = 'autoload'" => array( 'public', 'wptests_options', 'autoload' ), + ); + + foreach ( $cases as $query => $params ) { + $driver = $this->create_show_index_driver(); + + $indexes = $driver->query( $query ); + + $this->assertNotCount( 0, $indexes, $query ); + $this->assertSame( 'wptests_options', $indexes[0]->Table, $query ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries, $query ); + $this->assertStringContainsString( 'pg_catalog.pg_index', $queries[0]['sql'], $query ); + $this->assertSame( $params, $queries[0]['params'], $query ); + } + } + + /** + * Tests unsupported SHOW INDEX-family clauses fail before reaching the backend. + */ + public function test_show_index_family_unsupported_syntax_does_not_reach_backend(): void { + $queries = array( + 'SHOW KEYS FROM wptests_options WHERE Key_name LIKE autoload', + 'SHOW KEYS FROM wptests_options LIMIT 1', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_show_index_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW INDEX statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW INDEX statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SHOW EXTENDED INDEX-family statements return the same rows as SHOW INDEX. + */ + public function test_show_extended_index_family_returns_same_catalog_rows_as_show_index(): void { + $queries = array( + 'SHOW EXTENDED INDEX FROM wptests_options', + 'SHOW EXTENDED INDEXES FROM wptests_options', + 'SHOW EXTENDED KEYS FROM wptests_options', + ); + + foreach ( $queries as $query ) { + $index_driver = $this->create_show_index_driver(); + $extended_driver = $this->create_show_index_driver(); + + $indexes = $index_driver->query( 'SHOW INDEX FROM wptests_options' ); + $extended = $extended_driver->query( $query ); + + $this->assertEquals( $indexes, $extended, $query ); + $this->assertSame( $index_driver->get_last_column_count(), $extended_driver->get_last_column_count(), $query ); + $this->assertSame( $index_driver->get_last_column_meta(), $extended_driver->get_last_column_meta(), $query ); + $this->assertStringNotContainsString( + 'SHOW EXTENDED', + strtoupper( $extended_driver->get_last_postgresql_queries()[0]['sql'] ), + $query + ); + } + } + + /** + * Tests catalog-backed MySQL introspection queries are cached until metadata changes. + */ + public function test_mysql_introspection_result_cache_reuses_catalog_rows_until_metadata_changes(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + $this->install_information_schema_fixture( $driver ); + $driver->get_connection()->get_pdo()->exec( + 'CREATE TABLE wptests_options ( + option_id INTEGER, + option_name TEXT, + option_value TEXT, + autoload TEXT + )' + ); + + $describe_catalog_queries = 0; + $show_columns_catalog_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$describe_catalog_queries, &$show_columns_catalog_queries ): void { + if ( false !== strpos( $sql, 'describe_rows' ) ) { + ++$describe_catalog_queries; + } + if ( false !== strpos( $sql, 'show_columns_rows' ) ) { + ++$show_columns_catalog_queries; + } + } + ); + + $describe = $driver->query( 'DESC `wptests_options`;' ); + $describe[0]->Field = 'mutated'; + + $cached_describe = $driver->query( 'DESC `wptests_options`;' ); + + $this->assertSame( 1, $describe_catalog_queries ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 'option_id', $cached_describe[0]->Field ); + $this->assertSame( 'Field', $driver->get_last_column_meta()[0]['name'] ); + + $columns = $driver->query( 'SHOW COLUMNS FROM `wptests_options`' ); + $columns[0]->Field = 'mutated'; + + $cached_columns = $driver->query( 'SHOW COLUMNS FROM `wptests_options`' ); + + $this->assertSame( 1, $show_columns_catalog_queries ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 'option_id', $cached_columns[0]->Field ); + $this->assertSame( 'Null', $driver->get_last_column_meta()[2]['name'] ); + + $driver->query( 'ALTER TABLE wptests_options ADD KEY option_value (option_value)' ); + + $indexed_columns = $driver->query( 'SHOW COLUMNS FROM `wptests_options`' ); + + $this->assertSame( 2, $show_columns_catalog_queries ); + $this->assertSame( 'MUL', $indexed_columns[2]->Key ); + + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_options ( + option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + option_name varchar(191) NOT NULL DEFAULT '', + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (option_id) + )" + ); + $driver->query( 'DESC `wptests_options`;' ); + + $this->assertSame( 2, $describe_catalog_queries ); + + $index_driver = $this->create_show_index_driver(); + $show_index_catalog_queries = 0; + $index_driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$show_index_catalog_queries ): void { + if ( false !== strpos( $sql, 'show_index_fixture' ) ) { + ++$show_index_catalog_queries; + } + } + ); + + $indexes = $index_driver->query( 'SHOW INDEX FROM `wptests_options`;' ); + $indexes[0]->Key_name = 'mutated'; + + $cached_indexes = $index_driver->query( 'SHOW INDEX FROM `wptests_options`;' ); + + $this->assertSame( 1, $show_index_catalog_queries ); + $this->assertSame( array(), $index_driver->get_last_postgresql_queries() ); + $this->assertSame( 'PRIMARY', $cached_indexes[0]->Key_name ); + $this->assertSame( 'Key_name', $index_driver->get_last_column_meta()[2]['name'] ); + } + + /** + * Tests introspection caching skips FETCH_FUNC closure arguments. + */ + public function test_mysql_introspection_result_cache_skips_fetch_func_closure_fetch_mode_args(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $describe_catalog_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$describe_catalog_queries ): void { + if ( false !== strpos( $sql, 'describe_rows' ) ) { + ++$describe_catalog_queries; + } + } + ); + + $fetch_field = static function ( ...$values ) { + return $values[0]; + }; + + $rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + $cached_rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + + $this->assertSame( 'option_id', $rows[0] ); + $this->assertSame( 'option_id', $cached_rows[0] ); + $this->assertSame( 2, $describe_catalog_queries ); + } + + /** + * Tests introspection caching skips FETCH_FUNC static callback results. + */ + public function test_mysql_introspection_result_cache_skips_fetch_func_static_callback_results(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $describe_catalog_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$describe_catalog_queries ): void { + if ( false !== strpos( $sql, 'describe_rows' ) ) { + ++$describe_catalog_queries; + } + } + ); + + self::$mysql_introspection_fetch_func_invocations = 0; + $fetch_field = array( self::class, 'fetch_dynamic_field_for_introspection_cache_test' ); + + $rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + $cached_rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + + $this->assertSame( + array( '1:option_id', '2:option_name', '3:option_value', '4:autoload' ), + $rows + ); + $this->assertSame( + array( '5:option_id', '6:option_name', '7:option_value', '8:autoload' ), + $cached_rows + ); + $this->assertSame( 8, self::$mysql_introspection_fetch_func_invocations ); + $this->assertSame( 2, $describe_catalog_queries ); + $this->assertCount( 1, $driver->get_last_postgresql_queries() ); + } + + /** + * Tests introspection caching skips FETCH_FUNC named function results. + */ + public function test_mysql_introspection_result_cache_skips_fetch_func_named_function_results(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $describe_catalog_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$describe_catalog_queries ): void { + if ( false !== strpos( $sql, 'describe_rows' ) ) { + ++$describe_catalog_queries; + } + } + ); + + global $wp_postgresql_driver_named_fetch_func_invocations; + $wp_postgresql_driver_named_fetch_func_invocations = 0; + $fetch_field = 'wp_postgresql_driver_fetch_dynamic_field_for_introspection_cache_test'; + + $rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + $cached_rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_FUNC, $fetch_field ); + + $this->assertSame( + array( '1:option_id', '2:option_name', '3:option_value', '4:autoload' ), + $rows + ); + $this->assertSame( + array( '5:option_id', '6:option_name', '7:option_value', '8:autoload' ), + $cached_rows + ); + $this->assertSame( 8, $wp_postgresql_driver_named_fetch_func_invocations ); + $this->assertSame( 2, $describe_catalog_queries ); + $this->assertCount( 1, $driver->get_last_postgresql_queries() ); + } + + /** + * Fetch a dynamic field value for the FETCH_FUNC introspection cache test. + * + * @param mixed ...$values Fetched row values. + * @return string Dynamic field value. + */ + public static function fetch_dynamic_field_for_introspection_cache_test( ...$values ): string { + ++self::$mysql_introspection_fetch_func_invocations; + return self::$mysql_introspection_fetch_func_invocations . ':' . $values[0]; + } + + /** + * Tests introspection caching skips FETCH_CLASS rows without public cloning. + */ + public function test_mysql_introspection_result_cache_skips_fetch_class_rows_without_public_clone(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $describe_catalog_queries = 0; + $driver->get_connection()->set_query_logger( + static function ( string $sql ) use ( &$describe_catalog_queries ): void { + if ( false !== strpos( $sql, 'describe_rows' ) ) { + ++$describe_catalog_queries; + } + } + ); + + $fetch_class = $this->get_no_public_clone_fetch_row_class_name(); + $rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_CLASS, $fetch_class ); + $cached_rows = $driver->query( 'DESC `wptests_options`;', PDO::FETCH_CLASS, $fetch_class ); + + $this->assertInstanceOf( $fetch_class, $rows[0] ); + $this->assertInstanceOf( $fetch_class, $cached_rows[0] ); + $this->assertSame( 'option_id', $rows[0]->Field ); + $this->assertSame( 'option_id', $cached_rows[0]->Field ); + $this->assertSame( 2, $describe_catalog_queries ); + } + + /** + * Tests MySQL-only runtime SET statements are handled before reaching PDO. + */ + public function test_mysql_runtime_set_statements_are_noops(): void { + $driver = $this->create_driver(); + + $queries = array( + 'SET autocommit = 0', + 'SET autocommit = 1;', + 'SET default_storage_engine = InnoDB', + 'SET storage_engine = InnoDB', + 'SET foreign_key_checks = 0', + 'SET foreign_key_checks = 1', + "SET SESSION sql_mode = ''", + "SET SQL_MODE = 'NO_AUTO_VALUE_ON_ZERO';", + ); + + foreach ( $queries as $query ) { + $driver->query( 'SELECT 1 AS previous_value' ); + + $this->assertSame( 0, $driver->query( $query ) ); + $this->assertSame( $query, $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( 0, $driver->get_last_return_value() ); + } + } + + /** + * Tests simple MySQL transaction-control statements use direct backend statements. + */ + public function test_mysql_transaction_control_statements_use_fast_backend_path(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'START TRANSACTION;' ) ); + $this->assertSame( 'START TRANSACTION;', $driver->get_last_mysql_query() ); + $this->assertSame( + array( + array( + 'sql' => 'BEGIN', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( 'CREATE TABLE transaction_test (id INTEGER)' ); + $driver->query( 'INSERT INTO transaction_test (id) VALUES (1)' ); + + $this->assertSame( 0, $driver->query( 'ROLLBACK' ) ); + $this->assertSame( + array( + array( + 'sql' => 'ROLLBACK', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $tables = $driver->query( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transaction_test'" ); + $this->assertSame( array(), $tables ); + + $this->assertSame( 0, $driver->query( 'COMMIT' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + + /** + * Tests public SAVEPOINT statements return MySQL-compatible results. + */ + public function test_mysql_savepoint_statements_use_public_query_path(): void { + $driver = $this->create_driver(); + $driver->set_sql_mode( 'STRICT_TRANS_TABLES' ); + + $this->assertSame( 0, $driver->query( 'START TRANSACTION' ) ); + $driver->query( 'CREATE TABLE savepoint_public (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'SAVEPOINT s1' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'SAVEPOINT "s1"', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'INSERT INTO savepoint_public VALUES (1)' ); + $driver->query( 'SELECT 1 AS warm_read' ); + + $this->assertSame( 0, $driver->query( 'ROLLBACK TO SAVEPOINT s1' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'ROLLBACK TO SAVEPOINT "s1"', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 0, $driver->query( 'SAVEPOINT s2' ) ); + $driver->query( 'INSERT INTO savepoint_public VALUES (2)' ); + $this->assertSame( 0, $driver->query( 'ROLLBACK WORK TO s2' ) ); + $this->assertSame( 'ROLLBACK TO SAVEPOINT "s2"', $this->get_last_single_postgresql_sql( $driver ) ); + $this->assertSame( 0, $driver->query( 'RELEASE SAVEPOINT s2' ) ); + + $this->assertSame( 0, $driver->query( 'SAVEPOINT s3' ) ); + $driver->query( 'INSERT INTO savepoint_public VALUES (3)' ); + $this->assertSame( 0, $driver->query( 'ROLLBACK WORK TO SAVEPOINT s3' ) ); + $this->assertSame( 'ROLLBACK TO SAVEPOINT "s3"', $this->get_last_single_postgresql_sql( $driver ) ); + $this->assertSame( 0, $driver->query( 'RELEASE SAVEPOINT s3' ) ); + + $this->assertSame( 0, $driver->query( 'RELEASE SAVEPOINT s1' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'RELEASE SAVEPOINT "s1"', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT COUNT(*) AS row_count FROM savepoint_public' ); + + $this->assertSame( '0', $rows[0]->row_count ); + } + + /** + * Tests unsupported savepoint-family statements fail before raw backend execution. + */ + public function test_unsupported_mysql_savepoint_statements_fail_closed_without_backend_execution(): void { + $cases = array( + 'RELEASE s', + 'ROLLBACK SAVEPOINT s', + ); + + foreach ( $cases as $query ) { + $driver = $this->create_driver(); + $driver->query( 'SAVEPOINT s' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SAVEPOINT statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SAVEPOINT statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests TRUNCATE TABLE removes rows and returns empty result metadata. + */ + public function test_mysql_truncate_table_removes_rows_and_returns_empty_metadata(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE truncate_test (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $driver->query( "INSERT INTO truncate_test (value) VALUES ('before')" ); + $driver->query( "INSERT INTO truncate_test (value) VALUES ('again')" ); + + $this->assertSame( 0, $driver->query( 'TRUNCATE TABLE truncate_test' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'DELETE FROM "truncate_test"', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT COUNT(*) AS row_count FROM truncate_test' ); + $this->assertSame( '0', $rows[0]->row_count ); + + $driver->query( "INSERT INTO truncate_test (value) VALUES ('after')" ); + $rows = $driver->query( 'SELECT id, value FROM truncate_test' ); + + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 'after', + ), + ), + $rows + ); + } + + /** + * Tests CREATE TABLE ... AS SELECT is translated and stores MySQL-facing metadata. + */ + public function test_create_table_as_select_translates_and_stores_metadata(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE ctas_source (`id` INTEGER, `name` TEXT)' ); + $driver->query( "INSERT INTO ctas_source (`id`, `name`) VALUES (1, 'one'), (2, 'two')" ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( 'CREATE TABLE ctas_copy AS SELECT `id`, `name` FROM `ctas_source` WHERE `id` > 1' ) + ); + $this->assertSame( + 'CREATE TABLE "ctas_copy" AS SELECT "id", "name" FROM "ctas_source" WHERE "id" > 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT * FROM ctas_copy' ); + $this->assertEquals( + array( + (object) array( + 'id' => '2', + 'name' => 'two', + ), + ), + $rows + ); + + $columns = $driver->query( 'SHOW COLUMNS FROM ctas_copy' ); + $this->assertSame( 'id', $columns[0]->Field ); + $this->assertSame( 'int', $columns[0]->Type ); + $this->assertSame( 'name', $columns[1]->Field ); + $this->assertSame( 'text', $columns[1]->Type ); + + $this->assertSame( + array( + array( + 'column_name' => 'id', + 'column_type' => 'int', + 'character_set_name' => null, + 'collation_name' => null, + 'is_nullable' => 'YES', + 'column_default' => null, + 'extra' => '', + ), + array( + 'column_name' => 'name', + 'column_type' => 'text', + 'character_set_name' => 'utf8mb4', + 'collation_name' => 'utf8mb4_unicode_ci', + 'is_nullable' => 'YES', + 'column_default' => null, + 'extra' => '', + ), + ), + $this->get_mysql_column_metadata_rows( $driver, 'ctas_copy' ) + ); + } + + /** + * Tests CREATE TABLE ... SELECT without AS and IF NOT EXISTS are translated. + */ + public function test_create_table_select_without_as_and_if_not_exists_translates(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE ctas_no_as_source (`id` INTEGER, `name` TEXT)' ); + $driver->query( "INSERT INTO ctas_no_as_source (`id`, `name`) VALUES (1, 'one'), (2, 'two')" ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( "CREATE TABLE IF NOT EXISTS ctas_no_as SELECT `id` FROM `ctas_no_as_source` WHERE `name` = 'one'" ) + ); + $this->assertSame( + 'CREATE TABLE IF NOT EXISTS "ctas_no_as" AS SELECT "id" FROM "ctas_no_as_source" WHERE "name" = \'one\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT * FROM ctas_no_as' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $rows ); + } + + /** + * Tests CREATE TABLE ... SELECT IF NOT EXISTS does not replace existing MySQL metadata. + */ + public function test_create_table_select_if_not_exists_preserves_existing_metadata(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE ctas_if_existing (id INTEGER NOT NULL, name TEXT NOT NULL)' ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE ctas_if_existing ( + id int NOT NULL, + name varchar(20) NOT NULL, + KEY name_key (name) + )' + ); + $driver->query( "INSERT INTO ctas_if_existing (id, name) VALUES (1, 'kept')" ); + $driver->query( 'CREATE TABLE ctas_if_source (changed INTEGER NOT NULL)' ); + $driver->query( 'INSERT INTO ctas_if_source (changed) VALUES (2)' ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'ctas_if_existing' ); + $indexes_before = $this->get_mysql_index_metadata_rows( $driver, 'ctas_if_existing' ); + + $this->assertSame( + 0, + $driver->query( 'CREATE TABLE IF NOT EXISTS ctas_if_existing AS SELECT changed FROM ctas_if_source' ) + ); + + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'ctas_if_existing' ) ); + $this->assertSame( $indexes_before, $this->get_mysql_index_metadata_rows( $driver, 'ctas_if_existing' ) ); + + $rows = $driver->query( 'SELECT id, name FROM ctas_if_existing' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'name' => 'kept', + ), + ), + $rows + ); + } + + /** + * Tests CREATE TABLE ... SELECT ignores supported MySQL table options. + */ + public function test_create_table_select_ignores_supported_mysql_table_options(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE ctas_options_source (`id` INTEGER, `name` TEXT)' ); + $driver->query( "INSERT INTO ctas_options_source (`id`, `name`) VALUES (1, 'one'), (2, 'two')" ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + 'CREATE TABLE ctas_with_options + ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + ROW_FORMAT=DYNAMIC + STATS_PERSISTENT=DEFAULT + COMMENT="option copy" + AS SELECT `id`, `name` FROM `ctas_options_source` WHERE `id` = 2' + ) + ); + $this->assertSame( + 'CREATE TABLE "ctas_with_options" AS SELECT "id", "name" FROM "ctas_options_source" WHERE "id" = 2', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT * FROM ctas_with_options' ); + $this->assertEquals( + array( + (object) array( + 'id' => '2', + 'name' => 'two', + ), + ), + $rows + ); + + $columns = $driver->query( 'SHOW COLUMNS FROM ctas_with_options' ); + $this->assertSame( array( 'id', 'name' ), array_column( $columns, 'Field' ) ); + $this->assertSame( array( 'int', 'text' ), array_column( $columns, 'Type' ) ); + + $create_table = $driver->query( 'SHOW CREATE TABLE ctas_with_options' )[0]->{'Create Table'}; + $this->assertStringEndsWith( "COMMENT='option copy'", $create_table ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + 'CREATE TEMPORARY TABLE IF NOT EXISTS ctas_temp_with_options + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + ENGINE InnoDB + SELECT 7 AS `id`' + ) + ); + $this->assertSame( + 'CREATE TEMPORARY TABLE IF NOT EXISTS "ctas_temp_with_options" AS SELECT 7 AS "id"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $temp_rows = $driver->query( 'SELECT * FROM ctas_temp_with_options' ); + $this->assertEquals( array( (object) array( 'id' => '7' ) ), $temp_rows ); + } + + /** + * Tests CREATE TABLE ... SELECT with explicit definitions creates then inserts. + */ + public function test_create_table_select_with_definitions_translates_and_stores_metadata(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE ctas_defined_source (`id` INTEGER, `name` TEXT, `score` INTEGER)' ); + $driver->query( "INSERT INTO ctas_defined_source (`id`, `name`, `score`) VALUES (1, 'one', 1), (2, 'two', 2)" ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + "CREATE TABLE ctas_with_definitions ( + `id` bigint(20) unsigned NOT NULL, + `name` varchar(20) NOT NULL COMMENT 'Copied name', + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='copy' + AS SELECT `id`, `name` FROM `ctas_defined_source` WHERE `score` > 1" + ) + ); + + $queries = array_column( $driver->get_last_postgresql_queries(), 'sql' ); + $this->assertCount( 2, $queries ); + $this->assertStringContainsString( 'CREATE TABLE "ctas_with_definitions"', $queries[0] ); + $this->assertStringContainsString( 'PRIMARY KEY ("id")', $queries[0] ); + $this->assertSame( + 'INSERT INTO "ctas_with_definitions" ("id", "name") SELECT "id", "name" FROM "ctas_defined_source" WHERE "score" > 1', + $queries[1] + ); + + $rows = $driver->query( 'SELECT id, name FROM ctas_with_definitions' ); + $this->assertEquals( + array( + (object) array( + 'id' => '2', + 'name' => 'two', + ), + ), + $rows + ); + + $columns = $driver->query( 'SHOW COLUMNS FROM ctas_with_definitions' ); + $this->assertSame( array( 'id', 'name' ), array_column( $columns, 'Field' ) ); + $this->assertSame( array( 'bigint(20) unsigned', 'varchar(20)' ), array_column( $columns, 'Type' ) ); + $this->assertSame( array( 'NO', 'NO' ), array_column( $columns, 'Null' ) ); + $this->assertSame( 'PRI', $columns[0]->Key ); + + $create_table = $driver->query( 'SHOW CREATE TABLE ctas_with_definitions' )[0]->{'Create Table'}; + $this->assertStringContainsString( '`id` bigint(20) unsigned NOT NULL', $create_table ); + $this->assertStringContainsString( "`name` varchar(20) NOT NULL COMMENT 'Copied name'", $create_table ); + $this->assertStringContainsString( 'PRIMARY KEY (`id`)', $create_table ); + $this->assertStringEndsWith( "COMMENT='copy'", $create_table ); + } + + /** + * Tests CREATE TABLE ... SELECT accepts main database-qualified targets. + */ + public function test_create_table_select_main_database_qualified_target_translates(): void { + $driver = $this->create_driver( 'wp' ); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE ctas_qualified_source (id INTEGER, name TEXT)' ); + $driver->query( "INSERT INTO ctas_qualified_source (id, name) VALUES (1, 'one'), (2, 'two')" ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( "CREATE TABLE wp.ctas_qualified_copy AS SELECT id FROM ctas_qualified_source WHERE name = 'two'" ) + ); + $this->assertSame( + 'CREATE TABLE "ctas_qualified_copy" AS SELECT id FROM ctas_qualified_source WHERE name = \'two\'', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT * FROM wp.ctas_qualified_copy' ); + $this->assertEquals( array( (object) array( 'id' => '2' ) ), $rows ); + } + + /** + * Tests CREATE TEMPORARY TABLE ... SELECT is translated and stores temporary metadata. + */ + public function test_create_temporary_table_select_translates_and_stores_metadata(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE ctas_temp_source (`id` INTEGER, `name` TEXT)' ); + $driver->query( "INSERT INTO ctas_temp_source (`id`, `name`) VALUES (1, 'one'), (2, 'two')" ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( 'CREATE TEMPORARY TABLE ctas_temp SELECT `name` FROM `ctas_temp_source` WHERE `id` = 2' ) + ); + $this->assertSame( + 'CREATE TEMPORARY TABLE "ctas_temp" AS SELECT "name" FROM "ctas_temp_source" WHERE "id" = 2', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT * FROM ctas_temp' ); + $this->assertEquals( array( (object) array( 'name' => 'two' ) ), $rows ); + + $columns = $driver->query( 'SHOW COLUMNS FROM ctas_temp' ); + $this->assertSame( 'name', $columns[0]->Field ); + $this->assertSame( 'text', $columns[0]->Type ); + + $this->assertSame( + array( + array( + 'column_name' => 'name', + 'column_type' => 'text', + 'character_set_name' => 'utf8mb4', + 'collation_name' => 'utf8mb4_unicode_ci', + 'is_nullable' => 'YES', + 'column_default' => null, + 'extra' => '', + ), + ), + $this->get_mysql_column_metadata_rows( $driver, 'ctas_temp', 'temp' ) + ); + } + + /** + * Tests CREATE TABLE ... AS (SELECT ...) parenthesized SELECT forms are translated. + */ + public function test_create_table_parenthesized_select_translates(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( 'CREATE TABLE ctas_parenthesized_source (`id` INTEGER, `label` TEXT)' ); + $driver->query( "INSERT INTO ctas_parenthesized_source (`id`, `label`) VALUES (1, 'name')" ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( 'CREATE TABLE ctas_parenthesized AS (SELECT `id`, `label` FROM `ctas_parenthesized_source`)' ) + ); + $this->assertSame( + 'CREATE TABLE "ctas_parenthesized" AS SELECT "id", "label" FROM "ctas_parenthesized_source"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT * FROM ctas_parenthesized' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'label' => 'name', + ), + ), + $rows + ); + + $columns = $driver->query( 'SHOW COLUMNS FROM ctas_parenthesized' ); + $this->assertSame( 'id', $columns[0]->Field ); + $this->assertSame( 'int', $columns[0]->Type ); + $this->assertSame( 'label', $columns[1]->Field ); + $this->assertSame( 'text', $columns[1]->Type ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( 'CREATE TEMPORARY TABLE ctas_parenthesized_temp (SELECT 2 AS `id`)' ) + ); + $this->assertSame( + 'CREATE TEMPORARY TABLE "ctas_parenthesized_temp" AS SELECT 2 AS "id"', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests CREATE TABLE ... LIKE copies source structure and MySQL-facing metadata. + */ + public function test_create_table_like_translates_and_copies_metadata(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( + "CREATE TABLE like_source ( + id int NOT NULL, + slug varchar(20) NOT NULL DEFAULT 'untitled', + score int NOT NULL DEFAULT 3, + note longtext DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug_unique (slug), + KEY score_key (score), + CONSTRAINT score_positive CHECK (score >= 0) + ) COMMENT='template'" + ); + $driver->query( "INSERT INTO like_source (id, slug, score, note) VALUES (1, 'existing', 4, 'source row')" ); + + $this->assertGreaterThanOrEqual( 0, $driver->query( 'CREATE TABLE like_copy LIKE like_source' ) ); + $logged_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'CREATE TABLE "like_copy"', $logged_sql ); + $this->assertStringNotContainsString( ' LIKE ', strtoupper( $logged_sql ) ); + + $count = $driver->query( 'SELECT COUNT(*) AS row_count FROM like_copy' ); + $this->assertSame( '0', $count[0]->row_count ); + + $this->assertSame( + $this->get_mysql_column_metadata_rows( $driver, 'like_source' ), + $this->get_mysql_column_metadata_rows( $driver, 'like_copy' ) + ); + $this->assertSame( + $this->get_mysql_index_metadata_rows( $driver, 'like_source' ), + $this->get_mysql_index_metadata_rows( $driver, 'like_copy' ) + ); + $this->assertSame( + $this->get_mysql_check_metadata_rows( $driver, 'like_source' ), + $this->get_mysql_check_metadata_rows( $driver, 'like_copy' ) + ); + + $create_table = $driver->query( 'SHOW CREATE TABLE like_copy' )[0]->{'Create Table'}; + $this->assertStringContainsString( 'UNIQUE KEY `slug_unique` (`slug`)', $create_table ); + $this->assertStringContainsString( 'KEY `score_key` (`score`)', $create_table ); + $this->assertStringContainsString( 'CONSTRAINT `score_positive` CHECK (score >= 0)', $create_table ); + + $this->assertSame( 1, $driver->query( "INSERT INTO like_copy (id, slug, score) VALUES (1, 'copy', 5)" ) ); + + try { + $driver->query( "INSERT INTO like_copy (id, slug, score) VALUES (2, 'copy', 6)" ); + $this->fail( 'Expected copied UNIQUE KEY to be enforced.' ); + } catch ( PDOException $e ) { + $this->assertNotSame( '', $e->getMessage() ); + } + + try { + $driver->query( "INSERT INTO like_copy (id, slug, score) VALUES (3, 'negative', -1)" ); + $this->fail( 'Expected copied CHECK constraint to be enforced.' ); + } catch ( PDOException $e ) { + $this->assertNotSame( '', $e->getMessage() ); + } + } + + /** + * Tests parenthesized CREATE TABLE ... LIKE copies source metadata. + */ + public function test_create_table_parenthesized_like_translates_and_copies_metadata(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( + "CREATE TABLE like_parenthesized_source ( + id int NOT NULL, + slug varchar(20) NOT NULL DEFAULT 'untitled', + PRIMARY KEY (id), + KEY slug_key (slug) + ) COMMENT='template'" + ); + + $this->assertGreaterThanOrEqual( 0, $driver->query( 'CREATE TABLE like_parenthesized_copy (LIKE like_parenthesized_source)' ) ); + $logged_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'CREATE TABLE "like_parenthesized_copy"', $logged_sql ); + $this->assertStringNotContainsString( ' LIKE ', strtoupper( $logged_sql ) ); + + $this->assertSame( + $this->get_mysql_column_metadata_rows( $driver, 'like_parenthesized_source' ), + $this->get_mysql_column_metadata_rows( $driver, 'like_parenthesized_copy' ) + ); + $this->assertSame( + $this->get_mysql_index_metadata_rows( $driver, 'like_parenthesized_source' ), + $this->get_mysql_index_metadata_rows( $driver, 'like_parenthesized_copy' ) + ); + } + + /** + * Tests CREATE TABLE ... LIKE IF NOT EXISTS does not replace existing MySQL metadata. + */ + public function test_create_table_like_if_not_exists_preserves_existing_metadata(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( + 'CREATE TABLE like_if_existing ( + id int NOT NULL, + name varchar(20) NOT NULL, + PRIMARY KEY (id), + KEY name_key (name) + )' + ); + $driver->query( + 'CREATE TABLE like_if_source ( + changed bigint(20) NOT NULL, + changed_text longtext DEFAULT NULL, + KEY changed_key (changed) + )' + ); + + $columns_before = $this->get_mysql_column_metadata_rows( $driver, 'like_if_existing' ); + $indexes_before = $this->get_mysql_index_metadata_rows( $driver, 'like_if_existing' ); + + $this->assertSame( + 0, + $driver->query( 'CREATE TABLE IF NOT EXISTS like_if_existing LIKE like_if_source' ) + ); + + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( $columns_before, $this->get_mysql_column_metadata_rows( $driver, 'like_if_existing' ) ); + $this->assertSame( $indexes_before, $this->get_mysql_index_metadata_rows( $driver, 'like_if_existing' ) ); + } + + /** + * Tests CREATE TEMPORARY TABLE ... LIKE stores isolated temporary metadata. + */ + public function test_create_temporary_table_like_copies_metadata_to_temporary_schema(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $driver->query( + "CREATE TABLE like_temp_source ( + id int NOT NULL, + `value` varchar(20) NOT NULL DEFAULT 'source', + PRIMARY KEY (id), + KEY value_key (`value`) + )" + ); + + $this->assertGreaterThanOrEqual( 0, $driver->query( 'CREATE TEMPORARY TABLE like_temp_copy LIKE like_temp_source' ) ); + $logged_sql = implode( "\n", array_column( $driver->get_last_postgresql_queries(), 'sql' ) ); + $this->assertStringContainsString( 'CREATE TEMPORARY TABLE "like_temp_copy"', $logged_sql ); + + $this->assertSame( array(), $this->get_mysql_column_metadata_rows( $driver, 'like_temp_copy' ) ); + $this->assertSame( + array_column( $this->get_mysql_column_metadata_rows( $driver, 'like_temp_source' ), 'column_name' ), + array_column( $this->get_mysql_column_metadata_rows( $driver, 'like_temp_copy', 'temp' ), 'column_name' ) + ); + $this->assertSame( + array_column( $this->get_mysql_index_metadata_rows( $driver, 'like_temp_source' ), 'key_name' ), + array_column( $this->get_mysql_index_metadata_rows( $driver, 'like_temp_copy', 'temp' ), 'key_name' ) + ); + + $this->assertSame( 1, $driver->query( 'INSERT INTO like_temp_copy (id) VALUES (1)' ) ); + $rows = $driver->query( 'SELECT id, value FROM like_temp_copy' ); + $this->assertSame( '1', $rows[0]->id ); + $this->assertSame( 'source', $rows[0]->value ); + } + + /** + * Tests CREATE TABLE accepts MySQL storage-only table options as no-ops. + */ + public function test_create_table_storage_options_are_supported_noops(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( + 'CREATE TABLE wptests_create_table_options ( + id INTEGER NOT NULL, + value TEXT, + PRIMARY KEY (id) + ) + KEY_BLOCK_SIZE=8 + MAX_ROWS=10 + MIN_ROWS=1 + AVG_ROW_LENGTH=100 + CHECKSUM=1 + DELAY_KEY_WRITE=1 + PACK_KEYS=1 + STATS_PERSISTENT=0 + STATS_AUTO_RECALC=1 + STATS_SAMPLE_PAGES=10 + COMPRESSION="zlib" + ENCRYPTION="Y" + DATA DIRECTORY "/tmp" + INDEX DIRECTORY "/tmp" + CONNECTION="mysql://x" + PASSWORD="x" + INSERT_METHOD=NO + TABLESPACE ts + SECONDARY_ENGINE=mock + AUTOEXTEND_SIZE=4M + UNION=(t1,t2) + ENGINE_ATTRIBUTE="{}" + SECONDARY_ENGINE_ATTRIBUTE="{}"' + ) + ); + $this->assertSame( + 'CREATE TABLE "wptests_create_table_options" ( + "id" integer NOT NULL, + "value" text, + PRIMARY KEY ("id") +)', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $columns = $driver->query( 'SHOW COLUMNS FROM wptests_create_table_options' ); + $this->assertSame( array( 'id', 'value' ), array_column( $columns, 'Field' ) ); + $this->assertSame( array( 'int', 'text' ), array_column( $columns, 'Type' ) ); + } + + /** + * Tests unsupported CREATE TABLE ... SELECT variants fail without backend execution. + */ + public function test_unsupported_create_table_select_constructs_fail_closed(): void { + $driver = $this->create_driver(); + + $queries = array( + 'partition before select' => 'CREATE TABLE ctas_partitioned PARTITION BY HASH(id) PARTITIONS 2 AS SELECT 1 AS id', + ); + + foreach ( $queries as $label => $query ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported CREATE TABLE statement to throw for ' . $label . '.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported CREATE TABLE statement.', $e->getMessage(), $label ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $label ); + } + } + } + + /** + * Tests CREATE TABLE ... LIKE fails closed when the source table has no metadata. + */ + public function test_create_table_like_missing_source_fails_closed(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'CREATE TABLE like_missing_copy LIKE like_missing_source' ); + $this->fail( 'Expected unsupported CREATE TABLE LIKE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported CREATE TABLE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests CREATE VIEW with a MySQL column list is translated and queryable. + */ + public function test_create_view_with_column_list_translates_and_reads_rows(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE view_source (`id` INTEGER, `name` TEXT)' ); + $driver->query( "INSERT INTO view_source (`id`, `name`) VALUES (1, 'one'), (2, 'two')" ); + + $this->assertGreaterThanOrEqual( + 0, + $driver->query( 'CREATE VIEW view_copy (`item_id`, `item_name`) AS SELECT `id`, `name` FROM `view_source` WHERE `id` > 1' ) + ); + $this->assertSame( + 'CREATE VIEW "view_copy" ("item_id", "item_name") AS SELECT "id", "name" FROM "view_source" WHERE "id" > 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT item_id, item_name FROM view_copy ORDER BY item_id' ); + $this->assertEquals( + array( + (object) array( + 'item_id' => '2', + 'item_name' => 'two', + ), + ), + $rows + ); + } + + /** + * Tests CREATE OR REPLACE VIEW and ALTER VIEW emit PostgreSQL CREATE OR REPLACE VIEW. + */ + public function test_create_or_replace_view_and_alter_view_translate_to_create_or_replace(): void { + $connection = new WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $this->assertSame( + 0, + $driver->query( 'CREATE OR REPLACE VIEW view_replace (`item_id`) AS SELECT 1 AS `item_id`' ) + ); + $this->assertSame( + 'CREATE OR REPLACE VIEW "view_replace" ("item_id") AS SELECT 1 AS "item_id"', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( + 0, + $driver->query( 'ALTER VIEW view_replace (`item_id`) AS SELECT 2 AS `item_id`' ) + ); + $this->assertSame( + 'CREATE OR REPLACE VIEW "view_replace" ("item_id") AS SELECT 2 AS "item_id"', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests DROP VIEW accepts IF EXISTS, multiple targets, and MySQL RESTRICT no-op. + */ + public function test_drop_view_if_exists_accepts_multiple_targets_and_restrict(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE VIEW drop_view_one AS SELECT 1 AS id' ); + $driver->query( 'CREATE VIEW drop_view_two AS SELECT 2 AS id' ); + + $this->assertSame( 0, $driver->query( 'DROP VIEW IF EXISTS drop_view_one, drop_view_two RESTRICT' ) ); + $this->assertSame( + array( + array( + 'sql' => 'DROP VIEW IF EXISTS "drop_view_one"', + 'params' => array(), + ), + array( + 'sql' => 'DROP VIEW IF EXISTS "drop_view_two"', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Tests main database-qualified VIEW DDL targets are accepted. + */ + public function test_view_ddl_accepts_main_database_qualified_targets(): void { + $driver = $this->create_driver( 'wp' ); + + $this->assertSame( 0, $driver->query( 'CREATE VIEW wp.qualified_view AS SELECT 1 AS id' ) ); + $this->assertSame( + 'CREATE VIEW "qualified_view" AS SELECT 1 AS id', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( 0, $driver->query( 'DROP VIEW wp.qualified_view CASCADE' ) ); + $this->assertSame( + 'DROP VIEW "qualified_view"', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests unsupported MySQL-only VIEW clauses fail without backend execution. + */ + public function test_unsupported_view_ddl_clauses_fail_closed(): void { + $driver = $this->create_driver(); + + $queries = array( + 'CREATE ALGORITHM = MERGE VIEW plugin_view AS SELECT 1 AS id' => 'Unsupported CREATE VIEW statement.', + 'CREATE DEFINER = root@localhost VIEW plugin_view AS SELECT 1 AS id' => 'Unsupported CREATE VIEW statement.', + 'CREATE SQL SECURITY DEFINER VIEW plugin_view AS SELECT 1 AS id' => 'Unsupported CREATE VIEW statement.', + 'CREATE VIEW plugin_view AS SELECT 1 AS id WITH CHECK OPTION' => 'Unsupported CREATE VIEW statement.', + 'CREATE VIEW plugin_view AS SELECT 1 AS id WITH LOCAL' => 'Unsupported CREATE VIEW statement.', + 'ALTER VIEW plugin_view AS SELECT 1 AS id WITH LOCAL CHECK OPTION' => 'Unsupported ALTER VIEW statement.', + 'CREATE VIEW information_schema.plugin_view AS SELECT 1 AS id' => 'Unsupported information_schema query.', + 'DROP VIEW information_schema.plugin_view' => 'Unsupported information_schema query.', + 'CREATE VIEW plugin_view (id,) AS SELECT 1 AS id' => 'Unsupported CREATE VIEW statement.', + ); + + foreach ( $queries as $query => $expected_message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported VIEW DDL to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unsupported CREATE statement families fail without backend execution. + */ + public function test_unsupported_create_statement_families_fail_closed(): void { + $driver = $this->create_driver(); + + $queries = array( + 'CREATE DATABASE plugin_db' => 'Unsupported CREATE DATABASE statement.', + 'CREATE SCHEMA plugin_schema' => 'Unsupported CREATE DATABASE statement.', + 'CREATE TRIGGER plugin_trigger BEFORE INSERT ON plugin_table FOR EACH ROW SET NEW.id = 1' => 'Unsupported CREATE TRIGGER statement.', + 'CREATE EVENT plugin_event ON SCHEDULE EVERY 1 DAY DO SELECT 1' => 'Unsupported CREATE EVENT statement.', + 'CREATE USER plugin_user' => 'Unsupported CREATE USER statement.', + 'CREATE SERVER plugin_server FOREIGN DATA WRAPPER mysql OPTIONS (HOST "localhost")' => 'Unsupported CREATE SERVER statement.', + 'CREATE LOGFILE GROUP plugin_logfile ADD UNDOFILE "undo.dat"' => 'Unsupported CREATE LOGFILE statement.', + 'CREATE SPATIAL REFERENCE SYSTEM 4326 NAME "WGS 84" ORGANIZATION "EPSG" IDENTIFIED BY 4326 DEFINITION "GEOGCS[]"' => 'Unsupported CREATE SPATIAL REFERENCE SYSTEM statement.', + 'CREATE TABLESPACE plugin_tablespace ADD DATAFILE "plugin.ibd"' => 'Unsupported CREATE TABLESPACE statement.', + ); + + foreach ( $queries as $query => $expected_message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported CREATE statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unsupported DROP statement families fail without backend execution. + */ + public function test_unsupported_drop_statement_families_fail_closed(): void { + $driver = $this->create_driver(); + + $queries = array( + 'DROP DATABASE plugin_db' => 'Unsupported DROP DATABASE statement.', + 'DROP SCHEMA IF EXISTS plugin_schema' => 'Unsupported DROP DATABASE statement.', + 'DROP PROCEDURE plugin_procedure' => 'Unsupported DROP PROCEDURE statement.', + 'DROP FUNCTION plugin_function' => 'Unsupported DROP FUNCTION statement.', + 'DROP TRIGGER plugin_trigger' => 'Unsupported DROP TRIGGER statement.', + 'DROP EVENT IF EXISTS plugin_event' => 'Unsupported DROP EVENT statement.', + 'DROP USER plugin_user' => 'Unsupported DROP USER statement.', + 'DROP ROLE plugin_role' => 'Unsupported DROP ROLE statement.', + 'DROP SPATIAL REFERENCE SYSTEM 4326' => 'Unsupported DROP SPATIAL REFERENCE SYSTEM statement.', + 'DROP TABLESPACE plugin_tablespace' => 'Unsupported DROP TABLESPACE statement.', + 'DROP UNDO TABLESPACE plugin_undo' => 'Unsupported DROP UNDO TABLESPACE statement.', + 'DROP SERVER plugin_server' => 'Unsupported DROP SERVER statement.', + 'DROP LOGFILE GROUP plugin_logfile' => 'Unsupported DROP LOGFILE statement.', + ); + + foreach ( $queries as $query => $expected_message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported DROP statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests unsupported ALTER/RENAME statement families fail without backend execution. + */ + public function test_unsupported_alter_and_rename_statement_families_fail_closed(): void { + $driver = $this->create_driver(); + + $queries = array( + 'ALTER DATABASE plugin_db CHARACTER SET utf8mb4' => 'Unsupported ALTER DATABASE statement.', + 'ALTER EVENT plugin_event DISABLE' => 'Unsupported ALTER EVENT statement.', + 'ALTER LOGFILE GROUP plugin_logfile ADD UNDOFILE "u.dat"' => 'Unsupported ALTER LOGFILE statement.', + 'ALTER SERVER plugin_server OPTIONS (HOST "localhost")' => 'Unsupported ALTER SERVER statement.', + 'ALTER TABLESPACE plugin_tablespace ADD DATAFILE "t.ibd"' => 'Unsupported ALTER TABLESPACE statement.', + 'ALTER UNDO TABLESPACE plugin_undo SET INACTIVE' => 'Unsupported ALTER UNDO TABLESPACE statement.', + 'RENAME USER old_user TO new_user' => 'Unsupported RENAME USER statement.', + ); + + foreach ( $queries as $query => $expected_message ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER/RENAME statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( $expected_message, $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests main database-qualified table names work for a focused basic operation slice. + */ + public function test_main_database_qualified_table_names_work_for_basic_table_operations(): void { + $driver = $this->create_driver( 'wp' ); + + $this->assertSame( 0, $driver->query( 'CREATE TABLE wp.t (id INT PRIMARY KEY)' ) ); + $this->assertStringStartsWith( 'CREATE TABLE "t"', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 1, $driver->query( 'INSERT INTO wp.t (id) VALUES (1)' ) ); + $this->assertSame( 'INSERT INTO "t" ("id") VALUES (1)', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $rows ); + $this->assertSame( 'SELECT * FROM t', $this->get_last_single_postgresql_sql( $driver ) ); + + $driver->query( 'UPDATE wp.t SET id = 2' ); + $rows = $driver->query( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '2' ) ), $rows ); + + $this->assertSame( 1, $driver->query( 'DELETE FROM wp.t WHERE id = 2' ) ); + $rows = $driver->query( 'SELECT * FROM wp.t' ); + $this->assertSame( array(), $rows ); + + $driver->query( 'INSERT INTO wp.t (id) VALUES (3)' ); + $this->assertSame( 0, $driver->query( 'TRUNCATE TABLE wp.t' ) ); + $this->assertSame( 0, $driver->get_last_column_count() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 'DELETE FROM "t"', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT * FROM wp.t' ); + $this->assertSame( array(), $rows ); + } + + /** + * Tests malformed main database-qualified CREATE TABLE targets fail closed. + */ + public function test_create_table_rejects_extra_qualified_main_database_target(): void { + $driver = $this->create_driver( 'wp' ); + + try { + $driver->query( 'CREATE TABLE wp.other.t (id INT)' ); + $this->fail( 'Expected extra qualified CREATE TABLE target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported CREATE TABLE statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests UNLOCK TABLES forms are MySQL compatibility no-ops. + */ + public function test_mysql_unlock_tables_statements_are_noops_without_backend_execution(): void { + $driver = $this->create_driver(); + + foreach ( array( 'UNLOCK TABLES', 'UNLOCK TABLE' ) as $query ) { + $driver->query( 'SELECT 1 AS previous_value' ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + $this->assertSame( 0, $driver->get_last_column_count(), $query ); + $this->assertSame( 0, $driver->get_last_return_value(), $query ); + } + } + + /** + * Tests LOCK TABLES forms validate existing tables and then no-op. + */ + public function test_mysql_lock_tables_existing_tables_are_noops_without_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_table_one (id INTEGER)' ); + $driver->query( 'CREATE TABLE lock_table_two (id INTEGER)' ); + $driver->query( 'CREATE TABLE lock_table_three (id INTEGER)' ); + + $cases = array( + 'LOCK TABLES lock_table_one READ', + 'LOCK TABLES lock_table_one WRITE', + 'LOCK TABLE lock_table_one READ', + 'LOCK TABLE lock_table_one WRITE', + 'LOCK TABLES lock_table_one READ, lock_table_two READ, lock_table_three WRITE', + ); + + foreach ( $cases as $query ) { + $driver->query( 'SELECT 1 AS previous_value' ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + $this->assertSame( 0, $driver->get_last_column_count(), $query ); + $this->assertSame( 0, $driver->get_last_return_value(), $query ); + } + } + + /** + * Tests LOCK TABLES accepts main database-qualified table references. + */ + public function test_mysql_lock_tables_accepts_main_database_qualified_table_references(): void { + $driver = $this->create_driver( 'wp' ); + $driver->query( 'CREATE TABLE lock_qualified_table (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'LOCK TABLES wp.lock_qualified_table READ' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + + /** + * Tests LOCK TABLES accepts existing temporary tables. + */ + public function test_mysql_lock_tables_accepts_existing_temporary_tables(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TEMPORARY TABLE lock_temp_table (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'LOCK TABLES lock_temp_table WRITE' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( array(), $driver->get_last_column_meta() ); + $this->assertSame( 0, $driver->get_last_column_count() ); + } + + /** + * Tests LOCK TABLES missing targets fail before raw backend execution. + */ + public function test_mysql_lock_tables_missing_table_fails_before_backend_execution(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_existing_table (id INTEGER)' ); + + try { + $driver->query( 'LOCK TABLES lock_existing_table READ, lock_missing_table WRITE' ); + $this->fail( 'Expected missing LOCK TABLES target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Table 'wptests.lock_missing_table' doesn't exist", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests LOCK TABLES information_schema targets fail closed. + */ + public function test_mysql_lock_tables_information_schema_targets_fail_closed(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'LOCK TABLES information_schema.tables READ' ); + $this->fail( 'Expected information_schema LOCK TABLES target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported LOCK TABLES statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests LOCK TABLES under USE information_schema fails closed. + */ + public function test_mysql_lock_tables_after_use_information_schema_fails_closed(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE tables (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + try { + $driver->query( 'LOCK TABLES tables READ' ); + $this->fail( 'Expected information_schema LOCK TABLES target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests LOCK TABLES accepts SQLite-compatible lock modes and aliases. + */ + public function test_mysql_lock_tables_accepts_read_local_low_priority_write_and_aliases(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_mode_table (id INTEGER)' ); + + $queries = array( + 'LOCK TABLES lock_mode_table READ LOCAL', + 'LOCK TABLES lock_mode_table LOW_PRIORITY WRITE', + 'LOCK TABLES lock_mode_table AS lock_alias READ', + 'LOCK TABLES lock_mode_table implicit_alias READ LOCAL, lock_mode_table LOW_PRIORITY WRITE', + ); + + foreach ( $queries as $query ) { + $driver->query( 'SELECT 1 AS previous_value' ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + $this->assertSame( 0, $driver->get_last_column_count(), $query ); + $this->assertSame( 0, $driver->get_last_return_value(), $query ); + } + } + + /** + * Tests malformed LOCK TABLES modes fail before raw backend execution. + */ + public function test_mysql_lock_tables_unsupported_modes_fail_before_backend_execution(): void { + $queries = array( + 'LOCK TABLES lock_mode_table LOW_PRIORITY READ', + 'LOCK TABLES lock_mode_table READ LOCAL LOCAL', + 'LOCK TABLES lock_mode_table WRITE LOCAL', + 'LOCK TABLES lock_mode_table AS READ', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_mode_table (id INTEGER)' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported LOCK TABLES mode to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported LOCK TABLES statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests LOCK/UNLOCK TABLES no-ops do not commit user transactions. + */ + public function test_mysql_lock_tables_noop_does_not_commit_user_transaction(): void { + $driver = $this->create_driver(); + $driver->query( 'CREATE TABLE lock_transaction_table (id INTEGER)' ); + + $this->assertSame( 0, $driver->query( 'START TRANSACTION' ) ); + $this->assertSame( 1, $driver->query( 'INSERT INTO lock_transaction_table (id) VALUES (1)' ) ); + + $this->assertSame( 0, $driver->query( 'LOCK TABLES lock_transaction_table WRITE' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( 0, $driver->query( 'UNLOCK TABLES' ) ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( 0, $driver->query( 'ROLLBACK' ) ); + + $rows = $driver->query( 'SELECT COUNT(*) AS lock_row_count FROM lock_transaction_table' ); + $this->assertSame( 0, (int) $rows[0]->lock_row_count ); + } + + /** + * Tests safe FLUSH statements used by admin tooling are MySQL compatibility no-ops. + */ + public function test_mysql_flush_tables_and_privileges_are_noops_without_backend_execution(): void { + $driver = $this->create_driver(); + + foreach ( + array( + 'FLUSH TABLES', + 'FLUSH TABLE', + 'FLUSH LOCAL TABLES', + 'FLUSH NO_WRITE_TO_BINLOG TABLES', + 'FLUSH PRIVILEGES', + 'FLUSH LOCAL PRIVILEGES', + ) as $query + ) { + $driver->query( 'SELECT 1 AS previous_value' ); + + $this->assertSame( 0, $driver->query( $query ), $query ); + $this->assertSame( $query, $driver->get_last_mysql_query(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + $this->assertSame( array(), $driver->get_last_column_meta(), $query ); + $this->assertSame( 0, $driver->get_last_column_count(), $query ); + } + } + + /** + * Tests unsafe or stateful FLUSH forms still fail before backend execution. + */ + public function test_unsupported_mysql_flush_statements_fail_closed_without_backend_execution(): void { + $queries = array( + 'FLUSH TABLES WITH READ LOCK', + 'FLUSH STATUS', + 'FLUSH BINARY LOGS', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported FLUSH statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported FLUSH statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests the emulated MySQL session SQL mode can be selected. + */ + public function test_select_session_sql_mode_returns_emulated_driver_state(): void { + $driver = $this->create_driver(); + + $driver->set_sql_mode( 'IGNORE_SPACE,NO_AUTO_VALUE_ON_ZERO' ); + + $rows = $driver->query( 'SELECT @@SESSION.sql_mode;' ); + + $this->assertSame( 'IGNORE_SPACE,NO_AUTO_VALUE_ON_ZERO', $rows[0]->{'@@SESSION.sql_mode'} ); + $this->assertSame( 'SELECT @@SESSION.sql_mode;', $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( '@@SESSION.sql_mode', $driver->get_last_column_meta()[0]['name'] ); + } + + /** + * Tests the PostgreSQL driver defaults to the same MySQL SQL modes as SQLite. + */ + public function test_default_sql_mode_matches_sqlite_backend_defaults(): void { + $driver = $this->create_driver(); + + $expected = 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES'; + + $this->assertSame( $expected, $driver->get_sql_mode() ); + + $rows = $driver->query( 'SELECT @@sql_mode' ); + $this->assertSame( $expected, $rows[0]->{'@@sql_mode'} ); + + $rows = $driver->query( "SHOW VARIABLES LIKE 'sql_mode'" ); + $this->assertCount( 1, $rows ); + $this->assertSame( $expected, $rows[0]->Value ); + } + + /** + * Tests supported SQL mode SET syntaxes normalize and report emulated state. + */ + public function test_sql_mode_set_syntaxes_update_emulated_state(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET sql_mode = "ERROR_FOR_DIVISION_BY_ZERO"' ) ); + $this->assertSame( 'ERROR_FOR_DIVISION_BY_ZERO', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( "SET @@sql_mode = 'NO_ENGINE_SUBSTITUTION'" ) ); + $this->assertSame( 'NO_ENGINE_SUBSTITUTION', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'NO_ZERO_DATE'" ) ); + $this->assertSame( 'NO_ZERO_DATE', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( "SET @@SESSION.sql_mode = 'NO_ZERO_IN_DATE'" ) ); + $rows = $driver->query( 'SELECT @@SESSION.sql_mode' ); + $this->assertSame( 'NO_ZERO_IN_DATE', $rows[0]->{'@@SESSION.sql_mode'} ); + + $this->assertSame( 0, $driver->query( 'SET @@session.SQL_mode = "only_full_group_by"' ) ); + $rows = $driver->query( 'SELECT @@session.SQL_mode' ); + $this->assertSame( 'ONLY_FULL_GROUP_BY', $rows[0]->{'@@session.SQL_mode'} ); + + $this->assertSame( 0, $driver->query( 'SET sql_mode = DEFAULT' ) ); + $this->assertSame( + 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES', + $driver->get_sql_mode() + ); + + $this->assertSame( 0, $driver->query( 'SET sql_mode = 0' ) ); + $this->assertSame( '', $driver->get_sql_mode() ); + } + + /** + * Tests SQL mode user-variable save/restore flows. + */ + public function test_sql_mode_can_be_saved_and_restored_through_user_variables(): void { + $driver = $this->create_driver(); + $initial_mode = $driver->get_sql_mode(); + + $this->assertSame( 0, $driver->query( 'SET @old_sql_mode = @@SESSION.sql_mode' ) ); + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'ANSI_QUOTES'" ) ); + $this->assertSame( 'ANSI_QUOTES', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( 'SET SESSION sql_mode = @old_sql_mode' ) ); + $this->assertSame( $initial_mode, $driver->get_sql_mode() ); + } + + /** + * Tests SQL-mode case conversion expressions mirror SQLite backend handling. + */ + public function test_sql_mode_case_conversion_expression_assignments_update_emulated_state(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'ANSI_QUOTES'" ) ); + $this->assertSame( 'ANSI_QUOTES', $driver->get_sql_mode() ); + + try { + $driver->query( 'SET sql_mode = LOWER("NO_ZERO_DATE")' ); + $this->fail( 'Expected ANSI_QUOTES double-quoted SET expression to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SET statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 'ANSI_QUOTES', $driver->get_sql_mode() ); + } + + $this->assertSame( 0, $driver->query( "SET sql_mode = LOWER('NO_ZERO_DATE')" ) ); + $this->assertSame( 'NO_ZERO_DATE', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( "SET sql_mode = UCASE(REPLACE(@@sql_mode, 'NO_ZERO_DATE', 'ansi_quotes,no_backslash_escapes'))" ) ); + $this->assertSame( 'ANSI_QUOTES,NO_BACKSLASH_ESCAPES', $driver->get_sql_mode() ); + + $this->assertSame( 0, $driver->query( "SET sql_mode = LCASE('PIPES_AS_CONCAT')" ) ); + $this->assertSame( 'PIPES_AS_CONCAT', $driver->get_sql_mode() ); + } + + /** + * Tests SQL-mode expressions supported by SQLite drive PostgreSQL zero-date behavior. + */ + public function test_sql_mode_expression_assignments_drive_zero_date_behavior(): void { + $driver = $this->create_driver(); + $this->install_posts_datetime_table_with_mysql_metadata( $driver ); + + $this->assertSame( 0, $driver->query( "SET sql_mode = REPLACE(@@sql_mode, 'NO_ZERO_DATE', '')" ) ); + $this->assertFalse( $driver->is_sql_mode_active( 'NO_ZERO_DATE' ) ); + $this->assertTrue( $driver->is_sql_mode_active( 'STRICT_TRANS_TABLES' ) ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (1, '0000-00-00 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ) + ); + + $this->assertSame( 0, $driver->query( "SET sql_mode = CONCAT(@@SESSION.sql_mode, ',NO_ZERO_DATE')" ) ); + $this->assertTrue( $driver->is_sql_mode_active( 'NO_ZERO_DATE' ) ); + + try { + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (2, '0000-00-00 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ); + $this->fail( 'Expected zero date to be rejected after expression re-enabled NO_ZERO_DATE.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '0000-00-00 00:00:00'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $this->assertSame( 0, $driver->query( "SET sql_mode = (SELECT REPLACE(@@sql_mode, 'NO_ZERO_IN_DATE', '') FROM DUAL)" ) ); + $this->assertFalse( $driver->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ); + + $this->assertSame( + 1, + $driver->query( + "INSERT INTO `wptests_posts` (`ID`, `post_date`, `post_date_gmt`, `post_modified`, `post_modified_gmt`) + VALUES (3, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-00-15 14:15:27', '2020-01-01 00:00:00')" + ) + ); + + $rows = $driver->query( 'SELECT post_modified FROM wptests_posts WHERE ID = 3' ); + $this->assertSame( '2020-00-15 14:15:27', $rows[0]->post_modified ); + + $this->assertSame( 0, $driver->query( "SET sql_mode = (SELECT CONCAT(@@sql_mode, ',NO_ZERO_IN_DATE'))" ) ); + $this->assertTrue( $driver->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ); + + try { + $driver->query( "UPDATE `wptests_posts` SET `post_modified` = '2020-00-16 14:15:27' WHERE `ID` = 3" ); + $this->fail( 'Expected zero-in-date to be rejected after expression re-enabled NO_ZERO_IN_DATE.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( "Incorrect datetime value: '2020-00-16 14:15:27'", $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests unsupported SQL-mode SET expressions fail before backend execution. + */ + public function test_unsupported_sql_mode_expression_assignment_fails_closed(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'SET sql_mode = SUBSTRING(@@sql_mode, 1, 10)' ); + $this->fail( 'Expected unsupported SQL-mode expression to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SET statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * Tests ANSI_QUOTES affects PostgreSQL query translation. + */ + public function test_ansi_quotes_sql_mode_treats_double_quoted_text_as_identifiers(): void { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY, post_title TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO wptests_posts ("ID", post_title) VALUES (1, \'Hello\')' ); + + $literal = $driver->query( 'SELECT "post_title" AS value' ); + $this->assertSame( 'post_title', $literal[0]->value ); + $this->assertSame( + "SELECT 'post_title' AS value", + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'ANSI_QUOTES'" ) ); + $rows = $driver->query( 'SELECT "ID", "post_title" FROM "wptests_posts" WHERE "ID" = 1' ); + + $this->assertCount( 1, $rows ); + $this->assertSame( '1', $rows[0]->ID ); + $this->assertSame( 'Hello', $rows[0]->post_title ); + $this->assertSame( + 'SELECT "ID", "post_title" FROM "wptests_posts" WHERE "ID" = 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests composite SQL modes expand and affect PostgreSQL tokenization. + */ + public function test_composite_sql_modes_expand_and_drive_postgresql_tokenization(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'ANSI'" ) ); + $this->assertSame( + 'REAL_AS_FLOAT,PIPES_AS_CONCAT,ANSI_QUOTES,IGNORE_SPACE,ONLY_FULL_GROUP_BY', + $driver->get_sql_mode() + ); + $this->assertTrue( $driver->is_sql_mode_active( 'ANSI_QUOTES' ) ); + $this->assertTrue( $driver->is_sql_mode_active( 'PIPES_AS_CONCAT' ) ); + + $driver->query( 'CREATE TABLE "wptests_ansi_mode" ("ID" INTEGER PRIMARY KEY, "post title" TEXT NOT NULL)' ); + $driver->query( 'INSERT INTO "wptests_ansi_mode" ("ID", "post title") VALUES (1, \'Hello\')' ); + $rows = $driver->query( 'SELECT "post title" FROM "wptests_ansi_mode" WHERE "ID" = 1' ); + $this->assertSame( 'Hello', $rows[0]->{'post title'} ); + $this->assertSame( + 'SELECT "post title" FROM "wptests_ansi_mode" WHERE "ID" = 1', + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( "SELECT 'a' || 'b' AS value" ); + $this->assertSame( 'ab', $rows[0]->value ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'TRADITIONAL'" ) ); + $this->assertSame( + 'STRICT_TRANS_TABLES,STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', + $driver->get_sql_mode() + ); + $this->assertFalse( $driver->is_sql_mode_active( 'ANSI_QUOTES' ) ); + + $rows = $driver->query( 'SELECT "post title" AS value' ); + $this->assertSame( 'post title', $rows[0]->value ); + $this->assertSame( + "SELECT 'post title' AS value", + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests NO_BACKSLASH_ESCAPES changes PostgreSQL string literal translation. + */ + public function test_no_backslash_escapes_sql_mode_changes_postgresql_string_literals(): void { + $driver = $this->create_driver(); + $backslash = chr( 92 ); + $query = "SELECT '{$backslash}n' AS value"; + + $driver->set_sql_mode( '' ); + $rows = $driver->query( $query ); + $this->assertSame( "\n", $rows[0]->value ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'" ) ); + $rows = $driver->query( $query ); + $this->assertSame( $backslash . 'n', $rows[0]->value ); + } + + /** + * Tests NO_BACKSLASH_ESCAPES disables PostgreSQL's default LIKE backslash escape. + */ + public function test_no_backslash_escapes_sql_mode_changes_postgresql_like_patterns(): void { + $driver = $this->create_driver(); + $backslash = chr( 92 ); + $query = "SELECT value FROM wptests_like_escape WHERE value LIKE 'abc{$backslash}_' OR value NOT LIKE 'def{$backslash}%'"; + + $driver->set_sql_mode( '' ); + $this->assertSame( + "SELECT value FROM wptests_like_escape WHERE value LIKE 'abc{$backslash}_' OR value NOT LIKE 'def{$backslash}%'", + $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $query ) + ); + + $driver->set_sql_mode( 'NO_BACKSLASH_ESCAPES' ); + $this->assertSame( + "SELECT value FROM wptests_like_escape WHERE value LIKE 'abc{$backslash}_' ESCAPE '' OR value NOT LIKE 'def{$backslash}%' ESCAPE ''", + $this->translate_driver_query_with_private_method( $driver, 'translate_mysql_compatible_query', $query ) + ); + + $cast_like_sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT CAST(meta_value AS DECIMAL(10,2)) LIKE '12{$backslash}_' AS matched FROM wptests_postmeta" + ); + $this->assertStringContainsString( "LIKE '12{$backslash}_' ESCAPE '' AS matched", $cast_like_sql ); + $this->assertStringNotContainsString( "ESCAPE '' ESCAPE ''", $cast_like_sql ); + + $this->assertSame( + "SELECT value FROM wptests_like_escape WHERE value LIKE 'abc{$backslash}_' ESCAPE '!'", + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT value FROM wptests_like_escape WHERE value LIKE 'abc{$backslash}_' ESCAPE '!'" + ) + ); + } + + /** + * Tests PIPES_AS_CONCAT switches || from logical OR to concatenation. + */ + public function test_pipes_as_concat_sql_mode_changes_postgresql_double_pipe_translation(): void { + $driver = $this->create_driver(); + + $driver->set_sql_mode( '' ); + $rows = $driver->query( 'SELECT 0 || 1 AS value' ); + $this->assertSame( '1', (string) $rows[0]->value ); + $this->assertSame( 'SELECT 0 OR 1 AS value', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'PIPES_AS_CONCAT'" ) ); + $rows = $driver->query( "SELECT 'a' || 'b' AS value" ); + $this->assertSame( 'ab', $rows[0]->value ); + $this->assertSame( "SELECT 'a' || 'b' AS value", $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests built-in MySQL version variables are selected from emulated state. + */ + public function test_select_builtin_version_variables_returns_mysql_compatible_values_without_backend_queries(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( 'SELECT @@version, @@version_comment' ); + + $this->assertSame( '8.0.38', $rows[0]->{'@@version'} ); + $this->assertSame( 'MySQL Community Server - GPL', $rows[0]->{'@@version_comment'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( '@@version', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( '@@version_comment', $driver->get_last_column_meta()[1]['name'] ); + } + + /** + * Tests emulated MySQL variables support explicit and implicit projection aliases. + */ + public function test_select_system_variables_support_mysql_projection_aliases_without_backend_queries(): void { + $driver = $this->create_driver(); + + $driver->set_sql_mode( 'IGNORE_SPACE' ); + + $rows = $driver->query( "SELECT @@SESSION.sql_mode AS mode, @@version version_alias, @@version_comment AS 'comment_alias'" ); + + $this->assertSame( 'IGNORE_SPACE', $rows[0]->mode ); + $this->assertSame( '8.0.38', $rows[0]->version_alias ); + $this->assertSame( 'MySQL Community Server - GPL', $rows[0]->comment_alias ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( + array( 'mode', 'version_alias', 'comment_alias' ), + array_map( + static function ( $column ) { + return $column['name']; + }, + $driver->get_last_column_meta() + ) + ); + } + + /** + * Tests MySQL variables inside otherwise ordinary SELECT projections are translated. + */ + public function test_mysql_variables_in_mixed_select_projection_are_translated(): void { + $driver = $this->create_driver(); + + $driver->set_sql_mode( 'IGNORE_SPACE' ); + $this->assertSame( 0, $driver->query( "SET @label = 'first'" ) ); + + $query = 'SELECT 1 AS n, @@SESSION.sql_mode AS mode, @label AS label'; + $rows = $driver->query( $query ); + + $this->assertSame( '1', $rows[0]->n ); + $this->assertSame( 'IGNORE_SPACE', $rows[0]->mode ); + $this->assertSame( 'first', $rows[0]->label ); + $this->assertSame( + "SELECT 1 AS n, 'IGNORE_SPACE' AS mode, 'first' AS label", + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT @@SESSION.sql_mode AS mode, 1 AS n, @label AS label' ); + + $this->assertSame( 'IGNORE_SPACE', $rows[0]->mode ); + $this->assertSame( '1', $rows[0]->n ); + $this->assertSame( 'first', $rows[0]->label ); + $this->assertSame( + "SELECT 'IGNORE_SPACE' AS mode, 1 AS n, 'first' AS label", + $this->get_last_single_postgresql_sql( $driver ) + ); + + $rows = $driver->query( 'SELECT @@SESSION.sql_mode AS mode FROM DUAL' ); + + $this->assertSame( 'IGNORE_SPACE', $rows[0]->mode ); + $this->assertSame( + "SELECT 'IGNORE_SPACE' AS mode", + $this->get_last_single_postgresql_sql( $driver ) + ); + + $this->assertSame( 0, $driver->query( "SET SESSION sql_mode = 'ANSI_QUOTES'" ) ); + $this->assertSame( 0, $driver->query( "SET @label = 'second'" ) ); + + $rows = $driver->query( $query ); + + $this->assertSame( 'ANSI_QUOTES', $rows[0]->mode ); + $this->assertSame( 'second', $rows[0]->label ); + $this->assertSame( + "SELECT 1 AS n, 'ANSI_QUOTES' AS mode, 'second' AS label", + $this->get_last_single_postgresql_sql( $driver ) + ); + } + + /** + * Tests unsupported system variables inside mixed SELECT projections fail before PDO. + */ + public function test_unsupported_system_variable_in_mixed_select_projection_fails_before_backend(): void { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( 'SELECT 1 AS n, @@definitely_unsupported_variable AS unsupported_value' ); + $this->fail( 'Expected unsupported MySQL system variable to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL system variable.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 0, $connection->get_query_count() ); + } + } + + /** + * Tests unsupported MySQL variable SELECT alias forms fail before backend execution. + */ + public function test_unsupported_mysql_variable_select_aliases_do_not_reach_backend(): void { + $driver = $this->create_driver(); + + foreach ( + array( + 'SELECT @@sql_mode AS', + 'SELECT @@sql_mode AS 1', + 'SELECT @@sql_mode 1', + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported MySQL variable SELECT statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL variable SELECT statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests WP-CLI/dump system-variable probes are selected from emulated state. + */ + public function test_wp_cli_dump_system_variable_probes_are_emulated_without_backend_queries(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( + 'SELECT @@GLOBAL.gtid_purged, @@GLOBAL.log_bin, @@GLOBAL.log_bin_trust_function_creators, @@SESSION.max_allowed_packet, @@lower_case_table_names, @@hostname, @@protocol_version' + ); + + $this->assertSame( '', $rows[0]->{'@@GLOBAL.gtid_purged'} ); + $this->assertSame( '0', $rows[0]->{'@@GLOBAL.log_bin'} ); + $this->assertSame( '0', $rows[0]->{'@@GLOBAL.log_bin_trust_function_creators'} ); + $this->assertSame( '67108864', $rows[0]->{'@@SESSION.max_allowed_packet'} ); + $this->assertSame( '0', $rows[0]->{'@@lower_case_table_names'} ); + $this->assertSame( 'localhost', $rows[0]->{'@@hostname'} ); + $this->assertSame( '10', $rows[0]->{'@@protocol_version'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $version = $driver->query( "SHOW VARIABLES LIKE 'version'" ); + $this->assertCount( 1, $version ); + $this->assertSame( '8.0.38', $version[0]->Value ); + + $version_where = $driver->query( "SHOW VARIABLES WHERE Variable_name = 'version'" ); + $this->assertCount( 1, $version_where ); + $this->assertSame( 'version', $version_where[0]->Variable_name ); + $this->assertSame( '8.0.38', $version_where[0]->Value ); + + $log_bin = $driver->query( "SHOW VARIABLES LIKE 'log_bin'" ); + $this->assertCount( 1, $log_bin ); + $this->assertSame( '0', $log_bin[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests WP-CLI/import SET toggles can be saved, changed, and reset. + */ + public function test_wp_cli_import_set_toggles_and_defaults_are_emulated_without_backend_queries(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET @old_sql_log_bin = @@sql_log_bin' ) ); + $this->assertSame( 0, $driver->query( 'SET @@sql_log_bin = 0' ) ); + $rows = $driver->query( 'SELECT @@sql_log_bin' ); + $this->assertSame( '0', $rows[0]->{'@@sql_log_bin'} ); + + $this->assertSame( 0, $driver->query( 'SET @@sql_log_bin = DEFAULT' ) ); + $rows = $driver->query( 'SELECT @@sql_log_bin' ); + $this->assertSame( '1', $rows[0]->{'@@sql_log_bin'} ); + + $this->assertSame( 0, $driver->query( 'SET @old_wait_timeout = @@wait_timeout' ) ); + $this->assertSame( 0, $driver->query( 'SET wait_timeout = 100' ) ); + $rows = $driver->query( 'SELECT @@wait_timeout' ); + $this->assertSame( '100', $rows[0]->{'@@wait_timeout'} ); + + $this->assertSame( 0, $driver->query( 'SET wait_timeout = @old_wait_timeout' ) ); + $rows = $driver->query( 'SELECT @@wait_timeout' ); + $this->assertSame( '28800', $rows[0]->{'@@wait_timeout'} ); + + $this->assertSame( 0, $driver->query( 'SET sql_quote_show_create = OFF, pseudo_replica_mode = ON' ) ); + $rows = $driver->query( 'SELECT @@sql_quote_show_create, @@pseudo_replica_mode' ); + $this->assertSame( '0', $rows[0]->{'@@sql_quote_show_create'} ); + $this->assertSame( '1', $rows[0]->{'@@pseudo_replica_mode'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests sql_warnings can be saved, changed, selected, and restored. + */ + public function test_sql_warnings_save_restore_flow_is_emulated_without_backend_queries(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET @old_sql_warnings = @@sql_warnings' ) ); + $this->assertSame( 0, $driver->query( 'SET sql_warnings = ON' ) ); + + $rows = $driver->query( 'SELECT @old_sql_warnings AS saved_warnings, @@sql_warnings warning_state' ); + $this->assertSame( '0', $rows[0]->saved_warnings ); + $this->assertSame( '1', $rows[0]->warning_state ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $show = $driver->query( "SHOW VARIABLES WHERE Variable_name = 'sql_warnings'" ); + $this->assertCount( 1, $show ); + $this->assertSame( 'sql_warnings', $show[0]->Variable_name ); + $this->assertSame( '1', $show[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( 0, $driver->query( 'SET @@sql_warnings = @old_sql_warnings' ) ); + $rows = $driver->query( 'SELECT @@sql_warnings warning_state' ); + $this->assertSame( '0', $rows[0]->warning_state ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests public SQL mode changes override earlier SQL SET state consistently. + */ + public function test_public_sql_mode_setter_overrides_sql_set_state_for_select_and_show_variables(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'" ) ); + $this->assertSame( 'NO_AUTO_VALUE_ON_ZERO', $driver->get_sql_mode() ); + + $driver->set_sql_mode( 'STRICT_ALL_TABLES' ); + + $this->assertSame( 'STRICT_ALL_TABLES', $driver->get_sql_mode() ); + + $rows = $driver->query( 'SELECT @@sql_mode' ); + $this->assertSame( 'STRICT_ALL_TABLES', $rows[0]->{'@@sql_mode'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name='sql_mode'" ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'sql_mode', $rows[0]->Variable_name ); + $this->assertSame( 'STRICT_ALL_TABLES', $rows[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests bare SHOW VARIABLES returns all emulated session variables. + */ + public function test_bare_show_variables_returns_all_known_session_variables_without_backend_queries(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET NAMES 'utf8' COLLATE 'utf8_general_ci'" ) ); + + $rows = $driver->query( 'SHOW VARIABLES' ); + + $variables = array(); + foreach ( $rows as $row ) { + $variables[ $row->Variable_name ] = $row->Value; + } + + $this->assertGreaterThan( 9, count( $variables ) ); + $this->assertSame( 'utf8', $variables['character_set_client'] ); + $this->assertSame( 'utf8', $variables['character_set_connection'] ); + $this->assertSame( 'utf8', $variables['character_set_results'] ); + $this->assertSame( 'utf8', $variables['character_set_database'] ); + $this->assertSame( 'utf8', $variables['character_set_server'] ); + $this->assertSame( 'utf8_general_ci', $variables['collation_connection'] ); + $this->assertSame( 'utf8_general_ci', $variables['collation_database'] ); + $this->assertSame( 'utf8_general_ci', $variables['collation_server'] ); + $this->assertSame( + 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES', + $variables['sql_mode'] + ); + $this->assertSame( '1', $variables['autocommit'] ); + $this->assertSame( 'InnoDB', $variables['default_storage_engine'] ); + $this->assertSame( '1', $variables['foreign_key_checks'] ); + $this->assertSame( '67108864', $variables['max_allowed_packet'] ); + $this->assertSame( 'SYSTEM', $variables['time_zone'] ); + $this->assertSame( 'SHOW VARIABLES', $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + $this->assertSame( 2, $driver->get_last_column_count() ); + $this->assertSame( + array( + array( + 'name' => 'Variable_name', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Variable_name', + 'mysqli:db' => 'wptests', + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 64, + 'precision' => 0, + 'native_type' => 'string', + ), + array( + 'name' => 'Value', + 'table' => '', + 'mysqli:orgtable' => '', + 'mysqli:orgname' => 'Value', + 'mysqli:db' => 'wptests', + 'mysqli:charsetnr' => 45, + 'mysqli:flags' => 0, + 'mysqli:type' => 253, + 'len' => 1024, + 'precision' => 0, + 'native_type' => 'string', + ), + ), + $driver->get_last_column_meta() + ); + } + + /** + * Tests SHOW GLOBAL/SESSION VARIABLES read the requested emulated scope. + */ + public function test_scoped_show_variables_read_requested_scope(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET NAMES 'utf8' COLLATE 'utf8_general_ci'" ) ); + + $bare = $driver->query( "SHOW VARIABLES WHERE Variable_name = 'character_set_client'" ); + $this->assertCount( 1, $bare ); + $this->assertSame( 'utf8', $bare[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $global = $driver->query( "SHOW GLOBAL VARIABLES WHERE Variable_name = 'character_set_client'" ); + $this->assertCount( 1, $global ); + $this->assertSame( 'utf8mb4', $global[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $session = $driver->query( "SHOW SESSION VARIABLES WHERE Variable_name = 'character_set_client'" ); + $this->assertEquals( $bare, $session ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests scoped SHOW VARIABLES LIKE and WHERE filters are emulated. + */ + public function test_scoped_show_variables_like_and_where_filters_work(): void { + $driver = $this->create_driver(); + + $global_like = $driver->query( "SHOW GLOBAL VARIABLES LIKE 'character_set_c%'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $global_like + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $session_like = $driver->query( "SHOW SESSION VARIABLES LIKE 'collation_%'" ); + $this->assertSame( + array( + 'collation_connection', + 'collation_database', + 'collation_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $session_like + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $global_where = $driver->query( "SHOW GLOBAL VARIABLES WHERE Variable_name = 'character_set_client'" ); + $this->assertCount( 1, $global_where ); + $this->assertSame( 'character_set_client', $global_where[0]->Variable_name ); + $this->assertSame( 'utf8mb4', $global_where[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $where_like = $driver->query( "SHOW VARIABLES WHERE Variable_name LIKE 'character_set_%'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $where_like + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $session_where = $driver->query( "SHOW SESSION VARIABLES WHERE Variable_name = 'collation_connection'" ); + $this->assertCount( 1, $session_where ); + $this->assertSame( 'collation_connection', $session_where[0]->Variable_name ); + $this->assertSame( 'utf8mb4_unicode_ci', $session_where[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $value_exact = $driver->query( "SHOW VARIABLES WHERE Value = 'utf8mb4'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $value_exact + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $value_like = $driver->query( "SHOW VARIABLES WHERE Value LIKE 'utf8mb4_%'" ); + $this->assertSame( + array( + 'default_collation_for_utf8mb4', + 'collation_connection', + 'collation_database', + 'collation_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $value_like + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $and_filter = $driver->query( "SHOW VARIABLES WHERE Variable_name LIKE 'character_set_%' AND Value = 'utf8mb4'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $and_filter + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $value_not_equal = $driver->query( "SHOW VARIABLES WHERE Value <> 'utf8mb4'" ); + $this->assertNotSame( array(), $value_not_equal ); + foreach ( $value_not_equal as $row ) { + $this->assertNotSame( 'utf8mb4', $row->Value ); + } + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $or_filter = $driver->query( "SHOW VARIABLES WHERE Variable_name LIKE 'character_set_%' OR Value = 'utf8mb4'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $or_filter + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW VARIABLES WHERE supports bounded numeric expressions on Value. + */ + public function test_show_variables_where_numeric_value_expressions_work(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SHOW VARIABLES WHERE (Value + 1) = 1025 AND Variable_name = 'group_concat_max_len'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'group_concat_max_len', $rows[0]->Variable_name ); + $this->assertSame( '1024', $rows[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $rows = $driver->query( "SHOW VARIABLES WHERE Value + Variable_name > 1 AND Variable_name = 'group_concat_max_len'" ); + + $this->assertCount( 1, $rows ); + $this->assertSame( 'group_concat_max_len', $rows[0]->Variable_name ); + $this->assertSame( '1024', $rows[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW VARIABLES WHERE supports generic predicate expressions. + */ + public function test_show_variables_where_generic_predicate_expressions_work(): void { + $driver = $this->create_driver(); + + $selected_values = $driver->query( "SHOW VARIABLES WHERE Variable_name IN ('character_set_client', 'collation_connection')" ); + $this->assertSame( + array( + 'character_set_client', + 'collation_connection', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $selected_values + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $unknown_not_in_values = $driver->query( "SHOW VARIABLES WHERE Value NOT IN ('utf8mb4', NULL)" ); + $this->assertSame( array(), $unknown_not_in_values ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $truthy_variables = $driver->query( 'SHOW VARIABLES WHERE NOT 0' ); + $this->assertNotCount( 0, $truthy_variables ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $bounded_names = $driver->query( "SHOW VARIABLES WHERE Variable_name BETWEEN 'character_set_client' AND 'character_set_results'" ); + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + ), + array_map( + static function ( $row ): string { + return $row->Variable_name; + }, + $bounded_names + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported SHOW VARIABLES WHERE clauses fail before backend execution. + */ + public function test_unsupported_show_variables_where_clause_does_not_reach_backend(): void { + $driver = $this->create_driver(); + + foreach ( + array( + "SHOW VARIABLES WHERE Unknown = 'utf8mb4'", + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW VARIABLES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW VARIABLES statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests SET NAMES updates MySQL-compatible SHOW VARIABLES output. + */ + public function test_set_names_updates_show_variables_session_state(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET NAMES 'utf8' COLLATE 'utf8_general_ci'" ) ); + + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $this->assertCount( 1, $collation ); + $this->assertSame( 'collation_connection', $collation[0]->Variable_name ); + $this->assertSame( 'utf8_general_ci', $collation[0]->Value ); + + $charset = $driver->query( "SHOW VARIABLES LIKE 'character_set_client'" ); + $this->assertCount( 1, $charset ); + $this->assertSame( 'utf8', $charset[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SET NAMES DEFAULT resets to the emulated MySQL defaults. + */ + public function test_set_names_default_resets_show_variables_session_state(): void { + $driver = $this->create_driver(); + + $driver->query( "SET NAMES 'utf8' COLLATE 'utf8_general_ci'" ); + $this->assertSame( 0, $driver->query( 'SET NAMES DEFAULT' ) ); + + $charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + $this->assertCount( 1, $charset ); + $this->assertSame( 'utf8mb4', $charset[0]->Value ); + + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $this->assertCount( 1, $collation ); + $this->assertSame( 'utf8mb4_unicode_ci', $collation[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SET CHARSET aliases update MySQL-compatible SHOW VARIABLES output. + */ + public function test_set_charset_aliases_update_show_variables_session_state(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET CHARSET utf8' ) ); + + $charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + $this->assertCount( 1, $charset ); + $this->assertSame( 'utf8', $charset[0]->Value ); + + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $this->assertCount( 1, $collation ); + $this->assertSame( 'utf8_general_ci', $collation[0]->Value ); + + $this->assertSame( 0, $driver->query( 'SET CHARACTER SET utf8mb4' ) ); + + $charset = $driver->query( "SHOW VARIABLES WHERE Variable_name='character_set_client'" ); + $this->assertCount( 1, $charset ); + $this->assertSame( 'utf8mb4', $charset[0]->Value ); + + $collation = $driver->query( "SHOW VARIABLES WHERE Variable_name='collation_connection'" ); + $this->assertCount( 1, $collation ); + $this->assertSame( 'utf8mb4_unicode_ci', $collation[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests supported MySQL session variables can be selected with MySQL aliases. + */ + public function test_session_system_variables_can_be_selected_with_mysql_aliases(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET character_set_client = 'latin1'" ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'latin1', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( "SET @@character_set_client = 'utf8mb3'" ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'utf8mb3', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( "SET @@session.character_set_client = 'utf8mb4'" ) ); + $rows = $driver->query( 'SELECT @@session.character_set_client' ); + $this->assertSame( 'utf8mb4', $rows[0]->{'@@session.character_set_client'} ); + + $this->assertSame( 0, $driver->query( 'SET default_storage_engine = InnoDB' ) ); + $rows = $driver->query( 'SELECT @@default_storage_engine' ); + $this->assertSame( 'InnoDB', $rows[0]->{'@@default_storage_engine'} ); + + $rows = $driver->query( 'SELECT @@SESSION.max_allowed_packet' ); + $this->assertSame( '67108864', $rows[0]->{'@@SESSION.max_allowed_packet'} ); + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name='max_allowed_packet'" ); + $this->assertSame( '67108864', $rows[0]->Value ); + + $this->assertSame( 0, $driver->query( "SET SESSION time_zone = '+00:00'" ) ); + $rows = $driver->query( 'SELECT @@time_zone' ); + $this->assertSame( '+00:00', $rows[0]->{'@@time_zone'} ); + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name='time_zone'" ); + $this->assertSame( '+00:00', $rows[0]->Value ); + + $this->assertSame( 0, $driver->query( 'SET GLOBAL foreign_key_checks = 0' ) ); + $rows = $driver->query( 'SELECT @@GLOBAL.foreign_key_checks' ); + $this->assertSame( '0', $rows[0]->{'@@GLOBAL.foreign_key_checks'} ); + $rows = $driver->query( 'SELECT @@SESSION.foreign_key_checks' ); + $this->assertSame( '1', $rows[0]->{'@@SESSION.foreign_key_checks'} ); + $rows = $driver->query( "SHOW GLOBAL VARIABLES WHERE Variable_name='foreign_key_checks'" ); + $this->assertSame( '0', $rows[0]->Value ); + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name='foreign_key_checks'" ); + $this->assertSame( '1', $rows[0]->Value ); + + $this->assertSame( 0, $driver->query( "SET GLOBAL sql_mode = 'ANSI_QUOTES'" ) ); + $rows = $driver->query( 'SELECT @@GLOBAL.sql_mode' ); + $this->assertSame( 'ANSI_QUOTES', $rows[0]->{'@@GLOBAL.sql_mode'} ); + $this->assertSame( + 'ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES', + $driver->get_sql_mode() + ); + $rows = $driver->query( "SHOW GLOBAL VARIABLES WHERE Variable_name='sql_mode'" ); + $this->assertSame( 'ANSI_QUOTES', $rows[0]->Value ); + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name='sql_mode'" ); + $this->assertSame( $driver->get_sql_mode(), $rows[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests keyword-valued MySQL session variables match SQLite backend compatibility. + */ + public function test_keyword_session_system_variables_can_be_set_and_selected(): void { + $driver = $this->create_driver(); + + $cases = array( + 'SET default_collation_for_utf8mb4 = utf8mb4_0900_ai_ci' => array( '@@default_collation_for_utf8mb4', 'utf8mb4_0900_ai_ci' ), + 'SET resultset_metadata = FULL' => array( '@@resultset_metadata', 'FULL' ), + 'SET session_track_gtids = OWN_GTID' => array( '@@session_track_gtids', 'OWN_GTID' ), + 'SET session_track_transaction_info = STATE' => array( '@@session_track_transaction_info', 'STATE' ), + 'SET transaction_isolation = SERIALIZABLE' => array( '@@transaction_isolation', 'SERIALIZABLE' ), + 'SET use_secondary_engine = FORCED' => array( '@@use_secondary_engine', 'FORCED' ), + ); + + foreach ( $cases as $query => $expected ) { + $this->assertSame( 0, $driver->query( $query ), $query ); + $rows = $driver->query( 'SELECT ' . $expected[0] ); + $this->assertSame( $expected[1], $rows[0]->{ $expected[0] }, $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests SQLite-backed ON/OFF system variables are emulated for PostgreSQL too. + */ + public function test_on_off_session_system_variables_can_be_set_and_selected(): void { + $driver = $this->create_driver(); + + $cases = array( + 'SET autocommit = ON' => array( '@@autocommit', '1' ), + 'SET big_tables = OFF' => array( '@@big_tables', '0' ), + 'SET end_markers_in_json = ON' => array( '@@end_markers_in_json', '1' ), + 'SET explicit_defaults_for_timestamp = OFF' => array( '@@explicit_defaults_for_timestamp', '0' ), + 'SET keep_files_on_create = ON' => array( '@@keep_files_on_create', '1' ), + 'SET old_alter_table = OFF' => array( '@@old_alter_table', '0' ), + 'SET print_identified_with_as_hex = ON' => array( '@@print_identified_with_as_hex', '1' ), + 'SET require_row_format = OFF' => array( '@@require_row_format', '0' ), + 'SET select_into_disk_sync = ON' => array( '@@select_into_disk_sync', '1' ), + 'SET session_track_schema = ON' => array( '@@session_track_schema', '1' ), + 'SET session_track_state_change = OFF' => array( '@@session_track_state_change', '0' ), + 'SET show_create_table_skip_secondary_engine = ON' => array( '@@show_create_table_skip_secondary_engine', '1' ), + 'SET show_create_table_verbosity = OFF' => array( '@@show_create_table_verbosity', '0' ), + 'SET sql_auto_is_null = ON' => array( '@@sql_auto_is_null', '1' ), + 'SET sql_big_selects = OFF' => array( '@@sql_big_selects', '0' ), + 'SET sql_buffer_result = ON' => array( '@@sql_buffer_result', '1' ), + 'SET sql_safe_updates = OFF' => array( '@@sql_safe_updates', '0' ), + 'SET sql_warnings = ON' => array( '@@sql_warnings', '1' ), + 'SET transaction_read_only = OFF' => array( '@@transaction_read_only', '0' ), + ); + + foreach ( $cases as $query => $expected ) { + $this->assertSame( 0, $driver->query( $query ), $query ); + $rows = $driver->query( 'SELECT ' . $expected[0] ); + $this->assertSame( $expected[1], $rows[0]->{ $expected[0] }, $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + + $this->assertSame( 0, $driver->query( "SET autocommit = 'on', big_tables = 'off'" ) ); + $rows = $driver->query( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $rows[0]->{'@@autocommit'} ); + $this->assertSame( '0', $rows[0]->{'@@big_tables'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests GROUP_CONCAT length SET state matches SQLite backend compatibility. + */ + public function test_group_concat_max_len_can_be_set_selected_and_restored(): void { + $driver = $this->create_driver(); + + $default = $driver->query( "SHOW VARIABLES LIKE 'group_concat_max_len'" ); + $this->assertCount( 1, $default ); + $this->assertSame( 'group_concat_max_len', $default[0]->Variable_name ); + $this->assertSame( '1024', $default[0]->Value ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $this->assertSame( 0, $driver->query( 'SET @old_group_concat_max_len = @@SESSION.group_concat_max_len' ) ); + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = 1000000' ) ); + + $rows = $driver->query( 'SELECT @@SESSION.group_concat_max_len' ); + $this->assertSame( '1000000', $rows[0]->{'@@SESSION.group_concat_max_len'} ); + + $rows = $driver->query( "SHOW VARIABLES WHERE Variable_name = 'group_concat_max_len'" ); + $this->assertCount( 1, $rows ); + $this->assertSame( '1000000', $rows[0]->Value ); + + $this->assertSame( 0, $driver->query( 'SET group_concat_max_len = @old_group_concat_max_len' ) ); + $rows = $driver->query( 'SELECT @@group_concat_max_len' ); + $this->assertSame( '1024', $rows[0]->{'@@group_concat_max_len'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests the default GROUP_CONCAT length limit is enforced for supported translations. + */ + public function test_group_concat_uses_default_group_concat_max_len(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 1 => str_repeat( 'a', 600 ), + 2 => str_repeat( 'b', 600 ), + ) + ); + + $rows = $driver->query( "SELECT GROUP_CONCAT(value ORDER BY id SEPARATOR '') AS combined FROM group_concat_values" ); + + $this->assertSame( str_repeat( 'a', 600 ) . str_repeat( 'b', 424 ), $rows[0]->combined ); + $this->assertSame( 1024, strlen( $rows[0]->combined ) ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'STRING_AGG', $sql ); + $this->assertStringContainsString( ', 1024', $sql ); + } + + /** + * Tests small GROUP_CONCAT length limits truncate standard ORDER/SEPARATOR forms. + */ + public function test_group_concat_max_len_truncates_supported_group_concat_shapes(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 2 => 'two', + 1 => 'one', + 3 => 'three', + ) + ); + + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = 5' ) ); + $rows = $driver->query( "SELECT GROUP_CONCAT(value ORDER BY id SEPARATOR '|') AS combined FROM group_concat_values" ); + + $this->assertSame( 'one|t', $rows[0]->combined ); + $this->assertStringContainsString( ', 5', $this->get_last_single_postgresql_sql( $driver ) ); + + $rows = $driver->query( 'SELECT GROUP_CONCAT(value ORDER BY id) AS combined FROM group_concat_values' ); + $this->assertSame( 'one,t', $rows[0]->combined ); + + $rows = $driver->query( 'SELECT GROUP_CONCAT(value) AS combined FROM group_concat_values WHERE id = 1' ); + $this->assertSame( 'one', $rows[0]->combined ); + } + + /** + * Tests SET group_concat_max_len = DEFAULT restores the default runtime behavior. + */ + public function test_group_concat_max_len_default_restore_updates_group_concat_output(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 1 => 'alpha', + 2 => 'beta', + ) + ); + + $query = "SELECT GROUP_CONCAT(value ORDER BY id SEPARATOR '|') AS combined FROM group_concat_values"; + + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = 5' ) ); + $rows = $driver->query( $query ); + $this->assertSame( 'alpha', $rows[0]->combined ); + + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = DEFAULT' ) ); + $rows = $driver->query( $query ); + $this->assertSame( 'alpha|beta', $rows[0]->combined ); + + $variable = $driver->query( 'SELECT @@group_concat_max_len' ); + $this->assertSame( '1024', $variable[0]->{'@@group_concat_max_len'} ); + } + + /** + * Tests GROUP_CONCAT translations do not reuse stale group_concat_max_len state. + */ + public function test_group_concat_max_len_translation_does_not_use_stale_cached_state(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 1 => 'alpha', + 2 => 'beta', + ) + ); + + $query = "SELECT GROUP_CONCAT(value ORDER BY id SEPARATOR '|') AS combined FROM group_concat_values"; + + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = 5' ) ); + $rows = $driver->query( $query ); + $this->assertSame( 'alpha', $rows[0]->combined ); + $this->assertStringContainsString( ', 5', $this->get_last_single_postgresql_sql( $driver ) ); + + $this->assertSame( 0, $driver->query( 'SET SESSION group_concat_max_len = 9' ) ); + $rows = $driver->query( $query ); + $this->assertSame( 'alpha|bet', $rows[0]->combined ); + $this->assertStringContainsString( ', 9', $this->get_last_single_postgresql_sql( $driver ) ); + } + + /** + * Tests GROUP_CONCAT(expr, expr, ...) concatenates row expressions before aggregation. + */ + public function test_group_concat_multi_expression_rows_are_concatenated_before_aggregation(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 2 => 'two', + 1 => 'one', + 3 => 'three', + ) + ); + + $rows = $driver->query( "SELECT GROUP_CONCAT(id, ':', value ORDER BY id SEPARATOR '|') AS combined FROM group_concat_values" ); + + $this->assertSame( '1:one|2:two|3:three', $rows[0]->combined ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( "CAST(id AS text) || CAST(':' AS text) || CAST(value AS text)", $sql ); + $this->assertStringContainsString( "STRING_AGG(CAST(CAST(id AS text) || CAST(':' AS text) || CAST(value AS text) AS text), CAST('|' AS text) ORDER BY id)", $sql ); + $this->assertStringNotContainsString( 'GROUP_CONCAT', $sql ); + } + + /** + * Tests GROUP_CONCAT multi-expression rows preserve NULL skip semantics. + */ + public function test_group_concat_multi_expression_rows_skip_null_composites(): void { + $driver = $this->create_driver(); + + $driver->query( + 'CREATE TABLE group_concat_multi_values ( + id INTEGER PRIMARY KEY, + prefix TEXT NOT NULL, + suffix TEXT NULL + )' + ); + $driver->query( + "INSERT INTO group_concat_multi_values (id, prefix, suffix) VALUES + (1, 'a', '1'), + (2, 'b', NULL), + (3, 'c', '3')" + ); + + $rows = $driver->query( "SELECT GROUP_CONCAT(prefix, suffix ORDER BY id SEPARATOR ',') AS combined FROM group_concat_multi_values" ); + + $this->assertSame( 'a1,c3', $rows[0]->combined ); + } + + /** + * Tests GROUP_CONCAT(DISTINCT expr) deduplicates values and keeps group_concat_max_len behavior. + */ + public function test_group_concat_distinct_default_separator_deduplicates_values(): void { + $driver = $this->create_driver(); + $this->create_group_concat_values_table( + $driver, + array( + 1 => 'alpha', + 2 => 'beta', + 3 => 'alpha', + 4 => 'beta', + ) + ); + + $rows = $driver->query( 'SELECT GROUP_CONCAT(DISTINCT value) AS combined FROM group_concat_values' ); + $parts = explode( ',', $rows[0]->combined ); + sort( $parts ); + + $this->assertSame( array( 'alpha', 'beta' ), $parts ); + $sql = $this->get_last_single_postgresql_sql( $driver ); + $this->assertStringContainsString( 'GROUP_CONCAT(DISTINCT CAST(value AS text))', $sql ); + $this->assertStringContainsString( ', 1024', $sql ); + } + + /** + * Tests GROUP_CONCAT(DISTINCT expr SEPARATOR literal) translates for PostgreSQL. + */ + public function test_group_concat_distinct_literal_separator_translates_for_postgresql(): void { + $connection = new class( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ) extends WP_PostgreSQL_Connection { + public function get_driver_name(): string { + return 'pgsql'; + } + }; + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT GROUP_CONCAT(DISTINCT value SEPARATOR '|') AS combined FROM group_concat_values" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( "STRING_AGG(DISTINCT CAST(value AS text), CAST('|' AS text))", $sql ); + $this->assertStringNotContainsString( 'GROUP_CONCAT', $sql ); + } + + /** + * Tests GROUP_CONCAT(DISTINCT expr ORDER BY expr) translates for PostgreSQL. + */ + public function test_group_concat_distinct_order_by_same_expression_translates_for_postgresql(): void { + $connection = new class( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ) extends WP_PostgreSQL_Connection { + public function get_driver_name(): string { + return 'pgsql'; + } + }; + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT GROUP_CONCAT(DISTINCT value ORDER BY value DESC SEPARATOR '|') AS combined FROM group_concat_values" + ); + + $this->assertNotNull( $sql ); + $this->assertStringContainsString( + "STRING_AGG(DISTINCT CAST(value AS text), CAST('|' AS text) ORDER BY CAST(value AS text) DESC)", + $sql + ); + $this->assertStringNotContainsString( 'GROUP_CONCAT', $sql ); + } + + /** + * Tests unsupported GROUP_CONCAT() forms fail before backend execution. + */ + public function test_unsupported_group_concat_forms_fail_closed_before_backend_execution(): void { + $queries = array( + 'SELECT GROUP_CONCAT(DISTINCT id, value) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(DISTINCT value ORDER BY id) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(DISTINCT value ORDER BY value, id) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(DISTINCT value SEPARATOR separator_value) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(value ORDER id) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(value SEPARATOR) AS combined FROM group_concat_values', + 'SELECT GROUP_CONCAT(value SEPARATOR "," SEPARATOR "|") AS combined FROM group_concat_values', + ); + + foreach ( $queries as $query ) { + $connection = new WP_PostgreSQL_Query_Spy_Connection(); + $driver = new WP_PostgreSQL_Driver( $connection, 'wptests' ); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported GROUP_CONCAT() form to fail closed.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported MySQL runtime function form.', $e->getMessage(), $query ); + } + + $this->assertSame( 0, $connection->get_query_count(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + /** + * Tests unsafe GROUP_CONCAT length SET forms fail before reaching PDO. + */ + public function test_unsupported_group_concat_max_len_set_forms_fail_closed(): void { + $queries = array( + 'SET GLOBAL group_concat_max_len = 1000000', + 'SET GLOBAL group_concat_max_len = DEFAULT', + 'SET @@GLOBAL.group_concat_max_len = 1000000', + 'SET @@GLOBAL.group_concat_max_len = DEFAULT', + 'SET SESSION group_concat_max_len = OFF', + 'SET SESSION group_concat_max_len = 1 + 1', + ); + + foreach ( $queries as $query ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SET statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SET statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * Tests comma-separated boolean SET assignments are applied atomically. + */ + public function test_comma_separated_boolean_set_assignments_are_atomic(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET autocommit = ON, big_tables = OFF' ) ); + + $rows = $driver->query( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $rows[0]->{'@@autocommit'} ); + $this->assertSame( '0', $rows[0]->{'@@big_tables'} ); + + try { + $driver->query( 'SET autocommit = OFF, unsupported_setting = 1' ); + $this->fail( 'Expected unsupported SET statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SET statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + $rows = $driver->query( 'SELECT @@autocommit, @@big_tables' ); + $this->assertSame( '1', $rows[0]->{'@@autocommit'} ); + $this->assertSame( '0', $rows[0]->{'@@big_tables'} ); + } + + /** + * Tests user variables can be set, incremented, selected, and used for restore. + */ + public function test_user_variables_can_be_set_incremented_selected_and_used_for_restore(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( 'SET @my_var = 1' ) ); + $rows = $driver->query( 'SELECT @my_var' ); + $this->assertSame( '1', $rows[0]->{'@my_var'} ); + + $this->assertSame( 0, $driver->query( 'SET @my_var = @my_var + 1' ) ); + $rows = $driver->query( 'SELECT @my_var' ); + $this->assertSame( '2', $rows[0]->{'@my_var'} ); + + $this->assertSame( 0, $driver->query( 'SET @saved_cs_client = @@character_set_client' ) ); + $this->assertSame( 0, $driver->query( 'SET character_set_client = latin1' ) ); + + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'latin1', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( 'SET character_set_client = @saved_cs_client' ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'utf8mb4', $rows[0]->{'@@character_set_client'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests conditional-comment SET wrappers work for supported backup and restore forms. + */ + public function test_conditional_comment_set_wrappers_handle_supported_backup_and_restore(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( '/*!50503 SET NAMES utf8 */;' ) ); + $this->assertSame( 0, $driver->query( '/*!40101 SET @saved_cs_client = @@character_set_client */; ' ) ); + $this->assertSame( 0, $driver->query( '/*!50503 SET character_set_client = latin1 */;' ) ); + + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'latin1', $rows[0]->{'@@character_set_client'} ); + + $this->assertSame( 0, $driver->query( '/*!40101 SET character_set_client = @saved_cs_client */;' ) ); + $rows = $driver->query( 'SELECT @@character_set_client' ); + $this->assertSame( 'utf8', $rows[0]->{'@@character_set_client'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests SHOW VARIABLES LIKE honors MySQL wildcard patterns. + */ + public function test_show_variables_like_matches_wildcard_patterns(): void { + $driver = $this->create_driver(); + + $rows = $driver->query( "SHOW VARIABLES LIKE 'character_set_%'" ); + + $this->assertSame( + array( + 'character_set_client', + 'character_set_connection', + 'character_set_results', + 'character_set_database', + 'character_set_server', + ), + array_map( + static function ( $row ) { + return $row->Variable_name; + }, + $rows + ) + ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * Tests unsupported SET statements fail before reaching PDO. + */ + public function test_unsupported_set_statements_do_not_reach_backend(): void { + $driver = $this->create_driver(); + + foreach ( + array( + 'SET unsupported_setting = 1', + 'SET foreign_key_checks = 0, unsupported_setting = 1', + 'SET autocommit = 1 + 1', + 'SET @my_var = @my_var * 1', + "SET @@version = '8.0.39'", + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SET statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SET statement.', $e->getMessage() ); + $this->assertSame( $query, $driver->get_last_mysql_query() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + } + + /** + * Install a PostgreSQL-like options table and matching MySQL column metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_options_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_options ( + option_name TEXT NOT NULL UNIQUE, + option_value TEXT NOT NULL, + autoload TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_options ( + option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + option_name varchar(191) NOT NULL DEFAULT '', + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name) + )" + ); + } + + /** + * Install a PostgreSQL-like posts table with MySQL datetime metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_posts_datetime_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_posts ( + "ID" INTEGER PRIMARY KEY, + post_date TEXT NOT NULL, + post_date_gmt TEXT NOT NULL, + post_modified TEXT NOT NULL, + post_modified_gmt TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_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_modified datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + post_modified_gmt timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (ID) + )" + ); + } + + /** + * Install a DML coercion table with temporal/YEAR MySQL metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_strict_dml_values_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_strict_values ( + id INTEGER PRIMARY KEY, + date_value TEXT, + datetime_value TEXT, + timestamp_value TEXT, + year_value TEXT + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_strict_values ( + id int(11) NOT NULL, + date_value date DEFAULT NULL, + datetime_value datetime DEFAULT NULL, + timestamp_value timestamp DEFAULT NULL, + year_value year DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + } + + /** + * Install a DML coercion table with integer-family MySQL metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_strict_integer_values_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_strict_ints ( + id INTEGER PRIMARY KEY, + int_value INTEGER, + tiny_unsigned INTEGER, + small_value INTEGER, + int_unsigned TEXT + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_strict_ints ( + id int(11) NOT NULL, + int_value int(11) DEFAULT NULL, + tiny_unsigned tinyint(3) unsigned DEFAULT NULL, + small_value smallint(6) DEFAULT NULL, + int_unsigned int(10) unsigned DEFAULT NULL, + PRIMARY KEY (id) + )' + ); + } + + /** + * Install a DML coercion table with bounded text MySQL metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_strict_text_values_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_strict_texts ( + id INTEGER PRIMARY KEY, + varchar_value TEXT, + char_value TEXT, + tinytext_value TEXT + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_strict_texts ( + id int(11) NOT NULL, + varchar_value varchar(3) DEFAULT NULL, + char_value char(3) DEFAULT NULL, + tinytext_value tinytext, + PRIMARY KEY (id) + )' + ); + } + + /** + * Install a term relationships table with MySQL composite key metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Table name. + */ + private function install_term_relationships_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver, string $table_name ): void { + $driver->query( + sprintf( + 'CREATE TABLE `%s` ( + `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`) + )', + $table_name + ) + ); + } + + /** + * Install an upsert table with ambiguous MySQL duplicate-key metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_ambiguous_upsert_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE ambiguous_upsert ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE ambiguous_upsert ( + id bigint(20) unsigned NOT NULL, + slug varchar(191) NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug (slug) + )' + ); + } + + /** + * Install an upsert table with a MySQL prefix unique key. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_prefix_ambiguous_upsert_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE prefix_ambiguous ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL, + value TEXT NOT NULL + )' + ); + $driver->get_connection()->query( + 'CREATE UNIQUE INDEX prefix_ambiguous__slug ON prefix_ambiguous (SUBSTR(CAST(slug AS text), 1, 10))' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE prefix_ambiguous ( + id bigint(20) unsigned NOT NULL, + slug varchar(255) NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug (slug(10)) + )' + ); + } + + /** + * Install an identity table with MySQL auto_increment metadata. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_identity_upsert_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_identity_upsert ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_identity_upsert ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + value longtext NOT NULL, + PRIMARY KEY (id) + )' + ); + } + + /** + * Install an identity upsert table with a non-AUTO_INCREMENT unique key. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_identity_unique_upsert_table_with_mysql_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->query( + 'CREATE TABLE wptests_identity_unique_upsert ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + value TEXT NOT NULL + )' + ); + $driver->store_mysql_schema_metadata( + 'CREATE TABLE wptests_identity_unique_upsert ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + slug varchar(191) NOT NULL, + value longtext NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY slug (slug) + )' + ); + } + + /** + * Creates a PostgreSQL driver backed by an injected in-memory PDO. + * + * @return WP_PostgreSQL_Driver + */ + private function create_driver( string $db_name = 'wptests' ): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + return new WP_PostgreSQL_Driver( $connection, $db_name ); + } + + /** + * Creates a small table for GROUP_CONCAT behavior tests. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param array $rows Values keyed by integer ID. + */ + private function create_group_concat_values_table( WP_PostgreSQL_Driver $driver, array $rows ): void { + $driver->query( 'CREATE TABLE group_concat_values (id INTEGER PRIMARY KEY, value TEXT NOT NULL)' ); + + foreach ( $rows as $id => $value ) { + $driver->query( + sprintf( + 'INSERT INTO group_concat_values (id, value) VALUES (%d, %s)', + $id, + $driver->get_connection()->quote( $value ) + ) + ); + } + } + + /** + * Creates a PostgreSQL driver with tables used by index hint translation tests. + * + * @return WP_PostgreSQL_Driver Driver under test. + */ + private function create_driver_with_index_hint_tables(): WP_PostgreSQL_Driver { + $driver = $this->create_driver(); + + $driver->query( 'CREATE TABLE t (id INTEGER, value TEXT)' ); + $driver->query( 'CREATE TABLE j (t_id INTEGER, value TEXT)' ); + + return $driver; + } + + /** + * Get the last single PostgreSQL SQL statement executed by a driver. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @return string Last PostgreSQL SQL. + */ + private function get_last_single_postgresql_sql( WP_PostgreSQL_Driver $driver ): string { + $queries = $driver->get_last_postgresql_queries(); + + $this->assertCount( 1, $queries ); + return $queries[0]['sql']; + } + + /** + * Assert the last logged PostgreSQL SQL statements without parameters. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string[] $sql Expected SQL statements. + */ + private function assert_last_postgresql_sql_statements( WP_PostgreSQL_Driver $driver, array $sql ): void { + $this->assertSame( + array_map( + static function ( string $statement ): array { + return array( + 'sql' => $statement, + 'params' => array(), + ); + }, + $sql + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * Assert the last query used the materialized INSERT ... SELECT upsert flow. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Target table name. + * @return string[] Logged SQL statements. + */ + private function assert_last_upsert_select_materialized_sql( WP_PostgreSQL_Driver $driver, string $table_name ): array { + $queries = $driver->get_last_postgresql_queries(); + $this->assertGreaterThanOrEqual( 4, count( $queries ) ); + + $sql = array_column( $queries, 'sql' ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_upsert_select_[a-f0-9]{12}"$/', $sql[0] ); + $this->assertSame( $sql[0], $sql[3] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_upsert_select_[a-f0-9]{12}" AS SELECT /', $sql[1] ); + $this->assertStringStartsWith( + sprintf( + 'INSERT INTO "%s" ', + $table_name + ), + $sql[2] + ); + $this->assertStringContainsString( ' FROM "__wp_pg_upsert_select_', $sql[2] ); + $this->assertStringContainsString( ' WHERE 1 = 1 ON CONFLICT ', $sql[2] ); + + return $sql; + } + + /** + * Assert the last query used the materialized REPLACE ... SELECT flow. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Target table name. + * @return string[] Logged SQL statements. + */ + private function assert_last_replace_select_materialized_sql( WP_PostgreSQL_Driver $driver, string $table_name ): array { + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 5, $queries ); + + $sql = array_column( $queries, 'sql' ); + $this->assertRegExp( '/^DROP TABLE IF EXISTS "__wp_pg_replace_select_[a-f0-9]{12}"$/', $sql[0] ); + $this->assertSame( $sql[0], $sql[4] ); + $this->assertRegExp( '/^CREATE TEMPORARY TABLE "__wp_pg_replace_select_[a-f0-9]{12}" AS SELECT /', $sql[1] ); + $this->assertStringStartsWith( + 'DELETE FROM "' . $table_name . '" AS "__wp_pg_replace_target" WHERE EXISTS (SELECT 1 FROM "__wp_pg_replace_select_', + $sql[2] + ); + $this->assertStringStartsWith( + 'INSERT INTO "' . $table_name . '" ', + $sql[3] + ); + + return $sql; + } + + /** + * Get the first logged PostgreSQL SQL statement containing a string. + * + * @param array[] $queries Logged PostgreSQL query records. + * @param string $needle SQL fragment to find. + * @return string Matching SQL statement. + */ + private function get_logged_postgresql_sql_containing( array $queries, string $needle ): string { + foreach ( $queries as $query ) { + if ( false !== strpos( $query['sql'], $needle ) ) { + return $query['sql']; + } + } + + $this->fail( 'Expected PostgreSQL SQL containing: ' . $needle ); + } + + /** + * Assert a PostgreSQL SQL string does not contain raw MySQL index hints. + * + * @param string $sql PostgreSQL SQL. + */ + private function assert_postgresql_sql_omits_mysql_index_hints( string $sql ): void { + $uppercase_sql = strtoupper( $sql ); + foreach ( array( 'USE INDEX', 'USE KEY', 'FORCE INDEX', 'FORCE KEY', 'IGNORE INDEX', 'IGNORE KEY' ) as $hint ) { + $this->assertStringNotContainsString( $hint, $uppercase_sql ); + } + } + + /** + * Creates a SQLite-backed driver whose connection reports a stale insert ID. + * + * @return WP_PostgreSQL_Driver Driver under test. + */ + private function create_driver_with_stale_connection_insert_id(): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Connection_Stale_Insert_ID_SQLite_Connection(); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + + /** + * Creates a SQLite-backed driver that uses PostgreSQL quote translation. + * + * @return WP_PostgreSQL_Driver Driver under test. + */ + private function create_driver_with_postgresql_quote_translation(): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + + /** + * Creates a connection fixture that exercises production PostgreSQL table administration catalogs. + * + * @return WP_PostgreSQL_Connection Connection fixture. + */ + private function create_table_administration_catalog_fixture_connection(): WP_PostgreSQL_Connection { + $pdo = new PDO( 'sqlite::memory:' ); + return new class( array( 'pdo' => $pdo ) ) extends WP_PostgreSQL_Connection { + /** + * PostgreSQL catalog existence queries issued by the driver. + * + * @var array[] + */ + private $table_administration_catalog_queries = array(); + + /** + * Constructor. + * + * @param array $options Connection options. + */ + public function __construct( array $options ) { + parent::__construct( $options ); + + $pdo = $this->get_pdo(); + $pdo->exec( + 'CREATE TABLE table_administration_catalog_fixture ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + relkind TEXT NOT NULL + )' + ); + $pdo->exec( + "INSERT INTO table_administration_catalog_fixture + (table_schema, table_name, relkind) + VALUES ('pg_temp_7', 'administration_temp', 'r')" + ); + $pdo->exec( "ATTACH DATABASE ':memory:' AS information_schema" ); + $pdo->exec( + 'CREATE TABLE information_schema.tables ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + table_type TEXT NOT NULL + )' + ); + $pdo->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES ('pg_temp_7', 'administration_temp', 'LOCAL TEMPORARY')" + ); + } + + /** + * Execute fixture-backed PostgreSQL catalog queries. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + if ( + false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) + && false !== strpos( $sql, 'pg_my_temp_schema()' ) + ) { + return parent::query( + 'SELECT table_schema AS nspname + FROM table_administration_catalog_fixture + WHERE table_schema = \'pg_temp_7\' + AND lower(table_name) = lower(?) + AND relkind IN (\'r\', \'p\') + LIMIT 1', + array( $params[0] ?? '' ) + ); + } + + if ( false !== strpos( $sql, 'FROM pg_catalog.pg_class c' ) ) { + $this->table_administration_catalog_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + return parent::query( + 'SELECT 1 + FROM table_administration_catalog_fixture + WHERE table_schema = ? + AND table_name = ? + AND relkind IN (\'r\', \'p\') + LIMIT 1', + $params + ); + } + + return parent::query( $sql, $params ); + } + + /** + * Get captured table administration catalog queries. + * + * @return array[] Catalog queries. + */ + public function get_table_administration_catalog_queries(): array { + return $this->table_administration_catalog_queries; + } + + /** + * Report PostgreSQL for branch selection while keeping SQLite execution available. + * + * @return string Driver name. + */ + public function get_driver_name(): string { + return 'pgsql'; + } + }; + } + + /** + * Quote a MySQL string literal for parser-facing tests. + * + * @param string $value Literal value. + * @return string MySQL string literal. + */ + private function quote_mysql_string_literal_for_test( string $value ): string { + $backslash = chr( 92 ); + + return "'" . strtr( + $value, + array( + $backslash => $backslash . $backslash, + "'" => $backslash . "'", + '"' => $backslash . '"', + "\0" => $backslash . '0', + ) + ) . "'"; + } + + /** + * Get a DML identity metadata fixture row. + * + * @param string $table_name Table name. + * @param string $column_name Identity column name. + * @param string $sequence_name Sequence name. + * @return array[] Fixture metadata rows. + */ + private function get_dml_identity_metadata_fixture( string $table_name, string $column_name, string $sequence_name ): array { + return array( + array( + 'table_schema' => 'public', + 'table_name' => $table_name, + 'column_name' => $column_name, + 'ordinal_position' => 1, + 'data_type' => 'bigint', + 'is_identity' => 'YES', + 'column_default' => null, + 'mysql_column_type' => 'bigint(20)', + 'mysql_extra' => 'auto_increment', + 'sequence_schema' => 'public', + 'sequence_name' => $sequence_name, + ), + ); + } + + /** + * Assert that a logged query is a guarded identity sequence repair query. + * + * @param array $query Logged query. + * @param string $table_name Table name. + * @param string $column_name Identity column name. + * @param string $sequence_name Sequence name. + */ + private function assert_sequence_repair_query( array $query, string $table_name, string $column_name, string $sequence_name ): void { + $sequence_identifier = '"public"."' . $sequence_name . '"'; + + $this->assertSame( array( $sequence_identifier ), $query['params'] ); + $this->assertStringContainsString( 'SELECT last_value, is_called FROM ' . $sequence_identifier, $query['sql'] ); + $this->assertStringContainsString( 'MAX("' . $column_name . '") AS max_identity_value FROM "public"."' . $table_name . '"', $query['sql'] ); + $this->assertStringContainsString( 'SELECT pg_catalog.setval(CAST(? AS regclass), table_state.max_identity_value, true)', $query['sql'] ); + $this->assertStringContainsString( 'table_state.max_identity_value > sequence_state.last_value', $query['sql'] ); + $this->assertStringContainsString( 'NOT sequence_state.is_called', $query['sql'] ); + } + + /** + * Creates a PostgreSQL driver with a SQLite shim for SUBSTRING(text, pattern). + * + * @return WP_PostgreSQL_Driver + */ + private function create_driver_with_postgresql_substring_function(): WP_PostgreSQL_Driver { + $pdo_class = class_exists( 'Pdo\Sqlite' ) ? 'Pdo\Sqlite' : PDO::class; + $pdo = new $pdo_class( 'sqlite::memory:' ); + $substring = static function ( $value, $pattern ): ?string { + if ( null === $value ) { + return null; + } + + $php_pattern = '/' . str_replace( '/', '\\/', (string) $pattern ) . '/'; + if ( 1 === preg_match( $php_pattern, (string) $value, $matches ) ) { + return $matches[0]; + } + + return null; + }; + + if ( method_exists( $pdo, 'createFunction' ) ) { + $pdo->createFunction( 'SUBSTRING', $substring, 2 ); + } else { + $pdo->sqliteCreateFunction( 'SUBSTRING', $substring, 2 ); + } + + $connection = new WP_PostgreSQL_Connection( array( 'pdo' => $pdo ) ); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + + /** + * Creates a PostgreSQL driver with SQLite shims for text runtime functions. + * + * @return WP_PostgreSQL_Driver + */ + private function create_driver_with_postgresql_text_runtime_functions(): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Connection( + array( + 'pdo' => $this->create_pdo_with_postgresql_text_runtime_functions(), + ) + ); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + + /** + * Creates a PostgreSQL quote-translation driver with SQLite shims for text runtime functions. + * + * @return WP_PostgreSQL_Driver + */ + private function create_driver_with_postgresql_quote_translation_and_text_runtime_functions(): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Connection_Pgsql_Quote_SQLite_Connection( + array( + 'pdo' => $this->create_pdo_with_postgresql_text_runtime_functions(), + ) + ); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + + /** + * Creates a SQLite PDO with shims for PostgreSQL text runtime functions. + * + * @return PDO + */ + private function create_pdo_with_postgresql_text_runtime_functions(): PDO { + $pdo_class = class_exists( 'Pdo\Sqlite' ) ? 'Pdo\Sqlite' : PDO::class; + $pdo = new $pdo_class( 'sqlite::memory:' ); + + $octet_length = static function ( $value ): ?int { + return null === $value ? null : strlen( (string) $value ); + }; + $char_length = static function ( $value ): ?int { + if ( null === $value ) { + return null; + } + + $count = preg_match_all( '/./us', (string) $value ); + return false === $count ? strlen( (string) $value ) : $count; + }; + $convert_to = static function ( $value, $encoding ): ?string { + if ( null === $value ) { + return null; + } + + return 'UTF8' === strtoupper( (string) $encoding ) ? (string) $value : null; + }; + $strpos = static function ( $value, $needle ): ?int { + if ( null === $value || null === $needle ) { + return null; + } + + $position = strpos( (string) $value, (string) $needle ); + return false === $position ? 0 : $position + 1; + }; + $translate = static function ( $value, $from, $to ): ?string { + if ( null === $value || null === $from || null === $to ) { + return null; + } + + $map = array(); + $from = (string) $from; + $to = (string) $to; + $from_length = strlen( $from ); + $to_length = strlen( $to ); + for ( $i = 0; $i < $from_length; $i++ ) { + $map[ $from[ $i ] ] = $i < $to_length ? $to[ $i ] : ''; + } + + return strtr( (string) $value, $map ); + }; + + if ( method_exists( $pdo, 'createFunction' ) ) { + $pdo->createFunction( 'OCTET_LENGTH', $octet_length, 1 ); + $pdo->createFunction( 'CHAR_LENGTH', $char_length, 1 ); + $pdo->createFunction( 'CHARACTER_LENGTH', $char_length, 1 ); + $pdo->createFunction( 'CONVERT_TO', $convert_to, 2 ); + $pdo->createFunction( 'STRPOS', $strpos, 2 ); + $pdo->createFunction( 'TRANSLATE', $translate, 3 ); + } else { + $pdo->sqliteCreateFunction( 'OCTET_LENGTH', $octet_length, 1 ); + $pdo->sqliteCreateFunction( 'CHAR_LENGTH', $char_length, 1 ); + $pdo->sqliteCreateFunction( 'CHARACTER_LENGTH', $char_length, 1 ); + $pdo->sqliteCreateFunction( 'CONVERT_TO', $convert_to, 2 ); + $pdo->sqliteCreateFunction( 'STRPOS', $strpos, 2 ); + $pdo->sqliteCreateFunction( 'TRANSLATE', $translate, 3 ); + } + + return $pdo; + } + + /** + * Translate a query by calling a private driver translator. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $method_name Private driver method name. + * @param string $query MySQL query. + * @return string|null PostgreSQL SQL, or null when unsupported. + */ + private function translate_driver_query_with_private_method( WP_PostgreSQL_Driver $driver, string $method_name, string $query ): ?string { + $translator = Closure::bind( + function ( string $bound_method_name, string $bound_query ): ?string { + return $this->$bound_method_name( $bound_query ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + return $translator( $method_name, $query ); + } + + /** + * Translate a query to structured query data by calling a private method. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $method_name Private driver method name. + * @param string $query MySQL query. + * @return array|null PostgreSQL query data, or null when unsupported. + */ + private function translate_driver_query_data_with_private_method( WP_PostgreSQL_Driver $driver, string $method_name, string $query ): ?array { + $translator = Closure::bind( + function ( string $bound_method_name, string $bound_query ): ?array { + return $this->$bound_method_name( $bound_query ); + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + return $translator( $method_name, $query ); + } + + /** + * Get a private driver property for cache-focused assertions. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $property_name Private property name. + * @return mixed Private property value. + */ + private function get_driver_private_property( WP_PostgreSQL_Driver $driver, string $property_name ) { + $property_reader = Closure::bind( + function ( string $bound_property_name ) { + return $this->$bound_property_name; + }, + $driver, + WP_PostgreSQL_Driver::class + ); + + return $property_reader( $property_name ); + } + + /** + * Get expected PostgreSQL SQL for MySQL-compatible integer casts. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_integer_cast_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE CAST(COALESCE(SUBSTRING(%1$s, \'^[[:space:]]*[+-]?[0-9]+\'), \'0\') AS bigint) END', + $expression_text_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL-compatible decimal text coercion. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_numeric_cast_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $substring_sql = array(); + $numeric_patterns = array( + '^[[:space:]]*[+-]?[0-9]+[.][0-9]*[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[.][0-9]+[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[0-9]+[eE][+-]?[0-9]+', + '^[[:space:]]*[+-]?[0-9]+[.][0-9]*', + '^[[:space:]]*[+-]?[.][0-9]+', + '^[[:space:]]*[+-]?[0-9]+', + ); + + foreach ( $numeric_patterns as $pattern ) { + $substring_sql[] = sprintf( + 'SUBSTRING(%1$s, \'%2$s\')', + $expression_text_sql, + $pattern + ); + } + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL ELSE CAST(COALESCE(%2$s, \'0\') AS numeric) END', + $expression_text_sql, + implode( ', ', $substring_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_ADD/DATE_SUB arithmetic. + * + * @param string $operator PostgreSQL interval operator. + * @param string $expression_sql PostgreSQL date/time expression SQL. + * @param string $value_sql PostgreSQL interval value SQL. + * @param string $unit PostgreSQL interval unit. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_date_arithmetic_sql( string $operator, string $expression_sql, string $value_sql, string $unit ): string { + return $this->get_expected_date_arithmetic_with_interval_sql( + $operator, + $expression_sql, + $this->get_expected_mysql_interval_sql( $value_sql, $unit ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_ADD/DATE_SUB arithmetic with an interval SQL expression. + * + * @param string $operator PostgreSQL interval operator. + * @param string $expression_sql PostgreSQL date/time expression SQL. + * @param string $interval_sql PostgreSQL interval SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_date_arithmetic_with_interval_sql( string $operator, string $expression_sql, string $interval_sql ): string { + return sprintf( + '(%1$s %2$s %3$s)', + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ), + $operator, + $interval_sql + ); + } + + /** + * Get expected PostgreSQL SQL for a MySQL-compatible interval expression. + * + * @param string $value_sql PostgreSQL interval value SQL. + * @param string $unit Normalized interval unit. + * @return string PostgreSQL interval SQL. + */ + private function get_expected_mysql_interval_sql( string $value_sql, string $unit ): string { + $interval_unit = '3 months' === $unit ? $unit : '1 ' . $unit; + + return sprintf( + "(%1\$s * INTERVAL '%2\$s')", + $this->get_expected_mysql_interval_value_sql( $value_sql, $unit ), + $interval_unit + ); + } + + /** + * Get expected PostgreSQL SQL for parsed MySQL composite interval components. + * + * @param array $components Parsed interval component value/unit pairs. + * @return string PostgreSQL interval SQL. + */ + private function get_expected_mysql_composite_interval_sql( array $components ): string { + $parts = array(); + foreach ( $components as $component ) { + $parts[] = sprintf( + "(CAST('%1\$s' AS double precision) * INTERVAL '1 %2\$s')", + $component[0], + $component[1] + ); + } + + return '(' . implode( ' + ', $parts ) . ')'; + } + + /** + * Get expected PostgreSQL SQL for a MySQL-compatible interval value. + * + * @param string $value_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_interval_value_sql( string $value_sql, string $unit ): string { + $value_cast_sql = 'second' === $unit + ? $this->get_expected_mysql_numeric_cast_sql( $value_sql ) + : $this->get_expected_mysql_integer_cast_sql( $value_sql ); + + return sprintf( 'CAST(%s AS double precision)', $value_cast_sql ); + } + + /** + * Get expected PostgreSQL SQL for a supported MySQL WEEK() mode. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @param int $mode MySQL WEEK() mode. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_week_sql( string $expression_sql, int $mode ): string { + $timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ); + + switch ( $mode ) { + case 0: + return $this->get_expected_mysql_sunday_week_mode_zero_sql( $timestamp_sql ); + + case 1: + return $this->get_expected_mysql_week_mode_one_timestamp_sql( $timestamp_sql ); + + case 2: + return $this->get_expected_mysql_sunday_week_mode_two_sql( $timestamp_sql ); + + case 3: + return $this->get_expected_mysql_iso_week_timestamp_sql( $timestamp_sql ); + + case 4: + return $this->get_expected_mysql_sunday_week_mode_four_sql( $timestamp_sql ); + + case 5: + return $this->get_expected_mysql_monday_week_mode_five_sql( $timestamp_sql ); + + case 6: + return $this->get_expected_mysql_sunday_week_mode_six_sql( $timestamp_sql ); + + case 7: + return $this->get_expected_mysql_monday_week_mode_seven_sql( $timestamp_sql ); + } + + throw new InvalidArgumentException( 'Unsupported MySQL WEEK() mode.' ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 1). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_week_mode_one_sql( string $expression_sql ): string { + return $this->get_expected_mysql_week_mode_one_timestamp_sql( + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(timestamp, 1). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_week_mode_one_timestamp_sql( string $timestamp_sql ): string { + $week_start_sql = sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = sprintf( + "(CASE WHEN EXTRACT(ISODOW FROM %1\$s) <= 4 THEN DATE_TRUNC('week', %1\$s) ELSE DATE_TRUNC('week', %1\$s) + INTERVAL '1 week' END)", + $year_start_sql + ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for a zero-padded MySQL week expression. + * + * @param string $week_sql PostgreSQL integer week expression. + * @return string PostgreSQL text expression. + */ + private function get_expected_mysql_zero_padded_week_sql( string $week_sql ): string { + return sprintf( "LPAD(CAST(%s AS text), 2, '0')", $week_sql ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 0). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_sunday_week_mode_zero_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_sunday_of_year_sql( $year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 2). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_sunday_week_mode_two_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_sunday_of_year_sql( $year_start_sql ); + $previous_year_start_sql = sprintf( "(%s - INTERVAL '1 year')", $year_start_sql ); + $previous_first_week_sql = $this->get_expected_mysql_first_sunday_of_year_sql( $previous_year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %4$s)) / 604800) AS integer) + 1 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $previous_first_week_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 3). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_iso_week_timestamp_sql( string $timestamp_sql ): string { + return sprintf( "CAST(TO_CHAR(%s, 'IW') AS integer)", $timestamp_sql ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 4). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_sunday_week_mode_four_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_sunday_four_day_week_of_year_sql( $year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 5). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_monday_week_mode_five_sql( string $timestamp_sql ): string { + $week_start_sql = sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_monday_of_year_sql( $year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN 0 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 6). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_sunday_week_mode_six_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_sunday_four_day_week_of_year_sql( $year_start_sql ); + $previous_year_start_sql = sprintf( "(%s - INTERVAL '1 year')", $year_start_sql ); + $next_year_start_sql = sprintf( "(%s + INTERVAL '1 year')", $year_start_sql ); + $previous_first_week_sql = $this->get_expected_mysql_first_sunday_four_day_week_of_year_sql( $previous_year_start_sql ); + $next_first_week_sql = $this->get_expected_mysql_first_sunday_four_day_week_of_year_sql( $next_year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s >= %5$s THEN 1 WHEN %2$s < %3$s THEN CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %4$s)) / 604800) AS integer) + 1 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $previous_first_week_sql, + $next_first_week_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL WEEK(expr, 7). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL integer expression. + */ + private function get_expected_mysql_monday_week_mode_seven_sql( string $timestamp_sql ): string { + $week_start_sql = sprintf( "DATE_TRUNC('week', %s)", $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_monday_of_year_sql( $year_start_sql ); + $previous_year_start_sql = sprintf( "(%s - INTERVAL '1 year')", $year_start_sql ); + $previous_first_week_sql = $this->get_expected_mysql_first_monday_of_year_sql( $previous_year_start_sql ); + + return sprintf( + 'CASE WHEN %1$s IS NULL THEN NULL WHEN %2$s < %3$s THEN CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %4$s)) / 604800) AS integer) + 1 ELSE CAST(FLOOR(EXTRACT(EPOCH FROM (%2$s - %3$s)) / 604800) AS integer) + 1 END', + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $previous_first_week_sql + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%X'). + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL text expression. + */ + private function get_expected_mysql_sunday_week_mode_two_year_sql( string $timestamp_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $timestamp_sql ); + $year_start_sql = sprintf( "DATE_TRUNC('year', %s)", $timestamp_sql ); + $first_week_start_sql = $this->get_expected_mysql_first_sunday_of_year_sql( $year_start_sql ); + + return sprintf( + "CASE WHEN %1\$s IS NULL THEN NULL WHEN %2\$s < %3\$s THEN TO_CHAR(%4\$s - INTERVAL '1 year', 'YYYY') ELSE TO_CHAR(%4\$s, 'YYYY') END", + $timestamp_sql, + $week_start_sql, + $first_week_start_sql, + $year_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for the Sunday-start week containing a timestamp. + * + * @param string $timestamp_sql PostgreSQL timestamp expression. + * @return string PostgreSQL timestamp expression. + */ + private function get_expected_mysql_sunday_week_start_sql( string $timestamp_sql ): string { + return sprintf( + "(DATE_TRUNC('day', %1\$s) - (CAST(EXTRACT(DOW FROM %1\$s) AS integer) * INTERVAL '1 day'))", + $timestamp_sql + ); + } + + /** + * Get expected PostgreSQL SQL for the first Sunday in a year. + * + * @param string $year_start_sql PostgreSQL timestamp expression for January 1. + * @return string PostgreSQL timestamp expression. + */ + private function get_expected_mysql_first_sunday_of_year_sql( string $year_start_sql ): string { + return sprintf( + "(%1\$s + (MOD(7 - CAST(EXTRACT(DOW FROM %1\$s) AS integer), 7) * INTERVAL '1 day'))", + $year_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for the first Sunday-start week with four days in a year. + * + * @param string $year_start_sql PostgreSQL timestamp expression for January 1. + * @return string PostgreSQL timestamp expression. + */ + private function get_expected_mysql_first_sunday_four_day_week_of_year_sql( string $year_start_sql ): string { + $week_start_sql = $this->get_expected_mysql_sunday_week_start_sql( $year_start_sql ); + + return sprintf( + "(CASE WHEN EXTRACT(DOW FROM %1\$s) <= 3 THEN %2\$s ELSE %2\$s + INTERVAL '1 week' END)", + $year_start_sql, + $week_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for the first Monday in a year. + * + * @param string $year_start_sql PostgreSQL timestamp expression for January 1. + * @return string PostgreSQL timestamp expression. + */ + private function get_expected_mysql_first_monday_of_year_sql( string $year_start_sql ): string { + return sprintf( + "(%1\$s + (MOD(8 - CAST(EXTRACT(ISODOW FROM %1\$s) AS integer), 7) * INTERVAL '1 day'))", + $year_start_sql + ); + } + + /** + * Get expected PostgreSQL SQL for a MySQL weekday index function. + * + * @param string $function_name Lowercase MySQL function name. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_weekday_index_sql( string $function_name, string $expression_sql ): string { + $timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ); + + if ( 'dayofweek' === $function_name ) { + return sprintf( 'CAST(EXTRACT(DOW FROM %s) AS integer) + 1', $timestamp_sql ); + } + + return sprintf( 'CAST(EXTRACT(ISODOW FROM %s) AS integer) - 1', $timestamp_sql ); + } + + /** + * Get expected PostgreSQL SQL for a supported MySQL DATE_FORMAT() format. + * + * @param string $format MySQL DATE_FORMAT format. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_date_format_sql( string $format, string $expression_sql ): string { + if ( '%H.%i' === $format ) { + return $this->get_expected_mysql_date_format_hour_minute_sql( $expression_sql ); + } + + if ( '%Y-%m-%d' === $format ) { + return $this->get_expected_mysql_date_format_year_month_day_sql( $expression_sql ); + } + + throw new InvalidArgumentException( 'Unsupported test date format.' ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%H.%i'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_date_format_hour_minute_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $zero_date_format_sql = sprintf( + 'CASE WHEN %1$s ~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}\' THEN CAST(SUBSTRING(%1$s FROM 12 FOR 2) || \'.\' || SUBSTRING(%1$s FROM 15 FOR 2) AS double precision) ELSE 0 END', + $expression_text_sql + ); + + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(TO_CHAR(%3$s, \'HH24.MI\') AS double precision) END', + $this->get_expected_zero_date_condition_sql( $expression_text_sql ), + $zero_date_format_sql, + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE_FORMAT(expr, '%Y-%m-%d'). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_date_format_year_month_day_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CASE WHEN %1$s THEN NULL WHEN %2$s THEN SUBSTRING(%3$s FROM 1 FOR 10) ELSE TO_CHAR(%4$s, \'YYYY-MM-DD\') END', + $this->get_expected_empty_temporal_condition_sql( $expression_text_sql ), + $this->get_expected_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql, + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATE(expr). + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_date_sql( string $expression_sql ): string { + return $this->get_expected_mysql_date_format_year_month_day_sql( $expression_sql ); + } + + /** + * Get expected PostgreSQL SQL for MySQL DATEDIFF(expr1, expr2). + * + * @param string $start_sql PostgreSQL start expression SQL. + * @param string $end_sql PostgreSQL end expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_datediff_sql( string $start_sql, string $end_sql ): string { + return sprintf( + 'CAST((CAST(%1$s AS date) - CAST(%2$s AS date)) AS integer)', + $this->get_expected_zero_date_safe_timestamp_sql( $start_sql ), + $this->get_expected_zero_date_safe_timestamp_sql( $end_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL for MySQL TIMESTAMPDIFF(unit, expr1, expr2). + * + * @param string $unit Normalized TIMESTAMPDIFF unit. + * @param string $start_sql PostgreSQL start expression SQL. + * @param string $end_sql PostgreSQL end expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_timestampdiff_sql( string $unit, string $start_sql, string $end_sql ): string { + $start_timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( $start_sql ); + $end_timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( $end_sql ); + + if ( 'microsecond' === $unit ) { + return sprintf( + 'CAST(TRUNC(EXTRACT(EPOCH FROM (%2$s - %1$s)) * 1000000) AS bigint)', + $start_timestamp_sql, + $end_timestamp_sql + ); + } + + $seconds_per_unit = array( + 'second' => 1, + 'minute' => 60, + 'hour' => 3600, + 'day' => 86400, + 'week' => 604800, + ); + if ( isset( $seconds_per_unit[ $unit ] ) ) { + return sprintf( + 'CAST(TRUNC(EXTRACT(EPOCH FROM (%2$s - %1$s)) / %3$d) AS bigint)', + $start_timestamp_sql, + $end_timestamp_sql, + $seconds_per_unit[ $unit ] + ); + } + + $month_sql = $this->get_expected_mysql_timestampdiff_month_sql( $start_timestamp_sql, $end_timestamp_sql ); + if ( 'month' === $unit ) { + return $month_sql; + } + + if ( 'quarter' === $unit ) { + return sprintf( 'CAST(TRUNC((%s)::numeric / 3) AS bigint)', $month_sql ); + } + + return sprintf( 'CAST(TRUNC((%s)::numeric / 12) AS bigint)', $month_sql ); + } + + /** + * Get expected PostgreSQL SQL for MySQL TIMESTAMPDIFF(MONTH, ...). + * + * @param string $start_timestamp_sql Zero-date-safe start timestamp SQL. + * @param string $end_timestamp_sql Zero-date-safe end timestamp SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_mysql_timestampdiff_month_sql( string $start_timestamp_sql, string $end_timestamp_sql ): string { + $month_delta_sql = sprintf( + '((CAST(EXTRACT(YEAR FROM %2$s) AS integer) * 12 + CAST(EXTRACT(MONTH FROM %2$s) AS integer)) - (CAST(EXTRACT(YEAR FROM %1$s) AS integer) * 12 + CAST(EXTRACT(MONTH FROM %1$s) AS integer)))', + $start_timestamp_sql, + $end_timestamp_sql + ); + $start_remainder_sql = sprintf( "TO_CHAR(%s, 'DD HH24:MI:SS.US')", $start_timestamp_sql ); + $end_remainder_sql = sprintf( "TO_CHAR(%s, 'DD HH24:MI:SS.US')", $end_timestamp_sql ); + + return sprintf( + 'CAST(CASE WHEN %1$s IS NULL OR %2$s IS NULL THEN NULL WHEN %2$s >= %1$s THEN (%3$s - CASE WHEN %4$s < %5$s THEN 1 ELSE 0 END) ELSE (%3$s + CASE WHEN %4$s > %5$s THEN 1 ELSE 0 END) END AS bigint)', + $start_timestamp_sql, + $end_timestamp_sql, + $month_delta_sql, + $end_remainder_sql, + $start_remainder_sql + ); + } + + /** + * Get expected zero-date-safe PostgreSQL date/time extract SQL. + * + * @param string $unit PostgreSQL EXTRACT unit. + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_zero_date_safe_extract_sql( string $unit, string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + $empty_date_condition = $this->get_expected_empty_temporal_condition_sql( $expression_text_sql ); + $zero_date_condition = $this->get_expected_zero_date_condition_sql( $expression_text_sql ); + + return sprintf( + 'CASE WHEN %1$s THEN NULL WHEN %2$s THEN %3$s ELSE CAST(EXTRACT(%4$s FROM %5$s) AS integer) END', + $empty_date_condition, + $zero_date_condition, + $this->get_expected_zero_date_extract_part_sql( $unit, $expression_text_sql ), + $unit, + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ) + ); + } + + /** + * Get expected PostgreSQL SQL that casts a MySQL date/time without casting zero dates. + * + * @param string $expression_sql PostgreSQL expression SQL. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_zero_date_safe_timestamp_sql( string $expression_sql ): string { + $expression_text_sql = sprintf( 'CAST(%s AS text)', $expression_sql ); + + return sprintf( + 'CAST(CASE WHEN %1$s OR %2$s THEN NULL ELSE %3$s END AS timestamp)', + $this->get_expected_empty_temporal_condition_sql( $expression_text_sql ), + $this->get_expected_zero_date_condition_sql( $expression_text_sql ), + $expression_text_sql + ); + } + + /** + * Get expected PostgreSQL text SQL for a temporal expression comparison operand. + * + * @param string $expression_sql PostgreSQL temporal expression SQL. + * @param bool $returns_timestamp Whether the expression SQL is already a timestamp. + * @return string PostgreSQL text expression SQL. + */ + private function get_expected_temporal_expression_comparison_text_sql( string $expression_sql, bool $returns_timestamp ): string { + if ( $returns_timestamp ) { + return sprintf( + "TO_CHAR(%s, 'YYYY-MM-DD HH24:MI:SS')", + $expression_sql + ); + } + + return sprintf( + "CASE WHEN CAST(%1\$s AS text) IS NULL THEN NULL WHEN CAST(%1\$s AS text) = '' THEN '' WHEN CAST(%1\$s AS text) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN CAST(%1\$s AS text) || ' 00:00:00' ELSE CAST(%1\$s AS text) END", + $expression_sql + ); + } + + /** + * Get expected condition that detects MySQL empty temporal strings. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL condition SQL. + */ + private function get_expected_empty_temporal_condition_sql( string $expression_text_sql ): string { + return sprintf( "%s = ''", $expression_text_sql ); + } + + /** + * Get expected condition that detects MySQL zero or partial-zero date strings. + * + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL condition SQL. + */ + private function get_expected_zero_date_condition_sql( string $expression_text_sql ): string { + return sprintf( + '%1$s ~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}\' AND (SUBSTRING(%1$s FROM 1 FOR 4) = \'0000\' OR SUBSTRING(%1$s FROM 6 FOR 2) = \'00\' OR SUBSTRING(%1$s FROM 9 FOR 2) = \'00\')', + $expression_text_sql + ); + } + + /** + * Get expected PostgreSQL SQL that extracts one part from a zero-ish date string. + * + * @param string $unit PostgreSQL EXTRACT unit. + * @param string $expression_text_sql PostgreSQL expression cast to text. + * @return string PostgreSQL expression SQL. + */ + private function get_expected_zero_date_extract_part_sql( string $unit, string $expression_text_sql ): string { + switch ( $unit ) { + case 'DOY': + return 'NULL'; + + case 'YEAR': + return sprintf( 'CAST(SUBSTRING(%s FROM 1 FOR 4) AS integer)', $expression_text_sql ); + + case 'MONTH': + return sprintf( 'CAST(SUBSTRING(%s FROM 6 FOR 2) AS integer)', $expression_text_sql ); + + case 'QUARTER': + return sprintf( 'CAST(FLOOR((CAST(SUBSTRING(%s FROM 6 FOR 2) AS integer) + 2) / 3.0) AS integer)', $expression_text_sql ); + + case 'DAY': + return sprintf( 'CAST(SUBSTRING(%s FROM 9 FOR 2) AS integer)', $expression_text_sql ); + + case 'HOUR': + $start = 12; + break; + + case 'MINUTE': + $start = 15; + break; + + case 'SECOND': + $start = 18; + break; + + default: + throw new InvalidArgumentException( 'Unsupported test extract unit.' ); + } + + return sprintf( + 'CASE WHEN %1$s ~ \'^[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}\' THEN CAST(SUBSTRING(%1$s FROM %2$d FOR 2) AS integer) ELSE 0 END', + $expression_text_sql, + $start + ); + } + + /** + * Check whether an injected SQLite backend table exists. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $schema SQLite schema name. + * @param string $table_name Table name. + * @return bool Whether the table exists. + */ + private function sqlite_table_exists( WP_PostgreSQL_Driver $driver, string $schema, string $table_name ): bool { + if ( 'temp' === $schema ) { + $catalog = 'sqlite_temp_master'; + } elseif ( 'main' === $schema ) { + $catalog = 'sqlite_master'; + } else { + throw new InvalidArgumentException( 'Unsupported SQLite schema for test table lookup.' ); + } + + $stmt = $driver->get_connection()->query( + sprintf( + "SELECT name FROM %s WHERE type = 'table' AND name = ?", + $catalog + ), + array( $table_name ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * Get stored MySQL column metadata rows for a table. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Table name. + * @param string $schema Metadata schema name. + * @return array Stored metadata rows. + */ + private function get_mysql_column_metadata_rows( WP_PostgreSQL_Driver $driver, string $table_name, string $schema = 'public' ): array { + $stmt = $driver->get_connection()->query( + sprintf( + 'SELECT column_name, column_type, character_set_name, collation_name, is_nullable, column_default, extra + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY ordinal_position', + $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_COLUMN_METADATA_TABLE ) + ), + array( $schema, $table_name ) + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Get stored MySQL index metadata rows for a table. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Table name. + * @param string $schema Metadata schema name. + * @return array Stored metadata rows. + */ + private function get_mysql_index_metadata_rows( WP_PostgreSQL_Driver $driver, string $table_name, string $schema = 'public' ): array { + $stmt = $driver->get_connection()->query( + sprintf( + 'SELECT key_name, seq_in_index, column_name, non_unique, index_type, collation, sub_part, nullable + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY index_ordinal, seq_in_index', + $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_INDEX_METADATA_TABLE ) + ), + array( $schema, $table_name ) + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Store temporary-table metadata for tests that create backend-specific temp tables. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $query MySQL CREATE TEMPORARY TABLE query. + */ + private function store_mysql_temporary_schema_metadata_for_test( WP_PostgreSQL_Driver $driver, string $query ): void { + $method = new ReflectionMethod( WP_PostgreSQL_Driver::class, 'store_mysql_temporary_schema_metadata' ); + if ( PHP_VERSION_ID < 80100 && method_exists( $method, 'setAccessible' ) ) { + $method->setAccessible( true ); + } + + $method->invoke( $driver, $query ); + } + + /** + * Get stored MySQL foreign key metadata rows for a table. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Table name. + * @param string $schema Metadata schema name. + * @return array Stored metadata rows. + */ + private function get_mysql_foreign_key_metadata_rows( WP_PostgreSQL_Driver $driver, string $table_name, string $schema = 'public' ): array { + $stmt = $driver->get_connection()->query( + sprintf( + 'SELECT constraint_name, seq_in_index, column_name, referenced_table_name, referenced_column_name, update_rule, delete_rule + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY constraint_name, seq_in_index', + $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_FOREIGN_KEY_METADATA_TABLE ) + ), + array( $schema, $table_name ) + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Get stored MySQL CHECK constraint metadata rows for a table. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Table name. + * @param string $schema Metadata schema name. + * @return array Stored metadata rows. + */ + private function get_mysql_check_metadata_rows( WP_PostgreSQL_Driver $driver, string $table_name, string $schema = 'public' ): array { + $stmt = $driver->get_connection()->query( + sprintf( + 'SELECT constraint_name, check_clause, enforced + FROM %s + WHERE table_schema = ? AND table_name = ? + ORDER BY constraint_ordinal, constraint_name', + $driver->get_connection()->quote_identifier( WP_PostgreSQL_Driver::MYSQL_CHECK_METADATA_TABLE ) + ), + array( $schema, $table_name ) + ); + + return $stmt->fetchAll( PDO::FETCH_ASSOC ); + } + + /** + * Get the expected SHOW GRANTS result rows. + * + * @return object[] Expected result rows. + */ + private function get_show_grants_expected_result(): array { + return array( + (object) array( + 'Grants for root@%' => $this->get_show_grants_expected_value(), + ), + ); + } + + /** + * Get the expected static SHOW GRANTS row value. + * + * @return string Expected grant text. + */ + private function get_show_grants_expected_value(): string { + return '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'; + } + + /** + * Get a fetch row class name whose clone operation is not publicly callable. + * + * @return string Fetch row class name. + */ + private function get_no_public_clone_fetch_row_class_name(): string { + $class_name = 'WP_PostgreSQL_Driver_No_Public_Clone_Fetch_Row'; + if ( class_exists( $class_name, false ) ) { + return $class_name; + } + + $prototype = new class() { + /** + * Fetched values keyed by column name. + * + * @var array + */ + private $values = array(); + + /** + * Store a fetched column value. + * + * @param string $name Column name. + * @param mixed $value Column value. + */ + public function __set( string $name, $value ): void { + $this->values[ $name ] = $value; + } + + /** + * Get a fetched column value. + * + * @param string $name Column name. + * @return mixed Column value. + */ + public function __get( string $name ) { + return $this->values[ $name ] ?? null; + } + + /** + * Prevent public cloning. + */ + private function __clone() {} + }; + + class_alias( get_class( $prototype ), $class_name ); + + return $class_name; + } + + /** + * Creates a PostgreSQL driver with SHOW INDEX fixture rows. + * + * @return WP_PostgreSQL_Driver + */ + private function create_show_index_driver(): WP_PostgreSQL_Driver { + $connection = new WP_PostgreSQL_Driver_Show_Index_Fixture_Connection(); + return new WP_PostgreSQL_Driver( $connection, 'wptests' ); + } + + /** + * Install backend tables used by Site Health TABLE_ROWS emulation tests. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_site_health_table_count_fixture( WP_PostgreSQL_Driver $driver ): void { + $pdo = $driver->get_connection()->get_pdo(); + $connection = $driver->get_connection(); + $schema = $connection->quote_identifier( 'public' ); + + $pdo->exec( "ATTACH DATABASE ':memory:' AS public" ); + foreach ( array( 'wptests_options', 'wptests_posts' ) as $table_name ) { + $pdo->exec( + sprintf( + 'CREATE TABLE %s.%s (id INTEGER)', + $schema, + $connection->quote_identifier( $table_name ) + ) + ); + } + + $options_table = $schema . '.' . $connection->quote_identifier( 'wptests_options' ); + $posts_table = $schema . '.' . $connection->quote_identifier( 'wptests_posts' ); + + $pdo->exec( 'INSERT INTO ' . $options_table . ' (id) VALUES (1), (2)' ); + $pdo->exec( 'INSERT INTO ' . $posts_table . ' (id) VALUES (1)' ); + } + + /** + * Get the SHOW TABLE STATUS result column names. + * + * @return string[] Column names. + */ + private function get_show_table_status_column_names(): array { + return array( + 'Name', + 'Engine', + 'Version', + 'Row_format', + 'Rows', + 'Avg_row_length', + 'Data_length', + 'Max_data_length', + 'Index_length', + 'Data_free', + 'Auto_increment', + 'Create_time', + 'Update_time', + 'Check_time', + 'Collation', + 'Checksum', + 'Create_options', + 'Comment', + ); + } + + /** + * Get the Name value from a SHOW TABLE STATUS row. + * + * @param object $row SHOW TABLE STATUS row. + * @return string Table name. + */ + private function get_show_table_status_row_name( $row ): string { + return $row->Name; + } + + /** + * Install SHOW TABLE STATUS AUTO_INCREMENT fixture rows. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_show_table_status_auto_increment_fixture( WP_PostgreSQL_Driver $driver ): void { + $pdo = $driver->get_connection()->get_pdo(); + + $pdo->exec( 'CREATE TABLE wptests_posts ("ID" INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)' ); + $pdo->exec( + "INSERT INTO wptests_posts (value) + VALUES ('a'), ('b'), ('c'), ('d'), ('e')" + ); + $pdo->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_plain', 'BASE TABLE')" + ); + $pdo->exec( + "INSERT INTO information_schema.columns + (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, collation_name, is_nullable, column_default, is_identity) + VALUES + ('public', 'wptests_posts', 'ID', 1, 'bigint', NULL, NULL, 'NO', NULL, 'YES'), + ('public', 'wptests_plain', 'id', 1, 'bigint', NULL, NULL, 'NO', NULL, 'NO')" + ); + } + + /** + * Install a table shape covered by SHOW CREATE TABLE reconstruction tests. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + * @param string $table_name Table name. + */ + private function install_show_create_table_fixture( WP_PostgreSQL_Driver $driver, string $table_name ): void { + $driver->query( + sprintf( + "CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL, + title varchar(191) NOT NULL DEFAULT '', + description text, + status varchar(20) NOT NULL DEFAULT 'draft', + PRIMARY KEY (id), + UNIQUE KEY title (title), + KEY status (status) + )", + $table_name + ) + ); + $driver->store_mysql_schema_metadata( + sprintf( + "CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + title varchar(191) NOT NULL DEFAULT '', + description text, + status varchar(20) NOT NULL DEFAULT 'draft', + PRIMARY KEY (id), + UNIQUE KEY title (title), + KEY status (status) + )", + $table_name + ) + ); + } + + /** + * Install MySQL-facing metadata for direct information_schema SELECT tests. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_direct_information_schema_options_metadata( WP_PostgreSQL_Driver $driver ): void { + $driver->store_mysql_schema_metadata( + "CREATE TABLE wptests_options ( + option_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + option_name varchar(191) NOT NULL DEFAULT '', + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL DEFAULT 'yes', + PRIMARY KEY (option_id), + UNIQUE KEY option_name (option_name), + KEY autoload (autoload) + )" + ); + } + + /** + * Install a small information_schema fixture into the injected PDO. + * + * @param WP_PostgreSQL_Driver $driver Driver under test. + */ + private function install_information_schema_fixture( WP_PostgreSQL_Driver $driver ): void { + $pdo = $driver->get_connection()->get_pdo(); + + $pdo->exec( "ATTACH DATABASE ':memory:' AS information_schema" ); + $pdo->exec( + 'CREATE TABLE information_schema.tables ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + table_type TEXT NOT NULL + )' + ); + $pdo->exec( + 'CREATE TABLE information_schema.columns ( + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + ordinal_position INTEGER NOT NULL, + data_type TEXT NOT NULL, + character_maximum_length INTEGER, + numeric_precision INTEGER, + numeric_scale INTEGER, + datetime_precision INTEGER, + collation_name TEXT, + is_nullable TEXT NOT NULL, + column_default TEXT, + is_identity TEXT NOT NULL + )' + ); + $pdo->exec( + 'CREATE TABLE information_schema.table_constraints ( + constraint_schema TEXT NOT NULL, + constraint_name TEXT NOT NULL, + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + constraint_type TEXT NOT NULL + )' + ); + $pdo->exec( + 'CREATE TABLE information_schema.key_column_usage ( + constraint_schema TEXT NOT NULL, + constraint_name TEXT NOT NULL, + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + ordinal_position INTEGER, + position_in_unique_constraint INTEGER + )' + ); + $pdo->exec( + 'CREATE TABLE information_schema.referential_constraints ( + constraint_schema TEXT NOT NULL, + constraint_name TEXT NOT NULL, + unique_constraint_schema TEXT NOT NULL, + unique_constraint_name TEXT NOT NULL, + match_option TEXT NOT NULL, + update_rule TEXT NOT NULL, + delete_rule TEXT NOT NULL + )' + ); + $pdo->exec( + 'CREATE TABLE information_schema.check_constraints ( + constraint_schema TEXT NOT NULL, + constraint_name TEXT NOT NULL, + check_clause TEXT NOT NULL + )' + ); + $pdo->exec( + 'CREATE TABLE information_schema.constraint_column_usage ( + constraint_schema TEXT NOT NULL, + constraint_name TEXT NOT NULL, + table_schema TEXT NOT NULL, + table_name TEXT NOT NULL, + column_name TEXT NOT NULL + )' + ); + + $pdo->exec( + "INSERT INTO information_schema.tables + (table_schema, table_name, table_type) + VALUES + ('public', 'wptests_options', 'BASE TABLE'), + ('public', 'wptests_posts', 'BASE TABLE'), + ('public', 'wptests_view', 'VIEW'), + ('other', 'other_table', 'BASE TABLE')" + ); + $pdo->exec( + "INSERT INTO information_schema.columns + (table_schema, table_name, column_name, ordinal_position, data_type, character_maximum_length, collation_name, is_nullable, column_default, is_identity) + VALUES + ('public', 'wptests_options', 'option_id', 1, 'bigint', NULL, NULL, 'NO', NULL, 'YES'), + ('public', 'wptests_options', 'option_name', 2, 'character varying', 191, 'utf8mb4_unicode_ci', 'NO', NULL, 'NO'), + ('public', 'wptests_options', 'option_value', 3, 'text', NULL, 'utf8mb4_unicode_ci', 'NO', NULL, 'NO'), + ('public', 'wptests_options', 'autoload', 4, 'character varying', 20, 'utf8mb4_unicode_ci', 'NO', '''yes''::character varying', 'NO')" + ); + $pdo->exec( + "INSERT INTO information_schema.table_constraints + (constraint_schema, constraint_name, table_schema, table_name, constraint_type) + VALUES + ('public', 'wptests_options_pkey', 'public', 'wptests_options', 'PRIMARY KEY'), + ('public', 'wptests_options_option_name_key', 'public', 'wptests_options', 'UNIQUE'), + ('public', 'wptests_posts_author_fk', 'public', 'wptests_posts', 'FOREIGN KEY'), + ('public', 'wptests_posts_status_chk', 'public', 'wptests_posts', 'CHECK')" + ); + $pdo->exec( + "INSERT INTO information_schema.key_column_usage + (constraint_schema, constraint_name, table_schema, table_name, column_name, ordinal_position, position_in_unique_constraint) + VALUES + ('public', 'wptests_options_pkey', 'public', 'wptests_options', 'option_id', 1, NULL), + ('public', 'wptests_options_option_name_key', 'public', 'wptests_options', 'option_name', 1, NULL), + ('public', 'wptests_posts_author_fk', 'public', 'wptests_posts', 'post_author', 1, 1)" + ); + $pdo->exec( + "INSERT INTO information_schema.referential_constraints + (constraint_schema, constraint_name, unique_constraint_schema, unique_constraint_name, match_option, update_rule, delete_rule) + VALUES + ('public', 'wptests_posts_author_fk', 'public', 'wptests_options_pkey', 'NONE', 'NO ACTION', 'CASCADE')" + ); + $pdo->exec( + "INSERT INTO information_schema.constraint_column_usage + (constraint_schema, constraint_name, table_schema, table_name, column_name) + VALUES + ('public', 'wptests_options_pkey', 'public', 'wptests_options', 'option_id'), + ('public', 'wptests_posts_author_fk', 'public', 'wptests_options', 'option_id')" + ); + $pdo->exec( + "INSERT INTO information_schema.check_constraints + (constraint_schema, constraint_name, check_clause) + VALUES + ('public', 'wptests_posts_status_chk', 'post_status IS NOT NULL')" + ); + } +} + +/** + * Fetch a dynamic field value for the named-function FETCH_FUNC cache test. + * + * @param mixed ...$values Fetched row values. + * @return string Dynamic field value. + */ +function wp_postgresql_driver_fetch_dynamic_field_for_introspection_cache_test( ...$values ): string { + global $wp_postgresql_driver_named_fetch_func_invocations; + + ++$wp_postgresql_driver_named_fetch_func_invocations; + return $wp_postgresql_driver_named_fetch_func_invocations . ':' . $values[0]; +} + +/** + * Query-counting connection for backend-fallthrough assertions. + */ +class WP_PostgreSQL_Query_Spy_Connection extends WP_PostgreSQL_Connection { + /** + * Number of backend queries attempted. + * + * @var int + */ + private $query_count = 0; + + /** + * Constructor. + */ + public function __construct() { + parent::__construct( array( 'pdo' => new PDO( 'sqlite::memory:' ) ) ); + } + + /** + * Execute a query and count backend attempts. + * + * @param string $sql SQL query. + * @param array $params Query parameters. + * @return PDOStatement Statement. + */ + public function query( string $sql, array $params = array() ): PDOStatement { + ++$this->query_count; + return parent::query( $sql, $params ); + } + + /** + * Get the backend query attempt count. + * + * @return int Query count. + */ + public function get_query_count(): int { + return $this->query_count; + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php new file mode 100644 index 000000000..7f2a562ca --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php @@ -0,0 +1,522 @@ +run_isolated_install_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +$root = sys_get_temp_dir() . '/wp-pg-install-' . str_replace( '.', '-', uniqid( '', true ) ) . '/'; +register_shutdown_function( 'wp_pg_install_test_remove_tree', $root ); +mkdir( $root . 'wp-admin/includes', 0777, true ); +file_put_contents( + $root . 'wp-admin/includes/schema.php', + 'queries[] = $statement; + return true; + } +}; + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php'; + +$result = postgresql_make_db_current_silent(); + +wp_pg_install_test_remove_tree( $root ); +wp_pg_install_test_respond( + array( + 'result' => $result, + 'queries' => $GLOBALS['wpdb']->queries, + ) +); +PHP + ); + + $this->assertTrue( $result['result'] ); + $this->assertSame( + array( + "CREATE TABLE \"wp_options\" (\n \"option_id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"option_name\" varchar(191) NOT NULL DEFAULT '',\n \"option_value\" text NOT NULL,\n \"autoload\" varchar(20) NOT NULL DEFAULT 'yes',\n PRIMARY KEY (\"option_id\")\n)", + 'CREATE UNIQUE INDEX "wp_options__option_name" ON "wp_options" ("option_name")', + 'CREATE INDEX "wp_options__autoload" ON "wp_options" ("autoload")', + ), + $result['queries'] + ); + } + + /** + * Tests schema installation executes translated DDL directly for the PostgreSQL driver. + */ + public function test_postgresql_make_db_current_silent_executes_translated_schema_directly_with_driver(): void { + $result = $this->run_isolated_install_script( + <<<'PHP' + require_once getcwd() . '/bootstrap.php'; + + class WP_PostgreSQL_Install_Test_Connection extends WP_PostgreSQL_Connection { + public $queries = array(); + private $test_pdo; + + public function __construct() { + $this->test_pdo = new PDO( 'sqlite::memory:' ); + } + + public function get_pdo(): PDO { + return $this->test_pdo; + } + + public function query( string $sql, array $params = array() ): PDOStatement { + $this->queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + + return $this->test_pdo->query( 'SELECT 1' ); + } + } + + class WP_PostgreSQL_Install_Test_Driver extends WP_PostgreSQL_Driver { + public $stored_schema = ''; + + public function store_mysql_schema_metadata( string $query ): void { + $this->stored_schema = $query; + } + } + + $root = sys_get_temp_dir() . '/wp-pg-install-' . str_replace( '.', '-', uniqid( '', true ) ) . '/'; + register_shutdown_function( 'wp_pg_install_test_remove_tree', $root ); + mkdir( $root . 'wp-admin/includes', 0777, true ); + file_put_contents( + $root . 'wp-admin/includes/schema.php', + 'dbh = $driver; + } + + public function query( $statement ) { + $this->fallback_query_called = true; + throw new RuntimeException( '$wpdb->query() should not receive translated PostgreSQL DDL.' ); + } + }; + + require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php'; + + $result = postgresql_make_db_current_silent(); + + wp_pg_install_test_remove_tree( $root ); + wp_pg_install_test_respond( + array( + 'result' => $result, + 'queries' => array_column( $connection->queries, 'sql' ), + 'fallback_query_called' => $GLOBALS['wpdb']->fallback_query_called, + 'stored_schema' => $driver->stored_schema, + ) + ); + PHP + ); + + $this->assertTrue( $result['result'] ); + $this->assertFalse( $result['fallback_query_called'] ); + $this->assertSame( + array( + "CREATE TABLE \"wp_options\" (\n \"option_id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"option_name\" varchar(191) NOT NULL DEFAULT '',\n \"option_value\" text NOT NULL,\n \"autoload\" varchar(20) NOT NULL DEFAULT 'yes',\n PRIMARY KEY (\"option_id\")\n)", + 'CREATE UNIQUE INDEX "wp_options__option_name" ON "wp_options" ("option_name")', + ), + $result['queries'] + ); + $this->assertStringContainsString( 'CREATE TABLE wp_options', $result['stored_schema'] ); + } + + /** + * Tests wp_install() creates schema before running populate helpers. + */ + public function test_wp_install_creates_schema_before_populating_site(): void { + $result = $this->run_isolated_install_script( + <<<'PHP' +function do_action( $hook, ...$args ) { + wp_pg_install_test_event( array( 'do_action', $hook ) ); +} + +require_once getcwd() . '/bootstrap.php'; + +function wp_pg_install_test_event( $event ) { + $GLOBALS['wp_pg_install_test_events'][] = $event; +} + +function wp_check_mysql_version() { + wp_pg_install_test_event( 'wp_check_mysql_version' ); +} + +function wp_cache_flush() { + wp_pg_install_test_event( 'wp_cache_flush' ); +} + +function wp_unschedule_hook( $hook ) { + wp_pg_install_test_event( array( 'wp_unschedule_hook', $hook ) ); +} + +function wp_schedule_event( $timestamp, $recurrence, $hook ) { + wp_pg_install_test_event( array( 'wp_schedule_event', $recurrence, $hook, is_numeric( $timestamp ) ) ); +} + +function populate_options() { + wp_pg_install_test_event( 'populate_options' ); +} + +function populate_roles() { + wp_pg_install_test_event( 'populate_roles' ); +} + +function update_option( $option, $value, $autoload = null ) { + wp_pg_install_test_event( array( 'update_option', $option, $value, $autoload ) ); + return true; +} + +function wp_guess_url() { + wp_pg_install_test_event( 'wp_guess_url' ); + return 'https://example.test'; +} + +function username_exists( $user_name ) { + wp_pg_install_test_event( array( 'username_exists', $user_name ) ); + return false; +} + +function wp_generate_password( $length, $special_chars ) { + wp_pg_install_test_event( array( 'wp_generate_password', $length, $special_chars ) ); + return 'generated-password'; +} + +function __( $text, $domain = null ) { + return $text; +} + +function wp_create_user( $user_name, $password, $user_email ) { + wp_pg_install_test_event( array( 'wp_create_user', $user_name, $password, $user_email ) ); + return 42; +} + +function update_user_meta( $user_id, $meta_key, $meta_value ) { + wp_pg_install_test_event( array( 'update_user_meta', $user_id, $meta_key, $meta_value ) ); + return true; +} + +class WP_User { + public $ID; + public $user_url; + + public function __construct( $user_id ) { + $this->ID = $user_id; + wp_pg_install_test_event( array( 'WP_User::__construct', $user_id ) ); + } + + public function set_role( $role ) { + wp_pg_install_test_event( array( 'WP_User::set_role', $role ) ); + } +} + +function wp_update_user( $user ) { + wp_pg_install_test_event( array( 'wp_update_user', $user->ID, $user->user_url ) ); + return $user->ID; +} + +function wp_install_defaults( $user_id ) { + wp_pg_install_test_event( array( 'wp_install_defaults', $user_id ) ); +} + +function wp_install_maybe_enable_pretty_permalinks() { + wp_pg_install_test_event( 'wp_install_maybe_enable_pretty_permalinks' ); +} + +function flush_rewrite_rules() { + wp_pg_install_test_event( 'flush_rewrite_rules' ); +} + +function wp_new_blog_notification( $blog_title, $guessurl, $user_id, $password ) { + wp_pg_install_test_event( array( 'wp_new_blog_notification', $blog_title, $guessurl, $user_id, $password ) ); +} + +$root = sys_get_temp_dir() . '/wp-pg-install-' . str_replace( '.', '-', uniqid( '', true ) ) . '/'; +register_shutdown_function( 'wp_pg_install_test_remove_tree', $root ); +mkdir( $root . 'wp-admin/includes', 0777, true ); +file_put_contents( + $root . 'wp-admin/includes/schema.php', + ' $GLOBALS['wp_pg_install_test_events'], + 'result' => $install_result, + ) +); +PHP + ); + + $this->assertSame( + array( + array( + 'schema_query', + "CREATE TABLE \"wp_options\" (\n \"option_id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"option_name\" varchar(191) NOT NULL DEFAULT '',\n \"option_value\" text NOT NULL,\n \"autoload\" varchar(20) NOT NULL DEFAULT 'yes',\n PRIMARY KEY (\"option_id\")\n)", + ), + array( + 'schema_query', + 'CREATE UNIQUE INDEX "wp_options__option_name" ON "wp_options" ("option_name")', + ), + array( 'wp_unschedule_hook', 'wp_version_check' ), + array( 'wp_unschedule_hook', 'wp_update_plugins' ), + array( 'wp_unschedule_hook', 'wp_update_themes' ), + array( 'wp_schedule_event', 'twicedaily', 'wp_version_check', true ), + array( 'wp_schedule_event', 'twicedaily', 'wp_update_plugins', true ), + array( 'wp_schedule_event', 'twicedaily', 'wp_update_themes', true ), + 'populate_options', + 'populate_roles', + ), + array_slice( $result['events'], 2, 10 ) + ); + $this->assertContains( + array( 'update_option', 'fresh_site', 1, false ), + $result['events'] + ); + $this->assertSame( 42, $result['result']['user_id'] ); + $this->assertSame( 'secret-password', $result['result']['password'] ); + } + + /** + * Tests network installation creates the global schema through the translator. + */ + public function test_install_network_creates_postgresql_global_schema(): void { + $result = $this->run_isolated_install_script( + <<<'PHP' +require_once getcwd() . '/bootstrap.php'; + +$root = sys_get_temp_dir() . '/wp-pg-install-' . str_replace( '.', '-', uniqid( '', true ) ) . '/'; +register_shutdown_function( 'wp_pg_install_test_remove_tree', $root ); +mkdir( $root . 'wp-admin/includes', 0777, true ); +file_put_contents( + $root . 'wp-admin/includes/schema.php', + 'queries[] = $statement; + return true; + } +}; + +require_once getcwd() . '/../../plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php'; + +install_network(); + +$installing_network = defined( 'WP_INSTALLING_NETWORK' ) && WP_INSTALLING_NETWORK; + +wp_pg_install_test_remove_tree( $root ); +wp_pg_install_test_respond( + array( + 'installing_network' => $installing_network, + 'queries' => $GLOBALS['wpdb']->queries, + ) +); +PHP + ); + + $this->assertTrue( $result['installing_network'] ); + $this->assertSame( + array( + "CREATE TABLE \"wp_site\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \"domain\" varchar(200) NOT NULL DEFAULT '',\n \"path\" varchar(100) NOT NULL DEFAULT '',\n PRIMARY KEY (\"id\")\n)", + 'CREATE INDEX "wp_site__domain" ON "wp_site" ("domain", "path")', + ), + $result['queries'] + ); + } + + /** + * Runs an install-functions script in a separate PHP process. + * + * @param string $script Script body without the opening PHP tag. + * @return array Decoded JSON response from the script. + */ + private function run_isolated_install_script( string $script ): array { + $script_file = tempnam( sys_get_temp_dir(), 'wp_pg_install_' ); + if ( false === $script_file ) { + $this->fail( 'Could not create temporary install-functions test script.' ); + } + + $script_written = file_put_contents( + $script_file, + "get_isolated_script_prelude() . "\n" . $script + ); + if ( false === $script_written ) { + unlink( $script_file ); + $this->fail( 'Could not write temporary install-functions test script.' ); + } + + $descriptor_spec = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + $process = proc_open( + escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( $script_file ), + $descriptor_spec, + $pipes, + __DIR__ + ); + + if ( ! is_resource( $process ) ) { + unlink( $script_file ); + $this->fail( 'Could not start isolated install-functions test process.' ); + } + + fclose( $pipes[0] ); + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + $exitcode = proc_close( $process ); + unlink( $script_file ); + + $this->assertSame( + 0, + $exitcode, + "Isolated install-functions script failed.\nSTDOUT:\n" . $stdout . "\nSTDERR:\n" . $stderr + ); + + $decoded = json_decode( $stdout, true ); + $this->assertIsArray( + $decoded, + "Isolated install-functions script did not return JSON.\nSTDOUT:\n" . $stdout . "\nSTDERR:\n" . $stderr + ); + + return $decoded; + } + + /** + * Gets helper code prepended to every isolated script. + * + * @return string PHP script body. + */ + private function get_isolated_script_prelude(): string { + return <<<'PHP' +function wp_pg_install_test_respond( array $payload ) { + echo json_encode( $payload ); +} + +function wp_pg_install_test_remove_tree( $path ) { + if ( ! is_dir( $path ) ) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $iterator as $item ) { + if ( $item->isDir() ) { + rmdir( $item->getPathname() ); + } else { + unlink( $item->getPathname() ); + } + } + + rmdir( $path ); +} +PHP; + } +} diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 18a0424c9..7322cf5a9 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -4241,7 +4241,7 @@ public function testTranslateLikeBinary() { 'CREATE TABLE _tmp_table ( ID INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL, name varchar(20) - )' + )' ); // Insert data into the table diff --git a/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php b/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php index 383b03f57..ecda87ada 100644 --- a/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php +++ b/packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php @@ -325,6 +325,64 @@ public function data_valid_escaped_strings(): array { ); } + public function test_double_quoted_text_without_ansi_quotes_remains_string(): void { + $lexer = new WP_MySQL_Lexer( '"my_column"' ); + $this->assertFalse( $lexer->is_sql_mode_active( WP_MySQL_Lexer::SQL_MODE_ANSI_QUOTES ) ); + + $this->assertTrue( $lexer->next_token() ); + $token = $lexer->get_token(); + + $this->assertSame( WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT, $token->id ); + $this->assertSame( 'my_column', $token->get_value() ); + } + + public function test_double_quoted_text_with_ansi_quotes_is_identifier_like(): void { + $lexer = new WP_MySQL_Lexer( '"my_column"', 80038, array( 'ANSI_QUOTES' ) ); + $this->assertTrue( $lexer->is_sql_mode_active( WP_MySQL_Lexer::SQL_MODE_ANSI_QUOTES ) ); + + $this->assertTrue( $lexer->next_token() ); + $token = $lexer->get_token(); + + $this->assertSame( WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, $token->id ); + $this->assertSame( 'my_column', $token->get_value() ); + } + + public function test_no_backslash_escapes_sql_mode_preserves_backslash_sequences(): void { + $lexer = new WP_MySQL_Lexer( "'\\n'" ); + $this->assertFalse( $lexer->is_sql_mode_active( WP_MySQL_Lexer::SQL_MODE_NO_BACKSLASH_ESCAPES ) ); + $this->assertTrue( $lexer->next_token() ); + $this->assertSame( "\n", $lexer->get_token()->get_value() ); + + $lexer = new WP_MySQL_Lexer( "'\\n'", 80038, array( 'NO_BACKSLASH_ESCAPES' ) ); + $this->assertTrue( $lexer->is_sql_mode_active( WP_MySQL_Lexer::SQL_MODE_NO_BACKSLASH_ESCAPES ) ); + $this->assertTrue( $lexer->next_token() ); + $this->assertSame( '\\n', $lexer->get_token()->get_value() ); + } + + public function test_pipes_as_concat_sql_mode_changes_double_pipe_token(): void { + $tokens = ( new WP_MySQL_Lexer( '1 || 0' ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::LOGICAL_OR_OPERATOR, $tokens[1]->id ); + + $tokens = ( new WP_MySQL_Lexer( '1 || 0', 80038, array( 'PIPES_AS_CONCAT' ) ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::CONCAT_PIPES_SYMBOL, $tokens[1]->id ); + } + + public function test_ignore_space_sql_mode_allows_whitespace_before_function_call_parentheses(): void { + $tokens = ( new WP_MySQL_Lexer( 'COUNT (*)' ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::IDENTIFIER, $tokens[0]->id ); + + $tokens = ( new WP_MySQL_Lexer( 'COUNT (*)', 80038, array( 'IGNORE_SPACE' ) ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::COUNT_SYMBOL, $tokens[0]->id ); + } + + public function test_high_not_precedence_sql_mode_emits_not2_token(): void { + $tokens = ( new WP_MySQL_Lexer( 'NOT 1' ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::NOT_SYMBOL, $tokens[0]->id ); + + $tokens = ( new WP_MySQL_Lexer( 'NOT 1', 80038, array( 'HIGH_NOT_PRECEDENCE' ) ) )->remaining_tokens(); + $this->assertSame( WP_MySQL_Lexer::NOT2_SYMBOL, $tokens[0]->id ); + } + /** * Test that a chunk boundary splitting a quoted string with a trailing * backslash does not cause an out-of-bounds string access. diff --git a/packages/mysql-proxy/composer.json b/packages/mysql-proxy/composer.json index b231344d9..0deec29f7 100644 --- a/packages/mysql-proxy/composer.json +++ b/packages/mysql-proxy/composer.json @@ -1,6 +1,8 @@ { "name": "wordpress/mysql-proxy", + "description": "A MySQL proxy that bridges the MySQL wire protocol to a PDO-like interface.", "type": "library", + "license": "GPL-2.0-or-later", "bin": [ "bin/wp-mysql-proxy.php" ], diff --git a/packages/mysql-proxy/src/class-mysql-protocol.php b/packages/mysql-proxy/src/class-mysql-protocol.php index 8423394ab..66d5c97e0 100644 --- a/packages/mysql-proxy/src/class-mysql-protocol.php +++ b/packages/mysql-proxy/src/class-mysql-protocol.php @@ -590,7 +590,7 @@ public static function read_length_encoded_int( string $payload, int &$offset ): $value = unpack( 'v', $payload, $offset )[1]; $offset += 2; } elseif ( 0xfd === $first_byte ) { - $value = unpack( 'VX', $payload, $offset )[1]; + $value = unpack( 'V', substr( $payload, $offset, 3 ) . "\0" )[1]; $offset += 3; } else { $value = unpack( 'P', $payload, $offset )[1]; diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php index b4504a030..103ed4ecc 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php @@ -1,6 +1,7 @@ skip_if_missing_test_client(); + $this->server = new MySQL_Server_Process( array( 'port' => $this->port, @@ -19,6 +22,10 @@ public function setUp(): void { } public function tearDown(): void { + if ( ! $this->server instanceof MySQL_Server_Process ) { + return; + } + $this->server->stop(); $exit_code = $this->server->get_exit_code(); if ( $this->hasFailed() || ( $exit_code > 0 && 143 !== $exit_code ) ) { @@ -32,4 +39,26 @@ public function tearDown(): void { ); } } + + private function skip_if_missing_test_client(): void { + switch ( static::class ) { + case WP_MySQL_Proxy_CLI_Test::class: + if ( null === ( new ExecutableFinder() )->find( 'mysql' ) ) { + $this->markTestSkipped( 'The mysql CLI client is not available.' ); + } + break; + + case WP_MySQL_Proxy_MySQLi_Test::class: + if ( ! class_exists( 'mysqli' ) ) { + $this->markTestSkipped( 'The mysqli extension is not available.' ); + } + break; + + case WP_MySQL_Proxy_PDO_Test::class: + if ( ! class_exists( 'PDO' ) || ! in_array( 'mysql', PDO::getAvailableDrivers(), true ) ) { + $this->markTestSkipped( 'The pdo_mysql extension is not available.' ); + } + break; + } + } } diff --git a/packages/php-ext-wp-mysql-parser/src/lib.rs b/packages/php-ext-wp-mysql-parser/src/lib.rs index 35f17fbd9..d0be38cba 100644 --- a/packages/php-ext-wp-mysql-parser/src/lib.rs +++ b/packages/php-ext-wp-mysql-parser/src/lib.rs @@ -24,6 +24,7 @@ const SQL_MODE_HIGH_NOT_PRECEDENCE: i64 = 1; const SQL_MODE_PIPES_AS_CONCAT: i64 = 2; const SQL_MODE_IGNORE_SPACE: i64 = 4; const SQL_MODE_NO_BACKSLASH_ESCAPES: i64 = 8; +const SQL_MODE_ANSI_QUOTES: i64 = 16; const STACK_RED_ZONE: usize = 128 * 1024; const STACK_GROW_SIZE: usize = 8 * 1024 * 1024; @@ -137,6 +138,7 @@ fn sql_modes_mask(sql_modes: &[String]) -> i64 { "PIPES_AS_CONCAT" => mask |= SQL_MODE_PIPES_AS_CONCAT, "IGNORE_SPACE" => mask |= SQL_MODE_IGNORE_SPACE, "NO_BACKSLASH_ESCAPES" => mask |= SQL_MODE_NO_BACKSLASH_ESCAPES, + "ANSI_QUOTES" => mask |= SQL_MODE_ANSI_QUOTES, _ => {} } } @@ -813,6 +815,7 @@ impl WpMySqlNativeLexer { self.bytes_already_read = at + 1; Some(match quote { b'`' => lex::BACK_TICK_QUOTED_ID, + b'"' if self.is_sql_mode_active(SQL_MODE_ANSI_QUOTES) => lex::BACK_TICK_QUOTED_ID, b'"' => lex::DOUBLE_QUOTED_TEXT, _ => lex::SINGLE_QUOTED_TEXT, }) diff --git a/packages/plugin-sqlite-database-integration/constants.php b/packages/plugin-sqlite-database-integration/constants.php index 15e6772a1..18d5eaed1 100644 --- a/packages/plugin-sqlite-database-integration/constants.php +++ b/packages/plugin-sqlite-database-integration/constants.php @@ -6,13 +6,31 @@ * @package wp-sqlite-integration */ +if ( ! function_exists( 'wp_sqlite_database_integration_normalize_db_engine' ) ) { + /** + * Normalizes supported database engine names. + * + * @param string $engine Database engine name. + * @return string Canonical database engine name. + */ + function wp_sqlite_database_integration_normalize_db_engine( $engine ) { + $engine = strtolower( (string) $engine ); + + if ( in_array( $engine, array( 'postgres', 'pgsql', 'postgresql' ), true ) ) { + return 'postgresql'; + } + + return $engine; + } +} + // Temporary - This will be in wp-config.php once SQLite is merged in Core. if ( ! defined( 'DB_ENGINE' ) ) { if ( defined( 'SQLITE_DB_DROPIN_VERSION' ) ) { define( 'DB_ENGINE', 'sqlite' ); } elseif ( defined( 'DATABASE_ENGINE' ) ) { // backwards compatibility with previous versions of the plugin. - define( 'DB_ENGINE', DATABASE_ENGINE ); + define( 'DB_ENGINE', wp_sqlite_database_integration_normalize_db_engine( DATABASE_ENGINE ) ); } else { define( 'DB_ENGINE', 'mysql' ); } diff --git a/packages/plugin-sqlite-database-integration/db.copy b/packages/plugin-sqlite-database-integration/db.copy index ef8291374..9c83e7213 100644 --- a/packages/plugin-sqlite-database-integration/db.copy +++ b/packages/plugin-sqlite-database-integration/db.copy @@ -24,17 +24,31 @@ if ( ! $sqlite_plugin_implementation_folder_path || ! file_exists( $sqlite_plugi return; } +// Resolve the selected backend. The unreplaced placeholder keeps existing +// copy-paste installs on SQLite. +$database_engine = defined( 'DB_ENGINE' ) + ? DB_ENGINE + : ( defined( 'DATABASE_ENGINE' ) ? DATABASE_ENGINE : '{DATABASE_ENGINE}' ); +$unreplaced_database_engine = '{' . 'DATABASE_ENGINE' . '}'; +if ( $unreplaced_database_engine === $database_engine ) { + $database_engine = 'sqlite'; +} +$database_engine = strtolower( (string) $database_engine ); +if ( in_array( $database_engine, array( 'postgres', 'pgsql', 'postgresql' ), true ) ) { + $database_engine = 'postgresql'; +} + // Constant for backward compatibility. if ( ! defined( 'DATABASE_TYPE' ) ) { - define( 'DATABASE_TYPE', 'sqlite' ); + define( 'DATABASE_TYPE', $database_engine ); } -// Define SQLite constant. +// Define database engine constant. if ( ! defined( 'DB_ENGINE' ) ) { - define( 'DB_ENGINE', 'sqlite' ); + define( 'DB_ENGINE', $database_engine ); } // Require the implementation from the plugin. -require_once $sqlite_plugin_implementation_folder_path . '/wp-includes/sqlite/db.php'; +require_once $sqlite_plugin_implementation_folder_path . '/wp-includes/db.php'; // Activate the performance-lab plugin if it is not already activated. add_action( diff --git a/packages/plugin-sqlite-database-integration/wp-includes/db.php b/packages/plugin-sqlite-database-integration/wp-includes/db.php new file mode 100644 index 000000000..a39c3de15 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/db.php @@ -0,0 +1,22 @@ +charset ) { + $this->charset = 'utf8mb4'; + } + } + + /** + * Method to set character set for the database. + * + * @param resource $dbh The database handle. + * @param string $charset Optional. The character set. + * @param string $collate Optional. The collation. + */ + public function set_charset( $dbh, $charset = null, $collate = null ) { + if ( ! isset( $charset ) ) { + $charset = $this->charset; + } + if ( ! isset( $collate ) ) { + $collate = $this->collate; + } + + if ( ! $this->has_cap( 'collation' ) || empty( $charset ) ) { + return; + } + + if ( $dbh instanceof WP_PostgreSQL_Driver ) { + $dbh->set_charset( (string) $charset, empty( $collate ) ? null : (string) $collate ); + } + } + + /** + * Method to get the character set for the database. + * + * @param string $table The table name. + * @param string $column The column name. + * @return string The character set. + */ + public function get_col_charset( $table, $column ) { + $tablekey = $this->get_postgresql_metadata_key( (string) $table ); + $columnkey = $this->get_postgresql_metadata_key( (string) $column ); + + if ( function_exists( 'apply_filters' ) ) { + $charset = apply_filters( 'pre_get_col_charset', null, $table, $column ); + if ( null !== $charset ) { + return $charset; + } + } + + if ( empty( $this->is_mysql ) ) { + return false; + } + + if ( ! array_key_exists( $tablekey, $this->table_charset ) ) { + $table_charset = $this->get_table_charset( $table ); + if ( function_exists( 'is_wp_error' ) && is_wp_error( $table_charset ) ) { + return $table_charset; + } + } + + if ( empty( $this->col_meta[ $tablekey ] ) ) { + return $this->table_charset[ $tablekey ]; + } + + if ( empty( $this->col_meta[ $tablekey ][ $columnkey ] ) ) { + return $this->table_charset[ $tablekey ]; + } + + if ( empty( $this->col_meta[ $tablekey ][ $columnkey ]->Collation ) ) { + return false; + } + + list( $charset ) = explode( '_', $this->col_meta[ $tablekey ][ $columnkey ]->Collation ); + return $charset; + } + + /** + * Retrieves the MySQL-compatible table charset from PostgreSQL metadata. + * + * @param string $table Table name. + * @return string|false|WP_Error Table charset, false for non-text tables, or an error. + */ + protected function get_table_charset( $table ) { + $tablekey = $this->get_postgresql_metadata_key( (string) $table ); + + if ( function_exists( 'apply_filters' ) ) { + $charset = apply_filters( 'pre_get_table_charset', null, $table ); + if ( null !== $charset ) { + return $charset; + } + } + + if ( array_key_exists( $tablekey, $this->table_charset ) ) { + return $this->table_charset[ $tablekey ]; + } + + $columns = $this->get_postgresql_column_charset_metadata( (string) $table ); + if ( false === $columns ) { + return new WP_Error( 'wpdb_get_table_charset_failure', __( 'Could not retrieve table charset.' ) ); + } + + $this->col_meta[ $tablekey ] = $columns; + $this->table_charset[ $tablekey ] = $this->get_postgresql_table_charset_from_columns( $columns ); + + return $this->table_charset[ $tablekey ]; + } + + /** + * Strips invalid text using PostgreSQL-compatible PHP charset handling. + * + * WordPress core falls back to MySQL CONVERT(... USING ...) calls for + * legacy charsets. PostgreSQL does not have MySQL charset names, so mirror + * the core pre-truncation and UTF-8 paths, then emulate the conversion step + * in PHP for the charsets covered by core's charset tests. + * + * @param array $data Field data. + * @return array|WP_Error Field data with invalid text removed, or error. + */ + protected function strip_invalid_text( $data ) { + foreach ( $data as &$value ) { + $charset = $value['charset']; + + if ( is_array( $value['length'] ) ) { + $length = $value['length']['length']; + $truncate_by_byte_length = 'byte' === $value['length']['type']; + } else { + $length = false; + $truncate_by_byte_length = false; + } + + if ( false === $charset || ! is_string( $value['value'] ) ) { + continue; + } + + $needs_validation = true; + if ( + 'latin1' === $charset + || ( ! isset( $value['ascii'] ) && $this->check_ascii( $value['value'] ) ) + ) { + $truncate_by_byte_length = true; + $needs_validation = false; + } + + if ( $truncate_by_byte_length ) { + mbstring_binary_safe_encoding(); + if ( false !== $length && strlen( $value['value'] ) > $length ) { + $value['value'] = substr( $value['value'], 0, $length ); + } + reset_mbstring_encoding(); + + if ( ! $needs_validation ) { + continue; + } + } + + if ( ( 'utf8' === $charset || 'utf8mb3' === $charset || 'utf8mb4' === $charset ) && function_exists( 'mb_strlen' ) ) { + $value['value'] = $this->strip_postgresql_invalid_utf8_text( $value['value'], $charset, $length ); + continue; + } + + $stripped = $this->strip_postgresql_invalid_legacy_text( $value['value'], $charset, $value['length'] ); + if ( false === $stripped ) { + return new WP_Error( 'wpdb_strip_invalid_text_failure', __( 'Could not strip invalid text.' ) ); + } + + $value['value'] = $stripped; + } + unset( $value ); + + return $data; + } + + /** + * Gets the maximum string length for a PostgreSQL-backed column. + * + * Core wpdb skips length detection when the connection is not MySQL. The + * PostgreSQL schema translator still preserves varchar lengths, so expose + * them in the same shape wpdb::strip_invalid_text() expects. + * + * @param string $table Table name. + * @param string $column Column name. + * @return array|false Maximum length data, or false when unrestricted/unknown. + */ + public function get_col_length( $table, $column ) { + if ( ! $this->dbh instanceof WP_PostgreSQL_Driver ) { + return false; + } + + $table = $this->normalize_postgresql_table_name( (string) $table ); + $column = trim( (string) $column, "`\" \t\n\r\0\x0B" ); + $tablekey = $this->get_postgresql_metadata_key( (string) $table ); + $columnkey = $this->get_postgresql_metadata_key( (string) $column ); + + $columns = array_key_exists( $tablekey, $this->col_meta ) + ? $this->col_meta[ $tablekey ] + : $this->get_postgresql_column_charset_metadata( $table ); + if ( false !== $columns && isset( $columns[ $columnkey ] ) ) { + $length = $this->get_postgresql_column_length_from_mysql_type( + (string) $columns[ $columnkey ]->Type + ); + if ( false !== $length ) { + return $length; + } + } + + if ( + isset( $this->postgresql_column_length_cache[ $tablekey ] ) + && array_key_exists( $columnkey, $this->postgresql_column_length_cache[ $tablekey ] ) + ) { + return $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ]; + } + + try { + $stmt = $this->dbh->get_connection()->query( + 'SELECT data_type, character_maximum_length + FROM information_schema.columns + WHERE ( + table_schema = ( + SELECT nspname + FROM pg_catalog.pg_namespace + WHERE oid = pg_my_temp_schema() + ) + OR table_schema = current_schema() + ) + AND table_name = ? + AND column_name = ? + ORDER BY CASE + WHEN table_schema = ( + SELECT nspname + FROM pg_catalog.pg_namespace + WHERE oid = pg_my_temp_schema() + ) THEN 0 + ELSE 1 + END + LIMIT 1', + array( $table, $column ) + ); + $row = $stmt->fetch( PDO::FETCH_ASSOC ); + } catch ( Throwable $e ) { + return false; + } + + if ( ! is_array( $row ) ) { + $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ] = false; + return false; + } + + $type = strtolower( (string) ( $row['data_type'] ?? '' ) ); + $length = isset( $row['character_maximum_length'] ) ? (int) $row['character_maximum_length'] : 0; + + if ( in_array( $type, array( 'character varying', 'character', 'varchar', 'char' ), true ) && $length > 0 ) { + $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ] = array( + 'type' => 'char', + 'length' => $length, + ); + return $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ]; + } + + if ( 'text' === $type ) { + $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ] = array( + 'type' => 'byte', + 'length' => 65535, + ); + return $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ]; + } + + $this->postgresql_column_length_cache[ $tablekey ][ $columnkey ] = false; + return false; + } + + /** + * Determines the best charset/collation pair for PostgreSQL-backed wpdb. + * + * Core returns early for non-mysqli handles. The PostgreSQL backend still + * advertises MySQL-compatible charset capabilities to WordPress, so apply + * the same utf8-to-utf8mb4 upgrade rules without the mysqli guard. + * + * @param string $charset Requested charset. + * @param string $collate Requested collation. + * @return array{charset: string, collate: string} Charset/collation pair. + */ + public function determine_charset( $charset, $collate ) { + if ( empty( $this->dbh ) ) { + return compact( 'charset', 'collate' ); + } + + if ( 'utf8' === $charset ) { + $charset = 'utf8mb4'; + } + + if ( 'utf8mb4' === $charset ) { + if ( ! $collate || 'utf8_general_ci' === $collate ) { + $collate = 'utf8mb4_unicode_ci'; + } else { + $collate = str_replace( 'utf8_', 'utf8mb4_', $collate ); + } + } + + if ( $this->has_cap( 'utf8mb4_520' ) && 'utf8mb4_unicode_ci' === $collate ) { + $collate = 'utf8mb4_unicode_520_ci'; + } + + return compact( 'charset', 'collate' ); + } + + /** + * Strip invalid UTF-8 text using WordPress core's local regex path. + * + * @param string $value Text value. + * @param string $charset MySQL charset. + * @param int|false $length Optional character length. + * @return string Stripped value. + */ + private function strip_postgresql_invalid_utf8_text( string $value, string $charset, $length ): string { + $regex = '/ + ( + (?: [\x00-\x7F] + | [\xC2-\xDF][\x80-\xBF] + | \xE0[\xA0-\xBF][\x80-\xBF] + | [\xE1-\xEC][\x80-\xBF]{2} + | \xED[\x80-\x9F][\x80-\xBF] + | [\xEE-\xEF][\x80-\xBF]{2}'; + + if ( 'utf8mb4' === $charset ) { + $regex .= ' + | \xF0[\x90-\xBF][\x80-\xBF]{2} + | [\xF1-\xF3][\x80-\xBF]{3} + | \xF4[\x80-\x8F][\x80-\xBF]{2} + '; + } + + $regex .= '){1,40} + ) + | . + /x'; + + $value = preg_replace( $regex, '$1', $value ); + if ( false !== $length && mb_strlen( $value, 'UTF-8' ) > $length ) { + $value = mb_substr( $value, 0, $length, 'UTF-8' ); + } + + return $value; + } + + /** + * Strip invalid text for MySQL legacy charsets using PHP conversion. + * + * @param string $value Text value. + * @param string $charset MySQL charset. + * @param array|false $length Optional length metadata. + * @return string|false Stripped value, or false when unsupported. + */ + private function strip_postgresql_invalid_legacy_text( string $value, string $charset, $length ) { + $charset = $this->normalize_postgresql_mysql_charset( $charset ); + $connection_charset = $this->get_postgresql_connection_charset(); + + if ( is_array( $length ) && 'byte' === $length['type'] ) { + return $this->strip_postgresql_invalid_trailing_bytes( $value, $connection_charset ); + } + + if ( $charset === $connection_charset && $this->is_postgresql_single_byte_mysql_charset( $charset ) ) { + if ( is_array( $length ) ) { + return substr( $value, 0, (int) $length['length'] ); + } + + return $value; + } + + if ( ! function_exists( 'mb_convert_encoding' ) ) { + return false; + } + + $target_encoding = $this->get_postgresql_php_encoding_for_mysql_charset( $charset ); + $connection_encoding = $this->get_postgresql_php_encoding_for_mysql_charset( $connection_charset ); + if ( null === $target_encoding || null === $connection_encoding ) { + return false; + } + + $target_value = $value; + if ( $target_encoding !== $connection_encoding ) { + $target_value = mb_convert_encoding( $value, $target_encoding, $connection_encoding ); + } + + if ( is_array( $length ) ) { + $target_value = mb_substr( $target_value, 0, (int) $length['length'], $target_encoding ); + } + + if ( $target_encoding === $connection_encoding ) { + return $this->strip_postgresql_invalid_trailing_bytes( $target_value, $connection_charset ); + } + + return mb_convert_encoding( $target_value, $connection_encoding, $target_encoding ); + } + + /** + * Strip a partial trailing multibyte sequence after byte truncation. + * + * @param string $value Text value. + * @param string $charset MySQL charset. + * @return string|false Stripped value, or false when unsupported. + */ + private function strip_postgresql_invalid_trailing_bytes( string $value, string $charset ) { + $charset = $this->normalize_postgresql_mysql_charset( $charset ); + if ( $this->is_postgresql_single_byte_mysql_charset( $charset ) ) { + return $value; + } + + $encoding = $this->get_postgresql_php_encoding_for_mysql_charset( $charset ); + if ( null === $encoding || ! function_exists( 'mb_check_encoding' ) ) { + return false; + } + + while ( '' !== $value && ! mb_check_encoding( $value, $encoding ) ) { + $value = substr( $value, 0, -1 ); + } + + return $value; + } + + /** + * Get the current MySQL-compatible connection charset. + * + * @return string Charset. + */ + private function get_postgresql_connection_charset(): string { + if ( ! empty( $this->charset ) ) { + return $this->normalize_postgresql_mysql_charset( (string) $this->charset ); + } + + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + return $this->normalize_postgresql_mysql_charset( $this->dbh->get_charset() ); + } + + return 'utf8mb4'; + } + + /** + * Normalize a MySQL charset for PostgreSQL adapter logic. + * + * @param string $charset Charset. + * @return string Normalized charset. + */ + private function normalize_postgresql_mysql_charset( string $charset ): string { + $charset = strtolower( trim( $charset, "'\"` \t\n\r\0\x0B" ) ); + return 'utf8mb3' === $charset ? 'utf8' : $charset; + } + + /** + * Check whether a charset is single-byte for truncation purposes. + * + * @param string $charset MySQL charset. + * @return bool Whether the charset is single-byte. + */ + private function is_postgresql_single_byte_mysql_charset( string $charset ): bool { + return in_array( + $this->normalize_postgresql_mysql_charset( $charset ), + array( 'ascii', 'binary', 'cp1251', 'hebrew', 'koi8r', 'latin1', 'tis620' ), + true + ); + } + + /** + * Map a MySQL charset to a PHP mbstring encoding. + * + * @param string $charset MySQL charset. + * @return string|null PHP encoding, or null when unsupported. + */ + private function get_postgresql_php_encoding_for_mysql_charset( string $charset ): ?string { + $encodings = array( + 'ascii' => 'ASCII', + 'big5' => 'BIG-5', + 'cp1251' => 'Windows-1251', + 'hebrew' => 'ISO-8859-8', + 'koi8r' => 'KOI8-R', + 'latin1' => 'ISO-8859-1', + 'ujis' => 'EUC-JP', + 'utf8' => 'UTF-8', + 'utf8mb4' => 'UTF-8', + ); + + $charset = $this->normalize_postgresql_mysql_charset( $charset ); + return $encodings[ $charset ] ?? null; + } + + /** + * Store MySQL charset metadata for a successfully created PostgreSQL table. + * + * @param string $query Original MySQL CREATE TABLE query. + */ + private function store_postgresql_create_table_charset_metadata( string $query ): void { + if ( ! $this->has_usable_postgresql_connection() ) { + return; + } + + $table_name = $this->get_postgresql_create_table_name( $query ); + if ( null !== $table_name ) { + $this->clear_postgresql_table_charset_cache( array( $table_name ) ); + } + + if ( ! class_exists( 'WP_PostgreSQL_Create_Table_Translator', false ) ) { + return; + } + + if ( ! $this->is_postgresql_mysql_charset_metadata_create_query( $query ) ) { + return; + } + + if ( $this->is_postgresql_create_temporary_table_query( $query ) ) { + try { + $metadata = ( new WP_PostgreSQL_Create_Table_Translator() )->extract_schema_metadata( $query ); + foreach ( $metadata as $table ) { + if ( empty( $table['table_name'] ) ) { + continue; + } + + $table_name = (string) $table['table_name']; + $tablekey = $this->get_postgresql_metadata_key( $table_name ); + $this->clear_postgresql_table_charset_cache( array( $table_name ) ); + + $rows = array(); + foreach ( (array) ( $table['columns'] ?? array() ) as $column ) { + $rows[] = array( + 'column_name' => (string) $column['name'], + 'column_type' => (string) $column['type'], + 'collation_name' => $column['collation'], + ); + } + + $columns = $this->format_postgresql_charset_column_rows( $rows ); + if ( ! empty( $columns ) ) { + $this->postgresql_temporary_charset_metadata[ $tablekey ] = $columns; + } + } + } catch ( Throwable $e ) { + return; + } + return; + } + + try { + $metadata = ( new WP_PostgreSQL_Create_Table_Translator() )->extract_schema_metadata( $query ); + if ( empty( $metadata ) ) { + return; + } + + $this->ensure_postgresql_charset_metadata_table(); + + foreach ( $metadata as $table ) { + if ( empty( $table['table_name'] ) ) { + continue; + } + + $table_name = (string) $table['table_name']; + $this->dbh->get_connection()->query( + 'DELETE FROM ' . $this->quote_identifier( self::MYSQL_CHARSET_METADATA_TABLE ) . ' + WHERE table_schema = current_schema() + AND lower(table_name) = lower(?)', + array( $table_name ) + ); + + foreach ( (array) ( $table['columns'] ?? array() ) as $column ) { + $this->dbh->get_connection()->query( + 'INSERT INTO ' . $this->quote_identifier( self::MYSQL_CHARSET_METADATA_TABLE ) . ' ( + table_schema, + table_name, + column_name, + ordinal_position, + column_type, + character_set_name, + collation_name + ) VALUES ( + current_schema(), + ?, + ?, + ?, + ?, + ?, + ? + )', + array( + $table_name, + (string) $column['name'], + (int) $column['ordinal'], + (string) $column['type'], + $column['charset'], + $column['collation'], + ) + ); + } + + $this->clear_postgresql_table_charset_cache( array( $table_name ) ); + } + } catch ( Throwable $e ) { + return; + } + } + + /** + * Delete MySQL charset metadata for successfully dropped PostgreSQL tables. + * + * @param string $query Original DROP TABLE query. + */ + private function delete_postgresql_dropped_table_charset_metadata( string $query ): void { + if ( ! $this->has_usable_postgresql_connection() ) { + return; + } + + $tables = $this->get_postgresql_drop_table_names( $query ); + if ( empty( $tables ) ) { + return; + } + + $this->clear_postgresql_table_charset_cache( $tables ); + + if ( $this->is_postgresql_drop_temporary_table_query( $query ) ) { + return; + } + + try { + if ( ! $this->postgresql_charset_metadata_table_exists() ) { + return; + } + + foreach ( $tables as $table ) { + $this->dbh->get_connection()->query( + 'DELETE FROM ' . $this->quote_identifier( self::MYSQL_CHARSET_METADATA_TABLE ) . ' + WHERE table_schema = current_schema() + AND lower(table_name) = lower(?)', + array( $table ) + ); + } + } catch ( Throwable $e ) { + return; + } + } + + /** + * Clear cached charset metadata for table names. + * + * @param string[] $tables Table names. + */ + private function clear_postgresql_table_charset_cache( array $tables ): void { + foreach ( $tables as $table ) { + $tablekey = $this->get_postgresql_metadata_key( (string) $table ); + if ( self::MYSQL_CHARSET_METADATA_TABLE === $this->normalize_postgresql_table_name( (string) $table ) ) { + $this->postgresql_charset_metadata_table_exists = null; + } + + unset( + $this->table_charset[ $tablekey ], + $this->col_meta[ $tablekey ], + $this->postgresql_temporary_charset_metadata[ $tablekey ], + $this->postgresql_column_charset_metadata_cache[ $tablekey ], + $this->postgresql_column_length_cache[ $tablekey ] + ); + } + } + + /** + * Clear all derived PostgreSQL charset metadata caches. + */ + private function clear_all_postgresql_table_charset_cache(): void { + $this->table_charset = array(); + $this->col_meta = array(); + $this->postgresql_column_charset_metadata_cache = array(); + $this->postgresql_column_length_cache = array(); + $this->postgresql_charset_metadata_table_exists = null; + } + + /** + * Ensure the PostgreSQL side table for MySQL charset metadata exists. + */ + private function ensure_postgresql_charset_metadata_table(): void { + $this->dbh->get_connection()->query( + 'CREATE TABLE IF NOT EXISTS ' . $this->quote_identifier( self::MYSQL_CHARSET_METADATA_TABLE ) . ' ( + table_schema text NOT NULL, + table_name text NOT NULL, + column_name text NOT NULL, + ordinal_position integer NOT NULL, + column_type text NOT NULL, + character_set_name text, + collation_name text, + PRIMARY KEY (table_schema, table_name, column_name) + )' + ); + $this->postgresql_charset_metadata_table_exists = true; + } + + /** + * Check whether a CREATE TABLE query carries MySQL charset metadata. + * + * The WordPress PostgreSQL installer executes already-translated PostgreSQL + * DDL. That SQL must not be fed back into the MySQL parser used for metadata + * extraction. + * + * @param string $query CREATE TABLE query. + * @return bool Whether the query should be parsed as MySQL charset DDL. + */ + private function is_postgresql_mysql_charset_metadata_create_query( string $query ): bool { + return 1 === preg_match( '/\b(?:CHARSET|CHARACTER\s+SET|COLLATE|ASCII|UNICODE|BINARY)\b/i', $query ); + } + + /** + * Tokenize MySQL-flavored SQL using the active PostgreSQL driver SQL modes. + * + * @param string $query MySQL-flavored SQL. + * @return WP_MySQL_Token[] Token stream. + */ + private function get_postgresql_mysql_tokens( string $query ): array { + $sql_modes = array(); + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + $sql_mode = $this->dbh->get_sql_mode(); + $sql_modes = '' === $sql_mode ? array() : explode( ',', $sql_mode ); + } + + $lexer = new WP_MySQL_Lexer( $query, 80038, $sql_modes ); + return $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + } + + /** + * Check whether a CREATE TABLE query creates a temporary table. + * + * @param string $query CREATE TABLE query. + * @return bool Whether the query is CREATE TEMPORARY TABLE. + */ + private function is_postgresql_create_temporary_table_query( string $query ): bool { + if ( ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return false; + } + + $tokens = $this->get_postgresql_mysql_tokens( $query ); + + return isset( $tokens[0], $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::CREATE_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id; + } + + /** + * Get the table name from a CREATE TABLE query. + * + * @param string $query CREATE TABLE query. + * @return string|null Table name, or null when unavailable. + */ + private function get_postgresql_create_table_name( string $query ): ?string { + if ( ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return null; + } + + $tokens = $this->get_postgresql_mysql_tokens( $query ); + + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::CREATE_SYMBOL !== $tokens[0]->id ) { + return null; + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::NOT_SYMBOL === $tokens[ $position + 1 ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 2 ]->id + ) { + $position += 3; + } + + $table_name = $this->get_postgresql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $table_name ) { + return null; + } + + ++$position; + while ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id + ) { + $qualified_table_name = $this->get_postgresql_identifier_token_value( $tokens[ $position + 1 ] ); + if ( null === $qualified_table_name ) { + break; + } + + $table_name = $qualified_table_name; + $position += 2; + } + + return $table_name; + } + + /** + * Check whether a DROP TABLE query targets temporary tables. + * + * @param string $query DROP TABLE query. + * @return bool Whether the query is DROP TEMPORARY TABLE. + */ + private function is_postgresql_drop_temporary_table_query( string $query ): bool { + if ( ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return false; + } + + $tokens = $this->get_postgresql_mysql_tokens( $query ); + + return isset( $tokens[0], $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::DROP_SYMBOL === $tokens[0]->id + && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id; + } + + /** + * Check whether the side table for MySQL charset metadata exists. + * + * @return bool Whether the metadata table exists in the current schema. + */ + private function postgresql_charset_metadata_table_exists(): bool { + if ( null !== $this->postgresql_charset_metadata_table_exists ) { + return $this->postgresql_charset_metadata_table_exists; + } + + try { + $stmt = $this->dbh->get_connection()->query( + 'SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = ? + )', + array( self::MYSQL_CHARSET_METADATA_TABLE ) + ); + $this->postgresql_charset_metadata_table_exists = (bool) $stmt->fetchColumn(); + } catch ( Throwable $e ) { + $this->postgresql_charset_metadata_table_exists = false; + } + + return $this->postgresql_charset_metadata_table_exists; + } + + /** + * Load MySQL-compatible column metadata for a PostgreSQL table. + * + * @param string $table Table name. + * @return array|false Column metadata keyed by lowercase column name, or false. + */ + private function get_postgresql_column_charset_metadata( string $table ) { + if ( ! $this->has_usable_postgresql_connection() ) { + return false; + } + + $tablekey = $this->get_postgresql_metadata_key( $table ); + if ( array_key_exists( $tablekey, $this->postgresql_column_charset_metadata_cache ) ) { + return $this->postgresql_column_charset_metadata_cache[ $tablekey ]; + } + + $temp_schema = $this->get_postgresql_temporary_table_schema( $table ); + if ( false === $temp_schema ) { + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = false; + return false; + } + + if ( null !== $temp_schema ) { + if ( array_key_exists( $tablekey, $this->postgresql_temporary_charset_metadata ) ) { + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = $this->postgresql_temporary_charset_metadata[ $tablekey ]; + return $this->postgresql_column_charset_metadata_cache[ $tablekey ]; + } + + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = $this->get_native_postgresql_column_charset_metadata( $table, $temp_schema ); + return $this->postgresql_column_charset_metadata_cache[ $tablekey ]; + } + + $columns = $this->get_stored_postgresql_column_charset_metadata( $table ); + if ( false !== $columns && ! empty( $columns ) ) { + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = $columns; + return $columns; + } + + $columns = $this->get_driver_postgresql_column_charset_metadata( $table ); + if ( false !== $columns && ! empty( $columns ) ) { + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = $columns; + return $columns; + } + + $this->postgresql_column_charset_metadata_cache[ $tablekey ] = $this->get_native_postgresql_column_charset_metadata( $table ); + return $this->postgresql_column_charset_metadata_cache[ $tablekey ]; + } + + /** + * Get the active temporary schema for a table name. + * + * @param string $table Table name. + * @return string|null|false Temporary schema, null when not temporary, or false on failure. + */ + private function get_postgresql_temporary_table_schema( string $table ) { + try { + $stmt = $this->dbh->get_connection()->query( + 'SELECT n.nspname + FROM pg_catalog.pg_class c + INNER JOIN pg_catalog.pg_namespace n + ON n.oid = c.relnamespace + WHERE n.oid = pg_my_temp_schema() + AND lower(c.relname) = lower(?) + AND c.relkind IN (\'r\', \'p\') + LIMIT 1', + array( $this->normalize_postgresql_table_name( $table ) ) + ); + $schema = $stmt->fetchColumn(); + } catch ( Throwable $e ) { + return false; + } + + return false === $schema ? null : (string) $schema; + } + + /** + * Load previously stored MySQL charset metadata. + * + * @param string $table Table name. + * @return array|false Column metadata, or false when unavailable. + */ + private function get_stored_postgresql_column_charset_metadata( string $table ) { + if ( ! $this->postgresql_charset_metadata_table_exists() ) { + return false; + } + + try { + $stmt = $this->dbh->get_connection()->query( + 'SELECT column_name, column_type, collation_name + FROM ' . $this->quote_identifier( self::MYSQL_CHARSET_METADATA_TABLE ) . ' + WHERE table_schema = current_schema() + AND lower(table_name) = lower(?) + ORDER BY ordinal_position', + array( $this->normalize_postgresql_table_name( $table ) ) + ); + $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + } catch ( Throwable $e ) { + return false; + } + + return $this->format_postgresql_charset_column_rows( $rows ); + } + + /** + * Load MySQL charset metadata through the PostgreSQL driver's SHOW COLUMNS path. + * + * The driver stores MySQL-facing column metadata as part of CREATE TABLE + * translation. Reusing it keeps wpdb charset checks aligned with DESCRIBE and + * SHOW FULL COLUMNS without depending on the adapter side table being present. + * + * @param string $table Table name. + * @return array|false Column metadata, or false when unavailable. + */ + private function get_driver_postgresql_column_charset_metadata( string $table ) { + if ( ! $this->dbh instanceof WP_PostgreSQL_Driver ) { + return false; + } + + $table_name = $this->normalize_postgresql_table_name( $table ); + if ( '' === $table_name ) { + return false; + } + + try { + $rows = $this->dbh->query( + 'SHOW FULL COLUMNS FROM ' . $this->quote_postgresql_mysql_identifier( $table_name ), + PDO::FETCH_ASSOC + ); + } catch ( Throwable $e ) { + return false; + } + + if ( ! is_array( $rows ) || empty( $rows ) ) { + return false; + } + + $metadata_rows = array(); + foreach ( $rows as $row ) { + if ( is_object( $row ) ) { + $row = get_object_vars( $row ); + } + + if ( ! is_array( $row ) || empty( $row['Field'] ) ) { + continue; + } + + $metadata_rows[] = array( + 'column_name' => $row['Field'], + 'column_type' => $row['Type'] ?? '', + 'collation_name' => $row['Collation'] ?? null, + ); + } + + if ( empty( $metadata_rows ) ) { + return false; + } + + return $this->format_postgresql_charset_column_rows( $metadata_rows ); + } + + /** + * Quote an identifier for a MySQL statement handled by the PostgreSQL driver. + * + * @param string $identifier Identifier. + * @return string Backtick-quoted MySQL identifier. + */ + private function quote_postgresql_mysql_identifier( string $identifier ): string { + return '`' . str_replace( '`', '``', $identifier ) . '`'; + } + + /** + * Synthesize MySQL metadata from PostgreSQL catalogs when side metadata is absent. + * + * @param string $table Table name. + * @return array|false Column metadata, or false when unavailable. + */ + private function get_native_postgresql_column_charset_metadata( string $table, ?string $table_schema = null ) { + try { + $table_schema_sql = null === $table_schema ? 'current_schema()' : '?'; + $params = null === $table_schema + ? array( $this->normalize_postgresql_table_name( $table ) ) + : array( $table_schema, $this->normalize_postgresql_table_name( $table ) ); + + $stmt = $this->dbh->get_connection()->query( + 'SELECT column_name, data_type, character_maximum_length + FROM information_schema.columns + WHERE table_schema = ' . $table_schema_sql . ' + AND lower(table_name) = lower(?) + ORDER BY ordinal_position', + $params + ); + $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + } catch ( Throwable $e ) { + return false; + } + + if ( empty( $rows ) ) { + return false; + } + + $columns = array(); + $default_collation = $this->get_postgresql_default_collation_for_charset( $this->charset ? $this->charset : 'utf8mb4' ); + + foreach ( $rows as $row ) { + $type = strtolower( (string) ( $row['data_type'] ?? '' ) ); + $length = isset( $row['character_maximum_length'] ) ? (int) $row['character_maximum_length'] : 0; + $mysqltype = $this->get_postgresql_native_mysql_column_type( $type, $length ); + $collation = in_array( $type, array( 'character varying', 'character', 'text' ), true ) ? $default_collation : null; + + $columns[] = array( + 'column_name' => (string) $row['column_name'], + 'column_type' => $mysqltype, + 'collation_name' => $collation, + ); + } + + return $this->format_postgresql_charset_column_rows( $columns ); + } + + /** + * Convert metadata rows into wpdb col_meta objects. + * + * @param array $rows Metadata rows. + * @return array Column metadata keyed by lowercase column name. + */ + private function format_postgresql_charset_column_rows( array $rows ): array { + $columns = array(); + + foreach ( $rows as $row ) { + $field = (string) ( $row['column_name'] ?? '' ); + if ( '' === $field ) { + continue; + } + + $columns[ $this->get_postgresql_metadata_key( $field ) ] = (object) array( + 'Field' => $field, + 'Type' => (string) ( $row['column_type'] ?? '' ), + 'Collation' => $row['collation_name'] ?? null, + ); + } + + return $columns; + } + + /** + * Convert a MySQL-facing column type into WordPress length metadata. + * + * @param string $column_type MySQL column type. + * @return array|false Column length metadata, or false when unrestricted/unknown. + */ + private function get_postgresql_column_length_from_mysql_type( string $column_type ) { + $typeinfo = explode( '(', $column_type, 2 ); + $type = strtolower( trim( $typeinfo[0] ) ); + $length = false; + + if ( ! empty( $typeinfo[1] ) ) { + $length = (int) trim( $typeinfo[1], ") \t\n\r\0\x0B" ); + } + + switch ( $type ) { + case 'char': + case 'varchar': + if ( false === $length || $length <= 0 ) { + return false; + } + + return array( + 'type' => 'char', + 'length' => $length, + ); + + case 'binary': + case 'varbinary': + if ( false === $length || $length <= 0 ) { + return false; + } + + return array( + 'type' => 'byte', + 'length' => $length, + ); + + case 'tinyblob': + case 'tinytext': + return array( + 'type' => 'byte', + 'length' => 255, + ); + + case 'blob': + case 'text': + return array( + 'type' => 'byte', + 'length' => 65535, + ); + + case 'mediumblob': + case 'mediumtext': + return array( + 'type' => 'byte', + 'length' => 16777215, + ); + + case 'longblob': + case 'longtext': + return array( + 'type' => 'byte', + 'length' => 4294967295, + ); + + default: + return false; + } + } + + /** + * Calculate WordPress's table charset value from column metadata. + * + * @param array $columns Column metadata. + * @return string|false Table charset, or false for tables without text columns. + */ + private function get_postgresql_table_charset_from_columns( array $columns ) { + $charsets = array(); + + foreach ( $columns as $column ) { + $collation = $column->{'Collation'}; + if ( ! empty( $collation ) ) { + list( $charset ) = explode( '_', $collation ); + + $charsets[ strtolower( $charset ) ] = true; + } + + $column_type = $column->{'Type'}; + + list( $type ) = explode( '(', $column_type ); + if ( in_array( strtoupper( $type ), array( 'BINARY', 'VARBINARY', 'TINYBLOB', 'MEDIUMBLOB', 'BLOB', 'LONGBLOB' ), true ) ) { + return 'binary'; + } + } + + if ( isset( $charsets['utf8mb3'] ) ) { + $charsets['utf8'] = true; + unset( $charsets['utf8mb3'] ); + } + + $count = count( $charsets ); + if ( 1 === $count ) { + return key( $charsets ); + } + + if ( 0 === $count ) { + return false; + } + + unset( $charsets['latin1'] ); + $count = count( $charsets ); + if ( 1 === $count ) { + return key( $charsets ); + } + + if ( 2 === $count && isset( $charsets['utf8'], $charsets['utf8mb4'] ) ) { + return 'utf8'; + } + + return 'ascii'; + } + + /** + * Convert PostgreSQL catalog types to MySQL-ish metadata types. + * + * @param string $type PostgreSQL data type. + * @param int $length Character length. + * @return string MySQL-ish column type. + */ + private function get_postgresql_native_mysql_column_type( string $type, int $length ): string { + if ( 'character varying' === $type ) { + return $length > 0 ? sprintf( 'varchar(%d)', $length ) : 'varchar'; + } + + if ( 'character' === $type ) { + return $length > 0 ? sprintf( 'char(%d)', $length ) : 'char'; + } + + if ( 'bytea' === $type ) { + return 'blob'; + } + + if ( 'integer' === $type ) { + return 'int'; + } + + if ( 'double precision' === $type || 'real' === $type || 'numeric' === $type ) { + return 'float'; + } + + return $type; + } + + /** + * Get the default MySQL collation for a charset. + * + * @param string $charset Charset. + * @return string Collation. + */ + private function get_postgresql_default_collation_for_charset( string $charset ): string { + $charset = strtolower( $charset ); + if ( 'utf8mb3' === $charset ) { + $charset = 'utf8'; + } + + $collations = array( + 'utf8' => 'utf8_general_ci', + 'utf8mb4' => 'utf8mb4_unicode_ci', + 'latin1' => 'latin1_swedish_ci', + 'big5' => 'big5_chinese_ci', + 'koi8r' => 'koi8r_general_ci', + 'cp1251' => 'cp1251_general_ci', + 'ascii' => 'ascii_general_ci', + ); + + return $collations[ $charset ] ?? $charset . '_general_ci'; + } + + /** + * Get table names from a DROP TABLE query. + * + * @param string $query DROP TABLE query. + * @return string[] Table names. + */ + private function get_postgresql_drop_table_names( string $query ): array { + if ( ! class_exists( 'WP_MySQL_Lexer', false ) ) { + return array(); + } + + $tokens = $this->get_postgresql_mysql_tokens( $query ); + + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return array(); + } + + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::TEMPORARY_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[ $position ]->id ) { + return array(); + } + + ++$position; + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::IF_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::EXISTS_SYMBOL === $tokens[ $position + 1 ]->id + ) { + $position += 2; + } + + $tables = array(); + while ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EOF !== $tokens[ $position ]->id ) { + $table_name = $this->parse_postgresql_table_reference( $tokens, $position ); + if ( null === $table_name ) { + break; + } + + $tables[] = $table_name; + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + break; + } + + ++$position; + } + + return $tables; + } + + /** + * Parse a table reference and return its final identifier. + * + * @param array $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @return string|null Table identifier, or null when unavailable. + */ + private function parse_postgresql_table_reference( array $tokens, int &$position ): ?string { + $table_name = $this->get_postgresql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $table_name ) { + return null; + } + + ++$position; + while ( + isset( $tokens[ $position ], $tokens[ $position + 1 ] ) + && WP_MySQL_Lexer::DOT_SYMBOL === $tokens[ $position ]->id + ) { + $qualified_table_name = $this->get_postgresql_identifier_token_value( $tokens[ $position + 1 ] ); + if ( null === $qualified_table_name ) { + break; + } + + $table_name = $qualified_table_name; + $position += 2; + } + + return $table_name; + } + + /** + * Get the value from an identifier token. + * + * @param object|null $token MySQL lexer token. + * @return string|null Identifier value, or null when the token is not an identifier. + */ + private function get_postgresql_identifier_token_value( $token ): ?string { + if ( + ! $token + || ! in_array( $token->id, array( WP_MySQL_Lexer::IDENTIFIER, WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), true ) + ) { + return null; + } + + return $token->get_value(); + } + + /** + * Normalize a table name for PostgreSQL metadata lookups. + * + * @param string $table Table identifier. + * @return string Table name. + */ + private function normalize_postgresql_table_name( string $table ): string { + $table = trim( $table, "`\" \t\n\r\0\x0B" ); + if ( false !== strpos( $table, '.' ) ) { + $table = substr( $table, strrpos( $table, '.' ) + 1 ); + $table = trim( $table, "`\" \t\n\r\0\x0B" ); + } + + return $table; + } + + /** + * Normalize an identifier for wpdb metadata cache keys. + * + * @param string $identifier Identifier. + * @return string Metadata key. + */ + private function get_postgresql_metadata_key( string $identifier ): string { + return strtolower( $this->normalize_postgresql_table_name( $identifier ) ); + } + + /** + * Checks whether the adapter has a usable PDO-backed PostgreSQL connection. + * + * @return bool Whether a real connection is available. + */ + private function has_usable_postgresql_connection(): bool { + if ( ! $this->dbh instanceof WP_PostgreSQL_Driver ) { + return false; + } + + try { + return $this->dbh->get_connection()->get_pdo() instanceof PDO; + } catch ( Throwable $e ) { + return false; + } + } + + /** + * Changes the current SQL mode. + * + * PostgreSQL does not expose MySQL sql_mode, but WordPress stores and checks + * this state through wpdb. Keep the emulated state on the driver and apply + * the same incompatible-mode filtering as core wpdb. + * + * @param array $modes Optional. A list of SQL modes to set. Default empty array. + */ + public function set_sql_mode( $modes = array() ) { + if ( empty( $modes ) ) { + if ( ! $this->dbh instanceof WP_PostgreSQL_Driver ) { + return; + } + + $result = $this->dbh->query( 'SELECT @@SESSION.sql_mode' ); + if ( ! isset( $result[0] ) ) { + return; + } + + $modes_str = $result[0]->{'@@SESSION.sql_mode'}; + if ( empty( $modes_str ) ) { + return; + } + + $modes = explode( ',', $modes_str ); + } + + $modes = array_map( 'strtoupper', (array) $modes ); + + $incompatible_modes = property_exists( $this, 'incompatible_modes' ) ? (array) $this->incompatible_modes : array(); + if ( function_exists( 'apply_filters' ) ) { + $incompatible_modes = (array) apply_filters( 'incompatible_sql_modes', $incompatible_modes ); + } + $incompatible_modes = array_map( 'strtoupper', $incompatible_modes ); + + foreach ( $modes as $i => $mode ) { + if ( in_array( $mode, $incompatible_modes, true ) ) { + unset( $modes[ $i ] ); + } + } + + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + $this->dbh->set_sql_mode( implode( ',', array_values( $modes ) ) ); + } + } + + /** + * Closes the current database connection. + * + * @return bool True when an open connection existed. + */ + public function close() { + if ( ! $this->dbh ) { + $this->ready = false; + return false; + } + + $this->dbh = null; + $this->ready = false; + return true; + } + + /** + * Method to select the database connection. + * + * @param string $db Database name. + * @param resource|null $dbh Optional link identifier. + * @return bool Whether the selected database matches the configured database. + */ + public function select( $db, $dbh = null ) { + if ( null === $dbh ) { + $dbh = $this->dbh; + } + + $this->ready = $dbh instanceof WP_PostgreSQL_Driver && (string) $db === (string) $this->dbname; + return $this->ready; + } + + /** + * Escapes string data without using mysqli. + * + * @param string $data The string to escape. + * @return string Escaped string. + */ + public function _real_escape( $data ) { + if ( ! is_scalar( $data ) ) { + return ''; + } + + $escaped = addslashes( (string) $data ); + return $this->add_placeholder_escape( $escaped ); + } + + /** + * Prints SQL/DB error. + * + * This mirrors wpdb::print_error() without calling mysqli_error() on the + * PostgreSQL driver object. + * + * @global array $EZSQL_ERROR Stores error information of query and error string. + * + * @param string $str The error to display. + * @return void|false Void if the showing of errors is enabled, false if disabled. + */ + public function print_error( $str = '' ) { + global $EZSQL_ERROR; + + if ( ! $str ) { + $str = $this->last_error; + } + + $EZSQL_ERROR[] = array( + 'query' => $this->last_query, + 'error_str' => $str, + ); + + if ( $this->suppress_errors ) { + return false; + } + + $caller = $this->get_caller(); + if ( $caller ) { + // Not translated, as this will only appear in the error log. + $error_str = sprintf( 'WordPress database error %1$s for query %2$s made by %3$s', $str, $this->last_query, $caller ); + } else { + $error_str = sprintf( 'WordPress database error %1$s for query %2$s', $str, $this->last_query ); + } + + error_log( $error_str ); + + if ( ! $this->show_errors ) { + return false; + } + + wp_load_translations_early(); + + if ( is_multisite() ) { + $msg = sprintf( + "%s [%s]\n%s\n", + __( 'WordPress database error:' ), + $str, + $this->last_query + ); + + if ( defined( 'ERRORLOGFILE' ) ) { + error_log( $msg, 3, ERRORLOGFILE ); + } + if ( defined( 'DIEONDBERROR' ) ) { + wp_die( $msg ); + } + } else { + $str = htmlspecialchars( $str, ENT_QUOTES ); + $query = htmlspecialchars( $this->last_query, ENT_QUOTES ); + + printf( + '

%s [%s]
%s

', + __( 'WordPress database error:' ), + $str, + $query + ); + } + } + + /** + * Quotes a PostgreSQL identifier. + * + * @param string $identifier Identifier to escape. + * @return string Escaped identifier. + */ + public function quote_identifier( $identifier ) { + return WP_PostgreSQL_Connection::quote_identifier_value( (string) $identifier ); + } + + /** + * Method to flush cached data. + */ + public function flush() { + $this->last_result = array(); + $this->col_info = null; + $this->last_query = null; + $this->rows_affected = 0; + $this->num_rows = 0; + $this->last_error = ''; + $this->result = null; + } + + /** + * Connects to the PostgreSQL database. + * + * @param bool $allow_bail Whether to bail on connection failure. + * @return bool Whether the connection succeeded. + */ + public function db_connect( $allow_bail = true ) { + $this->is_mysql = true; + + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + $this->ready = true; + return true; + } + + $this->ready = false; + $this->last_error = ''; + $this->init_charset(); + + if ( null === $this->dbname || '' === (string) $this->dbname ) { + $this->last_error = 'The database name was not set. The PostgreSQL backend requires DB_NAME.'; + if ( $allow_bail ) { + $this->bail( $this->last_error, 'db_connect_fail' ); + } + return false; + } + + try { + $connection = new WP_PostgreSQL_Connection( $this->get_connection_options() ); + $this->dbh = new WP_PostgreSQL_Driver( $connection, $this->dbname ); + $GLOBALS['@pdo'] = $connection->get_pdo(); + $this->ready = true; + $this->set_sql_mode(); + return true; + } catch ( Throwable $e ) { + $this->dbh = null; + $this->ready = false; + $this->last_error = $this->format_error_message( $e ); + + if ( $allow_bail ) { + $this->bail( $this->last_error, 'db_connect_fail' ); + } + + return false; + } + } + + /** + * Method to dummy out wpdb::check_connection(). + * + * @param bool $allow_bail Whether to bail on connection failure. + * @return bool Whether the connection is alive. + */ + public function check_connection( $allow_bail = true ) { + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + try { + $this->dbh->get_connection()->query( 'SELECT 1' ); + return true; + } catch ( Throwable $e ) { + $this->last_error = $this->format_error_message( $e ); + $this->dbh = null; + $this->ready = false; + } + } + + return $this->db_connect( $allow_bail ); + } + + /** + * Prepares a SQL query for safe execution. + * + * @param string $query Query statement with placeholders. + * @param array|mixed $args Variables to substitute. + * @param mixed ...$args Further variables to substitute. + * @return string|void Sanitized query string, if there is a query to prepare. + */ + public function prepare( $query, ...$args ) { + $wpdb_allow_unsafe_unquoted_parameters = $this->__get( 'allow_unsafe_unquoted_parameters' ); + if ( $wpdb_allow_unsafe_unquoted_parameters !== $this->allow_unsafe_unquoted_parameters ) { + $property = new ReflectionProperty( 'wpdb', 'allow_unsafe_unquoted_parameters' ); + $property->setAccessible( true ); + $property->setValue( $this, $this->allow_unsafe_unquoted_parameters ); + $property->setAccessible( false ); + } + + if ( null === $query ) { + return parent::prepare( $query, ...$args ); + } + + $identifier_prepare = $this->prepare_identifier_placeholders( $query, $args ); + if ( null === $identifier_prepare ) { + return parent::prepare( $query, ...$args ); + } + + if ( $identifier_prepare['passed_as_array'] ) { + $prepared = parent::prepare( $identifier_prepare['query'], $identifier_prepare['args'] ); + } else { + $prepared = parent::prepare( $identifier_prepare['query'], ...$identifier_prepare['args'] ); + } + + if ( ! is_string( $prepared ) ) { + return $prepared; + } + + foreach ( $identifier_prepare['identifiers'] as $marker => $identifier ) { + $prepared = str_replace( "'" . $marker . "'", $identifier, $prepared ); + } + + return $prepared; + } + + /** + * Rewrites common unnumbered identifier placeholders for PostgreSQL quoting. + * + * Core wpdb::prepare() hardcodes %i as a MySQL-backticked placeholder. This + * adapter supports the common unnumbered %i form by letting core prepare all + * non-identifier values, then replacing exact quoted marker values with + * PostgreSQL-quoted identifiers. Numbered or formatted identifier placeholders + * fall back to core behavior until they can be mapped safely. + * + * @param string $query Query statement with placeholders. + * @param array $args Variables to substitute. + * @return array|null Rewritten prepare data, or null to use parent behavior. + */ + private function prepare_identifier_placeholders( $query, array $args ) { + if ( ! is_string( $query ) || false === strpos( $query, '%i' ) ) { + return null; + } + + $scan = $this->rewrite_identifier_placeholder_query( $query ); + if ( null === $scan ) { + return null; + } + + $passed_as_array = isset( $args[0] ) && is_array( $args[0] ) && 1 === count( $args ); + $prepare_args = $passed_as_array ? $args[0] : $args; + $collision_args = $prepare_args; + $identifiers = array(); + static $marker_id = 0; + + foreach ( $scan['identifier_arg_indexes'] as $index => $arg_index ) { + if ( ! array_key_exists( $arg_index, $prepare_args ) ) { + continue; + } + + do { + ++$marker_id; + $marker = '__wp_pg_identifier_' . spl_object_hash( $this ) . '_' . $marker_id . '_' . $index . '__'; + } while ( $this->prepare_identifier_marker_has_collision( $marker, $query, $collision_args ) ); + + $identifiers[ $marker ] = $this->quote_identifier( $prepare_args[ $arg_index ] ); + $prepare_args[ $arg_index ] = $marker; + } + + return array( + 'query' => $scan['query'], + 'args' => $prepare_args, + 'identifiers' => $identifiers, + 'passed_as_array' => $passed_as_array, + ); + } + + /** + * Checks whether an internal identifier marker appears in caller-controlled SQL. + * + * @param string $marker Marker candidate. + * @param string $query Query statement with placeholders. + * @param array $args Variables to substitute. + * @return bool Whether the marker collides with the query or arguments. + */ + private function prepare_identifier_marker_has_collision( $marker, $query, array $args ) { + if ( false !== strpos( $query, $marker ) ) { + return true; + } + + foreach ( $args as $arg ) { + if ( ! is_scalar( $arg ) ) { + continue; + } + + if ( false !== strpos( (string) $arg, $marker ) ) { + return true; + } + } + + return false; + } + + /** + * Scans a prepare query and rewrites supported identifier placeholders. + * + * @param string $query Query statement with placeholders. + * @return array|null Rewritten query data, or null when unsupported. + */ + private function rewrite_identifier_placeholder_query( string $query ) { + $length = strlen( $query ); + $position = 0; + $copy_from = 0; + $placeholder_index = 0; + $rewritten = ''; + $has_identifier = false; + $has_numbered = false; + $has_escaped_candidate = false; + $identifier_arg_indexes = array(); + + while ( $position < $length ) { + if ( '%' !== $query[ $position ] ) { + ++$position; + continue; + } + + $run_start = $position; + while ( $position < $length && '%' === $query[ $position ] ) { + ++$position; + } + + $run_length = $position - $run_start; + if ( 0 === $run_length % 2 ) { + continue; + } + + $placeholder_start = $position - 1; + $placeholder = $this->read_prepare_placeholder( $query, $position ); + if ( null === $placeholder ) { + continue; + } + + if ( 1 < $run_length ) { + $has_escaped_candidate = true; + } + if ( $placeholder['numbered'] ) { + $has_numbered = true; + } + + if ( 'i' === $placeholder['type'] ) { + if ( '' !== $placeholder['format'] || 1 < $run_length ) { + return null; + } + + $has_identifier = true; + $identifier_arg_indexes[] = $placeholder_index; + $rewritten .= substr( $query, $copy_from, $placeholder_start - $copy_from ) . '%s'; + $copy_from = $placeholder['end']; + } + + $position = $placeholder['end']; + ++$placeholder_index; + } + + if ( ! $has_identifier || $has_numbered || $has_escaped_candidate ) { + return null; + } + + return array( + 'query' => $rewritten . substr( $query, $copy_from ), + 'identifier_arg_indexes' => $identifier_arg_indexes, + ); + } + + /** + * Reads a wpdb::prepare() placeholder after the opening percent sign. + * + * @param string $query Query statement with placeholders. + * @param int $offset Offset immediately after the opening percent sign. + * @return array|null Placeholder metadata, or null when no placeholder matches. + */ + private function read_prepare_placeholder( string $query, int $offset ) { + $length = strlen( $query ); + $format_start = $offset; + $position = $offset; + $numbered = false; + + if ( $position < $length && '1' <= $query[ $position ] && '9' >= $query[ $position ] ) { + $digits_start = $position; + while ( $position < $length && ctype_digit( $query[ $position ] ) ) { + ++$position; + } + + if ( $position < $length && '$' === $query[ $position ] ) { + $numbered = true; + ++$position; + } else { + $position = $digits_start; + } + } + + while ( $position < $length && $this->is_prepare_format_flag( $query[ $position ] ) ) { + ++$position; + } + + if ( $position < $length ) { + if ( ' ' === $query[ $position ] ) { + ++$position; + } elseif ( "'" === $query[ $position ] && $position + 1 < $length ) { + $position += 2; + } + } + + while ( $position < $length && $this->is_prepare_format_flag( $query[ $position ] ) ) { + ++$position; + } + + if ( $position < $length && '.' === $query[ $position ] ) { + ++$position; + if ( $position >= $length || ! ctype_digit( $query[ $position ] ) ) { + return null; + } + + while ( $position < $length && ctype_digit( $query[ $position ] ) ) { + ++$position; + } + } + + if ( $position >= $length || false === strpos( 'sdfFi', $query[ $position ] ) ) { + return null; + } + + return array( + 'format' => substr( $query, $format_start, $position - $format_start ), + 'type' => $query[ $position ], + 'end' => $position + 1, + 'numbered' => $numbered, + ); + } + + /** + * Checks whether a character is allowed in a wpdb prepare format segment. + * + * @param string $char Character to inspect. + * @return bool Whether the character is a format flag, sign, or width digit. + */ + private function is_prepare_format_flag( string $char ): bool { + return ctype_digit( $char ) || '-' === $char || '+' === $char; + } + + /** + * Performs a database query. + * + * @param string $query Database query. + * @return int|bool Boolean true for CREATE, ALTER, TRUNCATE and DROP queries. + * Number of rows affected/selected for all other queries. + * Boolean false on error. + */ + public function query( $query ) { + if ( ! $this->ready ) { + return false; + } + + $query = apply_filters( 'query', $query ); + + if ( ! $query ) { + $this->insert_id = 0; + return false; + } + + $this->flush(); + $this->func_call = "\$db->query(\"$query\")"; + + $check_current_query = true; + if ( property_exists( $this, 'check_current_query' ) ) { + $check_current_query = (bool) $this->check_current_query; + } + + if ( + $check_current_query + && is_string( $query ) + && method_exists( $this, 'check_ascii' ) + && method_exists( $this, 'strip_invalid_text_from_query' ) + && ! $this->check_ascii( $query ) + ) { + $stripped_query = $this->strip_invalid_text_from_query( $query ); + $this->flush(); + if ( $stripped_query !== $query ) { + $this->insert_id = 0; + $this->last_query = $query; + wp_load_translations_early(); + $this->last_error = __( 'WordPress database error: Could not perform query because it contains invalid data.' ); + return false; + } + } + + if ( property_exists( $this, 'check_current_query' ) ) { + $this->check_current_query = true; + } + $this->last_query = $query; + + if ( is_string( $query ) && $this->is_empty_where_update_query( $query ) ) { + $this->last_error = 'PostgreSQL query rejected because UPDATE requires a non-empty WHERE condition.'; + return false; + } + + $last_query_count = count( $this->queries ?? array() ); + $this->_do_query( $query ); + + if ( $this->last_error ) { + if ( $this->insert_id && in_array( $this->get_statement_keyword( $query ), array( 'insert', 'replace' ), true ) ) { + $this->insert_id = 0; + } + + $this->print_error(); + return false; + } + + $statement_type = $this->get_statement_keyword( $query ); + if ( 'create' === $statement_type ) { + $this->store_postgresql_create_table_charset_metadata( $query ); + } elseif ( 'drop' === $statement_type ) { + $this->delete_postgresql_dropped_table_charset_metadata( $query ); + } elseif ( in_array( $statement_type, array( 'alter', 'truncate' ), true ) ) { + $this->clear_all_postgresql_table_charset_cache(); + } + + if ( in_array( $statement_type, array( 'create', 'alter', 'truncate', 'drop' ), true ) ) { + $return_val = true; + } elseif ( in_array( $statement_type, array( 'insert', 'delete', 'update', 'replace' ), true ) ) { + $this->rows_affected = $this->dbh->get_last_return_value(); + + if ( in_array( $statement_type, array( 'insert', 'replace' ), true ) ) { + $this->insert_id = $this->dbh->get_insert_id(); + } + + $return_val = $this->rows_affected; + } else { + $num_rows = 0; + + if ( is_array( $this->result ) ) { + $this->last_result = $this->result; + $num_rows = count( $this->result ); + } + + $this->num_rows = $num_rows; + $return_val = $num_rows; + } + + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES && isset( $this->queries[ $last_query_count ] ) ) { + $this->queries[ $last_query_count ]['postgresql_queries'] = $this->dbh->get_last_postgresql_queries(); + } + + return $return_val; + } + + /** + * Method to return what the database can do. + * + * @param string $db_cap The feature to check. + * @return bool Whether the database feature is supported. + */ + public function has_cap( $db_cap ) { + switch ( strtolower( $db_cap ) ) { + case 'collation': + case 'group_concat': + case 'subqueries': + case 'identifier_placeholders': + case 'utf8mb4': + case 'utf8mb4_520': + return true; + case 'set_charset': + return version_compare( $this->db_version(), '5.0.7', '>=' ); + } + + return false; + } + + /** + * Method to return database version number. + * + * @return string PostgreSQL compatibility version. + */ + public function db_version() { + return '8.0'; + } + + /** + * Returns the server info string. + * + * @return string Server info. + */ + public function db_server_info() { + if ( $this->dbh instanceof WP_PostgreSQL_Driver ) { + return $this->dbh->get_postgresql_version(); + } + return 'PostgreSQL backend pending connection'; + } + + /** + * Internal function to perform the PostgreSQL query call. + * + * @param string $query The query to run. + */ + private function _do_query( $query ) { + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { + $this->timer_start(); + } + + try { + $this->result = $this->dbh->query( $query ); + } catch ( Throwable $e ) { + $this->last_error = $this->format_error_message( $e ); + } + + ++$this->num_queries; + + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { + $this->log_query( + $query, + $this->timer_stop(), + $this->get_caller(), + $this->time_start, + array() + ); + } + } + + /** + * Method to set the class variable $col_info. + * + * This overrides wpdb::load_col_info(), which uses mysqli metadata. + */ + protected function load_col_info() { + if ( $this->col_info ) { + return; + } + $this->col_info = array(); + foreach ( $this->dbh->get_last_column_meta() as $column ) { + $this->col_info[] = (object) array( + 'name' => $column['name'], + 'orgname' => $column['mysqli:orgname'], + 'table' => $column['table'], + 'orgtable' => $column['mysqli:orgtable'], + 'def' => '', + 'db' => $column['mysqli:db'], + 'catalog' => 'def', + 'max_length' => 0, + 'length' => $column['len'], + 'charsetnr' => $column['mysqli:charsetnr'], + 'flags' => $column['mysqli:flags'], + 'type' => $column['mysqli:type'], + 'decimals' => $column['precision'], + ); + } + } + + /** + * Builds PostgreSQL connection options from wpdb constructor state. + * + * @return array + */ + private function get_connection_options() { + $host = $this->dbhost; + $port = null; + + $host_data = $this->parse_db_host( $this->dbhost ); + if ( $host_data ) { + list( $host, $port, $socket ) = $host_data; + + if ( null !== $socket && '' !== $socket ) { + $host = $this->get_postgresql_socket_host( $socket ); + $socket_port = $this->get_postgresql_socket_port( $socket ); + if ( null === $port && null !== $socket_port ) { + $port = $socket_port; + } + } + } + + $options = array( + 'host' => $host, + 'port' => $port, + 'dbname' => $this->dbname, + 'user' => $this->dbuser, + 'password' => $this->dbpassword, + ); + + if ( isset( $GLOBALS['@pdo'] ) && $GLOBALS['@pdo'] instanceof PDO && $this->is_postgresql_pdo( $GLOBALS['@pdo'] ) ) { + $options['pdo'] = $GLOBALS['@pdo']; + } + + return $options; + } + + /** + * Returns the libpq socket directory when DB_HOST includes a socket file. + * + * @param string $socket Socket path or directory. + * @return string PostgreSQL host option. + */ + private function get_postgresql_socket_host( $socket ) { + $socket_file = basename( $socket ); + if ( 0 === strpos( $socket_file, '.s.PGSQL.' ) ) { + return dirname( $socket ); + } + return $socket; + } + + /** + * Returns the PostgreSQL port encoded in a socket file path. + * + * @param string $socket Socket path or directory. + * @return int|null PostgreSQL port. + */ + private function get_postgresql_socket_port( $socket ) { + $prefix = '.s.PGSQL.'; + $socket_file = basename( $socket ); + if ( 0 !== strpos( $socket_file, $prefix ) ) { + return null; + } + + $port = substr( $socket_file, strlen( $prefix ) ); + return ctype_digit( $port ) ? (int) $port : null; + } + + /** + * Checks whether a reusable PDO object is PostgreSQL-backed. + * + * @param PDO $pdo PDO instance. + * @return bool Whether the PDO driver is PostgreSQL. + */ + private function is_postgresql_pdo( PDO $pdo ) { + try { + return 'pgsql' === $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ); + } catch ( Throwable $e ) { + return false; + } + } + + /** + * Returns the first SQL statement keyword. + * + * @param string $query SQL query. + * @return string Lowercase statement keyword, or empty string. + */ + private function get_statement_keyword( $query ) { + $i = $this->get_statement_start_offset( $query ); + $length = strlen( $query ); + + $start = $i; + while ( $i < $length && ( ctype_alpha( $query[ $i ] ) || '_' === $query[ $i ] ) ) { + ++$i; + } + + return strtolower( substr( $query, $start, $i - $start ) ); + } + + /** + * Check whether an UPDATE statement has no WHERE condition. + * + * @param string $query SQL query. + * @return bool Whether this is an UPDATE ending with an empty WHERE clause. + */ + private function is_empty_where_update_query( string $query ): bool { + if ( 'update' !== $this->get_statement_keyword( $query ) ) { + return false; + } + + $statement = substr( $query, $this->get_statement_start_offset( $query ) ); + + return 1 === preg_match( '/^UPDATE\s+.+\s+WHERE\s*$/is', $statement ); + } + + /** + * Return the offset of the first SQL statement keyword after leading comments. + * + * @param string $query SQL query. + * @return int Statement keyword offset. + */ + private function get_statement_start_offset( string $query ): int { + $length = strlen( $query ); + $i = 0; + + while ( $i < $length ) { + $char = $query[ $i ]; + if ( ctype_space( $char ) ) { + ++$i; + continue; + } + + if ( '-' === $char && $i + 1 < $length && '-' === $query[ $i + 1 ] ) { + $i += 2; + while ( $i < $length && "\n" !== $query[ $i ] ) { + ++$i; + } + continue; + } + + if ( '#' === $char ) { + ++$i; + while ( $i < $length && "\n" !== $query[ $i ] ) { + ++$i; + } + continue; + } + + if ( '/' === $char && $i + 1 < $length && '*' === $query[ $i + 1 ] ) { + $i += 2; + while ( $i + 1 < $length && ! ( '*' === $query[ $i ] && '/' === $query[ $i + 1 ] ) ) { + ++$i; + } + $i += 2; + continue; + } + + break; + } + + return $i; + } + + /** + * Format PostgreSQL driver error message. + * + * @param Throwable $e Error. + * @return string Error message. + */ + private function format_error_message( Throwable $e ) { + return $e->getMessage(); + } +} diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php new file mode 100644 index 000000000..1817c36f8 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/db.php @@ -0,0 +1,55 @@ +%1$s

%2$s

', + 'PHP PDO Extension is not loaded', + 'Your PHP installation appears to be missing the PDO extension which is required for this version of WordPress and the type of database you have specified.' + ) + ), + 'PHP PDO Extension is not loaded.' + ); +} + +if ( ! extension_loaded( 'pdo_pgsql' ) ) { + wp_die( + new WP_Error( + 'pdo_driver_not_loaded', + sprintf( + '

%1$s

%2$s

', + 'PDO Driver for PostgreSQL is missing', + 'Your PHP installation appears not to have the PostgreSQL PDO driver loaded. This is required for this version of WordPress and the type of database you have specified.' + ) + ), + 'PDO Driver for PostgreSQL is missing.' + ); +} + +require_once __DIR__ . '/class-wp-postgresql-db.php'; +require_once __DIR__ . '/install-functions.php'; + +$GLOBALS['wpdb'] = new WP_PostgreSQL_DB( + defined( 'DB_USER' ) ? DB_USER : '', + defined( 'DB_PASSWORD' ) ? DB_PASSWORD : '', + defined( 'DB_NAME' ) ? DB_NAME : '', + defined( 'DB_HOST' ) ? DB_HOST : '' +); diff --git a/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php new file mode 100644 index 000000000..341a1008f --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php @@ -0,0 +1,197 @@ +translate_schema( $schema ); + + foreach ( $statements as $statement ) { + $statement_succeeded = false; + + try { + if ( $wpdb->dbh instanceof WP_PostgreSQL_Driver ) { + $wpdb->dbh->get_connection()->query( $statement ); + $statement_succeeded = true; + } else { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Generated from parsed WordPress schema DDL. + $statement_succeeded = false !== $wpdb->query( $statement ); + } + } catch ( Throwable $e ) { + $wpdb->last_error = $e->getMessage(); + } + + if ( ! $statement_succeeded ) { + $message = sprintf( + 'Error occurred while creating PostgreSQL tables or indexes.
Query was: %s
', + var_export( $statement, true ) + ); + $message .= sprintf( 'Error message is: %s', $wpdb->last_error ); + wp_die( $message, 'Database Error!' ); + } + } + + if ( $wpdb->dbh instanceof WP_PostgreSQL_Driver ) { + $wpdb->dbh->store_mysql_schema_metadata( $schema ); + } + + return true; + } +} + +if ( ! function_exists( 'install_network' ) ) { + /** + * Create WordPress multisite global tables for PostgreSQL. + */ + function install_network() { + if ( ! defined( 'WP_INSTALLING_NETWORK' ) ) { + define( 'WP_INSTALLING_NETWORK', true ); + } + + postgresql_make_db_current_silent( 'global' ); + } +} + +if ( ! function_exists( 'wp_install' ) ) { + /** + * Installs the site. + * + * Runs the required functions to set up and populate the database, + * including primary admin user and initial options. + * + * @param string $blog_title Site title. + * @param string $user_name User's username. + * @param string $user_email User's email. + * @param bool $is_public Whether the site is public. + * @param string $deprecated Optional. Not used. + * @param string $user_password Optional. User's chosen password. Default empty (random password). + * @param string $language Optional. Language chosen. Default empty. + * @return array { + * Data for the newly installed site. + * + * @type string $url The URL of the site. + * @type int $user_id The ID of the site owner. + * @type string $password The password of the site owner, if their user account didn't already exist. + * @type string $password_message The explanatory message regarding the password. + * } + */ + function wp_install( $blog_title, $user_name, $user_email, $is_public, $deprecated = '', $user_password = '', $language = '' ) { + if ( ! empty( $deprecated ) ) { + _deprecated_argument( __FUNCTION__, '2.6.0' ); + } + + wp_check_mysql_version(); + wp_cache_flush(); + postgresql_make_db_current_silent(); + + /* + * Ensure update checks are delayed after installation. + * + * This prevents users being presented with a maintenance mode screen + * immediately after installation. + */ + wp_unschedule_hook( 'wp_version_check' ); + wp_unschedule_hook( 'wp_update_plugins' ); + wp_unschedule_hook( 'wp_update_themes' ); + + wp_schedule_event( time() + HOUR_IN_SECONDS, 'twicedaily', 'wp_version_check' ); + wp_schedule_event( time() + ( 1.5 * HOUR_IN_SECONDS ), 'twicedaily', 'wp_update_plugins' ); + wp_schedule_event( time() + ( 2 * HOUR_IN_SECONDS ), 'twicedaily', 'wp_update_themes' ); + + populate_options(); + populate_roles(); + + update_option( 'blogname', $blog_title ); + update_option( 'admin_email', $user_email ); + update_option( 'blog_public', $is_public ); + + // Freshness of site - in the future, this could get more specific about actions taken, perhaps. + update_option( 'fresh_site', 1, false ); + + if ( $language ) { + update_option( 'WPLANG', $language ); + } + + $guessurl = wp_guess_url(); + + update_option( 'siteurl', $guessurl ); + + // If not a public site, don't ping. + if ( ! $is_public ) { + update_option( 'default_pingback_flag', 0 ); + } + + /* + * Create default user. If the user already exists, the user tables are + * being shared among sites. Just set the role in that case. + */ + $user_id = username_exists( $user_name ); + $user_password = trim( $user_password ); + $email_password = false; + $user_created = false; + + if ( ! $user_id && empty( $user_password ) ) { + $user_password = wp_generate_password( 12, false ); + $message = __( 'Note that password carefully! It is a random password that was generated just for you.', 'sqlite-database-integration' ); + $user_id = wp_create_user( $user_name, $user_password, $user_email ); + update_user_meta( $user_id, 'default_password_nag', true ); + $email_password = true; + $user_created = true; + } elseif ( ! $user_id ) { + // Password has been provided. + $message = '' . __( 'Your chosen password.', 'sqlite-database-integration' ) . ''; + $user_id = wp_create_user( $user_name, $user_password, $user_email ); + $user_created = true; + } else { + $message = __( 'User already exists. Password inherited.', 'sqlite-database-integration' ); + } + + $user = new WP_User( $user_id ); + $user->set_role( 'administrator' ); + + if ( $user_created ) { + $user->user_url = $guessurl; + wp_update_user( $user ); + } + + wp_install_defaults( $user_id ); + + wp_install_maybe_enable_pretty_permalinks(); + + flush_rewrite_rules(); + + wp_new_blog_notification( $blog_title, $guessurl, $user_id, ( $email_password ? $user_password : __( 'The password you chose during installation.', 'sqlite-database-integration' ) ) ); + + wp_cache_flush(); + + /** + * Fires after a site is fully installed. + * + * @since 3.9.0 + * + * @param WP_User $user The site owner. + */ + do_action( 'wp_install', $user ); + + return array( + 'url' => $guessurl, + 'user_id' => $user_id, + 'password' => $user_password, + 'password_message' => $message, + ); + } +} diff --git a/wp-setup.sh b/wp-setup.sh index 0e4321010..f6efb1027 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -9,10 +9,140 @@ set -e WP_VERSION="6.7.2" +WP_TEST_DB_BACKEND="${WP_TEST_DB_BACKEND:-${1:-sqlite}}" +WP_TEST_SKIP_WORDPRESS_NPM="${WP_TEST_SKIP_WORDPRESS_NPM:-0}" +WP_RELEASE_REPOSITORY_URL="${WP_RELEASE_REPOSITORY_URL:-https://github.com/WordPress/WordPress.git}" -DIR="$(dirname "$0")" +DIR="$(cd "$(dirname "$0")" && pwd)" WP_DIR="$DIR/wordpress" +wp_setup_acquire_lock() { + if wp_setup_try_acquire_lock; then + return + fi + + if wp_setup_try_acquire_recovery_lock || { wp_setup_recover_stale_recovery_lock && wp_setup_try_acquire_recovery_lock; }; then + if wp_setup_recover_stale_empty_lock && wp_setup_try_acquire_lock; then + wp_setup_release_recovery_lock + return + fi + + wp_setup_release_recovery_lock + fi + + wp_setup_report_active_or_ambiguous_lock +} + +wp_setup_try_acquire_lock() { + if mkdir "$WP_SETUP_LOCK_DIR" 2>/dev/null; then + if ! printf '%s\n' "$WP_SETUP_LOCK_OWNER_TOKEN" > "$WP_SETUP_LOCK_OWNER_FILE"; then + echo "Error: Could not write wp-setup.sh lock owner to '$WP_SETUP_LOCK_OWNER_FILE'." >&2 + exit 1 + fi + + trap wp_setup_release_locks EXIT + return 0 + fi + + return 1 +} + +wp_setup_try_acquire_recovery_lock() { + if mkdir "$WP_SETUP_LOCK_RECOVERY_DIR" 2>/dev/null; then + if ! printf '%s\n' "$WP_SETUP_LOCK_OWNER_TOKEN" > "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE"; then + echo "Error: Could not write wp-setup.sh lock recovery owner to '$WP_SETUP_LOCK_RECOVERY_OWNER_FILE'." >&2 + wp_setup_release_recovery_lock + exit 1 + fi + + trap wp_setup_release_locks EXIT + return 0 + fi + + return 1 +} + +wp_setup_recover_stale_recovery_lock() { + local stale_recovery_lock_dir + local unexpected_recovery_lock_entry + + stale_recovery_lock_dir="$(find "$WP_SETUP_LOCK_RECOVERY_DIR" -maxdepth 0 -type d -mmin "$WP_SETUP_LOCK_STALE_AGE_MINUTES" -print -quit 2>/dev/null || true)" + if [ -z "$stale_recovery_lock_dir" ]; then + return 1 + fi + + unexpected_recovery_lock_entry="$(find "$WP_SETUP_LOCK_RECOVERY_DIR" -mindepth 1 -maxdepth 1 ! -name 'owner' -print -quit 2>/dev/null || true)" + if [ -n "$unexpected_recovery_lock_entry" ]; then + return 1 + fi + + if [ -f "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE" ]; then + rm -f "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE" || return 1 + fi + + rmdir "$WP_SETUP_LOCK_RECOVERY_DIR" 2>/dev/null +} + +wp_setup_recover_stale_empty_lock() { + local stale_empty_lock_dir + + stale_empty_lock_dir="$(find "$WP_SETUP_LOCK_DIR" -maxdepth 0 -type d -empty -mmin "$WP_SETUP_LOCK_STALE_AGE_MINUTES" -print -quit 2>/dev/null || true)" + if [ -z "$stale_empty_lock_dir" ]; then + return 1 + fi + + rmdir "$WP_SETUP_LOCK_DIR" 2>/dev/null +} + +wp_setup_report_active_or_ambiguous_lock() { + echo 'Error: Another wp-setup.sh process is already running for this checkout.' >&2 + echo "If no setup process is running, remove '$WP_SETUP_LOCK_DIR' and rerun this command." >&2 + exit 1 +} + +wp_setup_release_locks() { + wp_setup_release_lock + wp_setup_release_recovery_lock +} + +wp_setup_release_lock() { + if [ -f "$WP_SETUP_LOCK_OWNER_FILE" ] && [ "$(sed -n '1p' "$WP_SETUP_LOCK_OWNER_FILE" 2>/dev/null || true)" = "$WP_SETUP_LOCK_OWNER_TOKEN" ]; then + rm -f "$WP_SETUP_LOCK_OWNER_FILE" + rmdir "$WP_SETUP_LOCK_DIR" 2>/dev/null || true + fi +} + +wp_setup_release_recovery_lock() { + if [ -f "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE" ] && [ "$(sed -n '1p' "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE" 2>/dev/null || true)" = "$WP_SETUP_LOCK_OWNER_TOKEN" ]; then + rm -f "$WP_SETUP_LOCK_RECOVERY_OWNER_FILE" + rmdir "$WP_SETUP_LOCK_RECOVERY_DIR" 2>/dev/null || true + fi +} + +case "$WP_TEST_DB_BACKEND" in + mysql) + WP_TEST_DB_BACKEND="mysql" + ;; + sqlite) + WP_TEST_DB_BACKEND="sqlite" + ;; + postgres|pgsql|postgresql) + WP_TEST_DB_BACKEND="postgresql" + ;; + *) + echo "Error: Unsupported WP_TEST_DB_BACKEND: $WP_TEST_DB_BACKEND" >&2 + exit 1 + ;; +esac + +WP_SETUP_LOCK_DIR="$DIR/.wp-setup.lock" +WP_SETUP_LOCK_OWNER_FILE="$WP_SETUP_LOCK_DIR/owner" +WP_SETUP_LOCK_RECOVERY_DIR="$DIR/.wp-setup.lock.recovery" +WP_SETUP_LOCK_RECOVERY_OWNER_FILE="$WP_SETUP_LOCK_RECOVERY_DIR/owner" +WP_SETUP_LOCK_OWNER_TOKEN="wp-setup.sh:$$:$(date +%s):$RANDOM:$RANDOM" +WP_SETUP_LOCK_STALE_AGE_MINUTES="+5" +wp_setup_acquire_lock + # 1. Ensure that Git is installed. echo "Checking if Git is installed..." if ! command -v git &> /dev/null; then @@ -22,15 +152,36 @@ fi # 2. Clone the WordPress repository, if it doesn't exist. echo "Cleaning up the WordPress repository..." +if [ -d "$WP_DIR" ]; then + UNWRITABLE_WORDPRESS_PATH="$(find "$WP_DIR" -type d ! -writable -print -quit 2>/dev/null || true)" + if [ -n "$UNWRITABLE_WORDPRESS_PATH" ]; then + echo "Fixing ownership for Docker-generated WordPress files..." + if command -v docker > /dev/null; then + docker run --rm -v "$WP_DIR":/workspace --user 0:0 alpine:3.20 chown -R "$(id -u):$(id -g)" /workspace || true + fi + + UNWRITABLE_WORDPRESS_PATH="$(find "$WP_DIR" -type d ! -writable -print -quit 2>/dev/null || true)" + if [ -n "$UNWRITABLE_WORDPRESS_PATH" ]; then + echo 'Error: Cannot clean the WordPress repository because it contains non-writable generated files.' >&2 + echo "First non-writable path: $UNWRITABLE_WORDPRESS_PATH" >&2 + echo "Fix ownership or remove '$WP_DIR' with appropriate permissions, then rerun this command." >&2 + exit 1 + fi + fi +fi rm -rf "$WP_DIR" echo "Cloning the WordPress repository..." git clone --depth 1 --branch "$WP_VERSION" https://github.com/WordPress/wordpress-develop.git "$WP_DIR" -# 3. Add "docker-compose.override.yml" to the WordPress repository. -echo "Adding 'docker-compose.override.yml' to the WordPress repository..." -cat << EOF > "$WP_DIR/docker-compose.override.yml" +if [ "$WP_TEST_DB_BACKEND" = "sqlite" ]; then + # 3. Add "docker-compose.override.yml" to the WordPress repository. + echo "Adding 'docker-compose.override.yml' to the WordPress repository..." + cat << EOF > "$WP_DIR/docker-compose.override.yml" services: wordpress-develop: + environment: + DB_ENGINE: $WP_TEST_DB_BACKEND + DATABASE_ENGINE: $WP_TEST_DB_BACKEND volumes: - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database @@ -38,6 +189,9 @@ services: php: # PHP temporarily pinned to 8.3.10, see: https://github.com/WordPress/wordpress-develop/pull/9602 image: wordpressdevelop/php@sha256:c0ba85936a9d1ac2c98bf3da2d62ceb0e5787a6b11e383630df0c5a5bf2534b5 + environment: + DB_ENGINE: $WP_TEST_DB_BACKEND + DATABASE_ENGINE: $WP_TEST_DB_BACKEND volumes: - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database @@ -45,23 +199,472 @@ services: cli: # PHP temporarily pinned to 8.3.10, see: https://github.com/WordPress/wordpress-develop/pull/9602 image: wordpressdevelop/cli@sha256:85ad7d7a9c3bd9a8775fc83aea7f7dfc0aad25b2bc4f7d740696b28cd2a0ef89 + environment: + DB_ENGINE: $WP_TEST_DB_BACKEND + DATABASE_ENGINE: $WP_TEST_DB_BACKEND + volumes: + - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration + - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database +EOF +elif [ "$WP_TEST_DB_BACKEND" = "postgresql" ]; then + # 3. Add "docker-compose.override.yml" to the WordPress repository. + echo "Adding PostgreSQL 'docker-compose.override.yml' to the WordPress repository..." + cat << 'EOF' > "$WP_DIR/tools/local-env/postgres-init.sql" +CREATE DATABASE wordpress_develop_tests; +EOF + cat << 'EOF' > "$WP_DIR/tools/local-env/Dockerfile.postgresql-php" +FROM wordpressdevelop/php@sha256:c0ba85936a9d1ac2c98bf3da2d62ceb0e5787a6b11e383630df0c5a5bf2534b5 + +USER root + +RUN if command -v git > /dev/null; then \ + git config --system --add safe.directory /var/www \ + || git config --global --add safe.directory /var/www; \ + fi + +RUN if command -v apt-get > /dev/null; then \ + apt-get update \ + && apt-get install -y --no-install-recommends libpq-dev \ + && docker-php-ext-install pdo_pgsql \ + && rm -rf /var/lib/apt/lists/*; \ + elif command -v apk > /dev/null; then \ + apk add --no-cache postgresql-dev \ + && docker-php-ext-install pdo_pgsql; \ + else \ + echo 'Unsupported PHP base image: cannot install pdo_pgsql.' >&2; \ + exit 1; \ + fi +EOF + cat << 'EOF' > "$WP_DIR/tools/local-env/Dockerfile.postgresql-cli" +FROM wordpressdevelop/cli@sha256:85ad7d7a9c3bd9a8775fc83aea7f7dfc0aad25b2bc4f7d740696b28cd2a0ef89 + +USER root + +RUN if command -v git > /dev/null; then \ + git config --system --add safe.directory /var/www \ + || git config --global --add safe.directory /var/www; \ + fi + +RUN if command -v apt-get > /dev/null; then \ + apt-get update \ + && apt-get install -y --no-install-recommends libpq-dev \ + && docker-php-ext-install pdo_pgsql \ + && rm -rf /var/lib/apt/lists/*; \ + elif command -v apk > /dev/null; then \ + apk add --no-cache postgresql-dev \ + && docker-php-ext-install pdo_pgsql; \ + else \ + echo 'Unsupported CLI base image: cannot install pdo_pgsql.' >&2; \ + exit 1; \ + fi +EOF + cat << EOF > "$WP_DIR/docker-compose.override.yml" +services: + wordpress-develop: + environment: + DB_ENGINE: postgresql + DATABASE_ENGINE: postgresql + volumes: + - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration + - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database + depends_on: + mysql: !reset null + php: + condition: service_started + postgres: + condition: service_healthy + + php: + # PHP temporarily pinned to 8.3.10, see: https://github.com/WordPress/wordpress-develop/pull/9602 + image: wordpressdevelop/php-postgresql:local + build: + context: . + dockerfile: tools/local-env/Dockerfile.postgresql-php + environment: + DB_ENGINE: postgresql + DATABASE_ENGINE: postgresql + volumes: + - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration + - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database + + cli: + # PHP temporarily pinned to 8.3.10, see: https://github.com/WordPress/wordpress-develop/pull/9602 + image: wordpressdevelop/cli-postgresql:local + build: + context: . + dockerfile: tools/local-env/Dockerfile.postgresql-cli + environment: + DB_ENGINE: postgresql + DATABASE_ENGINE: postgresql volumes: - ../packages/plugin-sqlite-database-integration:/var/www/src/wp-content/plugins/sqlite-database-integration - ../packages/mysql-on-sqlite/src:/var/www/src/wp-content/plugins/sqlite-database-integration/wp-includes/database + depends_on: + mysql: !reset null + php: + condition: service_started + postgres: + condition: service_healthy + + mysql: !reset null + + postgres: + image: postgres:16-alpine + command: + - postgres + - -c + - fsync=off + - -c + - synchronous_commit=off + - -c + - full_page_writes=off + networks: + - wpdevnet + ports: + - "5432" + environment: + POSTGRES_DB: wordpress_develop + POSTGRES_USER: root + POSTGRES_PASSWORD: password + volumes: + - ./tools/local-env/postgres-init.sql:/docker-entrypoint-initdb.d/postgres-init.sql:ro + - postgres:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U root -d wordpress_develop" ] + timeout: 5s + interval: 5s + retries: 10 + +volumes: + mysql: !reset null + postgres: {} EOF +fi + +if [ "$WP_TEST_DB_BACKEND" != "mysql" ]; then + # 4. Add "db.php" to the "wp-content" directory. + echo "Adding '$WP_TEST_DB_BACKEND' db.php to the 'wp-content' directory..." + rm -f "$WP_DIR"/src/wp-content/db.php + cp "$DIR"/packages/plugin-sqlite-database-integration/db.copy "$WP_DIR"/src/wp-content/db.php + sed -i.bak "s#'{SQLITE_IMPLEMENTATION_FOLDER_PATH}'#__DIR__.'/plugins/sqlite-database-integration'#g" "$WP_DIR"/src/wp-content/db.php + sed -i.bak "s#{SQLITE_PLUGIN}#sqlite-database-integration/load.php#g" "$WP_DIR"/src/wp-content/db.php + sed -i.bak "s#{DATABASE_ENGINE}#$WP_TEST_DB_BACKEND#g" "$WP_DIR"/src/wp-content/db.php + rm -f "$WP_DIR"/src/wp-content/db.php.bak +else + echo "Using WordPress default MySQL test database." + rm -f "$WP_DIR"/src/wp-content/db.php +fi + +if [ "$WP_TEST_DB_BACKEND" = "sqlite" ]; then + # 5. Rewrite helper class WpdbExposedMethodsForTesting to extend WP_SQLite_DB. + echo "Rewriting helper class 'WpdbExposedMethodsForTesting' to extend WP_SQLite_DB..." + sed -i.bak "s#class WpdbExposedMethodsForTesting extends wpdb {#class WpdbExposedMethodsForTesting extends WP_SQLite_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php + rm -f "$WP_DIR"/tests/phpunit/includes/utils.php.bak +elif [ "$WP_TEST_DB_BACKEND" = "postgresql" ]; then + # 5. Rewrite helper class WpdbExposedMethodsForTesting to extend WP_PostgreSQL_DB. + echo "Rewriting helper class 'WpdbExposedMethodsForTesting' to extend WP_PostgreSQL_DB..." + sed -i.bak "s#class WpdbExposedMethodsForTesting extends wpdb {#require_once ABSPATH . 'wp-content/plugins/sqlite-database-integration/wp-includes/postgresql/class-wp-postgresql-db.php';\nclass WpdbExposedMethodsForTesting extends WP_PostgreSQL_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php + rm -f "$WP_DIR"/tests/phpunit/includes/utils.php.bak + + echo "Rewriting WordPress wpdb::prepare identifier expectation tests for PostgreSQL..." + node - "$WP_DIR/tests/phpunit/tests/db.php" << 'NODE' +const fs = require( 'fs' ); + +const file = process.argv[2]; +let contents = fs.readFileSync( file, 'utf8' ); + +const replacements = new Map( [ + [ "'SELECT * FROM `my_table` WHERE `my_field` = 321;'", "'SELECT * FROM \"my_table\" WHERE \"my_field\" = 321;'" ], + [ "'WHERE `evil_``_field` = 321;'", "'WHERE \"evil_`_field\" = 321;'" ], + [ "'WHERE `evil_````````````````_field` = 321;'", "'WHERE \"evil_````````_field\" = 321;'" ], + [ "'WHERE `````evil_field````` = 321;'", "'WHERE \"``evil_field``\" = 321;'" ], + [ "'WHERE `evil\\'field` = 321;'", "'WHERE \"evil\\'field\" = 321;'" ], + [ "'WHERE `evil_\\````_field` = 321;'", "'WHERE \"evil_\\``_field\" = 321;'" ], + [ "\"WHERE `evil_{$placeholder_escape}s_field` = 321;\"", "'WHERE \"evil_%s_field\" = 321;'" ], + [ "'WHERE `value``` = 321;'", "'WHERE \"value`\" = 321;'" ], + [ "'WHERE `` AND evil_value` = 321;'", "'WHERE `\" AND evil_value\" = 321;'" ], + [ "'WHERE `evil_value -- `` = 321;'", "'WHERE \"evil_value -- \"` = 321;'" ], + [ "'WHERE `` AND true -- ``` = 321;'", "'WHERE `\" AND true -- \"`` = 321;'" ], + [ "'WHERE ``` AND true -- `` = 321;'", "'WHERE ``\" AND true -- \"` = 321;'" ], + [ "\"WHERE `field' -- ` LIKE 'field\\' -- ' LIMIT 1\"", "\"WHERE \\\"field' -- \\\" LIKE 'field\\' -- ' LIMIT 1\"" ], +] ); + +for ( const [ from, to ] of replacements ) { + const count = contents.split( from ).length - 1; + if ( 1 !== count ) { + throw new Error( `Expected exactly one Tests_DB PostgreSQL prepare replacement for: ${ from }; found ${ count }.` ); + } + + contents = contents.replace( from, to ); +} + +fs.writeFileSync( file, contents ); +NODE + + echo "Rewriting WordPress local-env install script for PostgreSQL..." + node - "$WP_DIR/tools/local-env/scripts/install.js" << 'NODE' +const fs = require( 'fs' ); + +const file = process.argv[2]; +const replacements = [ + { + from: "local_env_utils.determine_auth_option();", + to: [ + "local_env_utils.determine_auth_option();", + "", + "install_postgresql_test_environment();", + "return;", + ], + }, + { + from: "const { renameSync, readFileSync, writeFileSync } = require( 'fs' );", + to: [ + "const fs = require( 'fs' );", + "const { existsSync, renameSync, readFileSync, writeFileSync } = fs;", + ], + }, + { + from: "wp_cli( 'config create --dbname=wordpress_develop --dbuser=root --dbpass=password --dbhost=mysql --path=/var/www/src --force' );", + to: [ + "wp_cli( 'config create --dbname=wordpress_develop --dbuser=root --dbpass=password --dbhost=postgres --path=/var/www/src --force --skip-check' );", + "wp_cli( 'config set DB_ENGINE postgresql --type=constant' );", + "wp_cli( 'config set DATABASE_ENGINE postgresql --type=constant' );", + ], + }, + { + from: "\t.replace( 'localhost', 'mysql' )", + to: [ + "\t.replace( 'localhost', 'postgres' )", + ], + }, + { + from: "renameSync( 'src/wp-config.php', 'wp-config.php' );", + to: [ + "if ( existsSync( 'src/wp-config.php' ) ) {", + "\trenameSync( 'src/wp-config.php', 'wp-config.php' );", + "}", + "if ( ! existsSync( 'wp-config.php' ) ) {", + "\tthrow new Error( 'wp-config.php was not generated.' );", + "}", + ], + }, + { + from: "\t.concat( \"\\ndefine( 'FS_METHOD', 'direct' );\\n\" );", + to: [ + "\t.concat( \"\\ndefine( 'DB_ENGINE', 'postgresql' );\\n\" )", + "\t.concat( \"define( 'DATABASE_ENGINE', 'postgresql' );\\n\" )", + "\t.concat( \"define( 'FS_METHOD', 'direct' );\\n\" );", + ], + }, + { + from: "\t\twp_cli( 'db reset --yes' );", + to: [ + "\t\t// PostgreSQL databases are created by the compose init SQL.", + ], + }, + { + from: "\t\tconst installCommand = process.env.LOCAL_MULTISITE === 'true' ? 'multisite-install' : 'install';", + to: [ + "\t\t// Skip WP-CLI site installation; the PHPUnit bootstrap owns the test schema.", + ], + }, + { + from: "\t\twp_cli( `core ${ installCommand } --title=\"WordPress Develop\" --admin_user=admin --admin_password=password --admin_email=test@test.com --skip-email --url=http://localhost:${process.env.LOCAL_PORT}` );", + to: [ + "\t\t// The PostgreSQL scaffold cannot use WP-CLI's MySQL-backed install commands.", + ], + }, +]; + +const input = fs.readFileSync( file, 'utf8' ).split( '\n' ); +const containsLines = ( lines, expected ) => { + for ( let index = 0; index <= lines.length - expected.length; index++ ) { + let matches = true; + for ( let offset = 0; offset < expected.length; offset++ ) { + if ( lines[ index + offset ] !== expected[ offset ] ) { + matches = false; + break; + } + } + if ( matches ) { + return true; + } + } + return false; +}; -# 4. Add "db.php" to the "wp-content" directory. -echo "Adding 'db.php' to the 'wp-content' directory..." -rm -f "$WP_DIR"/src/wp-content/db.php -cp "$DIR"/packages/plugin-sqlite-database-integration/db.copy "$WP_DIR"/src/wp-content/db.php -sed -i.bak "s#'{SQLITE_IMPLEMENTATION_FOLDER_PATH}'#__DIR__.'/plugins/sqlite-database-integration'#g" "$WP_DIR"/src/wp-content/db.php -sed -i.bak "s#{SQLITE_PLUGIN}#sqlite-database-integration/load.php#g" "$WP_DIR"/src/wp-content/db.php +const found = new Set(); +const output = []; +for ( const line of input ) { + const replacement = replacements.find( candidate => candidate.from === line ); + if ( replacement ) { + found.add( replacement.from ); + output.push( ...replacement.to ); + } else { + output.push( line ); + } +} -# 5. Rewrite helper class WpdbExposedMethodsForTesting to extend WP_SQLite_DB. -echo "Rewriting helper class 'WpdbExposedMethodsForTesting' to extend WP_SQLite_DB..." -sed -i.bak "s#class WpdbExposedMethodsForTesting extends wpdb {#class WpdbExposedMethodsForTesting extends WP_SQLite_DB {#g" "$WP_DIR"/tests/phpunit/includes/utils.php +for ( const replacement of replacements ) { + if ( ! found.has( replacement.from ) && ! containsLines( input, replacement.to ) ) { + throw new Error( `Expected line not found in ${ file }: ${ replacement.from }` ); + } +} + +let contents = output.join( '\n' ); +let importerExecReplacementCount = 0; +contents = contents.replace( + /exec -T php (rm -rf \$\{testPluginDirectory\}|git clone https:\/\/github\.com\/WordPress\/wordpress-importer\.git \$\{testPluginDirectory\} --depth=1)/g, + ( match, command ) => { + importerExecReplacementCount++; + return `run --rm --workdir /var/www php ${ command }`; + } +); + +if ( 2 !== importerExecReplacementCount ) { + throw new Error( `Expected to rewrite 2 WordPress Importer docker exec commands in ${ file }, rewrote ${ importerExecReplacementCount }.` ); +} + +contents += ` + +function install_postgresql_test_environment() { + write_postgresql_wp_config(); + write_postgresql_wp_tests_config(); + install_postgresql_wp_importer(); +} + +function write_postgresql_wp_config() { + let config = fs.readFileSync( 'wp-config-sample.php', 'utf8' ); + config = config + .replace( "define( 'DB_NAME', 'database_name_here' );", "define( 'DB_NAME', 'wordpress_develop' );" ) + .replace( "define( 'DB_USER', 'username_here' );", "define( 'DB_USER', 'root' );" ) + .replace( "define( 'DB_PASSWORD', 'password_here' );", "define( 'DB_PASSWORD', 'password' );" ) + .replace( "define( 'DB_HOST', 'localhost' );", "define( 'DB_HOST', 'postgres' );" ) + .replace( + "define( 'WP_DEBUG', false );", + "define( 'WP_DEBUG', " + get_postgresql_raw_constant_value( 'LOCAL_WP_DEBUG', 'true' ) + " );" + ) + .replace( + '/* Add any custom values between this line and the "stop editing" line. */', + [ + '/* Add any custom values between this line and the "stop editing" line. */', + '', + "define( 'DB_ENGINE', 'postgresql' );", + "define( 'DATABASE_ENGINE', 'postgresql' );", + "define( 'WP_DEBUG_LOG', " + get_postgresql_raw_constant_value( 'LOCAL_WP_DEBUG_LOG', 'true' ) + " );", + "define( 'WP_DEBUG_DISPLAY', " + get_postgresql_raw_constant_value( 'LOCAL_WP_DEBUG_DISPLAY', 'true' ) + " );", + "define( 'SCRIPT_DEBUG', " + get_postgresql_raw_constant_value( 'LOCAL_SCRIPT_DEBUG', 'true' ) + " );", + "define( 'WP_ENVIRONMENT_TYPE', " + quote_postgresql_php_string( get_postgresql_env_value( 'LOCAL_WP_ENVIRONMENT_TYPE', 'local' ) ) + " );", + "define( 'WP_DEVELOPMENT_MODE', " + quote_postgresql_php_string( get_postgresql_env_value( 'LOCAL_WP_DEVELOPMENT_MODE', 'core' ) ) + " );", + ].join( '\\n' ) + ); + + fs.rmSync( 'src/wp-config.php', { force: true } ); + fs.writeFileSync( 'wp-config.php', config ); +} + +function write_postgresql_wp_tests_config() { + const testConfig = fs.readFileSync( 'wp-tests-config-sample.php', 'utf8' ) + .replace( 'youremptytestdbnamehere', 'wordpress_develop_tests' ) + .replace( 'yourusernamehere', 'root' ) + .replace( 'yourpasswordhere', 'password' ) + .replace( 'localhost', 'postgres' ) + .replace( + "'WP_TESTS_DOMAIN', 'example.org'", + "'WP_TESTS_DOMAIN', " + quote_postgresql_php_string( get_postgresql_env_value( 'LOCAL_WP_TESTS_DOMAIN', 'example.org' ) ) + ) + .concat( "\\ndefine( 'DB_ENGINE', 'postgresql' );\\n" ) + .concat( "define( 'DATABASE_ENGINE', 'postgresql' );\\n" ) + .concat( "define( 'FS_METHOD', 'direct' );\\n" ); + + fs.writeFileSync( 'wp-tests-config.php', testConfig ); +} + +function install_postgresql_wp_importer() { + const testPluginDirectory = 'tests/phpunit/data/plugins/wordpress-importer'; + if ( fs.existsSync( testPluginDirectory + '/wordpress-importer.php' ) ) { + return; + } + + fs.rmSync( testPluginDirectory, { recursive: true, force: true } ); + execSync( 'git clone https://github.com/WordPress/wordpress-importer.git ' + testPluginDirectory + ' --depth=1', { stdio: 'inherit' } ); +} + +function get_postgresql_env_value( name, defaultValue ) { + return process.env[ name ] || defaultValue; +} + +function get_postgresql_raw_constant_value( name, defaultValue ) { + const value = get_postgresql_env_value( name, defaultValue ); + if ( /^(?:true|false|null|[0-9]+)$/i.test( value ) ) { + return value.toLowerCase(); + } + + throw new Error( \`Unsupported raw constant value for \${ name }: \${ value }\` ); +} + +function quote_postgresql_php_string( value ) { + return "'" + String( value ).replace( /\\\\/g, '\\\\\\\\' ).replace( /'/g, "\\\\'" ) + "'"; +} +`; + +fs.writeFileSync( file, contents ); +NODE +fi + +install_wordpress_release_assets() { + local release_asset_path + local release_dir + release_dir="$(mktemp -d "${TMPDIR:-/tmp}/wordpress-release-assets.XXXXXX")" + + echo "Hydrating WordPress release assets for PostgreSQL PHP tests..." + if ! git clone -c advice.detachedHead=false --depth 1 --filter=blob:none --sparse --single-branch --branch "$WP_VERSION" "$WP_RELEASE_REPOSITORY_URL" "$release_dir"; then + rm -rf "$release_dir" + return 1 + fi + + if ! git -C "$release_dir" sparse-checkout set \ + wp-admin/css \ + wp-admin/js \ + wp-includes/assets \ + wp-includes/blocks \ + wp-includes/css \ + wp-includes/js + then + rm -rf "$release_dir" + return 1 + fi + + for release_asset_path in \ + wp-admin/css \ + wp-admin/js \ + wp-includes/assets \ + wp-includes/blocks \ + wp-includes/css \ + wp-includes/js + do + if [ ! -e "$release_dir/$release_asset_path" ]; then + echo "Error: WordPress release asset path is missing: $release_asset_path" >&2 + rm -rf "$release_dir" + return 1 + fi + + rm -rf "$WP_DIR/src/$release_asset_path" + mkdir -p "$(dirname "$WP_DIR/src/$release_asset_path")" + cp -R "$release_dir/$release_asset_path" "$WP_DIR/src/$release_asset_path" + done + + rm -rf "$release_dir" +} # 6. Install dependencies. -echo "Installing dependencies..." -npm --prefix "$WP_DIR" install -npm --prefix "$WP_DIR" run build:dev +if [ "$WP_TEST_DB_BACKEND" = "postgresql" ] && [ "$WP_TEST_SKIP_WORDPRESS_NPM" = "1" ]; then + echo "Installing WordPress npm dependencies without building assets for PostgreSQL PHP tests..." + npm --prefix "$WP_DIR" install --include=dev --ignore-scripts --no-audit --no-fund + echo "Hydrating WordPress release assets and skipping JavaScript build for PostgreSQL PHP tests..." + install_wordpress_release_assets +else + echo "Installing dependencies..." + npm --prefix "$WP_DIR" install + npm --prefix "$WP_DIR" run build:dev +fi