diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 7be965a7c..f6451509a 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -13,6 +13,7 @@ permissions: {} jobs: test: name: End-to-end Tests + if: github.event_name != 'pull_request' || github.head_ref != 'codex/postgresql-backend' runs-on: ubuntu-latest timeout-minutes: 20 permissions: diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 5126e2ea3..0c967e29b 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -30,9 +30,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 +43,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 +181,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..48a0a57a4 100644 --- a/.github/workflows/wp-tests-end-to-end.yml +++ b/.github/workflows/wp-tests-end-to-end.yml @@ -13,6 +13,7 @@ permissions: {} jobs: test: name: WordPress End-to-end Tests + if: github.event_name != 'pull_request' || github.head_ref != 'codex/postgresql-backend' runs-on: ubuntu-latest timeout-minutes: 20 permissions: 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..b06884adf 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,823 @@ 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..d502d1d56 100644 --- a/.github/workflows/wp-tests-phpunit.yml +++ b/.github/workflows/wp-tests-phpunit.yml @@ -6,13 +6,17 @@ on: - main 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 +35,206 @@ 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 + continue-on-error: true + 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/composer.json b/composer.json index 689b44ed7..4c95f6f89 100644 --- a/composer.json +++ b/composer.json @@ -54,15 +54,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", 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/postgresql/class-wp-postgresql-connection.php b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php new file mode 100644 index 000000000..93e871994 --- /dev/null +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-connection.php @@ -0,0 +1,529 @@ +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..18de14753 --- /dev/null +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-create-table-translator.php @@ -0,0 +1,938 @@ + '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; + + /** + * 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' ); + $columns = array(); + $constraints = array(); + $indexes = array(); + + 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 ); + continue; + } + + $table_constraint = $table_element->get_first_child_node( 'tableConstraintDef' ); + if ( ! $table_constraint ) { + throw new InvalidArgumentException( 'Unsupported CREATE TABLE element.' ); + } + + if ( $table_constraint->has_child_token( WP_MySQL_Lexer::PRIMARY_SYMBOL ) ) { + $constraints[] = 'PRIMARY KEY (' . implode( ', ', $this->quote_key_parts( $table_constraint ) ) . ')'; + continue; + } + + $indexes[] = $this->translate_secondary_index( $table_constraint, $table_name, $if_not_exists ); + } + + $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 { + $lexer = new WP_MySQL_Lexer( $sql, 80038, array() ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer + ? $lexer->native_token_stream() + : $lexer->remaining_tokens(); + + return new WP_MySQL_Parser( $this->get_mysql_grammar(), $tokens ); + } + + /** + * 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. + * @return string PostgreSQL column definition. + */ + private function translate_column_definition( WP_Parser_Node $column_definition ): 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.' ); + } + + $is_auto_increment = null !== $field_definition->get_first_descendant_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ); + $parts = array( + $this->quote_identifier( $name ), + $this->translate_data_type( $field_definition->get_first_child_node( 'dataType' ), $is_auto_increment ), + ); + + foreach ( $field_definition->get_child_nodes( 'columnAttribute' ) as $attribute ) { + if ( $attribute->has_child_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ) ) { + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::NOT_SYMBOL ) ) { + $parts[] = 'NOT NULL'; + continue; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { + $parts[] = $this->translate_default_attribute( $attribute ); + } + } + + return implode( ' ', $parts ); + } + + /** + * 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_token = $data_type->get_first_child_token(); + if ( ! $type_token ) { + throw new InvalidArgumentException( 'Column data type is empty.' ); + } + + $type = strtolower( $type_token->get_value() ); + if ( 'bigint' === $type ) { + $postgresql_type = 'bigint'; + } elseif ( in_array( $type, array( 'int', 'integer', 'mediumint', 'smallint', 'tinyint' ), true ) ) { + $postgresql_type = 'integer'; + } elseif ( in_array( $type, array( 'varchar', 'char' ), true ) ) { + $length = $this->get_field_length( $data_type ); + $postgresql_type = $length ? sprintf( '%s(%d)', $type, $length ) : $type; + } elseif ( in_array( $type, array( 'tinytext', 'text', 'mediumtext', 'longtext', 'datetime', 'timestamp', 'date', 'time', 'year', 'geometrycollection', 'geomcollection' ), 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' ), true ) ) { + $precision_fragment = $this->get_numeric_precision_fragment( $data_type ); + $postgresql_type = '' === $precision_fragment ? 'double precision' : 'numeric' . $precision_fragment; + } elseif ( in_array( $type, array( 'decimal', '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 simple DEFAULT attribute. + * + * @param WP_Parser_Node $attribute Column attribute node. + * @return string PostgreSQL DEFAULT clause. + */ + private function translate_default_attribute( WP_Parser_Node $attribute ): string { + $value_token = $attribute->get_first_descendant_token( WP_MySQL_Lexer::NULL_SYMBOL ); + if ( $value_token ) { + return 'DEFAULT NULL'; + } + + foreach ( $attribute->get_descendant_tokens() as $token ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + continue; + } + + 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 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. + * @return string PostgreSQL CREATE INDEX statement. + */ + private function translate_secondary_index( WP_Parser_Node $table_constraint, string $table_name, bool $if_not_exists ): string { + $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 ) ) + ); + } + + /** + * Get quoted key parts from a MySQL key constraint. + * + * @param WP_Parser_Node $table_constraint Table constraint node. + * @return string[] Quoted PostgreSQL column names. + */ + private function quote_key_parts( WP_Parser_Node $table_constraint ): array { + return array_map( array( $this, 'quote_identifier' ), $this->get_key_parts( $table_constraint ) ); + } + + /** + * Get key part column names. + * + * Prefix lengths, e.g. meta_key(191), are deliberately ignored for the + * initial WordPress install slice. + * + * @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 ); + $columns = array(); + $column_types = array(); + $indexes = array(); + $ordinal = 1; + $index_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, + '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 ); + + 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, + 'ordinal' => $ordinal, + ); + + if ( $include_indexes ) { + $column_metadata['nullable'] = $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 && $field_definition->get_first_descendant_token( WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL ) ? 'auto_increment' : ''; + } + + $columns[] = $column_metadata; + $column_types[ strtolower( $name ) ] = $column_type; + ++$ordinal; + continue; + } + + if ( $include_indexes ) { + $table_constraint = $table_element->get_first_child_node( 'tableConstraintDef' ); + if ( $table_constraint ) { + $indexes[] = $this->extract_index_metadata( $table_constraint, $index_ordinal, $column_types ); + ++$index_ordinal; + } + } + } + + $metadata = array( + 'table_name' => $table_name, + 'columns' => $columns, + ); + + if ( $include_indexes ) { + $metadata['indexes'] = $indexes; + } + + return $metadata; + } + + /** + * 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 ); + $key_parts = $this->get_key_part_metadata( $table_constraint, $column_types ); + $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 ), + 'columns' => $key_parts, + ); + } + + /** + * 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. + * @return string Index type. + */ + private function get_mysql_index_type_metadata( WP_Parser_Node $table_constraint ): 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 ) ) { + return 'SPATIAL'; + } + + return 'BTREE'; + } + + /** + * 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. + * @return array[] Key part metadata. + */ + private function get_key_part_metadata( WP_Parser_Node $table_constraint, array $column_types ): 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 && ! $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, + 'sub_part' => $sub_part, + ); + } + + if ( empty( $key_parts ) ) { + throw new InvalidArgumentException( 'Index definition does not contain any key parts.' ); + } + + return $key_parts; + } + + /** + * 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; + } + + if ( $attribute->has_child_token( WP_MySQL_Lexer::NULL_SYMBOL ) ) { + return null; + } + + foreach ( $attribute->get_descendant_tokens() as $token ) { + if ( WP_MySQL_Lexer::DEFAULT_SYMBOL === $token->id ) { + continue; + } + + return $token->get_value(); + } + } + + return 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; + + $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 ) { + $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_token = $data_type->get_first_child_token(); + if ( ! $type_token ) { + throw new InvalidArgumentException( 'Column data type is empty.' ); + } + + $type = strtolower( $type_token->get_value() ); + if ( 'integer' === $type ) { + $type = 'int'; + } + + $numeric_precision = $this->get_numeric_precision_fragment( $data_type ); + if ( '' !== $numeric_precision && in_array( $type, array( 'decimal', 'double', 'float', 'numeric' ), true ) ) { + $type .= $numeric_precision; + } + + $length = $this->get_field_length( $data_type ); + if ( null !== $length && in_array( $type, array( 'bigint', 'binary', '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 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 + ); + } + + /** + * 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 ) : ''; + } + + /** + * 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..6337d050f --- /dev/null +++ b/packages/mysql-on-sqlite/src/postgresql/class-wp-postgresql-driver.php @@ -0,0 +1,22093 @@ + + */ + 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(); + + /** + * Most recently tokenized MySQL query. + * + * @var string|null + */ + private $mysql_token_cache_query = 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; + + /** + * MySQL-compatible session SQL mode state. + * + * @var string + */ + private $sql_mode = 'NO_ENGINE_SUBSTITUTION'; + + /** + * 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 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->sql_mode = $sql_mode; + unset( $this->mysql_session_variable_values['sql_mode'] ); + } + + /** + * Get the emulated MySQL session SQL mode. + * + * @return string Comma-separated SQL mode string. + */ + public function get_sql_mode(): string { + return $this->sql_mode; + } + + /** + * 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->reset_query_state(); + $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_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_grants_query = $this->get_show_grants_query( $query ); + if ( null !== $show_grants_query ) { + return $this->execute_show_grants_query( $fetch_mode, ...$fetch_mode_args ); + } + + if ( $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 ); + } + + $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; + } + + if ( $this->is_create_table_query( $query ) ) { + $this->validate_mysql_create_table_target_database( $query ); + + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $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 ); + } + return $result; + } + + $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; + } + + $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 ( 'drop_index' === ( $alter_query['metadata']['operation'] ?? '' ) ) { + $this->last_result = 0; + return $this->last_result; + } + return $result; + } + + $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_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; + } + + $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 = false; + $dml_identity_repair_query = null; + + $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; + } + + $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; + } + + $upsert_query = $this->translate_mysql_on_duplicate_key_update_query( $query ); + if ( null !== $upsert_query ) { + $query = $upsert_query['sql']; + $dml_identity_repair_query = $upsert_query; + $translated_for_postgresql = true; + } + + $replace_return_value = null; + $replace_query = $this->translate_simple_mysql_replace_query( $query ); + if ( null !== $replace_query ) { + if ( null !== $replace_query['conflict_column'] ) { + $replace_conflict_exists = $this->replace_conflict_exists( + $replace_query['table_name'], + $replace_query['conflict_column'], + $replace_query['conflict_value'] + ); + $replace_return_value = $replace_conflict_exists ? 2 : 1; + $replace_query['inserted_new_row'] = ! $replace_conflict_exists; + } + $query = $replace_query['sql']; + $dml_identity_repair_query = $replace_query; + $translated_for_postgresql = true; + } + + $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; + } + + $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; + } + + $translated_query = $this->translate_simple_mysql_update_query( $query ); + if ( null !== $translated_query ) { + $query = $translated_query; + $translated_for_postgresql = true; + } + + $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']; + } 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']; + } 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.' ); + } + + $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 !== $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; + } + + /** + * 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 ); + } + + /** + * Get the PostgreSQL execution SQL for a MySQL SELECT query. + * + * @param string $query MySQL SELECT query. + * @return array{sql: string, translated: bool} 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} 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_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, + ); + } + + $translated_query = $this->translate_simple_mysql_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} $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 ) { + foreach ( $statements as $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 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->last_column_meta = array(); + 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; + } + + $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; + $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 ( ! 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}|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 ) { + $name = $this->parse_mysql_system_variable_reference( $tokens, $position ); + if ( null === $name || ! $this->is_supported_mysql_system_variable( $name ) ) { + return null; + } + + return array( + 'type' => 'system', + 'name' => $name, + ); + } + + $name = strtolower( $tokens[ $position ]->get_value() ); + if ( ! $this->is_supported_mysql_system_variable( $name ) ) { + return null; + } + + ++$position; + return array( + 'type' => 'system', + 'name' => $name, + ); + } + + /** + * 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}|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 ); + if ( null === $value ) { + return null; + } + } + + return array( + 'target_type' => $target['type'], + 'name' => $target['name'], + 'value' => $value, + ); + } + + /** + * 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; + } + + 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 + ); + } + + return $this->get_mysql_user_variable_value( $user_variable_name ); + } + + if ( WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL === $tokens[ $position ]->id ) { + $system_variable_name = $this->parse_mysql_system_variable_reference( $tokens, $position ); + return null === $system_variable_name ? null : $this->get_mysql_system_variable_value( $system_variable_name ); + } + + $value = $this->get_mysql_set_literal_token_value( $tokens[ $position ] ); + if ( null === $value ) { + return null; + } + + ++$position; + return $value; + } + + /** + * 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::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 ) { + if ( isset( $tokens[ $position + 1 ] ) && WP_MySQL_Lexer::TO_SYMBOL === $tokens[ $position + 1 ]->id ) { + throw new InvalidArgumentException( 'Unsupported SAVEPOINT statement.' ); + } + + return null; + } + + 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, + 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, + 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->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, + sub_part TEXT, + nullable TEXT NOT NULL, + PRIMARY KEY (table_schema, table_name, key_name, seq_in_index) + )', + $this->connection->quote_identifier( self::MYSQL_INDEX_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(); + } + + /** + * 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, + ), + 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 { + if ( $this->mysql_column_metadata_column_exists( $column_name ) ) { + return; + } + + $this->connection->query( + sprintf( + 'ALTER TABLE %s ADD COLUMN %s %s', + $this->connection->quote_identifier( self::MYSQL_COLUMN_METADATA_TABLE ), + $this->connection->quote_identifier( $column_name ), + $column_type + ) + ); + } + + /** + * Check whether a MySQL column metadata field exists. + * + * @param string $column_name Column name. + * @return bool Whether the column exists. + */ + private function mysql_column_metadata_column_exists( 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( self::MYSQL_COLUMN_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( self::MYSQL_COLUMN_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() )->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 ); + + $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 ); + } + } + } + + /** + * 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_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->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 = 'public'; + $table_name = $metadata['table']; + + 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 ); + foreach ( $metadata['indexes'] ?? array() as $index ) { + $this->insert_mysql_index_metadata( $table_schema, $table_name, $index, $column_nullable ); + } + return; + } + + if ( 'change_column' === $metadata['operation'] ) { + $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 ); + $this->rename_mysql_index_column_metadata( + $table_schema, + $table_name, + $metadata['old_column'], + $column['name'] + ); + 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 ( '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 ( '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 ); + } + } + + /** + * 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 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) + 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'] ?? '', + ) + ); + $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 ); + } + + /** + * 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, sub_part, nullable) + 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'], + null === $column['sub_part'] ? null : (string) $column['sub_part'], + 'NO' === $is_nullable ? '' : 'YES', + ) + ); + } + + $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 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 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 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; + } + + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::FULLTEXT_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::SPATIAL_SYMBOL === $tokens[ $position ]->id + ) + ) { + throw new InvalidArgumentException( 'Unsupported CREATE INDEX statement.' ); + } + + 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.' ); + } + + $position = $key_list_end; + if ( ! $this->consume_mysql_supported_create_index_options( $tokens, $position, $statement_end ) ) { + 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 ); + + return array( + 'statements' => array( + 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'] ) + ), + ), + 'metadata' => array( + 'schema' => $table_schema, + 'table' => $table_name, + 'index' => array( + 'name' => $metadata_index_name, + 'non_unique' => $is_unique ? '0' : '1', + 'index_type' => 'BTREE', + 'columns' => $key_parts['metadata'], + ), + ), + ); + } + + /** + * 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' ) ) { + 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; + } + + if ( $is_unique ) { + 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->connection->quote_identifier( $column_name ) . $direction; + $metadata_parts[] = array( + 'column_name' => $column_name, + 'seq_in_index' => count( $metadata_parts ) + 1, + '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; + } + + /** + * 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. + * @return bool Whether all remaining options are supported. + */ + private function consume_mysql_supported_create_index_options( array $tokens, int &$position, int $statement_end ): bool { + 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 ] ) + ) { + $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 ) { + ++$position; + continue; + } + + return $position === $statement_end; + } + + return true; + } + + /** + * 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 ) ); + } + + /** + * 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 { + if ( ! preg_match( '/^\s*ALTER\s+TABLE\s+(?:`(?P[^`]+)`|(?P[A-Za-z0-9_]+))\s+(?P.+?)\s*;?\s*$/is', $query, $matches ) ) { + return null; + } + + $table_name = '' !== ( $matches['table_quoted'] ?? '' ) ? $matches['table_quoted'] : $matches['table']; + $clause = $this->trim_mysql_statement_fragment( $matches['clause'] ); + + if ( preg_match( '/^CHANGE\s+(?:COLUMN\s+)?(?:`(?P[^`]+)`|(?P[A-Za-z0-9_]+))\s+(?P.+)$/is', $clause, $change_matches ) ) { + $old_column = '' !== ( $change_matches['old_quoted'] ?? '' ) ? $change_matches['old_quoted'] : $change_matches['old']; + $column = $this->translate_mysql_column_definition_fragment( $change_matches['definition'] ); + if ( null === $column ) { + return null; + } + + $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( + 'public', + $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 ) + ); + } + } + + return array( + 'statements' => $statements, + 'metadata' => array( + 'operation' => 'change_column', + 'table' => $table_name, + 'old_column' => $old_column, + 'column' => $column['metadata'], + ), + ); + } + + $modify_query = $this->translate_mysql_dbdelta_modify_column_alter_query( $table_name, $clause ); + if ( null !== $modify_query ) { + return $modify_query; + } + + if ( preg_match( '/^ADD\s+COLUMN\s+(?P.+)$/is', $clause, $add_column_matches ) ) { + $column = $this->translate_mysql_column_definition_fragment( $add_column_matches['definition'] ); + if ( null === $column ) { + return null; + } + + return array( + 'statements' => array( + sprintf( + 'ALTER TABLE %s ADD COLUMN %s', + $this->connection->quote_identifier( $table_name ), + $column['sql'] + ), + ), + 'metadata' => array( + 'operation' => 'add_column', + 'table' => $table_name, + 'column' => $column['metadata'], + ), + ); + } + + if ( preg_match( '/^ADD\s+(?P(?:PRIMARY\s+KEY|(?:UNIQUE\s+)?(?:FULLTEXT\s+|SPATIAL\s+)?(?:KEY|INDEX))\b.+)$/is', $clause, $add_index_matches ) ) { + $index = $this->translate_mysql_index_definition_fragment( $table_name, $add_index_matches['definition'] ); + if ( null === $index ) { + return null; + } + + return array( + 'statements' => $index['statements'], + 'metadata' => array( + 'operation' => 'add_index', + 'table' => $table_name, + 'index' => $index['metadata'], + ), + ); + } + + if ( preg_match( '/^DROP\s+PRIMARY\s+KEY$/is', $clause ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + if ( preg_match( '/^DROP\s+(?:INDEX|KEY)\s+(?:`(?P[^`]+)`|(?P[A-Za-z0-9_]+))$/is', $clause, $drop_index_matches ) ) { + $index_name = '' !== ( $drop_index_matches['index_quoted'] ?? '' ) ? $drop_index_matches['index_quoted'] : $drop_index_matches['index']; + $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; + } + + if ( preg_match( '/^DROP\s+(?:INDEX|KEY)\b/is', $clause ) ) { + throw new InvalidArgumentException( 'Unsupported ALTER TABLE statement.' ); + } + + if ( preg_match( '/^ALTER\s+COLUMN\s+(?:`(?P[^`]+)`|(?P[A-Za-z0-9_]+))\s+SET\s+DEFAULT\s+(?P.+)$/is', $clause, $default_matches ) ) { + $column_name = '' !== ( $default_matches['column_quoted'] ?? '' ) ? $default_matches['column_quoted'] : $default_matches['column']; + $default = $this->translate_mysql_default_fragment( $default_matches['default'] ); + 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', + 'table' => $table_name, + 'column' => $column_name, + 'default' => $default['metadata'], + ), + ); + } + + return null; + } + + /** + * 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'] ); + $column = $this->translate_mysql_column_definition_fragment( $definition ); + 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 ) + ); + } + } + + $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_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'] ?? '' ) ); + } + + /** + * 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; + } + + 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::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 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_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 || $position !== $statement_end ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + + if ( 'PRIMARY' === strtoupper( $index_name ) ) { + throw new InvalidArgumentException( 'Unsupported DROP INDEX statement.' ); + } + + return $this->get_mysql_drop_index_translation( $table_reference, $index_name, 'DROP INDEX' ); + } + + /** + * 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']; + + return array( + 'statements' => array( + 'DROP INDEX ' . $this->get_postgresql_schema_identifier( $table_schema, $table_name . '__' . $index_name ), + ), + 'metadata' => array( + 'schema' => $table_schema, + 'table' => $table_name, + 'index' => $index_name, + ), + ); + } + + /** + * 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. + * @return array{sql: string, metadata: array}|null Translated column, or null when unsupported. + */ + private function translate_mysql_column_definition_fragment( string $definition ): ?array { + $definition = $this->trim_mysql_statement_fragment( $definition ); + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $wrapper = 'CREATE TABLE __wp_dbdelta_column (' . $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], + ); + } + + /** + * 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. + * @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 ): ?array { + $definition = $this->trim_mysql_statement_fragment( $definition ); + $translator = new WP_PostgreSQL_Create_Table_Translator(); + $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]; + $columns = array(); + foreach ( $index['columns'] as $column ) { + $columns[] = $this->connection->quote_identifier( $column['column_name'] ); + } + + if ( 'PRIMARY' === strtoupper( $index['name'] ) ) { + $statement = sprintf( + 'ALTER TABLE %s ADD PRIMARY KEY (%s)', + $this->connection->quote_identifier( $table_name ), + implode( ', ', $columns ) + ); + } 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 ) + ); + } + + return array( + 'statements' => array( $statement ), + '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 = $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 + ); + + if ( ! preg_match( '/\sDEFAULT\s+(?P.+)$/is', $definition_line, $matches ) ) { + return null; + } + + return trim( $matches['default'] ); + } + + /** + * 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 ); + 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; + } + + /** + * 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->last_column_meta = array(); + 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->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.' ); + } + + return array( + 'schema' => $this->get_mysql_writable_table_backend_schema( $table_reference, 'DESCRIBE' ), + 'table' => $table_reference['table'], + ); + } + + /** + * 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{column: string, value: string}|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' ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + + $schema_name = '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_exact_where_filter( $tokens, $position, $allowed_columns ); + if ( null === $where ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLES statement.' ); + } + + $position += 4; + } + + 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{filter_type: string, filter_pattern: string|null, filter_threshold: string|null}|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 ) ) { + throw new InvalidArgumentException( 'Unsupported SHOW TABLE STATUS statement.' ); + } + + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return array( + 'filter_type' => 'all', + '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( + 'filter_type' => 'like', + '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 + 1 ); + if ( null !== $filter ) { + 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 First WHERE predicate token position. + * @return array{filter_type: string, filter_pattern: string|null, filter_threshold: string|null}|null Parsed filter, or null when unsupported. + */ + private function get_show_table_status_where_filter( array $tokens, int $position ): ?array { + if ( ! isset( $tokens[ $position ] ) ) { + return null; + } + + $column = $this->get_mysql_show_output_column_name( + $tokens[ $position ], + array( 'auto_increment' => 'Auto_increment' ) + ); + if ( 'Auto_increment' !== $column || ! isset( $tokens[ $position + 1 ] ) ) { + return null; + } + + if ( + WP_MySQL_Lexer::GREATER_THAN_OPERATOR === $tokens[ $position + 1 ]->id + && isset( $tokens[ $position + 2 ] ) + && $this->is_mysql_unsigned_integer_token( $tokens[ $position + 2 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 3 ) + ) { + return array( + 'filter_type' => 'auto_increment_gt', + 'filter_pattern' => null, + 'filter_threshold' => $tokens[ $position + 2 ]->get_value(), + ); + } + + if ( + WP_MySQL_Lexer::IS_SYMBOL === $tokens[ $position + 1 ]->id + && isset( $tokens[ $position + 2 ] ) + && WP_MySQL_Lexer::NULL_SYMBOL === $tokens[ $position + 2 ]->id + && $this->is_at_mysql_query_end( $tokens, $position + 3 ) + ) { + return array( + 'filter_type' => 'auto_increment_is_null', + 'filter_pattern' => null, + 'filter_threshold' => null, + ); + } + + return null; + } + + /** + * 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.' ); + } + + if ( null !== $table_reference['schema'] ) { + if ( 0 === strcasecmp( $table_reference['schema'], 'information_schema' ) ) { + throw new InvalidArgumentException( 'Unsupported information_schema query.' ); + } + + if ( + 0 !== strcasecmp( $table_reference['schema'], $this->main_db_name ) + && 0 !== strcasecmp( $table_reference['schema'], 'public' ) + ) { + throw new InvalidArgumentException( 'Unsupported SHOW CREATE TABLE statement.' ); + } + } + + return array( + 'schema' => 'public', + '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_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_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}|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; + 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::VARIABLES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( $this->is_at_mysql_query_end( $tokens, $position ) ) { + return array( + 'type' => 'all', + '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', + 'pattern' => strtolower( $tokens[ $position + 1 ]->get_value() ), + ); + } + + if ( + isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id + && 'Variable_name' === $this->get_mysql_show_output_column_name( + $tokens[ $position + 1 ], + array( 'variable_name' => 'Variable_name' ) + ) + && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position + 2 ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 3 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 4 ) + ) { + return array( + 'type' => 'exact', + 'pattern' => strtolower( $tokens[ $position + 3 ]->get_value() ), + ); + } + + throw new InvalidArgumentException( 'Unsupported SHOW VARIABLES statement.' ); + } + + /** + * 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 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(); + } + + if ( + ! isset( $tokens[2], $tokens[3] ) + || WP_MySQL_Lexer::FOR_SYMBOL !== $tokens[2]->id + || WP_MySQL_Lexer::CURRENT_USER_SYMBOL !== $tokens[3]->id + ) { + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + + if ( $this->is_at_mysql_query_end( $tokens, 4 ) ) { + return array(); + } + + if ( + isset( $tokens[4], $tokens[5] ) + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $tokens[4]->id + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $tokens[5]->id + && $this->is_at_mysql_query_end( $tokens, 6 ) + ) { + return array(); + } + + throw new InvalidArgumentException( 'Unsupported SHOW GRANTS statement.' ); + } + + /** + * 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}|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 ], $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id + && WP_MySQL_Lexer::EQUAL_OPERATOR === $tokens[ $position + 2 ]->id + && $this->is_mysql_quoted_text_token( $tokens[ $position + 3 ] ) + && $this->is_at_mysql_query_end( $tokens, $position + 4 ) + ) { + $column = $this->get_mysql_show_output_column_name( $tokens[ $position + 1 ], $allowed_columns ); + if ( null === $column ) { + return null; + } + + return array( + 'type' => 'exact', + 'column' => $column, + 'pattern' => $tokens[ $position + 3 ]->get_value(), + ); + } + + return null; + } + + /** + * Parse a safe exact 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. + * @return array{column: string, value: string}|null Parsed filter, or null when unsupported. + */ + private function get_mysql_show_exact_where_filter( array $tokens, int $position, array $allowed_columns ): ?array { + if ( + ! isset( $tokens[ $position ], $tokens[ $position + 1 ], $tokens[ $position + 2 ], $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[ $position ]->id + || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position + 2 ]->id + || ! $this->is_mysql_quoted_text_token( $tokens[ $position + 3 ] ) + || ! $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; + } + + return array( + 'column' => $column, + 'value' => $tokens[ $position + 3 ]->get_value(), + ); + } + + /** + * 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::DATABASE_SYMBOL, + WP_MySQL_Lexer::DEFAULT_SYMBOL, + WP_MySQL_Lexer::KEY_SYMBOL, + WP_MySQL_Lexer::NULL_SYMBOL, + WP_MySQL_Lexer::PRIVILEGES_SYMBOL, + WP_MySQL_Lexer::TABLE_SYMBOL, + WP_MySQL_Lexer::TYPE_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{column: string, value: string}|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 = $table_reference['schema'] ?? 'public'; + $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 + ) + ) { + if ( null !== $table_reference['schema'] ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $schema_name = $this->get_mysql_identifier_token_value( $tokens[ $position + 1 ] ?? null ); + if ( null === $schema_name ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $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_exact_where_filter( $tokens, $position, $allowed_columns ); + if ( null === $where ) { + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + $position += 4; + } + + 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_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_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 are recognized as part of this + * family so unsupported forms fail before raw backend execution. + * + * @param string $query MySQL query. + * @return array{schema: string, table: string, where: array{column: string, value: string}|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; + $has_extended_modifier = false; + if ( WP_MySQL_Lexer::EXTENDED_SYMBOL === $tokens[ $position ]->id ) { + $has_extended_modifier = true; + ++$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; + } + + if ( $has_extended_modifier ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::FROM_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 + ) + ) { + if ( null !== $table_reference['schema'] ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + + $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'] = $schema_name; + $position += 2; + } + + $schema_name = $this->get_mysql_writable_table_backend_schema( $table_reference, 'SHOW INDEX' ); + $table_name = $table_reference['table']; + $where = null; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::WHERE_SYMBOL === $tokens[ $position ]->id ) { + $where = $this->get_mysql_show_exact_where_filter( + $tokens, + $position, + 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', + ) + ); + if ( null === $where ) { + throw new InvalidArgumentException( 'Unsupported SHOW INDEX statement.' ); + } + + $position += 4; + } + + 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; + } + + if ( ! isset( $tokens[1] ) || WP_MySQL_Lexer::TABLE_SYMBOL !== $tokens[1]->id ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + $tables = array(); + $position = 2; + 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; + } + + if ( ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + throw new InvalidArgumentException( 'Unsupported table administration statement.' ); + } + + return array( + 'operation' => $operation, + 'tables' => $tables, + ); + } + + /** + * 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_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_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, + ); + } + + /** + * 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 ( WP_MySQL_Lexer::READ_SYMBOL === $tokens[ $position ]->id ) { + $mode = 'read'; + } elseif ( WP_MySQL_Lexer::WRITE_SYMBOL === $tokens[ $position ]->id ) { + $mode = 'write'; + } else { + throw new InvalidArgumentException( 'Unsupported LOCK TABLES statement.' ); + } + + ++$position; + $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, + ); + } + + /** + * 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; + } + + /** + * 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 ) ) { + 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(); + + $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->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 exact MySQL WHERE filter. + * @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 ); + $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; + } + + if ( null !== $where_filter ) { + $sql .= sprintf( ' AND %s = ?', $this->get_show_columns_filter_column_expression( $where_filter['column'] ) ); + $params[] = $where_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 ); + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + + $this->store_mysql_introspection_result_in_cache( $cache_key ); + + return $this->last_result; + } + + /** + * 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 "''"; + } + + throw new InvalidArgumentException( 'Unsupported SHOW COLUMNS statement.' ); + } + + /** + * 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 exact MySQL WHERE filter. + * @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 ) { + $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)', + $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_CHARSET_METADATA_TABLE ) + ); + $params = array( $schema_name ); + + if ( null !== $like ) { + $sql .= " AND table_name LIKE ? ESCAPE '\\'"; + $params[] = $like; + } + + if ( null !== $where_filter ) { + $sql .= sprintf( ' AND %s = ?', $this->get_show_tables_filter_column_expression( $where_filter['column'], $table_column ) ); + $params[] = $where_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 ); + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + + 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 ) { + $rows = array(); + foreach ( $this->get_show_table_status_catalog_rows() 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 ) + ); + } + + $rows = $this->filter_show_table_status_rows( $rows, $show_table_status_query ); + + return $this->set_mysql_static_show_result( + 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', + ), + $rows, + $fetch_mode, + ...$fetch_mode_args + ); + } + + /** + * 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 + ); + $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 ); + $create_statement = $this->get_mysql_create_table_statement_from_metadata( $table_name, $columns, $indexes ); + $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 + 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, sub_part + 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 ); + } + + /** + * 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. + * @return string MySQL-compatible CREATE TABLE statement. + */ + private function get_mysql_create_table_statement_from_metadata( string $table_name, array $columns, array $indexes ): 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 ); + } + + $collation = $this->get_mysql_create_table_collation_from_metadata( $columns ); + $charset = $this->get_mysql_charset_from_collation( $collation ); + + return sprintf( + "CREATE TABLE %s (\n%s\n) ENGINE=InnoDB DEFAULT CHARSET=%s COLLATE=%s", + $this->quote_mysql_identifier( $table_name ), + implode( ",\n", $definitions ), + $charset, + $collation + ); + } + + /** + * 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 { + $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( (string) $column['extra'], 'auto_increment' ) ) { + $sql .= ' AUTO_INCREMENT'; + } + + if ( null !== $column['column_default'] ) { + $sql .= ' DEFAULT ' . $this->quote_mysql_utf8_string_literal( (string) $column['column_default'] ); + } elseif ( 'NO' !== strtoupper( (string) $column['is_nullable'] ) ) { + $sql .= ' DEFAULT NULL'; + } + + return $sql; + } + + /** + * 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'] ) ) { + return sprintf( + ' PRIMARY KEY (%s)', + implode( ', ', $this->get_mysql_create_table_index_column_definitions( $index ) ) + ); + } + + return 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 ) ) + ); + } + + /** + * 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'] ); + } + + $columns[] = $definition; + } + + return $columns; + } + + /** + * 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. + * + * @return array[] Catalog rows. + */ + private function get_show_table_status_catalog_rows(): array { + $sql = 'SELECT + t.table_name, + ( + 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 + WHERE t.table_schema = ? + AND t.table_type = ? + AND t.table_name NOT IN (?, ?, ?) + ORDER BY t.table_name'; + $params = array( + 'public', + 'BASE TABLE', + self::MYSQL_COLUMN_METADATA_TABLE, + self::MYSQL_INDEX_METADATA_TABLE, + self::MYSQL_CHARSET_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. + * @return array MySQL-shaped row. + */ + private function get_show_table_status_result_row( string $table_name, ?string $auto_increment ): 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' => null, + 'Update_time' => null, + 'Check_time' => null, + 'Collation' => $this->collation, + 'Checksum' => null, + 'Create_options' => '', + '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. + * @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 { + $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_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_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_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( 'public', $table_name, $identity_column ) + ); + $this->last_postgresql_queries[] = array( + 'sql' => $sequence_sql, + 'params' => array( 'public', $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( 'public', $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; + } + + return array_values( + array_filter( + $rows, + function ( array $row ) use ( $show_table_status_query ): bool { + if ( 'like' === $show_table_status_query['filter_type'] ) { + return null !== $show_table_status_query['filter_pattern'] + && $this->matches_mysql_like_pattern( (string) $row['Name'], $show_table_status_query['filter_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 = $this->get_mysql_session_variables(); + $rows = array(); + + foreach ( $variables as $variable_name => $value ) { + if ( + 'exact' === $show_variables_query['type'] + && $variable_name !== $show_variables_query['pattern'] + ) { + continue; + } + + if ( + 'like' === $show_variables_query['type'] + && ! $this->matches_mysql_like_pattern( $variable_name, $show_variables_query['pattern'] ) + ) { + continue; + } + + $rows[] = array( + 'Variable_name' => $variable_name, + 'Value' => $value, + ); + } + + $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; + } + + /** + * 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( + array( + array( + 'Collation' => 'binary', + 'Charset' => 'binary', + 'Id' => '63', + 'Default' => 'Yes', + 'Compiled' => 'Yes', + 'Sortlen' => '1', + 'Pad_attribute' => 'NO PAD', + ), + array( + 'Collation' => 'utf8_bin', + 'Charset' => 'utf8', + 'Id' => '83', + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => '1', + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8_general_ci', + 'Charset' => 'utf8', + 'Id' => '33', + 'Default' => 'Yes', + 'Compiled' => 'Yes', + 'Sortlen' => '1', + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8_unicode_ci', + 'Charset' => 'utf8', + 'Id' => '192', + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => '8', + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8mb4_bin', + 'Charset' => 'utf8mb4', + 'Id' => '46', + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => '1', + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8mb4_unicode_ci', + 'Charset' => 'utf8mb4', + 'Id' => '224', + 'Default' => '', + 'Compiled' => 'Yes', + 'Sortlen' => '8', + 'Pad_attribute' => 'PAD SPACE', + ), + array( + 'Collation' => 'utf8mb4_0900_ai_ci', + 'Charset' => 'utf8mb4', + 'Id' => '255', + 'Default' => 'Yes', + 'Compiled' => 'Yes', + 'Sortlen' => '0', + 'Pad_attribute' => 'NO PAD', + ), + ), + $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 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; + } + + /** + * 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; + } + + $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 ); + } + ) + ); + } + + /** + * 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_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; + } + + /** + * Match a string against a MySQL LIKE pattern. + * + * @param string $value Value to check. + * @param string $pattern MySQL LIKE pattern. + * @return bool Whether the pattern matches. + */ + private function matches_mysql_like_pattern( string $value, string $pattern ): bool { + $regex = '/^'; + $length = strlen( $pattern ); + + for ( $i = 0; $i < $length; $i++ ) { + $char = $pattern[ $i ]; + if ( '\\' === $char && $i + 1 < $length ) { + ++$i; + $regex .= preg_quote( $pattern[ $i ], '/' ); + continue; + } + + if ( '%' === $char ) { + $regex .= '.*'; + continue; + } + + if ( '_' === $char ) { + $regex .= '.'; + continue; + } + + $regex .= preg_quote( $char, '/' ); + } + + $regex .= '$/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( + 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->sql_mode, + ), + $this->mysql_session_variable_values + ); + } + + /** + * 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; + } + + /** + * Get an emulated MySQL system variable value. + * + * @param string $name Variable name. + * @return string|null Variable value, or null when unsupported. + */ + private function get_mysql_system_variable_value( string $name ): ?string { + $name = strtolower( $name ); + $variables = $this->get_mysql_session_variables(); + if ( array_key_exists( $name, $variables ) ) { + return $variables[ $name ]; + } + + if ( 'sql_mode' === $name ) { + return $this->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; + } + + /** + * 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. + * @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 { + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::AT_AT_SIGN_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $display_parts = array( '@@' ); + ++$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 + ) { + $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. + * @return string|null Normalized value, or null when unsupported. + */ + private function normalize_mysql_system_variable_assignment_value( string $name, string $value ): ?string { + 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', + 'old_alter_table', + 'print_identified_with_as_hex', + 'require_row_format', + 'select_into_disk_sync', + 'session_track_schema', + 'session_track_state_change', + 'show_create_table_skip_secondary_engine', + 'show_create_table_verbosity', + 'sql_auto_is_null', + 'sql_big_selects', + 'sql_buffer_result', + 'sql_notes', + 'sql_safe_updates', + 'sql_warnings', + 'transaction_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', + 'keep_files_on_create' => '0', + 'old_alter_table' => '0', + 'print_identified_with_as_hex' => '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_notes' => '1', + 'sql_safe_updates' => '0', + 'sql_warnings' => '0', + 'storage_engine' => 'InnoDB', + 'time_zone' => 'SYSTEM', + 'transaction_isolation' => 'REPEATABLE-READ', + 'transaction_read_only' => '0', + 'unique_checks' => '1', + 'use_secondary_engine' => 'ON', + ); + } + + /** + * 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( + '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 exact MySQL WHERE filter. + * @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 ); + $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->get_show_index_catalog_query(); + $params = array( + $resolved_schema, + $table_name, + ); + + if ( null !== $where_filter ) { + $sql .= sprintf( + ' +WHERE %s = ?', + $this->connection->quote_identifier( $where_filter['column'] ) + ); + $params[] = $where_filter['value']; + } + + $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 ); + $this->last_result = $stmt->fetchAll( $fetch_mode, ...$fetch_mode_args ); + + $this->store_mysql_introspection_result_in_cache( $cache_key ); + + return $this->last_result; + } + + /** + * 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; + + 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", + \'\' 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, + 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.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", + \'A\' 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", + \'\' 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 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(); + } + + /** + * 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 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; + } + + $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; + } + + $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; + } + + $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)', + $this->connection->quote_identifier( $target_ref['table'] ), + $target_alias_sql, + $target_alias_sql, + $target_alias_sql, + $this->translate_mysql_token_sequence_to_postgresql( $tokens, 3, $where_position ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $where_position + 1, $statement_end ) + ); + } + + /** + * 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. Multi-table DELETE variants and MySQL-only ORDER/LIMIT forms 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], $tokens[1] ) + || WP_MySQL_Lexer::DELETE_SYMBOL !== $tokens[0]->id + || WP_MySQL_Lexer::FROM_SYMBOL !== $tokens[1]->id + ) { + return null; + } + + $position = 2; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::WHERE_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + $where_position = $position; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $where_position + 1 ); + if ( null === $statement_end || $where_position + 1 >= $statement_end ) { + return null; + } + + $unsupported_tokens = array( + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::REGEXP_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + WP_MySQL_Lexer::USING_SYMBOL, + ); + if ( $this->contains_top_level_mysql_token( $tokens, $where_position + 1, $statement_end, $unsupported_tokens ) ) { + return null; + } + + return sprintf( + 'DELETE FROM %s WHERE %s', + $this->connection->quote_identifier( $table_name ), + $this->translate_mysql_token_sequence_to_postgresql( $tokens, $where_position + 1, $statement_end ) + ); + } + + /** + * 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: only explicit column-list VALUES + * inserts are supported, the conflict target must resolve to a known + * primary/unique key, and update assignments must use VALUES(column). + * + * @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; + } + + $position = 1; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::INTO_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $on_duplicate = $this->find_on_duplicate_key_update_clause( $tokens, $position ); + if ( null === $on_duplicate ) { + return null; + } + + $probe_safe_rows = array(); + $value_rows = $this->parse_mysql_values_rows( $tokens, $position, $on_duplicate, count( $columns ), $probe_safe_rows ); + if ( null === $value_rows ) { + return null; + } + + $conflict_columns = $this->get_mysql_upsert_conflict_target_columns( $table_name, $columns ); + if ( null === $conflict_columns ) { + return null; + } + + $column_lookup = array(); + foreach ( $columns as $column ) { + $column_lookup[ strtolower( $column ) ] = true; + } + + $table_column_lookup = $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $position = $on_duplicate + 4; + + $assignments = $this->parse_upsert_update_assignments( $tokens, $position, $column_lookup, $table_column_lookup ); + if ( null === $assignments || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + $inserted_value_rows = $this->get_mysql_upsert_inserted_value_rows( + $table_name, + $columns, + $value_rows, + $probe_safe_rows, + $conflict_columns + ); + if ( null === $inserted_value_rows ) { + return null; + } + + $sql_value_rows = array(); + foreach ( $value_rows as $values ) { + $sql_value_rows[] = '(' . implode( ', ', $values ) . ')'; + } + + return array( + 'action' => 'upsert', + 'sql' => sprintf( + 'INSERT INTO %s (%s) %s ON CONFLICT (%s) DO UPDATE SET %s', + $this->connection->quote_identifier( $table_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + 'VALUES ' . implode( ', ', $sql_value_rows ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $conflict_columns ) ), + implode( ', ', $assignments ) + ), + '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, + 'inserted_new_row' => count( $inserted_value_rows ) > 0, + ); + } + + /** + * 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. + * @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 { + $rows = array(); + $probe_safe_rows = array(); + + while ( $position < $end ) { + $probe_safe_values = array(); + $values = $this->parse_mysql_value_list_with_probe_safety( $tokens, $position, $probe_safe_values ); + if ( null === $values || count( $values ) !== $expected_count ) { + return null; + } + + $rows[] = $values; + $probe_safe_rows[] = $probe_safe_values; + + if ( $position === $end ) { + return $rows; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + } + + return count( $rows ) > 0 ? $rows : null; + } + + /** + * 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 string[]|null Translated SQL values, 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(); + $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 ); + $probe_safety[] = $this->is_supported_mysql_upsert_conflict_probe_token_sequence( $tokens, $value_start, $position ); + ++$position; + return $values; + } + + --$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 ); + $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 + ); + } + + /** + * Resolve the PostgreSQL upsert conflict target from MySQL index metadata. + * + * @param string $table_name Table name. + * @param string[] $columns Inserted column names. + * @return string[]|null Conflict target columns, or null when unsupported. + */ + private function get_mysql_upsert_conflict_target_columns( string $table_name, array $columns ): ?array { + $insert_column_lookup = array(); + $insert_columns = array(); + foreach ( $columns as $column ) { + $insert_column = strtolower( $column ); + + $insert_column_lookup[ $insert_column ] = true; + $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 : array_values( $cached ); + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT key_name, column_name, 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(), + 'has_sub_part' => false, + ); + } + + $column_name = (string) ( $row['column_name'] ?? '' ); + if ( '' === $column_name ) { + continue; + } + + $indexes[ $key_name ]['columns'][] = $column_name; + if ( null !== ( $row['sub_part'] ?? null ) && '' !== (string) $row['sub_part'] ) { + $indexes[ $key_name ]['has_sub_part'] = true; + } + } + + $candidates = array(); + foreach ( $indexes as $index ) { + if ( empty( $index['columns'] ) ) { + continue; + } + + foreach ( $index['columns'] as $column ) { + if ( ! isset( $insert_column_lookup[ strtolower( $column ) ] ) ) { + continue 2; + } + } + + if ( $index['has_sub_part'] ) { + $this->mysql_upsert_conflict_target_cache[ $cache_key ] = null; + return null; + } + + $candidates[] = $index['columns']; + } + + $conflict_columns = 1 === count( $candidates ) ? $candidates[0] : null; + + $this->mysql_upsert_conflict_target_cache[ $cache_key ] = $conflict_columns; + return null === $conflict_columns ? null : array_values( $conflict_columns ); + } + + /** + * 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 string[] $conflict_columns Conflict target columns. + * @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_columns ): ?array { + $column_indexes = array(); + foreach ( $columns as $index => $column ) { + $column_indexes[ strtolower( $column ) ] = $index; + } + + $conflict_indexes = array(); + foreach ( $conflict_columns as $column ) { + $column_key = strtolower( $column ); + if ( ! isset( $column_indexes[ $column_key ] ) ) { + return null; + } + + $conflict_indexes[] = array( + 'column' => $column, + 'index' => $column_indexes[ $column_key ], + ); + } + + $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; + } + + /** + * 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 ( 'NULL' === strtoupper( trim( $value ) ) ) { + return false; + } + + $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(); + } + + /** + * Translate simple single-row MySQL REPLACE statements to PostgreSQL. + * + * WordPress' wpdb::replace() emits a single VALUES row with an explicit + * column list. 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; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::INTO_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $parsed_values = $this->parse_mysql_value_list_with_ranges( $tokens, $position ); + if ( null === $parsed_values || count( $columns ) !== count( $parsed_values['values'] ) || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + $values = $parsed_values['values']; + $column_metadata = $this->is_mysql_strict_sql_mode_active() + ? array() + : $this->get_mysql_dml_column_metadata( $table_name ); + $this->normalize_non_strict_mysql_dml_values_for_columns( + $columns, + $values, + $parsed_values['ranges'], + $tokens, + $column_metadata + ); + $this->append_non_strict_dml_defaults_for_omitted_columns( $table_name, $columns, $values, $column_metadata ); + + $sql = sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $this->connection->quote_identifier( $table_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + implode( ', ', $values ) + ); + + $conflict_column = $this->get_simple_replace_conflict_column( $table_name, $columns ); + if ( null === $conflict_column ) { + return array( + 'action' => 'replace', + 'sql' => $sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $values, + 'conflict_column' => null, + 'conflict_value' => null, + 'inserted_new_row' => true, + ); + } + + $conflict_index = null; + foreach ( $columns as $index => $column ) { + if ( strtolower( $column ) === strtolower( $conflict_column ) ) { + $conflict_index = $index; + break; + } + } + + if ( null === $conflict_index ) { + return null; + } + + $assignments = array(); + foreach ( $columns as $column ) { + $assignments[] = sprintf( + '%s = excluded.%s', + $this->connection->quote_identifier( $column ), + $this->connection->quote_identifier( $column ) + ); + } + + return array( + 'action' => 'replace', + 'sql' => sprintf( + '%s ON CONFLICT (%s) DO UPDATE SET %s', + $sql, + $this->connection->quote_identifier( $conflict_column ), + implode( ', ', $assignments ) + ), + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $values, + 'conflict_column' => $conflict_column, + 'conflict_value' => $values[ $conflict_index ], + 'inserted_new_row' => true, + ); + } + + /** + * Check whether a simple REPLACE conflict target currently exists. + * + * @param string $table_name Table name. + * @param string $conflict_column Conflict column name. + * @param string $conflict_value Already translated SQL value. + * @return bool Whether the row exists. + */ + private function replace_conflict_exists( string $table_name, string $conflict_column, string $conflict_value ): bool { + if ( 'NULL' === strtoupper( $conflict_value ) ) { + return false; + } + + $stmt = $this->connection->query( + sprintf( + 'SELECT 1 FROM %s WHERE %s = %s LIMIT 1', + $this->connection->quote_identifier( $table_name ), + $this->connection->quote_identifier( $conflict_column ), + $conflict_value + ) + ); + + return false !== $stmt->fetchColumn(); + } + + /** + * 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 single-row 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 simple VALUES shape. Other MySQL-specific modifiers, + * INSERT ... SELECT/SET, missing column lists, multi-row values, and + * trailing clauses fall through unchanged. + * + * @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; + 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 ) { + return null; + } + + ++$position; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $parsed_values = $this->parse_mysql_value_list_with_ranges( $tokens, $position ); + if ( null === $parsed_values || count( $columns ) !== count( $parsed_values['values'] ) || ! $this->is_at_mysql_query_end( $tokens, $position ) ) { + return null; + } + + $values = $parsed_values['values']; + $column_metadata = $this->is_mysql_strict_sql_mode_active() + ? array() + : $this->get_mysql_dml_column_metadata( $table_name ); + $this->normalize_non_strict_mysql_dml_values_for_columns( + $columns, + $values, + $parsed_values['ranges'], + $tokens, + $column_metadata + ); + $this->append_non_strict_dml_defaults_for_omitted_columns( $table_name, $columns, $values, $column_metadata ); + + $sql = sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $this->connection->quote_identifier( $table_name ), + implode( ', ', array_map( array( $this->connection, 'quote_identifier' ), $columns ) ), + implode( ', ', $values ) + ); + + return array( + 'action' => 'insert', + 'sql' => $ignore ? $sql . ' ON CONFLICT DO NOTHING' : $sql, + 'table_name' => $table_name, + 'columns' => $columns, + 'values' => $values, + 'ignore' => $ignore, + 'inserted_new_row' => true, + ); + } + + /** + * 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, explicit 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; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::INTO_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$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 + ); + + $columns = $this->parse_mysql_identifier_list( $tokens, $position ); + if ( null === $columns ) { + return null; + } + + $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' => $table_reference_start, + 'end' => $table_reference_end, + 'sql' => $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 ); + + return array( + 'action' => 'insert', + 'sql' => $this->translate_mysql_token_sequence_with_replacements_to_postgresql( + $tokens, + 0, + $statement_end, + $replacements + ), + 'table_name' => $table_name, + 'columns' => $columns, + 'inserted_new_row' => true, + ); + } + + /** + * 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 { + $from_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::FROM_SYMBOL, $select_start + 1, $select_end ); + if ( null === $from_position || $select_start + 1 >= $from_position ) { + return array(); + } + + $projection_ranges = $this->split_top_level_mysql_arguments( $tokens, $select_start + 1, $from_position ); + 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(); + } + + $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 ); + + $group_items = null; + $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( + $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 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( array $column_metadata, array $tokens, int $start, int $end, string $projection_sql, ?array $scope ): ?string { + $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; + } + + $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; + } + + /** + * 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; + } + + 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'] + ); + } 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'] + ); + } else { + 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 ]; + } + + $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 ); + 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_identifier = $this->get_postgresql_qualified_identifier( $sequence_schema, $sequence_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 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 + 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, + $this->connection->quote_identifier( $column_name ), + $this->get_postgresql_qualified_identifier( $table_schema, $table_name ) + ); + $params = array( $sequence_identifier ); + + $this->connection->query( $sql, $params ); + $this->last_postgresql_queries[] = array( + 'sql' => $sql, + 'params' => $params, + ); + } + + /** + * 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. More complex UPDATE syntax falls + * 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_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; + } + + $position = 1; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::SET_SYMBOL !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + $statement_end = $this->get_mysql_statement_end_position( $tokens, $position ); + if ( null === $statement_end ) { + return null; + } + + $where_position = $this->find_top_level_mysql_token( $tokens, WP_MySQL_Lexer::WHERE_SYMBOL, $position, $statement_end ); + $set_end = $where_position ?? $statement_end; + if ( ! $this->is_supported_simple_update_set_clause( $tokens, $position, $set_end ) ) { + return null; + } + + $unsupported_tokens = array( + WP_MySQL_Lexer::JOIN_SYMBOL, + WP_MySQL_Lexer::LIMIT_SYMBOL, + WP_MySQL_Lexer::ORDER_SYMBOL, + WP_MySQL_Lexer::STRAIGHT_JOIN_SYMBOL, + ); + if ( $this->contains_top_level_mysql_token( $tokens, $position, $statement_end, $unsupported_tokens ) ) { + return null; + } + + $update_set_clause = $this->translate_simple_mysql_update_set_clause( $table_name, $tokens, $position, $set_end ); + if ( null === $update_set_clause ) { + return null; + } + + $sql = sprintf( + 'UPDATE %s SET %s', + $this->connection->quote_identifier( $table_name ), + $update_set_clause['set_sql'] + ); + + $where_sql = null; + if ( null !== $where_position ) { + if ( + $where_position + 1 >= $statement_end + || ! $this->is_supported_simple_mysql_expression_fragment( $tokens, $where_position + 1, $statement_end ) + ) { + return null; + } + + $where_sql = $this->translate_mysql_predicate_token_sequence_to_postgresql( + $tokens, + $where_position + 1, + $statement_end, + $this->get_mysql_single_table_scope( $table_name ) + ); + $where_sql = $where_sql['sql']; + } + + if ( null !== $where_sql ) { + $sql .= sprintf( + ' WHERE (%s) AND (%s)', + $where_sql, + $update_set_clause['changed_predicate_sql'] + ); + } else { + $sql .= ' WHERE ' . $update_set_clause['changed_predicate_sql']; + } + + return $sql; + } + + /** + * 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 ) { + $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; + } + } + + /** + * Translate a supported simple UPDATE SET clause with non-strict NULL coercion. + * + * @param string $table_name Table name. + * @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, array $tokens, int $start, int $end ): ?array { + $column_metadata = $this->is_mysql_strict_sql_mode_active() + ? array() + : $this->get_mysql_dml_column_metadata_lookup( $table_name ); + $assignments = array(); + $changed_predicates = array(); + + for ( $position = $start; $position < $end; ) { + $target_column = $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ); + if ( null === $target_column ) { + return null; + } + + if ( ! isset( $tokens[ $position + 1 ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position + 1 ]->id ) { + return null; + } + + $value_start = $position + 2; + $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->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_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, + $this->get_mysql_single_table_scope( $table_name ) + ); + $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 ); + } + } + + $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)', + $quoted_target_column, + $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 ), + ); + } + + /** + * 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 { + $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 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 { + if ( + $start + 1 !== $end + || ! isset( $tokens[ $start ] ) + || ! $this->is_mysql_string_literal_token( $tokens[ $start ] ) + ) { + 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; + } + + $value = $tokens[ $start ]->get_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 ); + } + + /** + * 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 { + $parts = $this->get_mysql_dml_date_parts( $value ); + if ( null === $parts ) { + return null; + } + + if ( $this->is_non_strict_mysql_dml_zero_date_allowed( $parts['year'], $parts['month'], $parts['day'] ) ) { + return $value; + } + + if ( checkdate( (int) $parts['month'], (int) $parts['day'], (int) $parts['year'] ) ) { + return $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 ) { + return null; + } + + $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 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})Z$/', $value, $matches ) ) { + return $matches[1] . ' ' . $matches[2]; + } + + if ( 1 === preg_match( '/^([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}:[0-9]{2}:[0-9]{2})$/', $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 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; + } + + if ( null !== ( $column_metadata['column_default'] ?? null ) ) { + return $this->connection->quote( (string) $column_metadata['column_default'] ); + } + + return $this->get_mysql_implicit_dml_default_sql( (string) ( $column_metadata['column_type'] ?? '' ) ); + } + + /** + * 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 ); + } + + /** + * 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 { + foreach ( explode( ',', $this->sql_mode ) as $mode ) { + $mode = strtoupper( trim( $mode ) ); + if ( 'STRICT_TRANS_TABLES' === $mode || 'STRICT_ALL_TABLES' === $mode ) { + return true; + } + } + + return false; + } + + /** + * 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 { + $mode = strtoupper( $mode ); + foreach ( explode( ',', $this->sql_mode ) as $active_mode ) { + if ( strtoupper( trim( $active_mode ) ) === $mode ) { + return true; + } + } + + return false; + } + + /** + * 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; + } + + $table_reference_start = $from_position + 1; + $position = $table_reference_start; + $table_name = $this->parse_mysql_main_database_table_name( $tokens, $position ); + if ( null === $table_name ) { + return null; + } + $table_reference_sql = $this->get_mysql_main_database_table_reference_sql( + $tokens, + $table_reference_start, + $position + ); + + $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 ); + 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_CHARSET_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)', + $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_CHARSET_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 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->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 { + if ( 0 !== strcasecmp( $this->db_name, 'information_schema' ) ) { + return false; + } + + $tokens = $this->get_mysql_tokens( $query ); + if ( ! isset( $tokens[0] ) ) { + return false; + } + + if ( WP_MySQL_Lexer::SELECT_SYMBOL === $tokens[0]->id ) { + return $this->information_schema_select_has_table_reference( $tokens ); + } + + if ( WP_MySQL_Lexer::SHOW_SYMBOL === $tokens[0]->id ) { + return $this->is_information_schema_table_scoped_show_query( $tokens ); + } + + if ( + in_array( + $tokens[0]->id, + array( + WP_MySQL_Lexer::EXPLAIN_SYMBOL, + WP_MySQL_Lexer::WITH_SYMBOL, + ), + true + ) + ) { + return true; + } + + 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 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 null !== $this->find_top_level_mysql_token( + $tokens, + WP_MySQL_Lexer::FROM_SYMBOL, + 1, + $statement_end + ); + } + + /** + * Check whether a SHOW query is table-scoped under USE information_schema. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @return bool Whether the SHOW query should be rejected. + */ + private function is_information_schema_table_scoped_show_query( array $tokens ): bool { + $position = 1; + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::EXTENDED_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::FULL_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + } + + if ( + isset( $tokens[ $position ] ) + && ( + WP_MySQL_Lexer::COLUMNS_SYMBOL === $tokens[ $position ]->id + || WP_MySQL_Lexer::FIELDS_SYMBOL === $tokens[ $position ]->id + ) + ) { + return true; + } + + if ( + isset( $tokens[1], $tokens[2] ) + && WP_MySQL_Lexer::CREATE_SYMBOL === $tokens[1]->id + && WP_MySQL_Lexer::TABLE_SYMBOL === $tokens[2]->id + ) { + return true; + } + + return 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 + ); + } + + /** + * 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 { + if ( $query === $this->mysql_token_cache_query ) { + return $this->mysql_token_cache_tokens; + } + + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + $this->mysql_token_cache_query = $query; + $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_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 WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $position Current token position, updated on success. + * @param array $column_lookup Insert-column lookup by lowercase name. + * @param array $table_column_lookup Table-column metadata lookup by lowercase name. + * @return string[]|null PostgreSQL SET assignments, or null when unsupported. + */ + private function parse_upsert_update_assignments( array $tokens, int &$position, array $column_lookup, array $table_column_lookup ): ?array { + $assignments = array(); + + while ( isset( $tokens[ $position ] ) ) { + $target_column = $this->get_mysql_identifier_token_value( $tokens[ $position ] ); + if ( + null === $target_column + || ! isset( $table_column_lookup[ strtolower( $target_column ) ] ) + ) { + return null; + } + + ++$position; + if ( ! isset( $tokens[ $position ] ) || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position ]->id ) { + return null; + } + + ++$position; + if ( + ! isset( $tokens[ $position + 3 ] ) + || WP_MySQL_Lexer::VALUES_SYMBOL !== $tokens[ $position ]->id + || 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_identifier_token_value( $tokens[ $position + 2 ] ); + if ( + null === $source_column + || ! isset( $column_lookup[ strtolower( $source_column ) ] ) + ) { + return null; + } + + $assignments[] = sprintf( + '%s = excluded.%s', + $this->connection->quote_identifier( $target_column ), + $this->connection->quote_identifier( $source_column ) + ); + $position += 4; + + if ( isset( $tokens[ $position ] ) && WP_MySQL_Lexer::COMMA_SYMBOL === $tokens[ $position ]->id ) { + ++$position; + continue; + } + + break; + } + + return count( $assignments ) > 0 ? $assignments : null; + } + + /** + * Validate the narrow SET clause supported by the simple UPDATE translator. + * + * @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( array $tokens, int $start, int $end ): bool { + for ( $position = $start; $position < $end; ) { + if ( + null === $this->get_mysql_identifier_token_value( $tokens[ $position ] ?? null ) + || ! isset( $tokens[ $position + 1 ] ) + || WP_MySQL_Lexer::EQUAL_OPERATOR !== $tokens[ $position + 1 ]->id + ) { + return false; + } + + $value_start = $position + 2; + $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; + } + + for ( $i = $start; $i < $end; $i++ ) { + if ( null === $this->get_mysql_identifier_token_value( $tokens[ $i ] ?? null ) ) { + return false; + } + + ++$i; + if ( $i >= $end ) { + return true; + } + + if ( WP_MySQL_Lexer::COMMA_SYMBOL !== $tokens[ $i ]->id ) { + return false; + } + } + + return false; + } + + /** + * 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 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' ) + || $order_position + 4 !== $end + || ! $this->is_mysql_identifier_like_token_value( $tokens[ $order_position + 2 ] ?? null, 'post_date' ) + || ! isset( $tokens[ $order_position + 3 ] ) + || WP_MySQL_Lexer::DESC_SYMBOL !== $tokens[ $order_position + 3 ]->id + ) { + return null; + } + + 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. + * @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 { + $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_to_postgresql( $tokens, $segment_start, $position ); + } + + $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_to_postgresql( $tokens, $segment_start, $position ); + } + + $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_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 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; + } + + $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 + ); + } + + /** + * 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; + } + + return array( + 'pattern_sql' => $pattern_sql, + 'escape_sql' => $escape_sql, + 'end' => $pattern_end, + ); + } + + /** + * 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'] ); + return $scope['aliases'][ $alias ] ?? null; + } + + 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'] ); + return $scope['aliases'][ $alias ] ?? null; + } + + 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; + } + + return array( + 'sql' => sprintf( + 'CAST(%s AS text)%s LIKE %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 ) + ), + '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 ]; + 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 ]; + } + + $lowercase_column_name = strtolower( $column_name ); + $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, $lowercase_column_name ) + ); + + $stored_column_name = $stmt->fetchColumn(); + $this->mysql_table_column_name_cache[ $table_cache_key ][ $column_cache_key ] = false === $stored_column_name + ? null + : (string) $stored_column_name; + 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; + } + + /** + * 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_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_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 ]; + 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 ( + 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 ]; + 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 ( + 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 ] ); + } + + /** + * 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 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_identifier_token_value( $token ) ) { + return true; + } + + return in_array( + $token->id, + array( + WP_MySQL_Lexer::AND_SYMBOL, + WP_MySQL_Lexer::CLOSE_PAR_SYMBOL, + WP_MySQL_Lexer::COMMA_SYMBOL, + WP_MySQL_Lexer::DECIMAL_NUMBER, + WP_MySQL_Lexer::EQUAL_OPERATOR, + 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::LESS_OR_EQUAL_OPERATOR, + WP_MySQL_Lexer::LESS_THAN_OPERATOR, + WP_MySQL_Lexer::LONG_NUMBER, + WP_MySQL_Lexer::MINUS_OPERATOR, + WP_MySQL_Lexer::NOT_EQUAL_OPERATOR, + WP_MySQL_Lexer::NULL_SYMBOL, + WP_MySQL_Lexer::OPEN_PAR_SYMBOL, + WP_MySQL_Lexer::PLUS_OPERATOR, + WP_MySQL_Lexer::SINGLE_QUOTED_TEXT, + WP_MySQL_Lexer::TRUE_SYMBOL, + WP_MySQL_Lexer::ULONGLONG_NUMBER, + ), + 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 + || null === $this->get_mysql_identifier_token_value( $tokens[ $start + 2 ] ) + ) { + return false; + } + + if ( $start + 3 === $end ) { + return true; + } + + return $start + 4 === $end + && ( + WP_MySQL_Lexer::ASC_SYMBOL === $tokens[ $start + 3 ]->id + || WP_MySQL_Lexer::DESC_SYMBOL === $tokens[ $start + 3 ]->id + ); + } + + /** + * 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() ) ); + } + + /** + * 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(); + } + + /** + * 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_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_regexp_operator_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_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 ) { + $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 ); + } + + if ( '' === $fragment ) { + continue; + } + + 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; + } + + /** + * 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 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'], + ); + } + + /** + * 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; + } + + /** + * 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'], + ); + } + + /** + * 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, + ); + } + + /** + * 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; + } + + /** + * 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 + ) { + $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 + ) { + $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 RAND(seed) calls to PostgreSQL random(). + * + * PostgreSQL setseed() is session-stateful, so RAND(seed) deliberately maps + * to random() for now instead of leaking deterministic seed state into later + * statements. Seeded deterministic ordering is left for a higher-fidelity + * emulation path. + * + * @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; + } + + return array( + 'sql' => 'random()', + 'token_id' => WP_MySQL_Lexer::IDENTIFIER, + 'position' => $bounds['close'], + ); + } + + /** + * 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_SUB(...) 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{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'] + ); + + return array( + 'sql' => sprintf( + '(%1$s %2$s (%3$s * INTERVAL %4$s))', + $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ), + $bounds['operator'], + $this->get_postgresql_mysql_interval_value_sql( $value_sql ), + $this->connection->quote( '1 ' . $bounds['interval_unit'] ) + ), + 'token_id' => $tokens[ $position ]->id, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for a supported MySQL DATE_ADD/DATE_SUB 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, 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::DATE_ADD_SYMBOL, + WP_MySQL_Lexer::DATE_SUB_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 ) { + return null; + } + + return array( + 'operator' => WP_MySQL_Lexer::DATE_SUB_SYMBOL === $tokens[ $position ]->id ? '-' : '+', + '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, + ); + } + + /** + * 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}|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 = $this->get_postgresql_simple_interval_unit( $tokens[ $end - 1 ] ); + if ( null === $unit ) { + return null; + } + + return array( + 'value_start' => $start + 1, + 'value_end' => $end - 1, + 'unit' => $unit, + ); + } + + /** + * 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::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::YEAR_SYMBOL: + return 'year'; + } + + return null; + } + + /** + * 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 { + return sprintf( 'CAST(%s AS double precision)', $this->get_postgresql_mysql_integer_cast_sql( $value_sql ) ); + } + + /** + * Translate MySQL WEEK(expr, 1) 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_mode_one_sql( $expression_sql ), + 'token_id' => WP_MySQL_Lexer::CASE_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * Get token bounds for supported MySQL WEEK(expr, mode) 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, 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 + || 2 !== count( $arguments ) + || ! $this->is_mysql_week_mode_one_argument( $tokens, $arguments[1]['start'], $arguments[1]['end'] ) + ) { + return null; + } + + return array( + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['end'], + 'close' => $after_close - 1, + ); + } + + /** + * Check whether a WEEK() mode argument is the supported MySQL mode 1. + * + * @param WP_MySQL_Token[] $tokens MySQL lexer token stream. + * @param int $start First mode token. + * @param int $end Final mode token, exclusive. + * @return bool Whether the mode is supported. + */ + private function is_mysql_week_mode_one_argument( array $tokens, int $start, int $end ): bool { + return $start + 1 === $end + && isset( $tokens[ $start ] ) + && WP_MySQL_Lexer::INT_NUMBER === $tokens[ $start ]->id + && '1' === $tokens[ $start ]->get_value(); + } + + /** + * 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 { + $timestamp_sql = $this->get_postgresql_zero_date_safe_timestamp_sql( $expression_sql ); + $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_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'] + ); + $sql = $this->get_postgresql_mysql_date_format_sql( $bounds['format'], $expression_sql ); + if ( null === $sql ) { + return null; + } + + return array( + 'sql' => $sql, + 'token_id' => WP_MySQL_Lexer::CASE_SYMBOL, + 'position' => $bounds['close'], + ); + } + + /** + * 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_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 ) + || ! $this->is_mysql_string_literal_range( $tokens, $arguments[1]['start'], $arguments[1]['end'] ) + ) { + return null; + } + + return array( + 'format' => $tokens[ $arguments[1]['start'] ]->get_value(), + 'expression_start' => $arguments[0]['start'], + 'expression_end' => $arguments[0]['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 '%Y-%m-%d': + return $this->get_postgresql_mysql_date_format_year_month_day_sql( $expression_sql ); + } + + return null; + } + + /** + * 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, '%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 SUBSTRING(%2$s FROM 1 FOR 10) ELSE TO_CHAR(%3$s, %4$s) END', + $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 ); + $zero_date_condition = $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ); + + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(EXTRACT(%3$s FROM %4$s) AS integer) END', + $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 THEN NULL ELSE %2$s END AS timestamp)', + $this->get_postgresql_zero_date_condition_sql( $expression_text_sql ), + $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 '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 '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::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'; + } + + 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() ); + } + + 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; + } + + /** + * 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 ( 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_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->translate_mysql_regexp_operator_to_postgresql( $tokens, $i, $end ) ) { + return true; + } + + if ( null !== $this->get_mysql_function_call_bounds( $tokens, $i, $end, 'rand' ) ) { + 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_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 { + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + 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 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 { + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + 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 ( + in_array( + $token->id, + array( + WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL, + WP_MySQL_Lexer::BACK_TICK_QUOTED_ID, + WP_MySQL_Lexer::CHARSET_SYMBOL, + WP_MySQL_Lexer::COLLATE_SYMBOL, + WP_MySQL_Lexer::ENGINE_SYMBOL, + WP_MySQL_Lexer::FULLTEXT_SYMBOL, + WP_MySQL_Lexer::ROW_FORMAT_SYMBOL, + WP_MySQL_Lexer::SPATIAL_SYMBOL, + WP_MySQL_Lexer::UNSIGNED_SYMBOL, + ), + true + ) + ) { + return true; + } + } + + return false; + } + + /** + * 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.' ); + } + + $columns[] = $variable['display']; + $row[ $variable['display'] ] = $variable['value']; + + 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 MySQL variable SELECT statement.' ); + } + + return array( + 'columns' => $columns, + 'row' => $row, + ); + } + + /** + * 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; + $name = $this->parse_mysql_system_variable_reference( $tokens, $position, $display ); + if ( null === $name || null === $display ) { + return null; + } + + $value = $this->get_mysql_system_variable_value( $name ); + 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..a4ae4c8bf --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Connection_Tests.php @@ -0,0 +1,478 @@ +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 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..0d1453aec --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Create_Table_Translator_Tests.php @@ -0,0 +1,214 @@ +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 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', + 'columns' => array( + array( + 'name' => 'a', + 'type' => 'varchar(50)', + 'charset' => 'latin1', + 'collation' => 'latin1_swedish_ci', + 'ordinal' => 1, + ), + array( + 'name' => 'b', + 'type' => 'text', + 'charset' => 'koi8r', + 'collation' => 'koi8r_general_ci', + 'ordinal' => 2, + ), + array( + 'name' => 'c', + 'type' => 'binary', + 'charset' => null, + 'collation' => null, + 'ordinal' => 3, + ), + array( + 'name' => 'd', + 'type' => 'int', + 'charset' => null, + 'collation' => null, + '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 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 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' ); + } + + /** + * 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..25b61101e --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_DB_Tests.php @@ -0,0 +1,3490 @@ +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', + 'COLLATION', + 'GROUP_CONCAT', + 'SUBQUERIES', + 'IDENTIFIER_PLACEHOLDERS', + 'set_charset', + 'SET_CHARSET', + ) as $capability +) { + $capabilities[ $capability ] = $db->has_cap( $capability ); +} + +wp_postgresql_db_test_respond( + array( + 'db_version' => $db->db_version(), + 'capabilities' => $capabilities, + ) +); +PHP + ); + + $this->assertSame( + array( + 'collation' => true, + 'group_concat' => true, + 'subqueries' => true, + 'identifier_placeholders' => true, + 'COLLATION' => true, + 'GROUP_CONCAT' => true, + 'SUBQUERIES' => true, + 'IDENTIFIER_PLACEHOLDERS' => true, + 'set_charset' => version_compare( $result['db_version'], '5.0.7', '>=' ), + 'SET_CHARSET' => version_compare( $result['db_version'], '5.0.7', '>=' ), + ), + $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 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, + ); + + if ( 'incompatible_sql_modes' === $hook_name ) { + $value[] = 'ANSI_QUOTES'; + } + + return $value; +} + +class wpdb { + public $incompatible_modes = array( 'NO_ZERO_DATE' ); +} + +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']; + +$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, + '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( 'NO_ENGINE_SUBSTITUTION', $result['initial_mode'] ); + $this->assertSame( 'NO_ENGINE_SUBSTITUTION', $result['mode_after_empty_call'] ); + $this->assertSame( array(), $result['filter_calls_after_empty'] ); + $this->assertSame( 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION', $result['mode_after_filtered_call'] ); + $this->assertSame( + array( + array( + 'hook_name' => 'incompatible_sql_modes', + 'value' => array( 'NO_ZERO_DATE' ), + ), + ), + $result['filter_calls_after_modes'] + ); + $this->assertSame( 'STRICT_TRANS_TABLES,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 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'; + + 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' ), + 'prepared_identifier' => $db->prepare( + 'SELECT * FROM %i 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( + 'SELECT * FROM `wptests_options` WHERE `option_name` = \'Bob\\\'s\'', + $result['prepared_identifier'] + ); + $this->assertSame( "SELECT 'Bob\\'s'", $result['prepared_string'] ); + } + + /** + * 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 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 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 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; + } +} + +$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( "UPDATE `wptests_options` SET `option_value` = 'x' WHERE" ); + +wp_postgresql_db_test_respond( + array( + 'return' => $return, + 'last_error' => $db->last_error, + 'queries' => $driver->get_recorded_queries(), + 'rows_affected' => $db->rows_affected, + 'num_queries' => $db->num_queries, + ) +); +PHP + ); + + $this->assertFalse( $result['return'] ); + $this->assertSame( + 'PostgreSQL query rejected because UPDATE requires a non-empty WHERE condition.', + $result['last_error'] + ); + $this->assertSame( array(), $result['queries'] ); + $this->assertSame( 0, $result['rows_affected'] ); + $this->assertSame( 0, $result['num_queries'] ); + } + + /** + * 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..01938b3c0 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Alter_Table_Fixture_Connection.php @@ -0,0 +1,136 @@ + 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' ); + } + + 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..7a5a90336 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Show_Index_Fixture_Connection.php @@ -0,0 +1,140 @@ + 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_column = $this->get_show_index_fixture_filter_column( $sql ); + if ( null !== $filter_column ) { + $fixture_sql .= sprintf( + ' + AND %s = ?', + $filter_column + ); + } 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 backing the SHOW INDEX filter in the driver query. + * + * @param string $sql Driver SQL query. + * @return string|null Fixture column name, or null for the legacy key_name filter. + */ + private function get_show_index_fixture_filter_column( string $sql ): ?string { + if ( ! preg_match( '/WHERE\s+"([^"]+)"\s+=\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', + ); + + return $columns[ $matches[1] ] ?? null; + } + + /** + * 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..2014ba0fa --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Driver_Tests.php @@ -0,0 +1,10856 @@ +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 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->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 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->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 ); + } + + /** + * 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(); + $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 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 ( PDOException $e ) { + $this->assertSame( 0, $connection->get_sequence_sync_query_count() ); + } + } + + /** + * Tests simple WordPress REPLACE statements update through PostgreSQL upserts. + */ + 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() ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_users" ("ID", "display_name") VALUES (2, \'Walter Replace Sobchak\') ON CONFLICT ("ID") DO UPDATE SET "ID" = excluded."ID", "display_name" = excluded."display_name"', + $queries[0]['sql'] + ); + + $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 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 ) ); + + $queries = $driver->get_last_postgresql_queries(); + $this->assertCount( 1, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_wc_customer_lookup" ("user_id", "email", "customer_id") VALUES (2, \'new@example.com\', \'1\') ON CONFLICT ("customer_id") DO UPDATE SET "user_id" = excluded."user_id", "email" = excluded."email", "customer_id" = excluded."customer_id"', + $queries[0]['sql'] + ); + + $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( 1, $queries ); + $this->assertStringContainsString( 'ON CONFLICT ("product_id") DO UPDATE SET', $queries[0]['sql'] ); + $this->assertStringContainsString( $this->get_expected_mysql_integer_cast_sql( "'4.000000'" ), $queries[0]['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(); + $this->install_options_table_with_mysql_metadata( $driver ); + + $replace = "REPLACE INTO `wptests_options` (`option_name`) VALUES ('siteurl')"; + $expected_sql = 'INSERT INTO "wptests_options" ("option_name", "option_value", "autoload") VALUES (\'siteurl\', \'\', \'yes\') ON CONFLICT ("option_name") DO UPDATE SET "option_name" = excluded."option_name", "option_value" = excluded."option_value", "autoload" = excluded."autoload"'; + + $this->assertSame( 1, $driver->query( $replace ) ); + $this->assertSame( + array( + array( + 'sql' => $expected_sql, + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $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->assertSame( + array( + array( + 'sql' => $expected_sql, + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $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 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( 2, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_users" ("ID", "display_name") VALUES (2, \'Donny Kerabatsos\') ON CONFLICT ("ID") DO UPDATE SET "ID" = excluded."ID", "display_name" = excluded."display_name"', + $queries[0]['sql'] + ); + $this->assert_sequence_repair_query( $queries[1], 'wptests_users', 'ID', 'wptests_users_ID_seq' ); + $this->assertSame( 1, $connection->get_sequence_sync_query_count() ); + } + + /** + * Tests REPLACE conflict update paths do not repair PostgreSQL sequences. + */ + public function test_replace_conflict_update_path_does_not_repair_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( 1, $queries ); + $this->assertSame( + 'INSERT INTO "wptests_users" ("ID", "display_name") VALUES (2, \'Walter Replace Sobchak\') ON CONFLICT ("ID") DO UPDATE SET "ID" = excluded."ID", "display_name" = excluded."display_name"', + $queries[0]['sql'] + ); + $this->assertSame( 0, $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() + ); + } + + /** + * 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 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( '16', $indexes[1]['sub_part'] ); + $this->assertSame( '', $indexes[1]['nullable'] ); + } + + /** + * 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 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 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 primary-key drop forms fail before backend execution. + */ + public function test_alter_table_drop_primary_key_forms_do_not_reach_backend(): 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 ) { + $driver = $this->create_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported ALTER TABLE primary-key drop to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported ALTER TABLE statement.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + } + + /** + * 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 unsupported standalone index DDL fails before backend execution. + */ + public function test_standalone_index_unsupported_syntax_does_not_reach_backend(): void { + $queries = array( + 'CREATE FULLTEXT INDEX idx_value ON wptests_index_fail (value)', + 'CREATE INDEX idx_value USING HASH ON wptests_index_fail (value)', + 'CREATE UNIQUE INDEX idx_value ON wptests_index_fail (value(16))', + 'CREATE INDEX idx_value ON information_schema.tables (name)', + 'CREATE INDEX idx_value ON other_db.wptests_index_fail (value)', + 'DROP INDEX `PRIMARY` ON wptests_index_fail', + '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 CHAR columns do not 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' => $query, + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $this->expectException( PDOException::class ); + $driver->query( "INSERT INTO plain_char_check (a) VALUES ('')" ); + } + + /** + * 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 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 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 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 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 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 ambiguous duplicate-key arbiters fail closed. + */ + public function test_ambiguous_on_duplicate_key_update_returns_null(): 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->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $upsert + ) + ); + + $this->expectException( PDOException::class ); + + $driver->query( $upsert ); + } + + /** + * Tests prefix unique duplicate-key arbiters fail closed. + */ + public function test_prefix_unique_on_duplicate_key_update_returns_null(): void { + $driver = $this->create_driver(); + + $this->install_prefix_ambiguous_upsert_table_with_mysql_metadata( $driver ); + + $upsert = "INSERT INTO `prefix_ambiguous` (`id`, `slug`, `value`) VALUES (2, 'existing-slug', 'new') + 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 + ) + ); + } + + /** + * 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 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 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(); + $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(); + $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 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 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 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 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 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 RAND(seed) are translated without mutating session seed state. + */ + public function test_rand_functions_are_translated_to_postgresql_random(): 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 random() LIMIT 1', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + + $driver->query( 'SELECT rand() AS random_value' ); + $this->assertSame( + array( + array( + 'sql' => 'SELECT random() AS random_value', + 'params' => array(), + ), + ), + $driver->get_last_postgresql_queries() + ); + } + + /** + * 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, 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 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( '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 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) ~ '^[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 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' => '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 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 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) ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'", $sql ); + $this->assertStringContainsString( "CAST(CASE WHEN 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) ~ '^[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 unsupported DATE_FORMAT specifiers remain unhandled. + */ + public function test_mysql_date_format_with_unsupported_specifier_remains_unhandled(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + "SELECT DATE_FORMAT(post_date, '%W') AS formatted_date" + ); + + $this->assertSame( + "SELECT DATE_FORMAT (post_date, '%W') AS formatted_date", + $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 DATE_ADD supports the simple MySQL interval units used by WordPress. + */ + public function test_mysql_date_add_supports_simple_interval_units_for_postgresql(): void { + $driver = $this->create_driver(); + $units = array( + 'SECOND' => 'second', + 'MINUTE' => 'minute', + 'HOUR' => 'hour', + 'DAY' => 'day', + 'WEEK' => 'week', + 'MONTH' => 'month', + '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 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 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) ~ '^[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 unsupported DATE_ADD interval units fall through without semantic rewriting. + */ + public function test_mysql_date_add_with_unsupported_interval_unit_fails_closed(): void { + $driver = $this->create_driver(); + + $sql = $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_compatible_query', + 'SELECT DATE_ADD(post_date_gmt, INTERVAL 1 DAY_SECOND) AS shifted' + ); + + $this->assertNull( $sql ); + } + + /** + * Tests unsupported ON DUPLICATE KEY INSERT shapes still reach PDO. + */ + public function test_unsupported_options_upsert_still_reaches_backend(): void { + $driver = $this->create_driver(); + + $this->install_options_table_with_mysql_metadata( $driver ); + + $unsupported_upsert = "INSERT INTO `wptests_options` (`option_name`, `option_value`, `autoload`) + VALUES ('siteurl', 'http://example.org', 'yes') + ON DUPLICATE KEY UPDATE `option_value` = 'http://example.net'"; + + $this->assertNull( + $this->translate_driver_query_with_private_method( + $driver, + 'translate_mysql_on_duplicate_key_update_query', + $unsupported_upsert + ) + ); + + $this->expectException( PDOException::class ); + + $driver->query( $unsupported_upsert ); + } + + /** + * 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 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 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 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 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 = option_name', + "SHOW COLUMNS FROM wptests_options WHERE Unknown = 'option_name'", + "SHOW COLUMNS FROM wptests_options WHERE Field = 'option_name' AND Type = 'varchar(191)'", + "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 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 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->assertNull( $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', + 'SHOW TABLE STATUS IN `wptests`', + ); + + foreach ( $cases as $query ) { + $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 ); + 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 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 = 'wptests_options'", + 'SHOW TABLE STATUS WHERE `Auto_increment` >= 1', + '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( 2, $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'] ); + 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 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 fail closed. + */ + public function test_show_create_table_information_schema_target_fails_closed(): void { + $driver = $this->create_driver(); + + try { + $driver->query( 'SHOW CREATE TABLE information_schema.tables' ); + $this->fail( 'Expected information_schema SHOW CREATE TABLE target to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $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 CURRENT_USER forms return the static grants row. + */ + public function test_show_grants_current_user_forms_return_static_row(): void { + $queries = array( + 'SHOW GRANTS FOR current_user();', + 'SHOW GRANTS FOR CURRENT_USER', + 'sHoW gRaNtS FoR CuRrEnT_UsEr()', + ); + + 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 unsupported SHOW GRANTS syntax fails before backend execution. + */ + public function test_unsupported_show_grants_syntax_fails_closed(): void { + $queries = array( + 'SHOW GRANTS FOR root', + 'SHOW GRANTS USING role1', + 'SHOW GRANTS FOR CURRENT_USER() USING role1', + ); + + 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 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 ) ); + + try { + $driver->query( "SHOW COLLATION WHERE 'Collation' = 'utf8_bin'" ); + $this->fail( 'Expected quoted SHOW COLLATION WHERE left operand to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW COLLATION statement.', $e->getMessage() ); + $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 ); + + $unsupported_where_queries = array( + "SHOW DATABASES WHERE 'Database' = 'information_schema'", + 'SHOW DATABASES WHERE "Database" = \'information_schema\'', + ); + foreach ( $unsupported_where_queries as $query ) { + try { + $driver->query( $query ); + $this->fail( 'Expected quoted SHOW DATABASES WHERE left operand to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW DATABASES statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + $schemas = $driver->query( 'SHOW SCHEMAS' ); + $this->assertEquals( $databases, $schemas ); + } + + /** + * 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->assertCount( 3, $tables ); + $this->assertSame( 'Tables_in_information_schema', $driver->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'wptests_options', $tables[0]->Tables_in_information_schema ); + $this->assertStringNotContainsString( 'SHOW TABLES', $driver->get_last_postgresql_queries()[0]['sql'] ); + + $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 information_schema table reads fail closed until routing is implemented. + */ + public function test_use_statement_information_schema_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 information_schema table metadata handlers fail closed until routing is implemented. + */ + public function test_use_statement_information_schema_table_metadata_handlers_fail_closed(): void { + $driver = $this->create_driver(); + $this->install_information_schema_fixture( $driver ); + + $this->assertSame( 0, $driver->query( 'USE information_schema' ) ); + + foreach ( + array( + 'SHOW COLUMNS FROM wptests_options', + 'SHOW FIELDS FROM wptests_options', + 'SHOW FULL FIELDS FROM wptests_options', + 'SHOW EXTENDED FIELDS FROM wptests_options', + ) as $query + ) { + try { + $driver->query( $query ); + $this->fail( 'Expected information_schema SHOW COLUMNS/FIELDS to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $driver->get_last_postgresql_queries(), $query ); + } + } + + $index_driver = $this->create_show_index_driver(); + + $this->assertSame( 0, $index_driver->query( 'USE information_schema' ) ); + + foreach ( + array( + 'SHOW INDEX FROM wptests_options', + 'SHOW KEYS FROM wptests_options', + 'SHOW EXTENDED INDEX FROM wptests_options', + 'SHOW EXTENDED INDEXES FROM wptests_options', + 'SHOW EXTENDED KEYS FROM wptests_options', + ) as $query + ) { + try { + $index_driver->query( $query ); + $this->fail( 'Expected information_schema SHOW INDEX to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage(), $query ); + $this->assertSame( array(), $index_driver->get_last_postgresql_queries(), $query ); + } + } + + $show_create_driver = $this->create_driver(); + $this->install_show_create_table_fixture( $show_create_driver, 'wptests_options' ); + + $this->assertSame( 0, $show_create_driver->query( 'USE information_schema' ) ); + + try { + $show_create_driver->query( 'SHOW CREATE TABLE wptests_options' ); + $this->fail( 'Expected information_schema SHOW CREATE TABLE to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported information_schema query.', $e->getMessage() ); + $this->assertSame( array(), $show_create_driver->get_last_postgresql_queries() ); + } + } + + /** + * 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 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', + 'CHECK TABLE `administration_one`' => 'check', + 'OPTIMIZE TABLE administration_one' => 'optimize', + 'REPAIR TABLE 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 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 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)' ); + + try { + $driver->query( 'CHECK TABLE administration_existing FOR UPGRADE' ); + $this->fail( 'Expected unsupported table administration clause to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported table administration statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * 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 = $driver->get_last_postgresql_queries()[0]['sql']; + $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 = $driver->get_last_postgresql_queries()[0]['sql']; + $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 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 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 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 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 INDEX FROM other_db.wptests_options FROM wptests', + 'SHOW KEYS IN wptests_options', + 'SHOW KEYS FROM other_db.wptests_options IN wptests', + 'SHOW KEYS FROM wptests_options WHERE Non_unique = 0', + '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 unsupported SHOW EXTENDED INDEX-family statements fail before reaching the backend. + */ + public function test_show_extended_index_family_does_not_reach_backend(): 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 ) { + $driver = $this->create_show_index_driver(); + + try { + $driver->query( $query ); + $this->fail( 'Expected unsupported SHOW EXTENDED 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 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( '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 WORK TO SAVEPOINT s', + 'ROLLBACK WORK TO 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 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 unsupported 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 WRITE', + 'LOCK TABLES lock_mode_table READ LOCAL', + ); + + 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 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 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 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' ); + + $this->assertSame( + array( + array( + 'Variable_name' => 'character_set_client', + 'Value' => 'utf8', + ), + array( + 'Variable_name' => 'character_set_connection', + 'Value' => 'utf8', + ), + array( + 'Variable_name' => 'character_set_results', + 'Value' => 'utf8', + ), + array( + 'Variable_name' => 'character_set_database', + 'Value' => 'utf8', + ), + array( + 'Variable_name' => 'character_set_server', + 'Value' => 'utf8', + ), + array( + 'Variable_name' => 'collation_connection', + 'Value' => 'utf8_general_ci', + ), + array( + 'Variable_name' => 'collation_database', + 'Value' => 'utf8_general_ci', + ), + array( + 'Variable_name' => 'collation_server', + 'Value' => 'utf8_general_ci', + ), + array( + 'Variable_name' => 'sql_mode', + 'Value' => 'NO_ENGINE_SUBSTITUTION', + ), + ), + array_map( + static function ( $row ) { + return array( + 'Variable_name' => $row->Variable_name, + 'Value' => $row->Value, + ); + }, + $rows + ) + ); + $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 match bare SHOW VARIABLES. + */ + public function test_scoped_show_variables_matches_bare_show_variables(): void { + $driver = $this->create_driver(); + + $this->assertSame( 0, $driver->query( "SET NAMES 'utf8' COLLATE 'utf8_general_ci'" ) ); + + $bare = $driver->query( 'SHOW VARIABLES' ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $global = $driver->query( 'SHOW GLOBAL VARIABLES' ); + $this->assertEquals( $bare, $global ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + + $session = $driver->query( 'SHOW SESSION VARIABLES' ); + $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() ); + + $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() ); + } + + /** + * 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(); + + try { + $driver->query( "SHOW VARIABLES WHERE Value = 'utf8mb4'" ); + $this->fail( 'Expected unsupported SHOW VARIABLES statement to throw.' ); + } catch ( InvalidArgumentException $e ) { + $this->assertSame( 'Unsupported SHOW VARIABLES statement.', $e->getMessage() ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + } + + /** + * 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'} ); + $this->assertSame( array(), $driver->get_last_postgresql_queries() ); + } + + /** + * 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 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->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) + )' + ); + } + + /** + * 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 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 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' ); + } + + /** + * 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 sprintf( + '(%1$s %2$s (%3$s * INTERVAL \'1 %4$s\'))', + $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ), + $operator, + $this->get_expected_mysql_interval_value_sql( $value_sql ), + $unit + ); + } + + /** + * 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 { + return sprintf( 'CAST(%s AS double precision)', $this->get_expected_mysql_integer_cast_sql( $value_sql ) ); + } + + /** + * 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 { + $timestamp_sql = $this->get_expected_zero_date_safe_timestamp_sql( $expression_sql ); + $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 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 SUBSTRING(%2$s FROM 1 FOR 10) ELSE TO_CHAR(%3$s, \'YYYY-MM-DD\') END', + $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 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 ); + $zero_date_condition = $this->get_expected_zero_date_condition_sql( $expression_text_sql ); + + return sprintf( + 'CASE WHEN %1$s THEN %2$s ELSE CAST(EXTRACT(%3$s FROM %4$s) AS integer) END', + $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 THEN NULL ELSE %2$s END AS timestamp)', + $this->get_expected_zero_date_condition_sql( $expression_text_sql ), + $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 '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 '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, 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 ); + } + + /** + * 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 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, + 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 + )' + ); + + $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')" + ); + $pdo->exec( + "INSERT INTO information_schema.key_column_usage + (constraint_schema, constraint_name, table_schema, table_name, column_name) + VALUES + ('public', 'wptests_options_pkey', 'public', 'wptests_options', 'option_id'), + ('public', 'wptests_options_option_name_key', 'public', 'wptests_options', 'option_name')" + ); + } +} + +/** + * 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]; +} 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..c8029ae69 --- /dev/null +++ b/packages/mysql-on-sqlite/tests/WP_PostgreSQL_Install_Functions_Tests.php @@ -0,0 +1,418 @@ +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 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-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/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 ); + } + + /** + * 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; + } + + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + 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; + } + + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + 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; + } + + if ( + ! isset( $tokens[ $position ] ) + || ! in_array( $tokens[ $position ]->id, array( WP_MySQL_Lexer::IDENTIFIER, WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ), true ) + ) { + return null; + } + + return $tokens[ $position ]->get_value(); + } + + /** + * 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; + } + + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + 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(); + } + + $lexer = new WP_MySQL_Lexer( $query ); + $tokens = $lexer instanceof WP_MySQL_Native_Lexer ? $lexer->native_token_stream() : $lexer->remaining_tokens(); + + if ( ! isset( $tokens[0] ) || WP_MySQL_Lexer::DROP_SYMBOL !== $tokens[0]->id ) { + return array(); + } + + $tables = array(); + foreach ( $tokens as $token ) { + if ( WP_MySQL_Lexer::IDENTIFIER === $token->id || WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $token->id ) { + $value = $token->get_value(); + if ( ! in_array( strtolower( $value ), array( 'drop', 'temporary', 'table', 'if', 'exists', 'restrict', 'cascade' ), true ) ) { + $tables[] = $value; + } + } + } + + return $tables; + } + + /** + * 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 ) ) { + return; + } + + $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 ); + } + + 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 ) { + 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 ); + } + + return parent::prepare( $query, ...$args ); + } + + /** + * 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 unquoted 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; + $identifiers = array(); + static $marker_id = 0; + + foreach ( $scan['identifier_arg_indexes'] as $index => $arg_index ) { + if ( ! array_key_exists( $arg_index, $prepare_args ) ) { + continue; + } + + ++$marker_id; + $marker = '__wp_pg_identifier_' . spl_object_hash( $this ) . '_' . $marker_id . '_' . $index . '__'; + $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, + ); + } + + /** + * 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 ) . '%0s'; + $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 ) && preg_match( '/^\s*UPDATE\s+.+\s+WHERE\s*$/is', $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 ) { + $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 + 1 < $length && '*' === $query[ $i + 1 ] ) { + $i += 2; + while ( $i + 1 < $length && ! ( '*' === $query[ $i ] && '/' === $query[ $i + 1 ] ) ) { + ++$i; + } + $i += 2; + continue; + } + + break; + } + + $start = $i; + while ( $i < $length && ( ctype_alpha( $query[ $i ] ) || '_' === $query[ $i ] ) ) { + ++$i; + } + + return strtolower( substr( $query, $start, $i - $start ) ); + } + + /** + * 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..4490abe97 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/postgresql/install-functions.php @@ -0,0 +1,184 @@ +translate_schema( $schema ); + + foreach ( $statements as $statement ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Generated from parsed WordPress schema DDL. + if ( false === $wpdb->query( $statement ) ) { + $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..22ff977c4 100755 --- a/wp-setup.sh +++ b/wp-setup.sh @@ -9,10 +9,37 @@ 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" +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" +if ! mkdir "$WP_SETUP_LOCK_DIR" 2>/dev/null; then + 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 +fi +trap 'rmdir "$WP_SETUP_LOCK_DIR" 2>/dev/null || true' EXIT + # 1. Ensure that Git is installed. echo "Checking if Git is installed..." if ! command -v git &> /dev/null; then @@ -22,15 +49,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 +86,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 +96,437 @@ 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 -# 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 + 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 -# 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 + 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 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; +}; + +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 ); + } +} + +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